{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"eroding-headline","type":"registry:component","title":"Eroding Headline","description":"A big statement that the wind builds out of sand as the scroll brings it in, holds while you read it, and erodes into sand as it leaves.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/eroding-headline.tsx","content":"\"use client\";\n\nimport { createContext, useContext, useEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { CSSProperties, ReactNode, RefObject } from \"react\";\n\n/**\n * ERODING HEADLINE.\n * A big statement that the wind builds out of sand as the scroll brings it in,\n * holds while you read it, and erodes into sand as it leaves.\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\".\n *\n * WIND. Every Aeolian entry reads one page-wide wind (aeWind on globalThis),\n * so separately pasted entries still turn together.\n *\n * DUST. The sand is drawn on a sticky canvas over the section, which no\n * ancestor with overflow: hidden may wrap; use overflow-x: clip instead.\n */\n\n/** The deepest ground: dusk, the footer. */\nconst AE_NIGHT = \"#150E0A\";\n\n/** Ink on the light grounds. */\nconst AE_UMBER = \"#2B1B12\";\n\nconst AE_DUNE = \"#B8743F\";\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/**\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\n/** The accent: Instrument Serif italic, one word at a time. */\nconst AE_SERIF = 'var(--font-ae-serif, var(--font-instrument-serif, \"Instrument Serif\")), \"Instrument Serif\", ui-serif, Georgia, serif';\n\n/** Labels and readouts. */\nconst AE_MONO = 'var(--font-ae-mono, var(--font-geist-mono, \"Geist Mono\")), \"Geist Mono\", ui-monospace, \"SFMono-Regular\", Menlo, monospace';\n\n/** Which ground a section sits on; the nav reads it to pick its ink. */\nexport type AeGround = \"night\" | \"haze\";\n\nconst aeClamp = (v: number, a = 0, b = 1) => (v < a ? a : v > b ? b : v);\n\n/** Where p sits between a and b, clamped to 0..1. */\nconst aeRange = (p: number, a: number, b: number) => aeClamp((p - a) / (b - a));\n\n/** Ease in and out: slow at both ends. */\nconst aeInOut = (t: number) => {\n  const x = aeClamp(t);\n  return x * x * (3 - 2 * x);\n};\n\nfunction aeReducedSub(fn: () => void) {\n  if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return () => {};\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", fn);\n  return () => mq.removeEventListener(\"change\", fn);\n}\n\nconst aeReducedSnap = () => typeof window !== \"undefined\" && typeof window.matchMedia === \"function\" && window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n/** True when the reader asked for reduced motion. Live: follows the system setting as it changes. */\nfunction useAeReduced(): boolean {\n  return useSyncExternalStore(aeReducedSub, aeReducedSnap, () => false);\n}\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\ninterface AeFrame {\n  /** The element's box, in viewport pixels. */\n  rect: DOMRect;\n  /** The visible part of whatever scrolls it: the scroller's box, or the window. */\n  view: { top: number; height: number; left: number; width: number };\n}\n\n/**\n * Run `apply` with the element's position whenever anything scrolls or\n * resizes, at most once per frame. One capture-phase scroll listener hears\n * every scroller, so the same section works on a page and inside a scrolling\n * preview. `apply` writes to the DOM; nothing here re-renders React.\n */\nfunction useAeScrollFrame<T extends HTMLElement>(ref: RefObject<T | null>, apply: (f: AeFrame) => void) {\n  const applyRef = useRef(apply);\n  useEffect(() => {\n    applyRef.current = apply;\n  });\n  useEffect(() => {\n    let raf = 0;\n    const run = () => {\n      raf = 0;\n      const el = ref.current;\n      if (!el) return;\n      const sp = aeScrollParent(el);\n      const r = sp ? sp.getBoundingClientRect() : null;\n      const view = sp && r ? { top: r.top, height: sp.clientHeight, left: r.left, width: sp.clientWidth } : { top: 0, height: window.innerHeight, left: 0, width: window.innerWidth };\n      applyRef.current({ rect: el.getBoundingClientRect(), view });\n    };\n    const kick = () => {\n      if (!raf) raf = requestAnimationFrame(run);\n    };\n    document.addEventListener(\"scroll\", kick, { capture: true, passive: true });\n    window.addEventListener(\"resize\", kick);\n    const ro = typeof ResizeObserver !== \"undefined\" ? new ResizeObserver(kick) : null;\n    if (ref.current && ro) ro.observe(ref.current);\n    run();\n    return () => {\n      document.removeEventListener(\"scroll\", kick, { capture: true });\n      window.removeEventListener(\"resize\", kick);\n      ro?.disconnect();\n      if (raf) cancelAnimationFrame(raf);\n    };\n  }, [ref]);\n}\n\n/** Progress of a tall pinned block: 0 when its top meets the view's top, 1 when its bottom meets the view's bottom. */\nfunction aePin(f: AeFrame): number {\n  const span = f.rect.height - f.view.height;\n  if (span <= 0) return f.rect.top <= f.view.top ? 1 : 0;\n  return aeClamp((f.view.top - f.rect.top) / span);\n}\n\n/** Progress of a block passing through the view: 0 as its top enters at the bottom, 1 as its bottom leaves at the top. */\nfunction aePass(f: AeFrame): number {\n  return aeClamp((f.view.top + f.view.height - f.rect.top) / (f.rect.height + f.view.height));\n}\n\n/** True once the element has come within `margin` of its scroller's view. Never goes back. */\nfunction useAeSeen<T extends HTMLElement>(ref: RefObject<T | null>, margin = \"200px\"): boolean {\n  const [seen, setSeen] = useState(false);\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || seen) return;\n    if (typeof IntersectionObserver === \"undefined\") {\n      const id = requestAnimationFrame(() => setSeen(true));\n      return () => cancelAnimationFrame(id);\n    }\n    const io = new IntersectionObserver(\n      (es) => {\n        if (es.some((e) => e.isIntersecting)) {\n          setSeen(true);\n          io.disconnect();\n        }\n      },\n      { root: aeScrollParent(el), rootMargin: margin },\n    );\n    io.observe(el);\n    return () => io.disconnect();\n  }, [ref, margin, seen]);\n  return seen;\n}\n\n/** How a scroll-told section is driven: by the page's scroll, or by its own slow loop (previews and embeds). */\nexport type AeDriveMode = \"scroll\" | \"auto\";\n\n/**\n * Drive a scene with one number from 0 to 1.\n *\n * - mode \"scroll\": the value chases the measured scroll a little each frame\n *   (v += (target - v) * 0.14), so a flicked wheel glides and stops clean.\n * - mode \"auto\": a slow loop that plays forward, holds, and plays back.\n * - `progress`: hold one moment.\n * - Reduced motion: hold `still` (the settled state).\n *\n * `render` writes to the DOM; nothing here re-renders React per frame.\n */\nfunction useAeDrive<T extends HTMLElement>(\n  ref: RefObject<T | null>,\n  { mode, progress, still, period = 16000, measure = aePin }: { mode: AeDriveMode; progress?: number; still: number; period?: number; measure?: (f: AeFrame) => number },\n  render: (p: number) => void,\n): boolean {\n  const reduced = useAeReduced();\n  const seen = useAeSeen(ref, \"200px\");\n  const renderRef = useRef(render);\n  const measureRef = useRef(measure);\n  useEffect(() => {\n    renderRef.current = render;\n    measureRef.current = measure;\n  });\n  const cur = useRef<number | null>(null);\n  const goal = useRef(0);\n  const raf = useRef(0);\n  const scrolling = mode === \"scroll\" && progress === undefined && !reduced;\n\n  useAeScrollFrame(ref, (f) => {\n    if (!scrolling) return;\n    goal.current = aeClamp(measureRef.current(f));\n    if (cur.current === null) {\n      cur.current = goal.current;\n      renderRef.current(cur.current);\n      return;\n    }\n    if (raf.current) return;\n    const step = () => {\n      const c = cur.current ?? goal.current;\n      const next = Math.abs(goal.current - c) < 0.0004 ? goal.current : c + (goal.current - c) * 0.14;\n      cur.current = next;\n      renderRef.current(next);\n      raf.current = next === goal.current ? 0 : requestAnimationFrame(step);\n    };\n    raf.current = requestAnimationFrame(step);\n  });\n  useEffect(\n    () => () => {\n      if (raf.current) cancelAnimationFrame(raf.current);\n      raf.current = 0;\n    },\n    [],\n  );\n\n  useEffect(() => {\n    if (scrolling) return;\n    if (progress !== undefined || reduced) {\n      renderRef.current(progress !== undefined ? aeClamp(progress) : still);\n      return;\n    }\n    renderRef.current(0);\n    if (!seen) return;\n    let id = 0;\n    const t0 = performance.now();\n    const loop = (now: number) => {\n      const u = ((now - t0) % period) / period;\n      // Forward over 55%, hold, back over 25%, hold.\n      const p = u < 0.55 ? aeInOut(u / 0.55) : u < 0.7 ? 1 : u < 0.95 ? 1 - aeInOut((u - 0.7) / 0.25) : 0;\n      renderRef.current(p);\n      id = requestAnimationFrame(loop);\n    };\n    id = requestAnimationFrame(loop);\n    return () => cancelAnimationFrame(id);\n  }, [scrolling, progress, reduced, still, period, seen]);\n  return reduced;\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\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\n/** \"*word*\" marks the accent: set in the italic serif, in sand. \"\\n\" breaks a line. */\nfunction aeAccent(text: string, accent: string): ReactNode[] {\n  return text.split(\"\\n\").map((line, i) => (\n    <span key={i} className=\"block\">\n      {line.split(\"*\").map((part, j) =>\n        j % 2 ? (\n          <em key={j} style={{ fontFamily: AE_SERIF, fontStyle: \"italic\", fontWeight: 400, fontStretch: \"100%\", letterSpacing: \"-0.01em\", color: accent }}>\n            {part}\n          </em>\n        ) : (\n          part\n        ),\n      )}\n    </span>\n  ));\n}\n\nexport interface ErodingHeadlineProps {\n  eyebrow?: string;\n  /** The statement: \"\\n\" breaks a line, \"*word*\" is the accent. */\n  text?: string;\n  sub?: string;\n  ground?: AeGround;\n  /** \"scroll\": built and blown away by the page's scroll. \"auto\": a slow loop (previews, embeds). */\n  mode?: AeDriveMode;\n  /** Hold one moment, 0..1 (0.5 = fully built). */\n  progress?: number;\n  className?: string;\n  style?: CSSProperties;\n}\n\n/**\n * ErodingHeadline — a big statement that the wind builds out of sand as the\n * scroll brings it in (p 0.08–0.38), holds, and blows away downwind as it\n * leaves (p 0.62–0.94). Scroll back and it rebuilds. The sand flies with the\n * page's wind.\n */\nexport function ErodingHeadline({\n  eyebrow = \"The camp\",\n  text = \"Every grain here\\narrived on the *wind*.\",\n  sub = \"A barchan is a dune shaped like a crescent, its horns pointing downwind. It walks: sand climbs its gentle back and slides down its steep face, a few metres a year. Our eleven tents follow one across the sand sea.\",\n  ground = \"haze\",\n  mode = \"scroll\",\n  progress,\n  className = \"\",\n  style,\n}: ErodingHeadlineProps) {\n  const rootRef = useRef<HTMLElement>(null);\n  const lineRef = useRef<HTMLHeadingElement>(null);\n  const { dust, layer, provide } = useAeDust();\n  const scrub = useRef<AeScrub | null>(null);\n  useEffect(() => {\n    const el = lineRef.current;\n    if (!el) return;\n    const s = dust.scrub(el, { reach: 1.2 });\n    scrub.current = s;\n    return () => {\n      s.drop();\n      scrub.current = null;\n    };\n  }, [dust]);\n  // New words or a new ground: snapshot the heading again so the sand spells what is shown.\n  useEffect(() => {\n    scrub.current?.refresh();\n  }, [text, ground]);\n  useAeDrive(rootRef, { mode, progress, still: 0.5, period: 13000, measure: aePass }, (p) => {\n    const s = scrub.current;\n    if (!s) return;\n    if (p < 0.5) s.set(\"gather\", aeRange(p, 0.08, 0.38));\n    else s.set(\"scatter\", aeRange(p, 0.62, 0.94));\n  });\n  const night = ground === \"night\";\n  const ink = night ? AE_HAZE : AE_UMBER;\n  return (\n    <section\n      ref={rootRef}\n      data-ae-ground={ground}\n      className={\"ae-scope @container relative w-full \" + className}\n      style={{ background: night ? AE_NIGHT : AE_HAZE, color: ink, fontFamily: AE_DISPLAY, ...style }}\n    >\n      <style>{AE_CSS}</style>\n      {layer}\n      {provide(\n        <div className=\"flex min-h-[86svh] flex-col justify-center px-5 py-[18cqh] @3xl:px-[7cqw]\">\n          <p className=\"mb-8 flex items-center gap-3 text-[11px] uppercase tracking-[0.16em]\" style={{ fontFamily: AE_MONO, opacity: 0.72 }}>\n            <span aria-hidden className=\"inline-block h-px w-8\" style={{ background: \"currentColor\" }} />\n            {eyebrow}\n          </p>\n          <h2 ref={lineRef} className=\"m-0 max-w-[16ch] text-balance font-light leading-[1.02] tracking-[-0.03em]\" style={{ fontSize: \"clamp(44px, 8.4cqw, 148px)\", fontStretch: \"112.5%\", fontWeight: 320 }}>\n            {aeAccent(text, night ? AE_SAND : AE_DUNE)}\n          </h2>\n          {sub ? (\n            <p className=\"mt-10 max-w-[38rem] text-[16.5px] leading-[1.6] @3xl:ml-[34cqw]\" style={{ fontStretch: \"100%\", opacity: 0.78 }}>\n              {sub}\n            </p>\n          ) : null}\n        </div>,\n      )}\n    </section>\n  );\n}\n","type":"registry:component","target":"components/viberdy/eroding-headline.tsx"}],"meta":{"tier":"free","tags":["text effect","scroll animation","dissolve","particles","headline","sand","webgl","typography"],"href":"https://viberdy.dev/library/eroding-headline","aiPrompt":"Build ErodingHeadline, a big statement that the wind builds out of sand as the scroll brings it in, holds while it is read, and erodes into sand as it leaves. 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\nDrive: useAeDrive with measure aePass, still 0.5 (fully built), period 13000; the headline is snapshotted once and scrubbed through dust.scrub(h2, { reach: 1.2 }): for p < 0.5, scrub.set(\"gather\", range(p, 0.08, 0.38)); for p >= 0.5, scrub.set(\"scatter\", range(p, 0.62, 0.94)). So it gathers between 8% and 38% of the section's pass, holds, scatters between 62% and 94%, and scrolling back runs the wind backwards; the sand flies with the page's wind (the store), windward side first. A useEffect on [text, ground] calls scrub.refresh() so the snapshot is painted again when the words or the ground change. The h2 is real text all along; only its opacity is zeroed while it is sand. The section owns a dust layer (useAeDust) unless a page layer is provided.\nText: \"\\n\" breaks a line (each line a block span) and \"*word*\" marks the accent, set in Instrument Serif italic (weight 400, stretch 100%, tracking -0.01em), sand on night and dune on haze. Default: \"Every grain here\" / \"arrived on the *wind*.\" h2: max 16ch, text-balance, clamp(44px, 8.4cqw, 148px), weight 320, stretch 112.5%, tracking -0.03em, leading 1.02. Section: haze ground (umber ink; the ground prop swaps to night with haze ink), min-height 86svh, flex column centred, padding 20px sides and 18cqh vertical (7cqw sides at the 768px container step); a mono 11px eyebrow \"The camp\" at 72% with a 32px rule above the h2 (margin 32px); a sub at 16.5px / 1.6, max 38rem, 78% opacity, margin-top 40px, indented 34cqw from @3xl: \"A barchan is a dune shaped like a crescent, its horns pointing downwind. It walks: sand climbs its gentle back and slides down its steep face, a few metres a year. Our eleven tents follow one across the sand sea.\" Reduced motion shows it whole. Props: eyebrow, text, sub, ground (\"haze\" | \"night\"), mode (\"scroll\" | \"auto\"), progress (0.5 = fully built), className, style.","license":"https://viberdy.dev/license"}}