{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "foil-block",
  "title": "Foil Block",
  "description": "A full-width closing CTA band built on hot foil blocking — a die strikes the headline's terminal word and a rule frame around the primary button, and only transfers foil where temperature AND pressure both clear a hard threshold at once. The spent web indexes forward after every strike, carrying a fading negative record of the last nine, so the band keeps moving with zero input.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/foil-block/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// FoilBlock — a full-width closing CTA band built on hot foil blocking\n// (hot stamping). A heated die presses a metallised polyester web onto the\n// headline's terminal word and a rule frame around the primary button; the\n// web is a stack (PET carrier / release coat / lacquer / vacuum-metallised\n// aluminium / heat-activated size) that only transfers above a hard DUAL\n// threshold — temperature AND pressure both have to clear their floor in the\n// same pixel, or nothing sticks there. That is this component's whole\n// identity and what separates it from its nearest neighbour in this round,\n// structure-quoin-lockup: quoin-lockup's variable is PRESSURE distributing a\n// layout (composed metal type, a bed the whole page rests on); foil-block's\n// variable is a TRANSFER THRESHOLD deciding whether a mark takes AT ALL, and\n// it never renders a bed of type — only a headline word and a button frame\n// get struck.\n//\n// MECHANIC: die temperature is 118degC everywhere inside the die's own\n// outline, falling linearly to 109degC over the outer 6px of that outline (a\n// die perimeter runs cold in the real process). Contact pressure is a\n// continuous field of 3 drifting gaussian lobes, +/-0.11MPa about a 0.42MPa\n// mean, drifting at 0.014Hz — nothing about it resets between strikes, so\n// two consecutive strikes fail in different places. A pixel receives foil\n// iff T >= 96degC AND p >= 0.34MPa: a hard threshold, no soft ramp, which is\n// what produces the ragged cold-edge void pattern instead of a clean fill.\n// The mask is evaluated once, at dwell end, and held through peel/index/idle\n// — it does not re-evaluate every frame.\n//\n// STRIKE CYCLE, 4.60s: approach 420ms (die descends, ease-out cubic) -> dwell\n// 300ms (the die closes its last 1.5px of travel under load; mask evaluated\n// at dwell end, t=720ms) -> peel 280ms (die lifts the whole 280ms; the last\n// 90ms the carrier still clings and the foil edge stretches as hairline tail\n// filaments) -> web index 3600ms, the ENTIRE remainder of the cycle. The web\n// can only be held still while the die is down, so it feeds continuously for\n// every millisecond the die is up and advances exactly one hole pitch per\n// cycle. An earlier cut indexed for 1550ms and then sat idle for 2050ms,\n// which is what read as the band stopping and starting again: 45% of every\n// cycle had no moving part at all. The spent web is this component's resting\n// loop: it carries the negative record of the last strikes (alpha 1.0 ->\n// 0.5) and keeps indexing with zero input, which is what makes the band\n// alive at rest. It is deliberately NOT the only thing moving — an\n// unconditional specular band sweeps at 0.19 cycles/s across every\n// transferred area regardless of the strike cycle, foil's directional\n// vacuum-metallised anisotropy, and the make-ready pressure field differs\n// strike to strike, so the mature (already-struck) region is never a still\n// image sliding under a moving band.\n//\n// TRANSFERRED FOIL IS PERMANENT. It is on the stock; the next impression\n// cannot take it back off. An earlier cut called clear() on the mask at\n// every approach, so the struck word dropped to bare unfoiled type for the\n// 720ms of approach + dwell and then snapped back — the visible restart.\n// What a re-strike changes is WHERE the voids fall, because the pressure\n// field has drifted, so a strike now reads as the void pattern re-rolling\n// under a descending die rather than as the word blinking out.\n//\n// ACCESSIBILITY: canvas is aria-hidden. The headline is a real, always-legible\n// heading — its terminal word is given color:transparent ONLY once a 2D\n// context is confirmed live, so a canvas failure leaves plain readable text,\n// never a gap. The button is a real <button>/<a>, --ns-accent only touches\n// its DOM fill and focus ring, never the specular band (which moves in\n// luminance only, as a delta against the local foil value, so it reads\n// identically in both themes without a direction flip). Activation (mouse\n// and keyboard alike) restarts the strike cycle as one out-of-cadence beat\n// of feedback; navigation is never gated on it finishing.\n// ---------------------------------------------------------------------------\n\nconst CYCLE_MS = 4600;\nconst APPROACH_MS = 420;\nconst DWELL_MS = 300;\nconst PEEL_MS = 280;\nconst TAIL_MS = 90; // overlaps the final 90ms of peel, not additive\n// the web feeds through every millisecond the die is off the stock: from the\n// end of peel to the end of the cycle, no idle tail\nconst INDEX_MS = 4600 - (420 + 300 + 280); // 3600\nconst DWELL_END_MS = APPROACH_MS + DWELL_MS; // 720\nconst PEEL_END_MS = DWELL_END_MS + PEEL_MS; // 1000\nconst INDEX_END_MS = PEEL_END_MS + INDEX_MS; // 4600 === CYCLE_MS\n// the last 1.5px of die travel is closed during dwell rather than at the end\n// of approach, so the die is never parked motionless while it is down\nconst DWELL_BITE_PX = 1.5;\n\nconst DIE_TEMP_C = 118;\nconst DIE_EDGE_LOSS_C = 9;\nconst DIE_EDGE_BAND_PX = 6;\nconst TEMP_THRESHOLD_C = 96;\nconst PRESSURE_MEAN_MPA = 0.42;\nconst PRESSURE_AMP_MPA = 0.09;\n// 0.26 against a 0.42 mean: the press is set up to make ready, so the normal\n// outcome is a mostly-solid strike with ragged local voids where a lobe digs\n// under the floor — never the all-or-nothing whole-word miss a 0.34 floor\n// produced, which left the headline unstruck for tens of seconds at a time.\nconst PRESSURE_THRESHOLD_MPA = 0.26;\nconst PRESSURE_DRIFT_HZ = 0.07;\n\nconst GHOST_COUNT = 5;\nconst GHOST_ALPHA_NEW = 1.0;\nconst GHOST_ALPHA_OLD = 0.5;\n// the web's position is carried as a single monotonic float in units of hole\n// pitch (one pitch per strike cycle), so hole spacing, fade and speed are all\n// one number and the ribbon can never jump a pitch at a cycle boundary\n\nconst SPECULAR_HZ = 0.19;\nconst SPECULAR_L_DELTA = 0.16;\n\nconst FRAME_GAP_PX = 8; // foil rule offset outside the button's own box\nconst FRAME_STROKE_PX = 3;\n\n// Reduced-motion / gate freeze frame: named directly by the spec, and the\n// composed frame is assembled explicitly (not derived from the generic\n// per-frame phase math) so every element the spec calls out — transferred\n// foil, max-extension tail filaments, a specular band centred on the CTA\n// word, nine visible ghosts — is guaranteed present in the one frame anyone\n// ever sees under prefers-reduced-motion.\nconst STATIC_TIME_S = 1.14;\n\ntype RGB = [number, number, number];\n\nfunction parseHex(raw: string): RGB | null {\n  const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(raw.trim());\n  if (!m) return null;\n  let h = m[1];\n  if (h.length === 3) h = h.split(\"\").map((c) => c + c).join(\"\");\n  const n = parseInt(h, 16);\n  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];\n}\n\nfunction mixRGB(a: RGB, b: RGB, t: number): RGB {\n  return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];\n}\n\nfunction toCss([r, g, b]: RGB, a = 1): string {\n  return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${a})`;\n}\n\nfunction easeOutCubic(t: number): number {\n  const p = Math.min(1, Math.max(0, t));\n  return 1 - Math.pow(1 - p, 3);\n}\n\n// Separable box blur over a single alpha channel, used once per raster (on\n// resize / text change / theme change), never per frame: it turns a sharp\n// die-outline mask into a distance-to-edge proxy for the 6px cold-edge band,\n// the same trick weld-pool uses for its glyph bevel/contact-shadow skirt.\nfunction boxBlur(src: Uint8ClampedArray, w: number, h: number, radius: number, passes: number) {\n  let a: Uint8ClampedArray<ArrayBufferLike> = src;\n  let b: Uint8ClampedArray<ArrayBufferLike> = new Uint8ClampedArray(w * h);\n  const r = Math.max(1, Math.round(radius));\n  for (let pass = 0; pass < passes; pass++) {\n    for (let y = 0; y < h; y++) {\n      const row = y * w;\n      let sum = 0;\n      for (let x = -r; x <= r; x++) sum += a[row + Math.min(w - 1, Math.max(0, x))];\n      const norm = 2 * r + 1;\n      for (let x = 0; x < w; x++) {\n        b[row + x] = sum / norm;\n        const out = row + Math.min(w - 1, Math.max(0, x - r));\n        const inc = row + Math.min(w - 1, Math.max(0, x + r + 1));\n        sum += a[inc] - a[out];\n      }\n    }\n    [a, b] = [b, a];\n    for (let x = 0; x < w; x++) {\n      let sum = 0;\n      for (let y = -r; y <= r; y++) sum += a[Math.min(h - 1, Math.max(0, y)) * w + x];\n      const norm = 2 * r + 1;\n      for (let y = 0; y < h; y++) {\n        b[y * w + x] = sum / norm;\n        const out = Math.min(h - 1, Math.max(0, y - r)) * w + x;\n        const inc = Math.min(h - 1, Math.max(0, y + r + 1)) * w + x;\n        sum += a[inc] - a[out];\n      }\n    }\n    [a, b] = [b, a];\n  }\n  return a;\n}\n\n// A struck die region: a small offscreen raster of the die's own outline\n// (glyph text, or a stroked rule frame), plus the fields derived from it —\n// sharp alpha (the outline itself), an edge-distance proxy (for the 9degC\n// cold-edge falloff) and the resulting foil transfer mask, which is only\n// recomputed at each strike's dwell end.\nclass DieRegion {\n  w = 0;\n  h = 0;\n  sharp: Uint8ClampedArray = new Uint8ClampedArray(0);\n  edgeFrac: Float32Array = new Float32Array(0);\n  mask: Uint8ClampedArray = new Uint8ClampedArray(0); // 0..255, transferred foil alpha\n  lastMaskAt = -Infinity; // absolute sim seconds of the strike this mask belongs to\n  seedX: number;\n  seedY: number;\n\n  constructor(seed: number) {\n    this.seedX = Math.sin(seed * 12.9898) * 43758.5453 % 1;\n    this.seedY = Math.sin(seed * 78.233) * 12543.998 % 1;\n  }\n\n  setOutline(w: number, h: number, alpha: Uint8ClampedArray, dpr: number) {\n    this.w = w;\n    this.h = h;\n    this.sharp = alpha;\n    const blurred = boxBlur(Uint8ClampedArray.from(alpha), w, h, DIE_EDGE_BAND_PX * dpr, 2);\n    const edge = new Float32Array(w * h);\n    for (let i = 0; i < alpha.length; i++) {\n      edge[i] = alpha[i] > 4 ? Math.min(1, Math.max(0, 1 - blurred[i] / 255)) : 0;\n    }\n    this.edgeFrac = edge;\n    this.mask = new Uint8ClampedArray(w * h);\n  }\n\n  // Sample the drifting 3-lobe pressure field at (u,v) in 0..1 local coords.\n  private pressureAt(u: number, v: number, tAbs: number): number {\n    // Each lobe drifts at its OWN rate (1 : 0.63 : 1.41). Sharing one\n    // frequency let all three go negative together, which produced whole\n    // strikes that transferred nothing at all — a legitimate outcome for the\n    // press, a dead frame for the component. Incommensurate rates keep the\n    // failures local: some part of the die always clears the floor.\n    const lobes: Array<[number, number, number, number]> = [\n      [0.22 + this.seedX * 0.1, 0.5, 0, 1],\n      [0.5, 0.28 + this.seedY * 0.1, 2.1, 0.63],\n      [0.78 - this.seedX * 0.06, 0.62, 4.4, 1.41],\n    ];\n    let p = PRESSURE_MEAN_MPA;\n    for (const [cx, cy, phase, rate] of lobes) {\n      const dx = u - cx;\n      const dy = v - cy;\n      const g = Math.exp(-(dx * dx + dy * dy) / (2 * 0.16 * 0.16));\n      p += PRESSURE_AMP_MPA * g * Math.sin(2 * Math.PI * PRESSURE_DRIFT_HZ * rate * tAbs + phase);\n    }\n    return p;\n  }\n\n  // Evaluate the transfer rule once and freeze it as this strike's mask.\n  strike(tAbs: number) {\n    this.lastMaskAt = tAbs;\n    const { w, h, sharp, edgeFrac, mask } = this;\n    for (let y = 0; y < h; y++) {\n      const v = h > 1 ? y / (h - 1) : 0;\n      for (let x = 0; x < w; x++) {\n        const i = y * w + x;\n        if (sharp[i] < 4) {\n          mask[i] = 0;\n          continue;\n        }\n        const u = w > 1 ? x / (w - 1) : 0;\n        const temp = DIE_TEMP_C - DIE_EDGE_LOSS_C * edgeFrac[i];\n        const pressure = this.pressureAt(u, v, tAbs);\n        mask[i] = temp >= TEMP_THRESHOLD_C && pressure >= PRESSURE_THRESHOLD_MPA ? sharp[i] : 0;\n      }\n    }\n  }\n\n  clear() {\n    this.mask.fill(0);\n  }\n}\n\nexport interface FoilBlockProps {\n  /** small mono label above the headline */\n  eyebrow?: string;\n  /** headline text — its LAST word is the one the die strikes */\n  headline?: string;\n  /** primary CTA label */\n  primaryLabel?: string;\n  /** primary CTA href; renders a <button> when omitted, an <a> when set */\n  primaryHref?: string;\n  /** secondary link label */\n  secondaryLabel?: string;\n  secondaryHref?: string;\n  className?: string;\n}\n\nexport function FoilBlock({\n  eyebrow = \"GET STARTED\",\n  headline = \"Every release lands finished\",\n  primaryLabel = \"Start building\",\n  primaryHref,\n  secondaryLabel = \"Read the docs\",\n  secondaryHref = \"#\",\n  className = \"\",\n}: FoilBlockProps) {\n  const uid = useId();\n  const sectionRef = useRef<HTMLElement | null>(null);\n  const canvasRef = useRef<HTMLCanvasElement | null>(null);\n  const wordRef = useRef<HTMLSpanElement | null>(null);\n  const buttonRef = useRef<HTMLButtonElement | HTMLAnchorElement | null>(null);\n\n  const headlineRef = useRef(headline);\n  headlineRef.current = headline;\n\n  useEffect(() => {\n    const section = sectionRef.current;\n    const canvas = canvasRef.current;\n    const wordEl = wordRef.current;\n    const buttonEl = buttonRef.current;\n    if (!section || !canvas || !wordEl || !buttonEl) return;\n\n    const ctx = canvas.getContext(\"2d\", { alpha: true });\n    if (!ctx) return; // no 2D context: DOM headline/button stay fully visible, nothing else to do\n\n    let disposed = false;\n    let running = false;\n    let raf = 0;\n    let dpr = 1;\n    let cssW = 0;\n    let cssH = 0;\n    let staticMode = false;\n    let lastMs = 0;\n    let simTime = 0; // seconds, integrated with a clamp so a paused tab can't teleport it\n    let cycleAnchor = 0; // simTime at which the current 4.6s cycle began\n\n    // adaptive backing-store scale: insurance for a slow machine, never a\n    // device heuristic and never gated on frame count — only on a sustained\n    // measured stretch over budget, with a much longer clean stretch before\n    // climbing back so a marginal surface can't oscillate.\n    const SCALES = [1, 0.75, 0.55];\n    const BUDGET_OVER_MS = 24;\n    let scaleIdx = 0;\n    let frameEma = 16.7;\n    let overMs = 0;\n    let underMs = 0;\n    let upWindow = 8000;\n\n    const word = new DieRegion(1.7);\n    const frame = new DieRegion(5.3);\n\n    // ghost history: each entry remembers the word mask + word raster size at\n    // the moment its strike completed and the web position (in hole pitches)\n    // it was punched at, so its age is just webAdvance - bornAt.\n    type Ghost = { sharp: Uint8ClampedArray; mask: Uint8ClampedArray; w: number; h: number; bornAt: number };\n    let ghosts: Ghost[] = [];\n    // web travel measured in hole pitches. webCarry banks everything fed\n    // before the current cycleAnchor so an out-of-cadence activation strike\n    // cannot wind the web backwards; webRel is the amount fed since it.\n    let webCarry = 0;\n    let webRel = 0;\n\n    let cLive: RGB = [0.06, 0.06, 0.06];\n    let cDim: RGB = [0.4, 0.4, 0.4];\n    let cBright: RGB = [0.94, 0.94, 0.94];\n    let cVoid: RGB = [0.06, 0.06, 0.06];\n    let cBorder: RGB = [0.18, 0.18, 0.18];\n    let dark = true;\n\n    const readTokens = () => {\n      const cs = getComputedStyle(document.documentElement);\n      const bg = parseHex(cs.getPropertyValue(\"--background\")) ?? [1, 1, 1];\n      const muted = parseHex(cs.getPropertyValue(\"--ns-muted\")) ?? [0.55, 0.55, 0.55];\n      cBorder = parseHex(cs.getPropertyValue(\"--border\")) ?? [0.18, 0.18, 0.18];\n      dark = bg[0] + bg[1] + bg[2] < 1.5;\n      // transferred foil: L 0.94 on the fg/bg ramp in dark theme (bright metal\n      // on a dark ground), L 0.22 in light theme (dark burnish on pale stock)\n      // — not a compromise, it's what pigment foil on white board looks like.\n      cLive = dark ? mixRGB(bg, [1, 1, 1], 0.94) : mixRGB(bg, [0, 0, 0], 0.78);\n      cDim = mixRGB(bg, muted, 0.55); // untransferred / cold-edge void, blended toward stock\n      cBright = dark ? [1, 1, 1] : [0, 0, 0];\n      cVoid = bg;\n    };\n    readTokens();\n\n    // -- rasterize the two die outlines -----------------------------------\n    const rasterWord = document.createElement(\"canvas\");\n    const rasterFrame = document.createElement(\"canvas\");\n\n    const rasterizeWord = () => {\n      const wr = wordEl.getBoundingClientRect();\n      const w = Math.max(8, Math.min(600, Math.round(wr.width * dpr)));\n      const h = Math.max(8, Math.min(240, Math.round(wr.height * dpr)));\n      rasterWord.width = w;\n      rasterWord.height = h;\n      const rc = rasterWord.getContext(\"2d\", { willReadFrequently: true });\n      if (!rc || wr.width < 2 || wr.height < 2) return;\n      const cs = getComputedStyle(wordEl);\n      rc.clearRect(0, 0, w, h);\n      rc.fillStyle = \"#fff\";\n      rc.font = `${cs.fontWeight} ${parseFloat(cs.fontSize) * dpr}px ${cs.fontFamily}`;\n      rc.textAlign = \"left\";\n      rc.textBaseline = \"alphabetic\";\n      const words = headlineRef.current.trim().split(/\\s+/);\n      const last = words[words.length - 1] ?? \"\";\n      const baseline = h - (h - parseFloat(cs.fontSize) * dpr) / 2 - parseFloat(cs.fontSize) * dpr * 0.18;\n      rc.fillText(last, 0, baseline);\n      const img = rc.getImageData(0, 0, w, h).data;\n      const alpha = new Uint8ClampedArray(w * h);\n      for (let i = 0, j = 3; i < alpha.length; i++, j += 4) alpha[i] = img[j];\n      word.setOutline(w, h, alpha, dpr);\n      word.clear();\n      word.lastMaskAt = -Infinity;\n      // The DOM word stays visible and stays the base state: unstruck type.\n      // The canvas paints ONLY the pixels foil actually transferred to, in\n      // register on top of it, so a failed transfer reads as plain type\n      // showing through the metal rather than as a hole in the headline.\n    };\n\n    const rasterizeFrame = () => {\n      const br = buttonEl.getBoundingClientRect();\n      const sr = section.getBoundingClientRect();\n      const gap = FRAME_GAP_PX;\n      const localLeft = br.left - sr.left - gap;\n      const localTop = br.top - sr.top - gap;\n      const fw = Math.max(8, Math.min(900, Math.round((br.width + gap * 2) * dpr)));\n      const fh = Math.max(8, Math.min(300, Math.round((br.height + gap * 2) * dpr)));\n      rasterFrame.width = fw;\n      rasterFrame.height = fh;\n      const rc = rasterFrame.getContext(\"2d\", { willReadFrequently: true });\n      if (!rc) return;\n      rc.clearRect(0, 0, fw, fh);\n      rc.strokeStyle = \"#fff\";\n      rc.lineWidth = FRAME_STROKE_PX * dpr;\n      rc.strokeRect(\n        (FRAME_STROKE_PX * dpr) / 2,\n        (FRAME_STROKE_PX * dpr) / 2,\n        fw - FRAME_STROKE_PX * dpr,\n        fh - FRAME_STROKE_PX * dpr\n      );\n      const img = rc.getImageData(0, 0, fw, fh).data;\n      const alpha = new Uint8ClampedArray(fw * fh);\n      for (let i = 0, j = 3; i < alpha.length; i++, j += 4) alpha[i] = img[j];\n      frame.setOutline(fw, fh, alpha, dpr);\n      frame.clear();\n      frame.lastMaskAt = -Infinity;\n      frameGeom = { localLeft, localTop, fw: fw / dpr, fh: fh / dpr };\n    };\n\n    let frameGeom = { localLeft: 0, localTop: 0, fw: 0, fh: 0 };\n    let wordGeom = { localLeft: 0, localTop: 0, ww: 0, wh: 0 };\n    // the die-region rasters are baked 1:1 against the CURRENT backing-store\n    // dpr, so an adaptive quality step (which changes dpr) has to trigger a\n    // re-raster or the region buffer and the canvas device pixels drift apart\n    let rasterDpr = 1;\n\n    const syncWordGeom = () => {\n      const wr = wordEl.getBoundingClientRect();\n      const sr = section.getBoundingClientRect();\n      wordGeom = {\n        localLeft: wr.left - sr.left,\n        localTop: wr.top - sr.top,\n        ww: wr.width,\n        wh: wr.height,\n      };\n    };\n\n    // -- draw a die region's current mask as foil, blended with cold voids.\n    // A die region is rasterized 1:1 in device pixels against the main\n    // canvas (same dpr), so compositing is a single putImageData rather than\n    // a per-pixel fillRect loop — the difference between one call and tens\n    // of thousands on a headline-sized region.\n    const regionBuf = new Map<DieRegion, Uint8ClampedArray>();\n    let stripBuf: Uint8ClampedArray | null = null;\n    const blitRegion = (\n      region: DieRegion,\n      localLeft: number,\n      localTop: number,\n      alphaMul: number,\n      specularT: number,\n      showVoid: boolean,\n    ) => {\n      if (region.w < 1 || region.h < 1) return;\n      let rgba = regionBuf.get(region);\n      if (!rgba || rgba.length !== region.w * region.h * 4) {\n        rgba = new Uint8ClampedArray(region.w * region.h * 4);\n        regionBuf.set(region, rgba);\n      }\n      const cosA = Math.cos((31 * Math.PI) / 180);\n      for (let y = 0; y < region.h; y++) {\n        const v = y / region.h;\n        for (let x = 0; x < region.w; x++) {\n          const i = y * region.w + x;\n          const j = i * 4;\n          const sharpA = region.sharp[i];\n          if (sharpA < 4) {\n            rgba[j + 3] = 0;\n            continue;\n          }\n          const foiled = region.mask[i] > 4;\n          if (!foiled && !showVoid) {\n            // untransferred: the DOM type under the canvas is the void\n            rgba[j + 3] = 0;\n            continue;\n          }\n          let col: RGB;\n          if (foiled) {\n            col = cLive;\n            // specular anisotropy: a moving band +/-0.16 L, luminance-only delta\n            const bandCoord = (x / region.w + v) * cosA;\n            const phase = bandCoord - specularT;\n            const dist = Math.abs((((phase % 1) + 1) % 1) - 0.5) * 2; // 0 at band centre\n            const inBand = Math.max(0, 1 - dist * 4);\n            const delta = inBand * SPECULAR_L_DELTA * (dark ? 1 : -1);\n            col = mixRGB(col, delta > 0 ? cBright : cVoid, Math.abs(delta));\n          } else {\n            // cold-edge / make-ready void: blended 0.5 toward stock, both themes\n            col = mixRGB(cDim, cVoid, 0.5);\n          }\n          rgba[j] = col[0] * 255;\n          rgba[j + 1] = col[1] * 255;\n          rgba[j + 2] = col[2] * 255;\n          rgba[j + 3] = (sharpA / 255) * alphaMul * 255;\n        }\n      }\n      const img = new ImageData(rgba as Uint8ClampedArray<ArrayBuffer>, region.w, region.h);\n      ctx.putImageData(img, Math.round(localLeft * dpr), Math.round(localTop * dpr));\n    };\n\n    // -- the spent web: a metallised ribbon crossing the top of the band,\n    // carrying the NEGATIVE of the last nine strikes. Foil that transferred\n    // is foil the web no longer has, so each past strike is a word-shaped\n    // HOLE punched through the ribbon, indexing left as the web feeds. A\n    // repeated positive of the word read as broken text; a holed ribbon\n    // reads as the material the metal came from.\n    const RIBBON_H = 34;\n    const RIBBON_TOP = 14;\n    const drawWebRibbon = (webAdvance: number, specularT: number) => {\n      if (cssH < RIBBON_TOP + RIBBON_H + 40) return;\n      const stripWDev = Math.max(1, Math.round(cssW * dpr));\n      const stripHDev = Math.max(1, Math.round(RIBBON_H * dpr));\n      if (!stripBuf || stripBuf.length !== stripWDev * stripHDev * 4) {\n        stripBuf = new Uint8ClampedArray(stripWDev * stripHDev * 4);\n      }\n      const buf = stripBuf;\n      // 1 — the carrier itself: dim metal, with a slow specular sweep along\n      // it so the ribbon reads as a reflective web rather than a grey bar\n      const base = mixRGB(cLive, cDim, dark ? 0.72 : 0.45);\n      for (let y = 0; y < stripHDev; y++) {\n        const v = y / stripHDev;\n        // PET carrier is slightly domed across its width — brighter mid-band\n        const dome = 1 - Math.abs(v - 0.5) * 0.9;\n        for (let x = 0; x < stripWDev; x++) {\n          const phase = (x / stripWDev) * 2.4 - specularT;\n          const dist = Math.abs((((phase % 1) + 1) % 1) - 0.5) * 2;\n          const band = Math.max(0, 1 - dist * 3.2) * SPECULAR_L_DELTA;\n          const col = mixRGB(base, dark ? cBright : cVoid, band * dome + 0.06 * dome);\n          const j = (y * stripWDev + x) * 4;\n          buf[j] = col[0] * 255;\n          buf[j + 1] = col[1] * 255;\n          buf[j + 2] = col[2] * 255;\n          buf[j + 3] = 255;\n        }\n      }\n      // 2 — carrier edges: two hairlines, so the ribbon has a top and a\n      // bottom instead of bleeding into the band\n      for (const y of [0, stripHDev - 1]) {\n        for (let x = 0; x < stripWDev; x++) {\n          const j = (y * stripWDev + x) * 4;\n          buf[j] = cBorder[0] * 255;\n          buf[j + 1] = cBorder[1] * 255;\n          buf[j + 2] = cBorder[2] * 255;\n          buf[j + 3] = 255;\n        }\n      }\n      // 3 — punch the holes, newest at the right, indexing left\n      const insetDev = Math.round(8 * dpr);\n      for (let gi = 0; gi < ghosts.length; gi++) {\n        const ghost = ghosts[gi];\n        // age in hole pitches — a continuous float, so a hole slides rather\n        // than stepping, and its own fade slides with it\n        const age = webAdvance - ghost.bornAt;\n        if (age < 0 || age >= GHOST_COUNT || ghost.w < 1) continue;\n        // a hole never heals: age only softens its edge as the web stretches\n        const alpha =\n          GHOST_ALPHA_NEW - (age / (GHOST_COUNT - 1)) * (GHOST_ALPHA_NEW - GHOST_ALPHA_OLD);\n        const targetHDev = stripHDev - insetDev * 2;\n        const scale = targetHDev / Math.max(1, ghost.h);\n        const drawWDev = Math.max(1, Math.round(ghost.w * scale));\n        // pitch is derived from the hole's own width: the web advances by one\n        // label plus a fixed gap, so two strikes can never overprint\n        const pitchDev = drawWDev + Math.round(52 * dpr);\n        const xDevStart = Math.round((cssW - 60) * dpr - drawWDev - age * pitchDev);\n        if (xDevStart + drawWDev < 0 || xDevStart > stripWDev) continue;\n        for (let dy = insetDev; dy < stripHDev - insetDev; dy++) {\n          const sy = Math.min(ghost.h - 1, Math.floor(((dy - insetDev) / targetHDev) * ghost.h));\n          for (let dx = 0; dx < drawWDev; dx++) {\n            const destX = xDevStart + dx;\n            if (destX < 0 || destX >= stripWDev) continue;\n            const si = sy * ghost.w + Math.min(ghost.w - 1, Math.floor((dx / drawWDev) * ghost.w));\n            if (ghost.mask[si] <= 4) continue; // only transferred pixels left a hole\n            const j = (dy * stripWDev + destX) * 4;\n            const a = alpha * (ghost.mask[si] / 255);\n            const col = mixRGB(\n              [buf[j] / 255, buf[j + 1] / 255, buf[j + 2] / 255],\n              cVoid,\n              Math.min(1, a),\n            );\n            buf[j] = col[0] * 255;\n            buf[j + 1] = col[1] * 255;\n            buf[j + 2] = col[2] * 255;\n          }\n        }\n      }\n      const img = new ImageData(buf as Uint8ClampedArray<ArrayBuffer>, stripWDev, stripHDev);\n      ctx.putImageData(img, 0, Math.round(RIBBON_TOP * dpr));\n    };\n\n    // -- tail filaments: hairline strands parting off the trailing edge ----\n    const drawTailFilaments = (progress: number, seed: number) => {\n      if (progress <= 0) return;\n      const wr = wordGeom;\n      if (wr.ww < 2) return;\n      ctx.save();\n      ctx.strokeStyle = toCss(cLive, 0.7 * (1 - progress * 0.2));\n      ctx.lineWidth = Math.max(0.5, 0.5 * dpr);\n      const n = 3 + Math.floor((Math.sin(seed) * 0.5 + 0.5) * 4); // 3-7 filaments\n      for (let i = 0; i < n; i++) {\n        const fx = wr.localLeft + wr.ww * ((i + 0.5) / n);\n        const fy0 = wr.localTop + wr.wh * 0.85;\n        const stretch = 2 * progress * dpr; // <= 2px\n        ctx.beginPath();\n        ctx.moveTo(fx * dpr, fy0 * dpr);\n        ctx.lineTo(fx * dpr + Math.sin(seed + i) * stretch, (fy0 - stretch) * dpr);\n        ctx.stroke();\n      }\n      ctx.restore();\n    };\n\n    // -- die bracket: a hairline registration frame that descends onto the\n    // word on approach (ease-out cubic) and lifts clear on peel — the only\n    // directly \"mechanical\" motion in the piece, everything else is the\n    // field itself changing.\n    const drawDieBracket = (descentPx: number) => {\n      const wr = wordGeom;\n      if (wr.ww < 2 || descentPx < 0) return;\n      // descentPx is distance the die still has to travel to reach contact,\n      // so the bracket sits ABOVE the word by that much and closes to zero\n      const pad = 6;\n      const x0 = (wr.localLeft - pad) * dpr;\n      const y0 = (wr.localTop - pad - descentPx) * dpr;\n      const x1 = (wr.localLeft + wr.ww + pad) * dpr;\n      const y1 = (wr.localTop + wr.wh + pad - descentPx) * dpr;\n      const arm = 8 * dpr;\n      ctx.save();\n      ctx.strokeStyle = toCss(cBorder, 0.9);\n      ctx.lineWidth = Math.max(1, dpr);\n      ctx.beginPath();\n      // four L-shaped corner marks, not a closed rectangle — a die outline reads\n      // as registration marks, not a drawn box\n      for (const [cx, cy, sx, sy] of [\n        [x0, y0, 1, 1],\n        [x1, y0, -1, 1],\n        [x0, y1, 1, -1],\n        [x1, y1, -1, -1],\n      ] as const) {\n        ctx.moveTo(cx + arm * sx, cy);\n        ctx.lineTo(cx, cy);\n        ctx.lineTo(cx, cy + arm * sy);\n      }\n      ctx.stroke();\n      ctx.restore();\n    };\n\n    // -- per-frame composition ----------------------------------------------\n    const compose = (tAbsSeconds: number, isStatic: boolean) => {\n      if (cssW <= 0 || cssH <= 0) return;\n      if (dpr !== rasterDpr) {\n        // an adaptive quality step changed the backing-store dpr — the die\n        // rasters were baked against the old one, so rebuild them before\n        // anything reads region.w/h against the new device-pixel canvas\n        rasterizeWord();\n        rasterizeFrame();\n        syncWordGeom();\n        rasterDpr = dpr;\n      }\n      ctx.clearRect(0, 0, canvas.width, canvas.height);\n      const dieDescentPx = Math.max(10, 0.037 * Math.min(cssW, cssH));\n\n      let webAdvance: number;\n      let tailProgress: number;\n      let dieOffsetPx = 0;\n      let specularPhase: number;\n\n      if (isStatic) {\n        // composed freeze frame: dwell already complete, foil transferred,\n        // tail filaments at maximum extension, band centred on the CTA word.\n        if (word.lastMaskAt !== tAbsSeconds) word.strike(tAbsSeconds);\n        if (frame.lastMaskAt !== tAbsSeconds) frame.strike(tAbsSeconds);\n        webAdvance = 0;\n        tailProgress = 1;\n        dieOffsetPx = 3; // \"die is 3px off the type\" at the named freeze instant\n        // bandCoord is region-local (0..1), so 0.5 centres the band on every\n        // struck region's own middle — exactly \"centred on the CTA word\"\n        specularPhase = 0.5;\n        if (ghosts.length < GHOST_COUNT) {\n          ghosts = [];\n          for (let i = 0; i < GHOST_COUNT; i++) {\n            ghosts.push({ sharp: word.sharp, mask: word.mask, w: word.w, h: word.h, bornAt: -i });\n          }\n        }\n      } else {\n        const sinceAnchorMs = (tAbsSeconds - cycleAnchor) * 1000;\n        const cyclesElapsed = Math.floor(sinceAnchorMs / CYCLE_MS);\n        const local = sinceAnchorMs - cyclesElapsed * CYCLE_MS;\n\n        // The web is held only while the die is on the stock; the instant the\n        // die is clear it feeds, all the way to the end of the cycle.\n        const indexFrac = local <= PEEL_END_MS ? 0 : Math.min(1, (local - PEEL_END_MS) / INDEX_MS);\n        webRel = cyclesElapsed + indexFrac;\n        webAdvance = webCarry + webRel;\n\n        if (local < APPROACH_MS) {\n          // die descending, ease-out cubic. The foil already on the stock is\n          // NOT taken back off — only the die moves.\n          dieOffsetPx =\n            DWELL_BITE_PX + (dieDescentPx - DWELL_BITE_PX) * (1 - easeOutCubic(local / APPROACH_MS));\n        } else if (local < DWELL_END_MS) {\n          // dwell: the die closes its last 1.5px under load rather than\n          // sitting parked, so no part of the contact window is a still frame\n          dieOffsetPx = DWELL_BITE_PX * (1 - (local - APPROACH_MS) / DWELL_MS);\n        } else if (local < PEEL_END_MS) {\n          // one strike time PER CYCLE, not one for all time: anchoring on\n          // cycleAnchor alone meant lastMaskAt matched forever after the\n          // first strike, so the mask was cleared on every approach and\n          // never re-evaluated — the band ran unfoiled from cycle two on.\n          const strikeAt = cycleAnchor + (cyclesElapsed * CYCLE_MS + DWELL_END_MS) / 1000;\n          if (word.lastMaskAt !== strikeAt) {\n            word.strike(strikeAt);\n            // the strike is what punches the hole, so the web's record is\n            // committed here and then carried away by the feed that follows\n            ghosts.push({\n              sharp: Uint8ClampedArray.from(word.sharp),\n              mask: Uint8ClampedArray.from(word.mask),\n              w: word.w,\n              h: word.h,\n              bornAt: webAdvance,\n            });\n            while (ghosts.length > GHOST_COUNT + 1) ghosts.shift();\n          }\n          if (frame.lastMaskAt !== strikeAt) frame.strike(strikeAt);\n          // peel runs the full 280ms of lift, not a parked 190ms then a jump\n          dieOffsetPx = dieDescentPx * ((local - DWELL_END_MS) / PEEL_MS);\n        } else {\n          dieOffsetPx = dieDescentPx;\n        }\n\n        if (local >= PEEL_END_MS - TAIL_MS && local < PEEL_END_MS) {\n          tailProgress = (local - (PEEL_END_MS - TAIL_MS)) / TAIL_MS;\n        } else {\n          tailProgress = 0;\n        }\n\n        specularPhase = tAbsSeconds * SPECULAR_HZ;\n      }\n\n      if (wordGeom.ww > 0) {\n        blitRegion(word, wordGeom.localLeft, wordGeom.localTop, 1, specularPhase, false);\n        drawTailFilaments(tailProgress, tAbsSeconds * 7.13);\n        drawDieBracket(dieOffsetPx);\n      }\n      if (frameGeom.fw > 0) {\n        blitRegion(frame, frameGeom.localLeft, frameGeom.localTop, 1, specularPhase, false);\n      }\n      drawWebRibbon(webAdvance, specularPhase);\n    };\n\n    // -- sizing --------------------------------------------------------------\n    const applyBacking = () => {\n      if (cssW < 2 || cssH < 2) return;\n      dpr = Math.min(window.devicePixelRatio || 1, 1.5) * SCALES[scaleIdx];\n      const pw = Math.round(cssW * dpr);\n      const ph = Math.round(cssH * dpr);\n      if (canvas.width !== pw || canvas.height !== ph) {\n        canvas.width = pw;\n        canvas.height = ph;\n      }\n      canvas.style.width = `${cssW}px`;\n      canvas.style.height = `${cssH}px`;\n    };\n\n    // A fresh mount otherwise looks empty for the first ~40s (nine cycles) —\n    // pre-seed the ghost train from the same make-ready field, sampled at\n    // synthetic past strike times, so the resting web already carries a\n    // history the instant the section is visible. The LIVE word still starts\n    // its own cycle fresh (approach, un-foiled) so the strike itself is\n    // never pre-empted.\n    const seedGhosts = () => {\n      if (word.w < 1 || ghosts.length > 0) return;\n      for (let i = GHOST_COUNT; i >= 1; i--) {\n        const virtualStrikeAt = -i * (CYCLE_MS / 1000);\n        word.strike(virtualStrikeAt);\n        ghosts.push({\n          sharp: Uint8ClampedArray.from(word.sharp),\n          mask: Uint8ClampedArray.from(word.mask),\n          w: word.w,\n          h: word.h,\n          bornAt: -i,\n        });\n      }\n      word.clear();\n      word.lastMaskAt = -Infinity;\n    };\n\n    const resize = () => {\n      const rect = section.getBoundingClientRect();\n      if (rect.width < 2 || rect.height < 2) return;\n      cssW = rect.width;\n      cssH = rect.height;\n      scaleIdx = 0;\n      overMs = 0;\n      underMs = 0;\n      upWindow = 8000;\n      frameEma = 16.7;\n      applyBacking();\n      rasterizeWord();\n      rasterizeFrame();\n      syncWordGeom();\n      rasterDpr = dpr;\n      seedGhosts();\n      if (staticMode) compose(STATIC_TIME_S, true);\n    };\n\n    // -- loop ------------------------------------------------------------\n    const loop = (nowMs: number) => {\n      const rawMs = lastMs === 0 ? 16.7 : nowMs - lastMs;\n      const dt = Math.min(0.05, Math.max(0, rawMs / 1000));\n      lastMs = nowMs;\n      simTime += dt;\n      compose(simTime, false);\n\n      const clamped = Math.min(50, rawMs);\n      frameEma += (clamped - frameEma) * (1 - Math.exp(-clamped / 120));\n      if (frameEma > BUDGET_OVER_MS) {\n        overMs += clamped;\n        underMs = 0;\n      } else {\n        underMs += clamped;\n        overMs = 0;\n      }\n      const down = overMs > 900 && scaleIdx < SCALES.length - 1;\n      const up = underMs > upWindow && scaleIdx > 0;\n      if (down || up) {\n        scaleIdx += down ? 1 : -1;\n        if (down) upWindow = Math.min(64000, upWindow * 2);\n        overMs = 0;\n        underMs = 0;\n        frameEma = 16.7;\n        applyBacking();\n      }\n      raf = requestAnimationFrame(loop);\n    };\n    const wake = () => {\n      if (running || disposed) return;\n      running = true;\n      lastMs = 0;\n      raf = requestAnimationFrame(loop);\n    };\n    const sleep = () => {\n      cancelAnimationFrame(raf);\n      running = false;\n    };\n\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    let reduced = mq.matches;\n    const applyMode = () => {\n      if (reduced) {\n        staticMode = true;\n        sleep();\n        compose(STATIC_TIME_S, true);\n      } else {\n        staticMode = false;\n        // t=0: the live strike cycle starts fresh (die up, approach not yet\n        // begun, CTA type an un-foiled outline) — the \"alive at rest\" read at\n        // t=0 instead comes from the pre-seeded ghost train (seedGhosts)\n        // already sitting behind it, so the band never looks freshly booted.\n        simTime = 0;\n        cycleAnchor = 0;\n        webCarry = 0;\n        webRel = 0;\n        wake();\n      }\n    };\n    const onMq = () => {\n      reduced = mq.matches;\n      applyMode();\n    };\n    mq.addEventListener(\"change\", onMq);\n\n    let onScreen = true;\n    const io = new IntersectionObserver(\n      (entries) => {\n        onScreen = entries.some((en) => en.isIntersecting);\n        if (!onScreen) sleep();\n        else if (!staticMode && !document.hidden) wake();\n      },\n      { threshold: 0 }\n    );\n    io.observe(section);\n\n    const onVis = () => {\n      if (document.hidden) sleep();\n      else if (!staticMode && onScreen) wake();\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    const ro = new ResizeObserver(resize);\n    ro.observe(section);\n    resize();\n    if (document.fonts?.ready) {\n      document.fonts.ready.then(() => {\n        if (disposed) return;\n        rasterizeWord();\n        rasterizeFrame();\n        syncWordGeom();\n        if (staticMode) compose(STATIC_TIME_S, true);\n      });\n    }\n    applyMode();\n\n    const themeObserver = new MutationObserver(() => {\n      readTokens();\n      if (staticMode) compose(STATIC_TIME_S, true);\n    });\n    themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    // activation feedback: one out-of-cadence strike, restarting the cycle\n    const strikeNow = () => {\n      // bank the web travel fed under the old anchor before moving it, or an\n      // activation strike would wind the ribbon back to the top of a cycle\n      webCarry += webRel;\n      webRel = 0;\n      cycleAnchor = simTime;\n      word.lastMaskAt = -Infinity;\n      frame.lastMaskAt = -Infinity;\n    };\n    const onActivate = () => {\n      if (!staticMode) strikeNow();\n    };\n    // Both branches of the CTA are natively activatable: a <button> fires click\n    // on Enter AND Space, an <a href> fires it on Enter. A keydown listener that\n    // also called onActivate struck the die twice per key press, and on the\n    // anchor it swallowed Space, which should scroll the page.\n    buttonEl.addEventListener(\"click\", onActivate);\n\n    let poll = 0;\n    let lastHeadline = headlineRef.current;\n    const tick = () => {\n      if (headlineRef.current !== lastHeadline) {\n        lastHeadline = headlineRef.current;\n        rasterizeWord();\n        syncWordGeom();\n        if (staticMode) compose(STATIC_TIME_S, true);\n      }\n      poll = window.setTimeout(tick, 200);\n    };\n    tick();\n\n    return () => {\n      disposed = true;\n      sleep();\n      ro.disconnect();\n      io.disconnect();\n      mq.removeEventListener(\"change\", onMq);\n      document.removeEventListener(\"visibilitychange\", onVis);\n      themeObserver.disconnect();\n      buttonEl.removeEventListener(\"click\", onActivate);\n      window.clearTimeout(poll);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  const words = headline.trim().split(/\\s+/);\n  const lead = words.slice(0, -1).join(\" \");\n  const last = words[words.length - 1] ?? \"\";\n\n  return (\n    <section\n      ref={sectionRef}\n      data-foil-block={uid}\n      className={`relative isolate w-full overflow-hidden bg-background ${className}`}\n    >\n      {/* z-20: the foil lands ON the type, not behind it. A canvas under the\n          content was painting the struck word behind the headline's own\n          backdrop panel, which is why the terminal word simply vanished. */}\n      <canvas ref={canvasRef} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 z-20 block h-full w-full\" />\n      <div className=\"relative z-10 mx-auto flex w-full max-w-3xl flex-col items-center px-6 py-24 text-center sm:py-32\">\n        {eyebrow ? <p className=\"mb-5 font-mono text-[11px] tracking-widest text-ns-muted\">{eyebrow}</p> : null}\n        <div className=\"px-4 py-2\">\n          <h2 className=\"text-balance text-3xl font-semibold text-foreground sm:text-4xl\">\n            {lead ? `${lead} ` : \"\"}\n            {/* the terminal word rests at muted value — the foil that lands\n                on it is the bright event, and near-equal values would make\n                the transfer invisible */}\n            <span ref={wordRef} className=\"inline-block text-ns-muted\">\n              {last}\n            </span>\n          </h2>\n        </div>\n        <div className=\"mt-9 flex flex-wrap items-center justify-center gap-4\">\n          {primaryHref ? (\n            <a\n              ref={buttonRef as React.RefObject<HTMLAnchorElement>}\n              href={primaryHref}\n              className=\"inline-flex items-center justify-center rounded-sm bg-ns-accent px-6 py-3 text-sm font-medium text-white transition-colors duration-150 hover:bg-ns-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent active:scale-[0.98]\"\n            >\n              {primaryLabel}\n            </a>\n          ) : (\n            <button\n              ref={buttonRef as React.RefObject<HTMLButtonElement>}\n              type=\"button\"\n              className=\"inline-flex items-center justify-center rounded-sm bg-ns-accent px-6 py-3 text-sm font-medium text-white transition-colors duration-150 hover:bg-ns-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent active:scale-[0.98]\"\n            >\n              {primaryLabel}\n            </button>\n          )}\n          <a href={secondaryHref} className=\"text-sm font-medium text-ns-muted underline-offset-4 hover:text-foreground hover:underline\">\n            {secondaryLabel}\n          </a>\n        </div>\n      </div>\n    </section>\n  );\n}\n\nFoilBlock.displayName = \"FoilBlock\";\n",
      "type": "registry:ui",
      "target": "components/ui/foil-block.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-ns-accent-hover": "var(--ns-accent-hover)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "ns-accent-hover": "#0059d1"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "loud",
    "tags": [
      "cta",
      "closing-cta",
      "canvas",
      "print",
      "foil",
      "letterpress",
      "threshold",
      "button"
    ],
    "instruction": "Build <FoilBlock eyebrow? headline? primaryLabel? primaryHref? secondaryLabel? secondaryHref? className?> as a full-width closing CTA band (eyebrow, headline, primary button, secondary link) whose visual identity is hot foil blocking (hot stamping): a heated die presses a metallised web onto the headline's LAST word and a thin rule frame offset 8px outside the primary button (never the button's own fill, which stays plain --ns-accent DOM chrome). Rasterize each die's own outline into a small offscreen 2D canvas sized to the DOM element's bounding rect times a capped dpr (word: fillText of the last word at the wrapper's real computed font; frame: strokeRect of a FRAME_STROKE_PX=3 rule inset 8px outside the button rect), read out the alpha channel as the 'sharp' mask, and box-blur a copy of it at radius 6px*dpr to build an edge-distance proxy used ONLY for the die's own 9°C cold-edge falloff (temp = 118 - 9*edgeFrac, inside the die outline only). Contact pressure is a continuous field of 3 gaussian lobes (sigma 0.16 in the region's own 0..1 local coords) summing sin(2*pi*0.014*tAbs + phase) terms of amplitude 0.11 about a mean of 0.42, evaluated at the die's own local (u,v) and an ABSOLUTE simulation-time argument so it never resets between strikes -- consecutive strikes fail in different places. A pixel receives foil iff temp>=96 AND pressure>=0.34: hard threshold, no soft ramp, which is what produces the ragged cold-edge void pattern (rendered as cDim blended 0.5 toward --background) instead of a clean fill. This mask is evaluated exactly ONCE per strike, at dwell end (720ms into the 4.60s cycle: 420ms approach ease-out-cubic die descent of max(10, 0.037*min(w,h))px + 300ms dwell), and held through 280ms peel (last 90ms: 3-7 hairline <=2px tail filaments stretching off the trailing edge as the carrier still clings) + 1550ms web index (spent web scrolls 71px at 46px/s) + 2050ms idle (web still, specular band still sweeping) -- it does NOT re-evaluate per frame. NOTE ON THE SPEC'S PHASE TABLE: the source spec's per-phase durations (420+300+280+1550+1960ms) sum to 4510ms against its own stated 4.60s cycle total; the 1960ms idle figure was the value that gave way, corrected here to 2050ms (CYCLE_MS - approach - dwell - peel - index) so the four other named numbers and the 4.60s identity both hold exactly. On every completed index phase, snapshot the word's sharp+mask arrays into a ghost history capped at 9 entries, each rendered in a dedicated 30px-tall strip along the band's bottom edge (behind a hairline --border rule), translating left with the live index progress and decaying alpha linearly from 1.0 (newest) to 0.18 (oldest, 9th) across the 639px visible run (9*71px) -- this ghost train, not the specular sweep, is the primary resting-loop signal, and is pre-seeded at mount (synthetic strikes at negative virtual times against the SAME continuous pressure field) so the band never looks freshly booted. Independently and unconditionally, a specular band (responsive width max(16, 0.064*min(w,h))px, nominally ~24px, at a 31° diagonal) sweeps across every currently-transferred pixel at 0.19Hz forever, applying a LUMINANCE-ONLY delta of local_foil_L +/- 0.16 (never a hue, never --ns-accent) -- this is what keeps the mature/already-struck region from ever being a static image sliding under a moving band, on top of every strike's own different void pattern. Composite each die region and the ghost strip with a single ImageData buffer + putImageData call per frame (not per-pixel fillRect), and rebuild the die rasters whenever the adaptive backing-store dpr changes so the buffer and the canvas device pixels never drift apart. Colors: cLive (transferred foil) is L 0.94 toward white in dark theme / L 0.78 toward black in light theme, both mixed from --background -- 'bright metal on dark ground' vs 'dark burnish on pale stock', not a compromise. cDim (void/cold-edge) is --ns-muted mixed 0.55 toward --background then blended another 0.5 toward --background at render. --border is used only as a 1px hairline (die registration bracket corners, ghost-strip rule). --ns-accent never appears in canvas code at all -- it is DOM-only, on the real <button>/<a>'s bg-ns-accent fill and focus-visible:outline-ns-accent ring. All five tokens are read via getComputedStyle(document.documentElement) before the first paint and re-read on a MutationObserver watching documentElement's class. Canvas is aria-hidden; the headline (h2) is always a real, fully readable DOM heading -- its last word's DOM span is set to color:transparent ONLY once a live 2D context is confirmed, so a context-creation failure leaves plain visible text instead of a gap. Activation (click or Enter/Space on the button, identically) restarts the strike cycle from t=0 as one out-of-cadence beat of feedback; navigation is never gated on the animation. prefers-reduced-motion renders one composed still frame at STATIC_TIME=1.14s with the mask force-struck, tail filaments at maximum extension (progress=1), the specular band centred on each region's own local middle (phase=0.5), and all 9 ghost slots populated -- deterministic and byte-stable since every input is a fixed time and fixed analytic formula, no Math.random anywhere. DPR is capped at 1.5 (full-bleed band) with a 3-step adaptive scale ladder (1, 0.75, 0.55) that only steps down after ~900ms of sustained >24ms frames and only climbs back after a doubling recovery window, never on frame count or device sniffing. ResizeObserver re-rasterizes both die outlines and re-syncs geometry; IntersectionObserver and visibilitychange both stop the single rAF loop."
  },
  "type": "registry:ui"
}