{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"sand-dissolve-button","type":"registry:component","title":"Sand Dissolve Button","description":"A pill button that turns to sand when pressed: it erodes from its windward edge, the grains blow off downwind, and it rebuilds itself out of sand as its answer.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/sand-dissolve-button.tsx","content":"\"use client\";\n\nimport { createContext, useContext, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport type { CSSProperties, ReactNode } from \"react\";\n\n/**\n * SAND DISSOLVE BUTTON.\n * A pill button that turns to sand when pressed: it erodes from its windward\n * edge, the grains blow off downwind, and it rebuilds itself out of sand as\n * its answer.\n *\n * FONTS. Archivo with its width axis for display, Instrument Serif italic for\n * the accent word, Geist Mono for labels. In Next: Archivo({ subsets:\n * [\"latin\"], axes: [\"wdth\"], variable: \"--font-ae\" }), Instrument_Serif({\n * subsets: [\"latin\"], weight: \"400\", style: \"italic\", variable:\n * \"--font-ae-serif\" }) and Geist_Mono({ subsets: [\"latin\"], variable:\n * \"--font-ae-mono\" }) on <html>. Without them it falls back to system fonts.\n *\n * CLIENT. The file starts with \"use client\". onPress is a function, so render\n * it from a client component (a server page.tsx cannot pass functions).\n *\n * WIND. Every Aeolian entry reads one page-wide wind (aeWind on globalThis),\n * so separately pasted entries still turn together.\n */\n\n/** Ink on the light grounds. */\nconst AE_UMBER = \"#2B1B12\";\n\n/** Lit sand: marks on the night ground. */\nconst AE_SAND = \"#DDAA72\";\n\n/** The pale ground, and the colour a sandstorm turns everything. */\nconst AE_HAZE = \"#EFE2CD\";\n\n/** The one filled accent: the low sun. */\nconst AE_EMBER = \"#E0602C\";\n\n/**\n * The display face: Archivo, wide and light. Set its width with font-stretch\n * (112.5% or 125%), not font-variation-settings, so a canvas can paint the\n * same instance. In Next: Archivo({ subsets: [\"latin\"], axes: [\"wdth\"], variable: \"--font-ae\" }).\n */\nconst AE_DISPLAY = 'var(--font-ae, var(--font-display, \"Archivo\")), \"Archivo\", ui-sans-serif, system-ui, sans-serif';\n\nconst aeClamp = (v: number, a = 0, b = 1) => (v < a ? a : v > b ? b : v);\n\nconst aeReducedSnap = () => typeof window !== \"undefined\" && typeof window.matchMedia === \"function\" && window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n/**\n * The scrolling box an element lives in: the nearest ancestor that actually\n * scrolls, or null for the window. Resolved on every frame, because a box only\n * becomes a scroller once its content is taller than it.\n */\nfunction aeScrollParent(el: Element | null): HTMLElement | null {\n  let n = el?.parentElement ?? null;\n  while (n && n !== document.body && n !== document.documentElement) {\n    const oy = getComputedStyle(n).overflowY;\n    if ((oy === \"auto\" || oy === \"scroll\" || oy === \"overlay\") && n.scrollHeight > n.clientHeight + 1) return n;\n    n = n.parentElement;\n  }\n  return null;\n}\n\n/**\n * The kit's scoped CSS: one focus ring (2px, offset 3px, sand on night and\n * umber on haze), tabular figures, the press, and transitions off for\n * anything a frame loop writes.\n */\nconst AE_CSS =\n  \".ae-scope{--ae-ring:\" + AE_SAND + \"}\" +\n  \".ae-scope[data-ae-ground=haze],.ae-scope [data-ae-ground=haze]{--ae-ring:\" + AE_UMBER + \"}\" +\n  \".ae-scope :focus-visible{outline:2px solid var(--ae-ring)!important;outline-offset:3px!important}\" +\n  \".ae-scope .ae-num{font-variant-numeric:tabular-nums}\" +\n  \".ae-scope [data-ae-live]{transition:none!important}\" +\n  \".ae-scope .ae-press{transition:transform 180ms cubic-bezier(.2,.8,.2,1),background-color 180ms,color 180ms,border-color 180ms}\" +\n  \".ae-scope .ae-press:active{transform:scale(.97)}\" +\n  \".ae-scope .ae-arr{transition:transform 240ms cubic-bezier(.2,.8,.2,1)}.ae-scope a:hover .ae-arr,.ae-scope button:hover .ae-arr{transform:translateX(3px)}\" +\n  \"@media (prefers-reduced-motion:reduce){.ae-scope .ae-press,.ae-scope .ae-arr{transition:none}}\";\n\n/** The kit's arrow: slides on hover. */\nfunction AeArrow({ size = 14 }: { size?: number }) {\n  return (\n    <svg className=\"ae-arr\" width={size} height={size} viewBox=\"0 0 14 14\" aria-hidden>\n      <path d=\"M2 7h9.5M7.8 3.4L11.5 7l-3.7 3.6\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\ninterface AeProgramSpec {\n  vert: string;\n  frag: string;\n  /** Transform-feedback outputs, in buffer order (interleaved off: one buffer each). */\n  feedback?: string[];\n}\n\ninterface AeProgram {\n  prog: WebGLProgram;\n  loc: (name: string) => WebGLUniformLocation | null;\n}\n\n/**\n * Compile and link every program, then hand them to `done` (null, and a logged\n * reason, when any fails or the context is lost). Nothing asks whether a\n * compile worked until the driver says it has finished\n * (KHR_parallel_shader_compile, polled once a frame), so a heavy shader never\n * blocks the page. Without the extension it links at once. Returns a cancel.\n */\nfunction aeLink(gl: WebGL2RenderingContext, specs: AeProgramSpec[], done: (progs: AeProgram[] | null) => void, label = \"Aeolian\"): () => void {\n  const built: { prog: WebGLProgram; vs: WebGLShader; fs: WebGLShader }[] = [];\n  for (const s of specs) {\n    const prog = gl.createProgram();\n    const vs = gl.createShader(gl.VERTEX_SHADER);\n    const fs = gl.createShader(gl.FRAGMENT_SHADER);\n    if (!prog || !vs || !fs) {\n      done(null);\n      return () => {};\n    }\n    gl.shaderSource(vs, s.vert);\n    gl.shaderSource(fs, s.frag);\n    gl.compileShader(vs);\n    gl.compileShader(fs);\n    gl.attachShader(prog, vs);\n    gl.attachShader(prog, fs);\n    if (s.feedback) gl.transformFeedbackVaryings(prog, s.feedback, gl.SEPARATE_ATTRIBS);\n    gl.linkProgram(prog);\n    built.push({ prog, vs, fs });\n  }\n  const finish = () => {\n    if (gl.isContextLost()) return done(null);\n    for (const b of built) {\n      if (!gl.getProgramParameter(b.prog, gl.LINK_STATUS)) {\n        console.warn(label + \": a WebGL2 program failed, showing the CSS ground instead.\", gl.getShaderInfoLog(b.fs) || gl.getShaderInfoLog(b.vs) || gl.getProgramInfoLog(b.prog));\n        return done(null);\n      }\n    }\n    done(\n      built.map((b) => {\n        const cache = new Map<string, WebGLUniformLocation | null>();\n        return {\n          prog: b.prog,\n          loc: (n: string) => {\n            if (!cache.has(n)) cache.set(n, gl.getUniformLocation(b.prog, n));\n            return cache.get(n) ?? null;\n          },\n        };\n      }),\n    );\n  };\n  const ext = gl.getExtension(\"KHR_parallel_shader_compile\") as { COMPLETION_STATUS_KHR: number } | null;\n  if (!ext) {\n    finish();\n    return () => {};\n  }\n  let raf = 0;\n  const poll = () => {\n    raf = 0;\n    if (gl.isContextLost() || built.every((b) => gl.getProgramParameter(b.prog, ext.COMPLETION_STATUS_KHR))) finish();\n    else raf = requestAnimationFrame(poll);\n  };\n  raf = requestAnimationFrame(poll);\n  return () => cancelAnimationFrame(raf);\n}\n\n/** Shared GLSL: hashes and value noise. */\nconst AE_NOISE_GLSL = [\n  \"float aeHash(vec2 p){ p = fract(p * vec2(443.897, 441.423)); p += dot(p, p.yx + 19.19); return fract((p.x + p.y) * p.x); }\",\n  \"float aeHash3(vec3 p){ p = fract(p * 0.1031); p += dot(p, p.zyx + 31.32); return fract((p.x + p.y) * p.z); }\",\n  \"float aeNoise2(vec2 x){\",\n  \"  vec2 i = floor(x), f = fract(x);\",\n  \"  f = f * f * (3.0 - 2.0 * f);\",\n  \"  return mix(mix(aeHash(i), aeHash(i + vec2(1, 0)), f.x), mix(aeHash(i + vec2(0, 1)), aeHash(i + vec2(1, 1)), f.x), f.y);\",\n  \"}\",\n  \"float aeNoise3(vec3 x){\",\n  \"  vec3 i = floor(x), f = fract(x);\",\n  \"  f = f * f * (3.0 - 2.0 * f);\",\n  \"  return mix(mix(mix(aeHash3(i), aeHash3(i + vec3(1, 0, 0)), f.x), mix(aeHash3(i + vec3(0, 1, 0)), aeHash3(i + vec3(1, 1, 0)), f.x), f.y),\",\n  \"             mix(mix(aeHash3(i + vec3(0, 0, 1)), aeHash3(i + vec3(1, 0, 1)), f.x), mix(aeHash3(i + vec3(0, 1, 1)), aeHash3(i + vec3(1, 1, 1)), f.x), f.y), f.z);\",\n  \"}\",\n  \"float aeFbm2(vec2 p){ float s = 0.0, a = 0.5; for (int i = 0; i < 4; i++){ s += a * aeNoise2(p); p = p * 2.03 + 17.1; a *= 0.5; } return s; }\",\n  \"vec3 aeAces(vec3 x){ return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0); }\",\n].join(\"\\n\");\n\n/**\n * The page's wind. `angle` is the way it blows TOWARD, in degrees on the\n * ground: 0 = to the right (east), 90 = away from you (north), 180 = left,\n * 270 = toward you. `speed` is in km/h. The dune stage, the grains, the dust\n * of every dissolve, the nav's vane and the footer all read this one store,\n * so turning the rose in the hero turns every grain on the page. It lives on\n * globalThis, so entries pasted as separate files still share one wind.\n */\nconst aeWind: { angle: number; speed: number; subs: Set<() => void> } = ((globalThis as unknown as { __aeolianWind?: { angle: number; speed: number; subs: Set<() => void> } }).__aeolianWind ??= { angle: 55, speed: 30, subs: new Set() });\n\n/**\n * The wind as a direction on a flat screen: x right, y down. Wind blowing\n * away from you reads as rising, toward you as falling, a little flattened\n * (the ground is seen at a low angle). Unit length.\n */\nfunction aeWindScreen(angle = aeWind.angle): [number, number] {\n  const a = (angle * Math.PI) / 180;\n  const x = Math.cos(a);\n  const y = -Math.sin(a) * 0.55;\n  const l = Math.hypot(x, y) || 1;\n  return [x / l, y / l];\n}\n\ninterface AeRaster {\n  canvas: HTMLCanvasElement;\n  /** The element's layout size in CSS pixels (unscaled by any transform above it). */\n  w: number;\n  h: number;\n  /** Resolves once inline SVGs (decoded as images) are painted in too. */\n  ready: Promise<void>;\n}\n\nconst AE_STRETCH: Record<string, string> = {\n  \"50%\": \"ultra-condensed\",\n  \"62.5%\": \"extra-condensed\",\n  \"75%\": \"condensed\",\n  \"87.5%\": \"semi-condensed\",\n  \"100%\": \"normal\",\n  \"112.5%\": \"semi-expanded\",\n  \"125%\": \"expanded\",\n  \"150%\": \"extra-expanded\",\n  \"200%\": \"ultra-expanded\",\n};\n\nfunction aeAlpha(color: string): number {\n  if (!color || color === \"transparent\") return 0;\n  const m = color.match(/rgba?\\(([^)]+)\\)/);\n  if (!m || !m[1]) return 1;\n  const parts = m[1].split(/[\\s,/]+/).filter(Boolean);\n  return parts.length > 3 ? parseFloat(parts[3] ?? \"1\") : 1;\n}\n\nfunction aeRadius(v: string, w: number, h: number): number {\n  const n = parseFloat(v) || 0;\n  return v.trim().endsWith(\"%\") ? (n / 100) * Math.min(w, h) : n;\n}\n\nfunction aeRoundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: [number, number, number, number]) {\n  const m = Math.min(w, h) / 2;\n  const [a, b, c, d] = r.map((v) => Math.max(0, Math.min(v, m))) as [number, number, number, number];\n  ctx.beginPath();\n  ctx.moveTo(x + a, y);\n  ctx.lineTo(x + w - b, y);\n  ctx.arcTo(x + w, y, x + w, y + b, b);\n  ctx.lineTo(x + w, y + h - c);\n  ctx.arcTo(x + w, y + h, x + w - c, y + h, c);\n  ctx.lineTo(x + d, y + h);\n  ctx.arcTo(x, y + h, x, y + h - d, d);\n  ctx.lineTo(x, y + a);\n  ctx.arcTo(x, y, x + a, y, a);\n  ctx.closePath();\n}\n\n/**\n * Paint `el` into a canvas at `res` pixels per CSS pixel: background colours,\n * borders and radii, overflow clips, child canvases, inline SVGs, input\n * values, and text, one character at a time at the exact place the browser\n * laid it out (read with Range rects), in the element's own font. That is\n * enough for anything built from flat fills and type, which is what every\n * Aeolian surface is. Everything is read synchronously at the call, so the\n * snapshot is of the element as it is now even if React redraws it a moment\n * later. The element's own opacity is ignored (a hidden element can still be\n * painted, ready to be built out of sand); nodes marked data-ae-skip are left out.\n */\nfunction aeRasterize(el: HTMLElement, res: number): AeRaster | null {\n  const R = el.getBoundingClientRect();\n  const w = el.offsetWidth;\n  const h = el.offsetHeight;\n  if (!w || !h || !R.width) return null;\n  const s = R.width / w;\n  const canvas = document.createElement(\"canvas\");\n  canvas.width = Math.max(1, Math.ceil(w * res));\n  canvas.height = Math.max(1, Math.ceil(h * res));\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) return null;\n  ctx.scale(res, res);\n  const X = (x: number) => (x - R.left) / s;\n  const Y = (y: number) => (y - R.top) / s;\n  const range = document.createRange();\n  const svgs: Promise<void>[] = [];\n\n  const text = (t: Text, cs: CSSStyleDeclaration, alpha: number) => {\n    const data = t.data;\n    if (!data.trim()) return;\n    ctx.globalAlpha = alpha * aeAlpha(cs.color);\n    ctx.fillStyle = cs.color;\n    ctx.font = cs.fontStyle + \" \" + cs.fontWeight + \" \" + cs.fontSize + \" \" + cs.fontFamily;\n    const c2 = ctx as unknown as { fontStretch?: string; letterSpacing?: string };\n    if (\"fontStretch\" in c2) c2.fontStretch = AE_STRETCH[cs.fontStretch] ?? \"normal\";\n    if (\"letterSpacing\" in c2) c2.letterSpacing = \"0px\";\n    ctx.textBaseline = \"alphabetic\";\n    const m = ctx.measureText(\"Hg\");\n    const asc = m.fontBoundingBoxAscent || parseFloat(cs.fontSize) * 0.8;\n    const desc = m.fontBoundingBoxDescent || parseFloat(cs.fontSize) * 0.2;\n    const upper = cs.textTransform === \"uppercase\";\n    for (let i = 0; i < data.length; i++) {\n      const code = data.charCodeAt(i);\n      const n = code >= 0xd800 && code <= 0xdbff ? 2 : 1;\n      let ch = data.slice(i, i + n);\n      if (/\\s/.test(ch)) continue;\n      range.setStart(t, i);\n      range.setEnd(t, i + n);\n      i += n - 1;\n      const rect = range.getClientRects()[0];\n      if (!rect || !rect.width) continue;\n      if (upper) ch = ch.toUpperCase();\n      const rh = rect.height / s;\n      ctx.fillText(ch, X(rect.left), Y(rect.top) + (rh - (asc + desc)) / 2 + asc);\n    }\n  };\n\n  const paint = (node: Element, alpha: number, root: boolean) => {\n    if (!root && node.hasAttribute(\"data-ae-skip\")) return;\n    const cs = getComputedStyle(node);\n    if (cs.display === \"none\" || (!root && cs.visibility === \"hidden\")) return;\n    const a = root ? alpha : alpha * (parseFloat(cs.opacity) || 0);\n    if (a < 0.01) return;\n    const r = node.getBoundingClientRect();\n    const x = X(r.left);\n    const y = Y(r.top);\n    const bw = r.width / s;\n    const bh = r.height / s;\n    const radii: [number, number, number, number] = [\n      aeRadius(cs.borderTopLeftRadius, bw, bh),\n      aeRadius(cs.borderTopRightRadius, bw, bh),\n      aeRadius(cs.borderBottomRightRadius, bw, bh),\n      aeRadius(cs.borderBottomLeftRadius, bw, bh),\n    ];\n    if (node instanceof SVGSVGElement) {\n      const clone = node.cloneNode(true) as SVGSVGElement;\n      clone.setAttribute(\"width\", String(bw));\n      clone.setAttribute(\"height\", String(bh));\n      clone.setAttribute(\"color\", cs.color);\n      clone.style.color = cs.color;\n      const img = new Image();\n      const src = \"data:image/svg+xml;charset=utf-8,\" + encodeURIComponent(new XMLSerializer().serializeToString(clone));\n      svgs.push(\n        new Promise<void>((done) => {\n          img.onload = () => {\n            ctx.save();\n            ctx.globalAlpha = a;\n            ctx.drawImage(img, x, y, bw, bh);\n            ctx.restore();\n            done();\n          };\n          img.onerror = () => done();\n          img.src = src;\n        }),\n      );\n      return;\n    }\n    ctx.save();\n    if (aeAlpha(cs.backgroundColor) > 0) {\n      ctx.globalAlpha = a * aeAlpha(cs.backgroundColor);\n      ctx.fillStyle = cs.backgroundColor;\n      aeRoundRect(ctx, x, y, bw, bh, radii);\n      ctx.fill();\n    }\n    const bt = parseFloat(cs.borderTopWidth) || 0;\n    const br = parseFloat(cs.borderRightWidth) || 0;\n    const bb = parseFloat(cs.borderBottomWidth) || 0;\n    const bl = parseFloat(cs.borderLeftWidth) || 0;\n    if (bt || br || bb || bl) {\n      const uniform = bt === br && br === bb && bb === bl && cs.borderTopColor === cs.borderBottomColor && cs.borderLeftColor === cs.borderRightColor && cs.borderTopColor === cs.borderLeftColor;\n      if (uniform && cs.borderTopStyle !== \"none\") {\n        ctx.globalAlpha = a * aeAlpha(cs.borderTopColor);\n        ctx.strokeStyle = cs.borderTopColor;\n        ctx.lineWidth = bt;\n        aeRoundRect(ctx, x + bt / 2, y + bt / 2, bw - bt, bh - bt, radii.map((v) => Math.max(0, v - bt / 2)) as [number, number, number, number]);\n        ctx.stroke();\n      } else {\n        const side = (on: number, color: string, style: string, sx: number, sy: number, sw: number, sh: number) => {\n          if (!on || style === \"none\" || aeAlpha(color) === 0) return;\n          ctx.globalAlpha = a * aeAlpha(color);\n          ctx.fillStyle = color;\n          ctx.fillRect(sx, sy, sw, sh);\n        };\n        side(bt, cs.borderTopColor, cs.borderTopStyle, x, y, bw, bt);\n        side(bb, cs.borderBottomColor, cs.borderBottomStyle, x, y + bh - bb, bw, bb);\n        side(bl, cs.borderLeftColor, cs.borderLeftStyle, x, y, bl, bh);\n        side(br, cs.borderRightColor, cs.borderRightStyle, x + bw - br, y, br, bh);\n      }\n    }\n    if (node instanceof HTMLCanvasElement) {\n      try {\n        ctx.globalAlpha = a;\n        ctx.drawImage(node, x, y, bw, bh);\n      } catch {\n        // A canvas we may not read (tainted): leave its box empty.\n      }\n    }\n    if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement) {\n      const isSelect = node instanceof HTMLSelectElement;\n      // Secrets never reach the sand: passwords, one-time codes and card fields paint as dots.\n      const secret = node instanceof HTMLInputElement && (node.type === \"password\" || /^(one-time-code|cc-)/.test(node.autocomplete));\n      const value = isSelect ? (node.selectedOptions[0]?.text ?? \"\") : secret ? \"•\".repeat(node.value.length) : node.value;\n      const shown = value || (isSelect ? \"\" : node.placeholder);\n      if (shown && !(node instanceof HTMLInputElement && /^(checkbox|radio|range|color|file|hidden)$/.test(node.type))) {\n        ctx.globalAlpha = a * (value ? 1 : 0.5);\n        ctx.fillStyle = cs.color;\n        ctx.font = cs.fontStyle + \" \" + cs.fontWeight + \" \" + cs.fontSize + \" \" + cs.fontFamily;\n        ctx.textBaseline = \"middle\";\n        const pl = (parseFloat(cs.paddingLeft) || 0) + bl;\n        const lineY = node instanceof HTMLTextAreaElement ? y + (parseFloat(cs.paddingTop) || 0) + bt + (parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.3) / 2 : y + bh / 2;\n        ctx.fillText(shown, x + pl, lineY, Math.max(1, bw - pl * 2));\n      }\n    }\n    if (cs.overflow !== \"visible\" && cs.overflow !== \"\") {\n      aeRoundRect(ctx, x, y, bw, bh, radii);\n      ctx.clip();\n    }\n    for (const c of Array.from(node.childNodes)) {\n      if (c.nodeType === 3) text(c as Text, cs, a);\n      else if (c.nodeType === 1) paint(c as Element, a, false);\n    }\n    ctx.restore();\n  };\n\n  paint(el, 1, true);\n  range.detach();\n  return { canvas, w, h, ready: Promise.all(svgs).then(() => undefined) };\n}\n\ninterface AeBurstOptions {\n  /** The whole burst, in ms (the element is gone a little past half way). */\n  duration?: number;\n  /** Blow this way instead of the page's wind (degrees, same compass as the wind store). */\n  angle?: number;\n  /** How far the grains fly, 0.3..2. */\n  reach?: number;\n}\n\n/** A burst you drive yourself (with the scroll): `set(\"scatter\", 0..1)` blows it away, `set(\"gather\", 0..1)` builds it. */\ninterface AeScrub {\n  set: (mode: \"scatter\" | \"gather\", c: number) => void;\n  /** Throw the snapshot away (the element changed); the next set() paints it again. */\n  refresh: () => void;\n  drop: () => void;\n}\n\ninterface AeDust {\n  /** Paint the element, hide it, and blow it away grain by grain. Resolves once it has fully let go (the grains fly on a little longer). */\n  scatter: (el: HTMLElement, o?: AeBurstOptions) => Promise<void>;\n  /** Build an element (laid out, typically hidden with opacity 0) out of sand blown in on the wind, then show it. */\n  gather: (el: HTMLElement, o?: AeBurstOptions) => Promise<void>;\n  scrub: (el: HTMLElement, o?: Omit<AeBurstOptions, \"duration\">) => AeScrub;\n}\n\n/** Release window and flight time, as fractions of a burst. */\nconst AE_RELEASE = 0.55;\n\nconst AE_FLIGHT = 0.45;\n\nconst AE_DUST_COMMON = [\n  \"uniform vec2 uView;\",\n  \"uniform vec2 uOrigin;\",\n  \"uniform vec2 uSize;\",\n  \"uniform float uCell;\",\n  \"uniform float uC;\",\n  \"uniform float uGather;\",\n  \"uniform vec2 uWind;\",\n  \"uniform float uReach;\",\n  \"uniform float uSeed;\",\n  \"uniform float uFlight;\",\n  AE_NOISE_GLSL,\n  /** When the grain at `pos` (element px) lets go, 0..1: windward first, along a ragged, patchy front. */\n  \"float aeRelease(vec2 pos){\",\n  \"  vec2 cell = floor(pos / uCell);\",\n  \"  float half_ = 0.5 * (abs(uWind.x) * uSize.x + abs(uWind.y) * uSize.y) + 1.0;\",\n  \"  float lin = 0.5 + 0.5 * dot(pos - 0.5 * uSize, uWind) / half_;\",\n  \"  float n = aeFbm2(pos / 46.0 + uSeed * 13.0);\",\n  \"  float g = aeHash(cell + uSeed * 71.0);\",\n  \"  return clamp(lin * 0.74 + (n - 0.5) * 0.42 + (g - 0.5) * 0.07 + 0.08, 0.0, 1.0);\",\n  \"}\",\n  \"vec2 aeClip(vec2 px){ return vec2(px.x / uView.x * 2.0 - 1.0, 1.0 - px.y / uView.y * 2.0); }\",\n].join(\"\\n\");\n\nconst AE_GRAIN_VERT = [\n  \"#version 300 es\",\n  \"precision highp float;\",\n  \"uniform sampler2D uTex;\",\n  \"uniform int uCols;\",\n  \"uniform float uRes;\",\n  \"uniform float uDpr;\",\n  AE_DUST_COMMON,\n  \"out vec4 vCol;\",\n  \"void main(){\",\n  \"  vCol = vec4(0.0);\",\n  \"  gl_Position = vec4(2.0, 2.0, 0.0, 1.0);\",\n  \"  gl_PointSize = 1.0;\",\n  \"  vec2 cell = vec2(float(gl_VertexID % uCols), float(gl_VertexID / uCols));\",\n  \"  vec2 pos = (cell + 0.5) * uCell;\",\n  \"  if (pos.y > uSize.y || pos.x > uSize.x) return;\",\n  \"  vec4 tex = texelFetch(uTex, ivec2(pos * uRes), 0);\",\n  \"  if (tex.a < 0.04) return;\",\n  \"  float tr = aeRelease(pos) * \" + AE_RELEASE.toFixed(3) + \";\",\n  \"  float tau = (uC - tr) / \" + AE_FLIGHT.toFixed(3) + \";\",\n  \"  if (tau <= 0.0 || tau >= 1.0) return;\",\n  \"  if (uGather > 0.5) tau = 1.0 - tau;\",\n  \"  vec4 r = vec4(aeHash(cell + 1.7 + uSeed), aeHash(cell * 1.31 + 9.2), aeHash(cell * 0.77 + 4.1), aeHash(cell * 2.13 + 7.7));\",\n  \"  vec2 d = uWind;\",\n  \"  vec2 nrm = vec2(-d.y, d.x);\",\n  \"  float t = tau * uFlight;\",\n  \"  float tp = mix(0.08, 0.5, r.x * r.x);\",\n  \"  vec2 U = d * uReach * (720.0 + 0.6 * max(uSize.x, uSize.y));\",\n  \"  vec2 v0 = d * 90.0 * r.y + vec2(0.0, -(110.0 + 220.0 * r.z) * uReach);\",\n  \"  const float G = 640.0;\",\n  \"  float e = 1.0 - exp(-t / tp);\",\n  \"  vec2 off = U * t + (v0 - U) * tp * e + vec2(0.0, G * tp * t - G * tp * tp * e);\",\n  // Eddies: a slow wander across the wind that grows as the grain travels.\n  \"  off += nrm * (aeNoise2(vec2(t * 1.6 + r.w * 7.0, r.y * 13.0)) - 0.5) * 120.0 * uReach * tau;\",\n  // Built from sand: the same flight run backward, blown in from upwind and settling from above.\n  \"  if (uGather > 0.5) off = vec2(-off.x, -abs(off.y) * 0.6);\",\n  \"  vec2 px = uOrigin + pos + off;\",\n  \"  gl_Position = vec4(aeClip(px), 0.0, 1.0);\",\n  \"  gl_PointSize = max(1.0, uCell * uDpr * (1.18 - 0.55 * tau));\",\n  \"  vec3 sand = mix(vec3(0.867, 0.667, 0.447), vec3(0.722, 0.455, 0.247), r.z);\",\n  \"  vec3 base = tex.rgb / max(tex.a, 0.001);\",\n  \"  vec3 col = mix(base, sand, smoothstep(0.04, 0.55, tau) * 0.78);\",\n  \"  float a = tex.a * (1.0 - smoothstep(0.55, 1.0, tau)) * (0.75 + 0.25 * r.w);\",\n  \"  vCol = vec4(col * a, a);\",\n  \"}\",\n].join(\"\\n\");\n\nconst AE_GRAIN_FRAG = [\n  \"#version 300 es\",\n  \"precision mediump float;\",\n  \"in vec4 vCol;\",\n  \"out vec4 o;\",\n  \"void main(){\",\n  \"  float d = length(gl_PointCoord - 0.5);\",\n  \"  o = vCol * smoothstep(0.52, 0.28, d);\",\n  \"}\",\n].join(\"\\n\");\n\nconst AE_SLAB_VERT = [\n  \"#version 300 es\",\n  \"precision highp float;\",\n  \"uniform vec2 uView;\",\n  \"uniform vec2 uOrigin;\",\n  \"uniform vec2 uSize;\",\n  \"out vec2 vUv;\",\n  \"void main(){\",\n  \"  int i = gl_VertexID;\",\n  \"  vec2 uv = vec2(float(i == 1 || i == 4 || i == 5), float(i == 2 || i == 3 || i == 5));\",\n  \"  vUv = uv;\",\n  \"  vec2 px = uOrigin + uv * uSize;\",\n  \"  gl_Position = vec4(px.x / uView.x * 2.0 - 1.0, 1.0 - px.y / uView.y * 2.0, 0.0, 1.0);\",\n  \"}\",\n].join(\"\\n\");\n\nconst AE_SLAB_FRAG = [\n  \"#version 300 es\",\n  \"precision highp float;\",\n  \"uniform sampler2D uTex;\",\n  AE_DUST_COMMON,\n  \"in vec2 vUv;\",\n  \"out vec4 o;\",\n  \"void main(){\",\n  \"  vec2 pos = vUv * uSize;\",\n  \"  vec4 tex = texture(uTex, vUv);\",\n  \"  float tr = aeRelease(pos) * \" + AE_RELEASE.toFixed(3) + \";\",\n  // Scatter: still here until its grain lets go. Gather: here once its grain has landed.\n  \"  float k = uGather > 0.5 ? step(tr + \" + AE_FLIGHT.toFixed(3) + \", uC) : step(uC, tr);\",\n  \"  if (k < 0.5) discard;\",\n  // A thin rim of loose sand along the front.\n  \"  float front = uGather > 0.5 ? uC - tr - \" + AE_FLIGHT.toFixed(3) + \" : tr - uC;\",\n  \"  float rim = 1.0 - smoothstep(0.0, 0.035, front);\",\n  \"  vec3 base = tex.rgb / max(tex.a, 0.001);\",\n  \"  vec3 col = mix(base, vec3(0.867, 0.667, 0.447), rim * 0.55);\",\n  \"  o = vec4(col * tex.a, tex.a);\",\n  \"}\",\n].join(\"\\n\");\n\ninterface AeBurst {\n  el: HTMLElement;\n  tex: WebGLTexture | null;\n  raster: AeRaster | null;\n  mode: \"scatter\" | \"gather\";\n  c: number;\n  t0: number;\n  dur: number;\n  angle: number | undefined;\n  reach: number;\n  seed: number;\n  /** Driven by its caller (scroll), not by the clock. */\n  scrubbed: boolean;\n  /** Superseded by a newer burst on the same element: only its grains still fly. */\n  ghost: boolean;\n  /** Where the element last was on the layer (CSS px), kept for when it leaves the DOM mid-flight. */\n  ox: number;\n  oy: number;\n  stale: boolean;\n  released: boolean;\n  settle: (() => void) | null;\n  finish: (() => void) | null;\n}\n\n/**\n * The dust layer's engine: one WebGL2 canvas laid over a whole page (or one\n * asset), drawing only while something is turning to sand. Each burst is a\n * snapshot texture drawn twice: a crisp slab that erodes along the wind, and\n * one grain per cell flown on a stateless path (so a burst can be played\n * forward, backward, or scrubbed by the scroll). Without WebGL2, or with\n * reduced motion, every burst is a 220ms fade.\n */\nfunction aeDustEngine() {\n  let canvas: HTMLCanvasElement | null = null;\n  let gl: WebGL2RenderingContext | null = null;\n  let progs: AeProgram[] | null = null;\n  let failed = false;\n  let raf = 0;\n  let cancelLink: (() => void) | null = null;\n  const bursts: AeBurst[] = [];\n  const waiting: (() => void)[] = [];\n  let seed = 1;\n\n  const fade = (el: HTMLElement, show: boolean) =>\n    new Promise<void>((done) => {\n      el.style.transition = \"opacity 220ms ease\";\n      el.style.opacity = show ? \"\" : \"0\";\n      window.setTimeout(() => {\n        el.style.transition = \"\";\n        done();\n      }, 230);\n    });\n\n  const ready = () => !!(gl && progs && !failed && !aeReducedSnap());\n\n  const upload = (b: AeBurst) => {\n    if (!gl) return;\n    const res = Math.min(2, Math.max(1, window.devicePixelRatio || 1));\n    b.raster = aeRasterize(b.el, res);\n    b.stale = false;\n    if (!b.raster) return;\n    if (!b.tex) b.tex = gl.createTexture();\n    const put = () => {\n      if (!gl || !b.tex || !b.raster) return;\n      gl.bindTexture(gl.TEXTURE_2D, b.tex);\n      gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);\n      try {\n        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, b.raster.canvas);\n      } catch {\n        // A tainted snapshot (some browsers taint SVG with foreignObject): draw nothing for it.\n        gl.deleteTexture(b.tex);\n        b.tex = null;\n        return;\n      }\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n    };\n    put();\n    // Inline SVGs land a moment later; upload again when they have.\n    const r = b.raster;\n    void r.ready.then(() => {\n      if (b.raster === r) put();\n    });\n  };\n\n  const free = (b: AeBurst) => {\n    const i = bursts.indexOf(b);\n    if (i >= 0) bursts.splice(i, 1);\n    if (gl && b.tex) gl.deleteTexture(b.tex);\n    b.tex = null;\n  };\n\n  const frame = (now: number) => {\n    raf = 0;\n    if (!canvas || !gl || !progs) return;\n    const [grain, slab] = progs as [AeProgram, AeProgram];\n    // A bleed box is display:none while idle, so its overhang never adds page scroll.\n    const bleedBox = canvas.dataset.aeBox ? canvas.parentElement : null;\n    if (bleedBox && bleedBox.style.display) bleedBox.style.display = \"\";\n    let cssH = canvas.offsetHeight;\n    if (!canvas.dataset.aeBox) {\n      const host = canvas.parentElement?.parentElement;\n      const sp = aeScrollParent(canvas);\n      const viewH = sp ? sp.clientHeight : window.innerHeight;\n      const hostH = host ? host.offsetHeight : viewH;\n      cssH = Math.max(1, Math.min(viewH, hostH));\n      if (canvas.style.height !== cssH + \"px\") canvas.style.height = cssH + \"px\";\n    }\n    cssH = Math.max(1, cssH);\n    const C = canvas.getBoundingClientRect();\n    const cssW = canvas.offsetWidth || 1;\n    const sc = C.width / cssW || 1;\n    const dpr = Math.min(2, window.devicePixelRatio || 1);\n    const W = Math.max(1, Math.round(cssW * dpr));\n    const H = Math.max(1, Math.round(cssH * dpr));\n    if (canvas.width !== W || canvas.height !== H) {\n      canvas.width = W;\n      canvas.height = H;\n    }\n    gl.viewport(0, 0, W, H);\n    gl.clearColor(0, 0, 0, 0);\n    gl.clear(gl.COLOR_BUFFER_BIT);\n    gl.enable(gl.BLEND);\n    gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);\n    let live = false;\n    for (const b of bursts.slice()) {\n      if (!b.scrubbed) b.c = aeClamp((now - b.t0) / b.dur);\n      if (!b.scrubbed && !b.released && b.c >= (b.mode === \"scatter\" ? AE_RELEASE : 1)) {\n        b.released = true;\n        b.settle?.();\n      }\n      const shown = b.mode === \"scatter\" ? b.c <= 0 : b.c >= 1;\n      if (b.scrubbed) {\n        b.el.style.opacity = shown ? \"\" : \"0\";\n        if (shown) continue;\n        if (b.stale || !b.raster) upload(b);\n      }\n      if (!b.scrubbed && b.c >= 1) {\n        if (b.mode === \"gather\" && !b.ghost) b.el.style.opacity = \"\";\n        b.finish?.();\n        free(b);\n        continue;\n      }\n      live = live || !b.scrubbed;\n      if (!b.raster || !b.tex) continue;\n      // An element replaced mid-flight (a route opening, a form sent) keeps its grains where it was.\n      if (b.el.isConnected) {\n        const R = b.el.getBoundingClientRect();\n        b.ox = (R.left - C.left) / sc;\n        b.oy = (R.top - C.top) / sc;\n      }\n      const { ox, oy } = b;\n      const area = b.raster.w * b.raster.h;\n      const cell = Math.max(1.5, Math.sqrt(area / 80000));\n      const cols = Math.ceil(b.raster.w / cell);\n      const rows = Math.ceil(b.raster.h / cell);\n      const wind = aeWindScreen(b.angle ?? aeWind.angle);\n      gl.activeTexture(gl.TEXTURE0);\n      gl.bindTexture(gl.TEXTURE_2D, b.tex);\n      for (const p of [slab, grain]) {\n        gl.useProgram(p.prog);\n        gl.uniform1i(p.loc(\"uTex\"), 0);\n        gl.uniform2f(p.loc(\"uView\"), cssW, cssH);\n        gl.uniform2f(p.loc(\"uOrigin\"), ox, oy);\n        gl.uniform2f(p.loc(\"uSize\"), b.raster.w, b.raster.h);\n        gl.uniform1f(p.loc(\"uCell\"), cell);\n        gl.uniform1f(p.loc(\"uC\"), b.c);\n        gl.uniform1f(p.loc(\"uGather\"), b.mode === \"gather\" ? 1 : 0);\n        gl.uniform2f(p.loc(\"uWind\"), wind[0], wind[1]);\n        gl.uniform1f(p.loc(\"uReach\"), b.reach);\n        gl.uniform1f(p.loc(\"uSeed\"), b.seed);\n        // Seconds of flight a burst gives its grains (a scrubbed one takes a nominal 1.5 s burst).\n        gl.uniform1f(p.loc(\"uFlight\"), ((b.dur || 1500) / 1000) * AE_FLIGHT);\n      }\n      if (!b.ghost) {\n        gl.useProgram(slab.prog);\n        gl.drawArrays(gl.TRIANGLES, 0, 6);\n      }\n      gl.useProgram(grain.prog);\n      gl.uniform1i(grain.loc(\"uCols\"), cols);\n      gl.uniform1f(grain.loc(\"uRes\"), b.raster.canvas.width / b.raster.w);\n      gl.uniform1f(grain.loc(\"uDpr\"), dpr);\n      gl.drawArrays(gl.POINTS, 0, cols * rows);\n    }\n    if (live) raf = requestAnimationFrame(frame);\n    else if (bleedBox && !bursts.length) bleedBox.style.display = \"none\";\n  };\n\n  const kick = () => {\n    if (!raf && canvas && gl && progs) raf = requestAnimationFrame(frame);\n  };\n\n  const begin = (el: HTMLElement, mode: \"scatter\" | \"gather\", o: AeBurstOptions, scrubbed: boolean): AeBurst => {\n    for (const old of bursts.filter((x) => x.el === el && !x.ghost)) {\n      old.settle?.();\n      // A scatter whose grains are still in the air keeps flying while the element builds again.\n      if (old.mode === \"scatter\" && !old.scrubbed && !scrubbed && mode === \"gather\") {\n        old.ghost = true;\n        continue;\n      }\n      if (old.mode === \"gather\") el.style.opacity = \"\";\n      old.finish?.();\n      free(old);\n    }\n    const b: AeBurst = {\n      el,\n      tex: null,\n      raster: null,\n      mode,\n      c: 0,\n      t0: performance.now(),\n      dur: o.duration ?? 1500,\n      angle: o.angle,\n      reach: aeClamp(o.reach ?? 1, 0.3, 2),\n      seed: (seed = (seed * 1.618) % 7.3) + 0.37,\n      scrubbed,\n      ghost: false,\n      ox: 0,\n      oy: 0,\n      stale: true,\n      released: false,\n      settle: null,\n      finish: null,\n    };\n    bursts.push(b);\n    return b;\n  };\n\n  const timed = (el: HTMLElement, mode: \"scatter\" | \"gather\", o: AeBurstOptions = {}) =>\n    new Promise<void>((resolve) => {\n      const go = () => {\n        if (!ready()) {\n          if (mode === \"gather\") el.style.opacity = \"0\";\n          void fade(el, mode === \"gather\").then(resolve);\n          return;\n        }\n        const b = begin(el, mode, o, false);\n        upload(b);\n        if (!b.raster) {\n          free(b);\n          el.style.opacity = mode === \"gather\" ? \"\" : \"0\";\n          resolve();\n          return;\n        }\n        el.style.opacity = \"0\";\n        let said = false;\n        const say = () => {\n          if (!said) {\n            said = true;\n            resolve();\n          }\n        };\n        if (mode === \"scatter\") b.settle = say;\n        else b.finish = say;\n        kick();\n      };\n      // The layer may still be compiling: wait for it (briefly) rather than fading.\n      if (!ready() && !failed && !aeReducedSnap() && canvas) {\n        let ran = false;\n        const once = () => {\n          if (ran) return;\n          ran = true;\n          go();\n        };\n        waiting.push(once);\n        window.setTimeout(once, 600);\n      } else go();\n    });\n\n  return {\n    attach(c: HTMLCanvasElement) {\n      canvas = c;\n      const g = c.getContext(\"webgl2\", { alpha: true, premultipliedAlpha: true, antialias: false });\n      if (!g) {\n        failed = true;\n        waiting.splice(0).forEach((f) => f());\n        return;\n      }\n      gl = g;\n      cancelLink = aeLink(\n        g,\n        [\n          { vert: AE_GRAIN_VERT, frag: AE_GRAIN_FRAG },\n          { vert: AE_SLAB_VERT, frag: AE_SLAB_FRAG },\n        ],\n        (p) => {\n          cancelLink = null;\n          progs = p;\n          failed = !p;\n          waiting.splice(0).forEach((f) => f());\n          kick();\n        },\n        \"Aeolian dust\",\n      );\n      // Never keep a click waiting on a slow compile.\n      window.setTimeout(() => waiting.splice(0).forEach((f) => f()), 700);\n    },\n    lost() {\n      failed = true;\n      progs = null;\n      for (const b of bursts.slice()) {\n        b.el.style.opacity = b.mode === \"scatter\" && !b.scrubbed ? \"0\" : \"\";\n        b.settle?.();\n        b.finish?.();\n        b.tex = null;\n        bursts.splice(bursts.indexOf(b), 1);\n      }\n    },\n    detach() {\n      cancelLink?.();\n      if (raf) cancelAnimationFrame(raf);\n      raf = 0;\n      for (const b of bursts.slice()) {\n        if (b.mode === \"gather\" || b.scrubbed) b.el.style.opacity = \"\";\n        b.settle?.();\n        b.finish?.();\n        free(b);\n      }\n      if (gl && progs) progs.forEach((p) => gl?.deleteProgram(p.prog));\n      gl = null;\n      progs = null;\n      canvas = null;\n    },\n    api: {\n      scatter: (el, o) => timed(el, \"scatter\", o),\n      gather: (el, o) => timed(el, \"gather\", o),\n      scrub: (el, o = {}) => {\n        let b: AeBurst | null = null;\n        let ro: ResizeObserver | null = null;\n        return {\n          set(mode, c) {\n            const k = aeClamp(c);\n            if (b && bursts.includes(b) && b.mode === mode && b.c === k) return;\n            if ((!b || !bursts.includes(b)) && ((mode === \"scatter\" && k <= 0) || (mode === \"gather\" && k >= 1))) {\n              el.style.opacity = \"\";\n              return;\n            }\n            if (!ready()) {\n              el.style.opacity = (mode === \"scatter\" ? k < 0.5 : k > 0.5) ? \"\" : \"0\";\n              return;\n            }\n            if (!b || !bursts.includes(b)) {\n              b = begin(el, mode, o, true);\n              if (!ro && typeof ResizeObserver !== \"undefined\") {\n                ro = new ResizeObserver(() => {\n                  if (b) b.stale = true;\n                });\n                ro.observe(el);\n              }\n            }\n            b.mode = mode;\n            b.c = k;\n            if (raf) return;\n            raf = requestAnimationFrame(frame);\n          },\n          refresh() {\n            if (b) b.stale = true;\n          },\n          drop() {\n            ro?.disconnect();\n            if (b) {\n              el.style.opacity = \"\";\n              free(b);\n              b = null;\n              kick();\n            }\n          },\n        };\n      },\n    } satisfies AeDust,\n  };\n}\n\nconst AeDustContext = createContext<AeDust | null>(null);\n\n/**\n * The layer's canvas: stuck to the top of the view inside its host, as tall as\n * the view (or the host, if shorter). With `bleed`, a box that reaches that\n * many px past the host instead (twice that sideways), for small hosts such\n * as a lone button, so the grains have room to fly.\n */\nfunction AeDustCanvas({ engine, bleed }: { engine: ReturnType<typeof aeDustEngine>; bleed?: number }) {\n  const ref = useRef<HTMLCanvasElement>(null);\n  useEffect(() => {\n    const c = ref.current;\n    if (!c) return;\n    engine.attach(c);\n    const lost = (e: Event) => {\n      e.preventDefault();\n      engine.lost();\n    };\n    c.addEventListener(\"webglcontextlost\", lost);\n    return () => {\n      c.removeEventListener(\"webglcontextlost\", lost);\n      engine.detach();\n    };\n  }, [engine]);\n  if (bleed)\n    return (\n      <span aria-hidden data-ae-skip=\"\" className=\"pointer-events-none absolute z-[60] block\" style={{ inset: -bleed + \"px \" + -bleed * 2 + \"px\", display: \"none\" }}>\n        <canvas ref={ref} data-ae-box=\"1\" className=\"absolute inset-0 block h-full w-full\" />\n      </span>\n    );\n  return (\n    <div aria-hidden data-ae-skip=\"\" className=\"pointer-events-none sticky top-0 z-[60] h-0 w-full\">\n      <canvas ref={ref} className=\"absolute left-0 top-0 w-full\" style={{ height: 0 }} />\n    </div>\n  );\n}\n\n/**\n * The dust for this part of the page: the page's layer when there is one\n * (an <AeDustContext> above), otherwise a layer of this asset's own. Render\n * `layer` first inside your (relatively positioned) root and wrap the rest in\n * `provide`, so everything inside shares it.\n */\nfunction useAeDust(bleed?: number): { dust: AeDust; layer: ReactNode; provide: (children: ReactNode) => ReactNode } {\n  const outer = useContext(AeDustContext);\n  const [own] = useState(() => (outer ? null : aeDustEngine()));\n  const dust = outer ?? (own as ReturnType<typeof aeDustEngine>).api;\n  return {\n    dust,\n    layer: own ? <AeDustCanvas engine={own} bleed={bleed} /> : null,\n    provide: (children) => (own ? <AeDustContext.Provider value={dust}>{children}</AeDustContext.Provider> : children),\n  };\n}\n\nconst DB_TONES = {\n  ember: { bg: AE_EMBER, ink: \"#1A0E07\", line: \"transparent\" },\n  umber: { bg: AE_UMBER, ink: AE_HAZE, line: \"transparent\" },\n  ghost: { bg: \"transparent\", ink: \"currentColor\", line: \"currentColor\" },\n} as const;\n\nexport interface SandDissolveButtonProps {\n  label?: string;\n  /** What the button says once it has re-formed. */\n  doneLabel?: string;\n  /** Your action. The button blows away while it runs and re-forms with doneLabel when it resolves (or with label again if it throws). */\n  onPress?: () => Promise<void> | void;\n  tone?: keyof typeof DB_TONES;\n  /** Blow this way instead of the page's wind (degrees: 0 right, 90 away, 180 left, 270 toward you). */\n  angle?: number;\n  /** Go back to the first label after this many ms (0 = stay done). */\n  resetAfter?: number;\n  disabled?: boolean;\n  className?: string;\n  style?: CSSProperties;\n}\n\n/**\n * SandDissolveButton — a pill that turns to sand when pressed: it erodes\n * from its windward edge, the grains blow off downwind, and it builds itself\n * back out of sand blown in from upwind, now saying what happened. The dust is\n * a real snapshot of the button (fill, type, icon), so any label works.\n */\nexport function SandDissolveButton({ label = \"Reserve a night\", doneLabel = \"Reserved\", onPress, tone = \"ember\", angle, resetAfter = 0, disabled = false, className = \"\", style }: SandDissolveButtonProps) {\n  const ref = useRef<HTMLButtonElement>(null);\n  const { dust, layer, provide } = useAeDust(150);\n  const [state, setState] = useState<\"idle\" | \"busy\" | \"done\">(\"idle\");\n  const shown = useRef<\"idle\" | \"done\">(\"idle\");\n  const opts = { duration: 1300, angle, reach: 0.8 };\n\n  // Whenever the label changes under the dust, build the new one out of sand.\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el || state === \"busy\") return;\n    if (shown.current === state) return;\n    shown.current = state;\n    void dust.gather(el, opts);\n    if (state === \"done\" && resetAfter > 0) {\n      const t = window.setTimeout(() => {\n        const b = ref.current;\n        if (!b) return;\n        void dust.scatter(b, opts).then(() => setState(\"idle\"));\n      }, resetAfter);\n      return () => window.clearTimeout(t);\n    }\n    // opts is rebuilt each render from stable props.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [state, dust, resetAfter]);\n\n  const press = async () => {\n    const el = ref.current;\n    if (!el || state !== \"idle\") return;\n    setState(\"busy\");\n    const gone = dust.scatter(el, opts);\n    let ok = true;\n    try {\n      await onPress?.();\n    } catch {\n      ok = false;\n    }\n    await gone;\n    if (!ok) shown.current = \"done\";\n    setState(ok ? \"done\" : \"idle\");\n  };\n\n  const t = DB_TONES[tone];\n  const text = state === \"done\" ? doneLabel : label;\n  return (\n    <span className={\"ae-scope relative inline-block \" + className} style={style}>\n      <style>{AE_CSS}</style>\n      {layer}\n      {provide(\n        <button\n          ref={ref}\n          type=\"button\"\n          onClick={press}\n          disabled={disabled}\n          aria-busy={state === \"busy\"}\n          aria-live=\"polite\"\n          className=\"ae-press inline-flex h-12 items-center justify-center gap-2.5 whitespace-nowrap rounded-full px-6 text-[14.5px] font-medium disabled:cursor-not-allowed disabled:opacity-50\"\n          style={{ background: t.bg, color: t.ink, border: \"1px solid \" + t.line, fontFamily: AE_DISPLAY, fontStretch: \"100%\" }}\n        >\n          {state === \"done\" ? (\n            <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" aria-hidden>\n              <path d=\"M2.5 7.4 5.6 10.3 11.5 3.8\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n            </svg>\n          ) : null}\n          {text}\n          {state === \"done\" ? null : <AeArrow />}\n        </button>,\n      )}\n    </span>\n  );\n}\n","type":"registry:component","target":"components/viberdy/sand-dissolve-button.tsx"}],"meta":{"tier":"free","tags":["button","dissolve","thanos snap","particles","webgl","micro interaction","sand","disintegrate"],"href":"https://viberdy.dev/library/sand-dissolve-button","aiPrompt":"Build SandDissolveButton, a pill button that turns to sand when pressed: it erodes from its windward edge, the grains blow off downwind while its action runs, and it rebuilds itself out of sand blown in from upwind as its answer. Style: Aeolian (wind-blown sand as a material, warm, cinematic, low sun). Fixed palette, no theming: night #150E0A (deepest ground, dusk, footer), umber #2B1B12 (ink on light grounds), dune #B8743F (mid sand, marks on haze), sand #DDAA72 (lit sand, marks on night), haze #EFE2CD (the pale ground, and the colour a sandstorm turns everything), ember #E0602C (the one filled accent: CTA, knob, active; its label ink is #1A0E07). Umber on haze is 12.9:1, haze on night 15.6:1. Type: Archivo variable loaded with its wdth axis (next/font: Archivo({ subsets: [\"latin\"], axes: [\"wdth\"], variable: \"--font-ae\" })) and read as var(--font-ae, var(--font-display, \"Archivo\")); display is wide and light: weight 300-360 (320 section headings, 330 hero), font-stretch 112.5% (125% for big numerals, the brand and the footer wordmark), set with the font-stretch property (never font-variation-settings, so the canvas rasterizer can paint the same instance), tracking -0.02em to -0.04em, line-height 1-1.02 (hero 0.98); body copy 14.5-16.5px at stretch 100%. Accent: Instrument Serif italic (var(--font-ae-serif, var(--font-instrument-serif, \"Instrument Serif\")), weight 400, stretch 100%, tracking -0.01em), one word per line at most, sand on night and dune on haze. Labels and readouts: Geist Mono (var(--font-ae-mono, var(--font-geist-mono, \"Geist Mono\"))), 10-12px, uppercase, tracking 0.14-0.16em. Buttons are full pills (48px high, 14.5px, weight 500); cards are radius 22-28px. Every section root carries data-ae-ground=\"night\" | \"haze\" (a nav reads it). One focus ring through a .ae-scope root: 2px solid, offset 3px, !important so site rules cannot beat it, sand on night and umber on haze. Tabular figures on numbers; a press scales to 0.97 in 180ms; arrows slide 3px on hover; anything a frame loop writes carries data-ae-live and has transitions off. Scroll-told parts are driven by one number p (0..1) from the hook useAeDrive(ref, { mode: \"scroll\" | \"auto\", progress, still, period, measure }, render): in scroll mode a document-level capture-phase scroll listener (plus resize and a ResizeObserver, at most once per frame) measures the section and the value chases it, v += (target - v) * 0.14 per frame, snapping when within 0.0004; the nearest ancestor that really scrolls (overflow-y auto/scroll/overlay and scrollHeight > clientHeight) is found every frame, otherwise the window, so the same section works on a page and inside a scrolling box. measure is aePin (a tall pinned block: (view.top - rect.top) / (rect.height - view.height)) or aePass (a block crossing the view: (view.top + view.height - rect.top) / (rect.height + view.height)). Mode \"auto\" (previews, embeds) plays a slow loop once the section has been seen (IntersectionObserver, 200px margin): u = ((now - t0) % period) / period, p = u < 0.55 ? smoothstep(u / 0.55) : u < 0.7 ? 1 : u < 0.95 ? 1 - smoothstep((u - 0.7) / 0.25) : 0 (period default 16000 ms); the progress prop holds one moment; reduced motion renders the settled value still. render writes straight to the DOM; nothing re-renders React per frame. Pinned runs are height calc(var(--ae-vh, 100svh) * N) holding a sticky top-0 box of var(--ae-vh, 100svh), and only when mode is scroll, progress is unset and motion is not reduced; otherwise the section is height ?? var(--ae-vh, 100svh) (one screen) and its box fills it. Each file exports its component, its props interface and the AeGround / AeDriveMode types. Reduced motion (live, via useSyncExternalStore on prefers-reduced-motion) shows each section's settled state and turns every dissolve into a 220ms fade. Same-page links go through a safe-href helper (https, http, mailto, tel, same-site paths and anchors only; tabs and newlines stripped; backslashes and // refused, anything else becomes #) and glide (auto under reduced motion) to the [data-ae-anchor] target inside the nearest [data-ae-site]. React 19, Tailwind v4 with container queries (@container on the root, @3xl = 768px for the desktop layout; JS phone checks use a container width under 700px). No three.js, no images, no assets: everything is hand-written WebGL2 or 2D canvas, WebGL canvases are aria-hidden, and every WebGL part falls back to a painted gradient.\n\nThe wind (shared module store, not React state): aeWind = { angle: 205, speed: 26, subs }. angle is the way it blows TOWARD, in degrees on the ground (0 = right/east, 90 = away from you/north, 180 = left, 270 = toward you), normalised to 0..360; speed is km/h clamped 0..60. aeSetWind({ angle?, speed? }) writes and calls every subscriber; aeOnWind(fn) subscribes and returns an unsubscribe; listeners write to the DOM, never to React state. aeWindScreen(angle) is the wind as a unit vector on the screen: x = cos a, y = -sin a * 0.55 (away from you reads as rising, flattened because the ground is seen low), normalised. aeWindFrom(angle): toward = (90 - angle) mod 360, from = (toward + 180) mod 360, index round(from / 22.5) mod 16 into N NNE NE ENE E ESE SE SSE S SSW SW WSW W WNW NW NNW. aeWindText = \"From ENE · 26 km/h\" (the default 205 reads ENE). The store lives on globalThis.__aeolianWind (created once with ??=, so parts pasted as separate files still share one wind). Every engine on the page reads this one store.\n\nThe rasterizer aeRasterize(el, res) paints a piece of the page into a 2D canvas (width ceil(offsetWidth * res), same for height; res = min(2, max(1, devicePixelRatio))) so it can become sand. The scale of any transform above the element is divided out (s = getBoundingClientRect().width / offsetWidth). It walks the subtree: skips display none, visibility hidden (not on the root), opacity under 0.01 and nodes marked data-ae-skip; the element's own opacity is ignored (a hidden element can still be painted); each node's background-colour (with per-corner radii, % radii use the smaller side), borders (uniform: one stroked rounded rect; otherwise four filled sides), overflow clip (rounded), child canvases (drawImage, tainted ones left blank), inline SVGs (cloned with width, height and the computed colour, serialised to a data: URL, drawn once the Image loads; raster.ready resolves when all have), input/textarea/select values or placeholder at 50% (not checkbox, radio, range, colour, file, hidden; password, one-time-code and cc-* autocomplete fields paint as dots, never their value), and text one character at a time at the exact place the browser laid it out (a Range per character, surrogate pairs kept together, rect centred on the font's ascent plus descent, uppercase applied when text-transform says so) in the element's own font (style, weight, size, family, and font-stretch mapped from percent to the canvas keyword: 62.5% extra-condensed, 75% condensed, 87.5% semi-condensed, 100% normal, 112.5% semi-expanded, 125% expanded, 150% extra-expanded, 200% ultra-expanded; canvas letterSpacing 0). Everything is read synchronously at the call, so the snapshot is the element as it is now even if React redraws it a moment later. It handles flat fills, type, borders and canvases, which is all an Aeolian surface is.\n\nThe dust layer turns any element into sand and back. API: const { dust, layer, provide } = useAeDust(bleed?): dust.scatter(el, { duration = 1500, angle?, reach = 1 }) paints the element, hides it and blows it away; resolves once it has fully let go (the grains fly on a little longer); dust.gather(el, opts) builds an element (laid out, typically opacity 0) out of sand blown in from upwind and then shows it, resolving when done; dust.scrub(el, { angle?, reach? }) returns { set(\"scatter\" | \"gather\", c 0..1), refresh(), drop() } for a burst driven by the scroll instead of a clock (scatter 0 = whole, 1 = gone; gather 0 = gone, 1 = whole; stateless, so it runs forward, backward or scrubbed). reach is clamped 0.3..2; angle blows another way than the store's. Render `layer` first inside your relatively positioned root and wrap the rest of the children in provide(...); if an AeDustContext is already above (the Barchan site provides one), the page's layer is reused and layer renders nothing. Layer element: an aria-hidden, data-ae-skip, pointer-events-none div, sticky top-0, z-[60], h-0, w-full, holding one WebGL2 canvas (premultiplied alpha, no antialias) whose CSS height is set in JS each frame to min(the scroller's visible height, the host's height) and whose pixel size is that times min(2, dpr). With bleed (a lone button): an absolute span inset -bleed px on top and bottom and -2 bleed px on the sides, canvas filling it, so grains have room to fly; that span is display:none while idle (the frame loop shows it when a burst starts and hides it again once none remain), so its overhang never adds page scroll. Engine: two programs, a slab and grains, drawn for every active burst each frame (premultiplied blend ONE, ONE_MINUS_SRC_ALPHA), the loop running only while a timed burst is live. The snapshot is uploaded as a texture (premultiplied, linear, clamp; texImage2D is wrapped in try/catch, and a tainted snapshot deletes the texture and simply draws nothing); inline SVGs land a moment later and upload again. Release: each grain lets go at tr = release(pos) * 0.55 (AE_RELEASE) of the burst and flies for the next 0.45 (AE_FLIGHT): tau = (c - tr) / 0.45, alive while 0 < tau < 1. release(pos) = clamp(lin * 0.74 + (fbm2(pos / 46 + seed * 13) - 0.5) * 0.42 + (hash(cell + seed * 71) - 0.5) * 0.07 + 0.08, 0, 1) with lin = 0.5 + 0.5 * dot(pos - size/2, wind) / (0.5 * (|wx| W + |wy| H) + 1), so the front is windward-first, ragged and patchy; wind = aeWindScreen(angle ?? store angle). Slab: the element's own texture on a quad, discarded where its grain has let go (scatter: kept while c <= tr; gather: kept once c >= tr + 0.45); a 0.035-wide rim of loose sand (mix 55% toward #DDAA72) along the front. Grains: cell = max(1.5, sqrt(W * H / 80000)) CSS px, one point per cell (cols * rows via gl_VertexID and texelFetch of the snapshot, skipped where alpha < 0.04); size max(1, cell * dpr * (1.18 - 0.55 tau)); with hashes r = (h(cell+1.7+seed), h(cell*1.31+9.2), h(cell*0.77+4.1), h(cell*2.13+7.7)) and unit wind d, normal n: L = reach * (170 + 0.45 * max(W, H)) * (0.5 + 0.95 r.x); along = L * (0.22 tau + 0.78 tau^2); side = 38 tau sin(5.5 tau + 6.2832 r.y) (0.4 + r.z) + 46 tau^2 (r.w - 0.5); lift = -(22 + 30 r.y) tau + 14 tau sin(7 tau + 6.2832 r.x); offset = d * along + n * side + (0, lift). Gather flips tau to 1 - tau, uses -d * along and lift * 0.6 so grains arrive from upwind. Each burst caches its origin (ox, oy) every frame while its element is connected, so an element replaced mid-flight (a route opening, a form sent) keeps its grains where it was. Colour: the snapshot's own colour mixed toward sand (0.867, 0.667, 0.447) or (0.722, 0.455, 0.247) by smoothstep(0.04, 0.55, tau) * 0.78; alpha tex.a * (1 - smoothstep(0.55, 1, tau)) * (0.75 + 0.25 r.w); a soft round point (smoothstep(0.52, 0.28, distance from centre)). Lifecycle: begin(el, mode) frees an older burst on the same element, except a still-flying scatter followed by a gather, which becomes a ghost (only its grains keep flying). On a timed scatter the element is set to opacity 0 right after upload and the promise resolves at c = 0.55; on gather the element's opacity is restored when c reaches 1 (and resolves). If the layer is still compiling a burst waits up to 600 ms (the whole attach gives up after 700 ms) before falling back. Scrubbed bursts keep a ResizeObserver that marks the snapshot stale (it is painted again on the next set). No WebGL2, a failed compile or reduced motion: every burst is a 220 ms opacity fade (resolves at 230 ms). webglcontextlost: the layer turns itself off and every burst finishes, restoring or hiding its element.\n\nShip the whole dust layer with the button: useAeDust(150) gives the button its own layer (a 150px bleed so grains have room to fly) unless a page layer is already provided above it, in which case the page's is reused. Render `layer` before the button inside the relatively positioned inline-block wrapper, and wrap the button in provide(...). The grains are a real snapshot of the button (fill, type, icon), so any label works.\nBehaviour: state idle | busy | done. Burst options { duration: 1300, angle, reach: 0.8 }. On click while idle: state = busy; start dust.scatter(button, opts) (the button is hidden as soon as the snapshot is uploaded); await onPress() in a try/catch; await the scatter promise (it resolves once the button has fully let go); then state = done if onPress resolved, or back to idle (showing the first label again) if it threw. A layout effect keyed on state builds the new face: when state changes to idle or done (and not busy) it calls dust.gather(button, opts), so the button re-forms out of sand saying what happened. With resetAfter > 0 and done, after that many ms it scatters again and then returns to idle (0 keeps it done). Label = state done ? doneLabel : label; done shows a 14px tick (path M2.5 7.4 5.6 10.3 11.5 3.8, stroke 1.6, round) before the text and drops the arrow; otherwise the kit's arrow follows the label.\nLook: native button, h-12 (48px), px-6, rounded-full, gap 10px, 14.5px weight 500, whitespace nowrap, stretch 100%, press scale 0.97; tones: ember (bg #E0602C, ink #1A0E07), umber (bg #2B1B12, ink haze #EFE2CD), ghost (transparent, currentColor ink and a 1px currentColor border); the others carry a 1px transparent border so sizes match; disabled is 50% opacity, not-allowed. aria-busy while busy and aria-live polite so the new label is announced; the wind is the store's unless angle (degrees: 0 right, 90 away, 180 left, 270 toward you) is given. Reduced motion or no WebGL2 turns the sand into a 220 ms fade. Props: label (\"Reserve a night\"), doneLabel (\"Reserved\"), onPress (() => Promise<void> | void), tone (\"ember\" | \"umber\" | \"ghost\"), angle, resetAfter (0), disabled, className, style. Also export useAeDust and the AeDust API (scatter, gather, scrub) so any element can be dissolved.","license":"https://viberdy.dev/license"}}