{"$schema":"https://ui.shadcn.com/schema/registry.json","author":"Kaiyu Hsu <uicapsule@kyh.io>","categories":["effects","pixel-art"],"dependencies":[],"description":"An ASCII trading terminal drawn from box-drawing glyphs, whose screen burns into drifting embers and reassembles as the next one — scrub backwards and the same debris replays in reverse.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { TerminalDisintegrate } from \"./terminal-disintegrate\";\n\nconst Preview = () => (\n  <main className=\"h-dvh w-full overflow-hidden\">\n    <TerminalDisintegrate />\n  </main>\n);\n\nexport default Preview;\n","path":"/preview.tsx","target":"uicapsule/terminal-disintegrate/preview.tsx","type":"registry:file"},{"content":"// Static datasets for the three phases. No network, no randomness — every\n// figure here gets drawn onto the canvas as characters.\n\nexport interface Candle {\n  o: number;\n  h: number;\n  l: number;\n  c: number;\n  v: number;\n}\nexport interface DepthRow {\n  price: string;\n  size: number;\n}\nexport interface OptionRow {\n  strike: number;\n  callBid: number;\n  callIv: number;\n  putBid: number;\n  putIv: number;\n  highlight?: boolean;\n}\n\n// Phase 1 — a bullish BTC breakout from ~106K to a 107.4K close with one pullback.\nexport const CANDLES: Candle[] = [\n  { c: 106_140, h: 106_180, l: 105_920, o: 106_020, v: 0.42 },\n  { c: 106_280, h: 106_320, l: 106_080, o: 106_140, v: 0.55 },\n  { c: 106_260, h: 106_400, l: 106_210, o: 106_280, v: 0.38 },\n  { c: 106_480, h: 106_520, l: 106_230, o: 106_260, v: 0.61 },\n  { c: 106_650, h: 106_690, l: 106_440, o: 106_480, v: 0.72 },\n  { c: 106_560, h: 106_720, l: 106_500, o: 106_650, v: 0.49 },\n  { c: 106_380, h: 106_600, l: 106_320, o: 106_560, v: 0.83 },\n  { c: 106_510, h: 106_540, l: 106_350, o: 106_380, v: 0.44 },\n  { c: 106_740, h: 106_780, l: 106_480, o: 106_510, v: 0.67 },\n  { c: 106_920, h: 106_960, l: 106_700, o: 106_740, v: 0.78 },\n  { c: 107_040, h: 107_080, l: 106_870, o: 106_920, v: 0.69 },\n  { c: 106_990, h: 107_120, l: 106_960, o: 107_040, v: 0.51 },\n  { c: 107_220, h: 107_260, l: 106_960, o: 106_990, v: 0.88 },\n  { c: 107_350, h: 107_380, l: 107_180, o: 107_220, v: 0.74 },\n  { c: 107_300, h: 107_420, l: 107_280, o: 107_350, v: 0.46 },\n  { c: 107_440, h: 107_480, l: 107_270, o: 107_300, v: 0.81 },\n  { c: 107_432, h: 107_460, l: 107_360, o: 107_440, v: 0.58 },\n];\n\nexport const ASKS: DepthRow[] = [\n  { price: \"107,448\", size: 0.184 },\n  { price: \"107,446\", size: 0.092 },\n  { price: \"107,440\", size: 0.451 },\n  { price: \"107,436\", size: 0.218 },\n];\n\nexport const BIDS: DepthRow[] = [\n  { price: \"107,432\", size: 0.238 },\n  { price: \"107,430\", size: 0.117 },\n  { price: \"107,428\", size: 0.892 },\n  { price: \"107,424\", size: 0.314 },\n];\n\n// Phase 2 — a XAU options chain, 26 DEC expiry, ATM at the 2680 strike.\nexport const OPTION_CHAIN: OptionRow[] = [\n  {\n    callBid: 56.4,\n    callIv: 14.2,\n    putBid: 3.05,\n    putIv: 13.8,\n    strike: 2640,\n  },\n  {\n    callBid: 48.2,\n    callIv: 13.9,\n    putBid: 5.2,\n    putIv: 13.5,\n    strike: 2650,\n  },\n  {\n    callBid: 40.4,\n    callIv: 13.6,\n    putBid: 8.1,\n    putIv: 13.4,\n    strike: 2660,\n  },\n  {\n    callBid: 32.9,\n    callIv: 13.3,\n    putBid: 12.3,\n    putIv: 13.6,\n    strike: 2670,\n  },\n  {\n    callBid: 25.85,\n    callIv: 13.4,\n    highlight: true,\n    putBid: 16.95,\n    putIv: 13.9,\n    strike: 2680,\n  },\n  {\n    callBid: 19.4,\n    callIv: 13.7,\n    putBid: 22.5,\n    putIv: 14.3,\n    strike: 2690,\n  },\n  {\n    callBid: 14.05,\n    callIv: 14.2,\n    putBid: 29.1,\n    putIv: 14.8,\n    strike: 2700,\n  },\n  {\n    callBid: 9.6,\n    callIv: 14.8,\n    putBid: 36.95,\n    putIv: 15.4,\n    strike: 2710,\n  },\n];\n\nexport const GREEKS = {\n  delta: 0.52,\n  gamma: 0.018,\n  rho: 0.34,\n  theta: -0.45,\n  vega: 1.2,\n};\n\n// Phase 3 — a bearish equity curve indexed to 100, peak ~131, trough ~83.\nexport const BEAR_CURVE: number[] = [\n  100, 104, 108, 113, 119, 124, 122, 127, 131, 128, 122, 116, 118, 121, 119, 113, 106, 99, 95, 91,\n  88, 86, 83, 87, 92, 95, 93, 96, 99, 102,\n];\n\nexport const RISK = {\n  cagr: \"23.8%\",\n  maxDrawdown: \"-17.8%\",\n  sharpe: \"2.14\",\n  vol: \"14.2%\",\n};\n","path":"/terminal-data.ts","target":"uicapsule/terminal-disintegrate/terminal-data.ts","type":"registry:file"},{"content":"\"use client\";\n\nimport type { Particle } from \"./terminal-particles\";\n\nimport { useEffect, useRef } from \"react\";\n\nimport { buildBtcFrame, buildGoldFrame, buildStatsFrame } from \"./terminal-frames\";\nimport {\n  buildEntrance,\n  buildIncoming,\n  buildOutgoing,\n  cellMetrics,\n  clamp01,\n  renderGrid,\n  renderParticles,\n  smoothstep,\n} from \"./terminal-particles\";\n\n// --- Choreography -----------------------------------------------------------\n// A single 0..1 scalar drives everything: hold, disintegrate, hold,\n// disintegrate, hold. The palette inverts inside the second transition.\nconst HOLD1_END = 0.03;\nconst TRANS1_END = 0.36;\nconst HOLD2_END = 0.55;\nconst TRANS2_END = 0.85;\nconst FLIP_START = 0.78;\nconst FLIP_END = 0.86;\n\n// Derived, so retuning the choreography can never desync the phase readout.\nconst PHASE_2_AT = (HOLD1_END + TRANS1_END) / 2;\nconst PHASE_3_AT = (HOLD2_END + TRANS2_END) / 2;\n\n// --- Animation tunables -----------------------------------------------------\nconst ENTRANCE_MS = 2000;\nconst CURL_AMP_PX = 22;\nconst SCORCH_AMP = 0.45;\n\n// The entrance is deliberately wilder than the transitions.\nconst TRANS_OPTS = { curlAmp: CURL_AMP_PX, scorchAmp: SCORCH_AMP };\nconst ENTRANCE_OPTS = { curlAmp: CURL_AMP_PX * 2.4, scorchAmp: SCORCH_AMP * 1.3 };\n\n// Idle autoplay ping-pongs 0 -> 1 -> 0 until the first drag. Snapping 1 back to\n// 0 would replay both transitions in half a second and hard-flip the palette,\n// so it reverses through them instead.\nconst SWEEP_MS = 8000;\nconst HOLD_MS = 900;\nconst CYCLE_MS = SWEEP_MS * 2 + HOLD_MS * 2;\n\n// --- Palette ----------------------------------------------------------------\nconst BG_LIGHT = \"#cff060\";\nconst FG_LIGHT = \"#1c5f27\";\nconst BG_DARK = \"#1c5f27\";\nconst FG_DARK = \"#cff060\";\n\n// Pinned, never inherited: every pixel here is a box-drawing or block-element\n// glyph, so the stack has to lead with faces that actually ship U+2500/U+2580.\nconst MONO_FAMILY =\n  '\"JetBrains Mono\", \"Cascadia Mono\", \"DejaVu Sans Mono\", Menlo, Consolas, \"Liberation Mono\", \"Noto Sans Mono\", monospace';\n\nconst hexToRgb = (hex: string) => {\n  const h = hex.replace(\"#\", \"\");\n  return {\n    b: Number.parseInt(h.slice(4, 6), 16),\n    g: Number.parseInt(h.slice(2, 4), 16),\n    r: Number.parseInt(h.slice(0, 2), 16),\n  };\n};\n\nconst mixHex = (a: string, b: string, t: number): string => {\n  const ca = hexToRgb(a);\n  const cb = hexToRgb(b);\n  const ch = (x: number, y: number) =>\n    Math.round(x + (y - x) * t)\n      .toString(16)\n      .padStart(2, \"0\");\n  return `#${ch(ca.r, cb.r)}${ch(ca.g, cb.g)}${ch(ca.b, cb.b)}`;\n};\n\nconst paletteAt = (p: number) => {\n  if (p <= FLIP_START) {\n    return { bg: BG_LIGHT, fg: FG_LIGHT };\n  }\n  if (p >= FLIP_END) {\n    return { bg: BG_DARK, fg: FG_DARK };\n  }\n  const t = smoothstep((p - FLIP_START) / (FLIP_END - FLIP_START));\n  return { bg: mixHex(BG_LIGHT, BG_DARK, t), fg: mixHex(FG_LIGHT, FG_DARK, t) };\n};\n\ninterface Layout {\n  w: number;\n  h: number;\n  cellW: number;\n  cellH: number;\n  canvasW: number;\n  canvasH: number;\n  frameY: number;\n  frameX1: number;\n  frameX2: number;\n  frameX3: number;\n}\n\ninterface ParticleSets {\n  entrance: Particle[];\n  t1out: Particle[];\n  t1in: Particle[];\n  t2out: Particle[];\n  t2in: Particle[];\n}\n\ninterface Runtime {\n  smoothP: number;\n  targetP: number;\n  lastDrawnP: number;\n  entranceDone: boolean;\n  entranceT: number;\n  autoElapsed: number;\n  interacted: boolean;\n  lastNow: number | null;\n  fg: string;\n}\n\nconst phaseAt = (p: number): 1 | 2 | 3 => {\n  if (p < PHASE_2_AT) {\n    return 1;\n  }\n  if (p < PHASE_3_AT) {\n    return 2;\n  }\n  return 3;\n};\n\n/* The idle cycle: sweep forward, hold, sweep back, rest. */\nconst autoTargetAt = (u: number): number => {\n  if (u < SWEEP_MS) {\n    return u / SWEEP_MS;\n  }\n  if (u < SWEEP_MS + HOLD_MS) {\n    return 1;\n  }\n  if (u < SWEEP_MS * 2 + HOLD_MS) {\n    return 1 - (u - SWEEP_MS - HOLD_MS) / SWEEP_MS;\n  }\n  return 0;\n};\n\nexport const TerminalDisintegrate = () => {\n  const rootRef = useRef<HTMLDivElement | null>(null);\n  const bgRef = useRef<HTMLDivElement | null>(null);\n  const canvasRef = useRef<HTMLCanvasElement | null>(null);\n  const chipRef = useRef<HTMLDivElement | null>(null);\n  const hintRef = useRef<HTMLDivElement | null>(null);\n  const railTrackRef = useRef<HTMLDivElement | null>(null);\n  const railFillRef = useRef<HTMLDivElement | null>(null);\n\n  useEffect(() => {\n    const canvasEl = canvasRef.current;\n    const rootEl = rootRef.current;\n    if (!canvasEl || !rootEl) {\n      return;\n    }\n\n    const ctx2d = canvasEl.getContext(\"2d\");\n    if (!ctx2d) {\n      return;\n    }\n\n    // Re-bound as non-nullable so the hoisted helpers below close over a\n    // narrowed type without needing assertions.\n    const canvas: HTMLCanvasElement = canvasEl;\n    const root: HTMLDivElement = rootEl;\n    const ctx: CanvasRenderingContext2D = ctx2d;\n\n    // The three screen grids are pure data — built once, never rebuilt.\n    const grids = {\n      btc: buildBtcFrame(),\n      gold: buildGoldFrame(),\n      stats: buildStatsFrame(),\n    };\n\n    const layout: Layout = {\n      canvasH: 0,\n      canvasW: 0,\n      cellH: 0,\n      cellW: 0,\n      frameX1: 0,\n      frameX2: 0,\n      frameX3: 0,\n      frameY: 0,\n      h: 0,\n      w: 0,\n    };\n\n    const particles: ParticleSets = {\n      entrance: [],\n      t1in: [],\n      t1out: [],\n      t2in: [],\n      t2out: [],\n    };\n\n    const S: Runtime = {\n      // Negative so the assembled first screen holds before it starts burning;\n      // `u` clamps at 0 until the lead-in is spent.\n      autoElapsed: -HOLD_MS,\n      entranceDone: false,\n      entranceT: 0,\n      fg: FG_LIGHT,\n      interacted: false,\n      lastDrawnP: -1,\n      lastNow: null,\n      smoothP: 0,\n      targetP: 0,\n    };\n\n    const reduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    if (reduce) {\n      S.entranceDone = true;\n      S.entranceT = 1;\n    }\n\n    const computeLayout = (stageW: number, stageH: number) => {\n      const m = cellMetrics(stageW, stageH);\n\n      const dpr = Math.min(2.5, window.devicePixelRatio || 1);\n      canvas.width = Math.round(stageW * dpr);\n      canvas.height = Math.round(stageH * dpr);\n      canvas.style.width = `${stageW}px`;\n      canvas.style.height = `${stageH}px`;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      // Resizing the backing store wipes the whole 2d state, so the font and\n      // baseline are (re)set here — the only place either can change — rather\n      // than being re-parsed on every frame of the loop.\n      ctx.textBaseline = \"top\";\n      ctx.font = `500 ${m.cellH * 0.92}px ${MONO_FAMILY}`;\n\n      // The three frames deliberately sit at two different X positions. The\n      // disintegration is a stage traversal — left, right, left — which is what\n      // makes the outgoing and incoming clouds visibly cross mid-flight rather\n      // than shimmer in place.\n      // The 8px slack reserve is what keeps the left frame off the stage edge;\n      // the progress rail is pinned at left:0 so the two can never coincide.\n      const slack = Math.max(0, stageW - m.canvasW);\n      const offset = Math.max(0, Math.min(slack / 2 - 8, m.canvasW * 0.55));\n      const leftX = stageW / 2 - offset - m.canvasW / 2;\n      const rightX = stageW / 2 + offset - m.canvasW / 2;\n      const frameY = Math.max(0, (stageH - m.canvasH) / 2);\n\n      layout.w = stageW;\n      layout.h = stageH;\n      layout.cellW = m.cellW;\n      layout.cellH = m.cellH;\n      layout.canvasW = m.canvasW;\n      layout.canvasH = m.canvasH;\n      layout.frameY = frameY;\n      layout.frameX1 = leftX;\n      layout.frameX2 = rightX;\n      layout.frameX3 = leftX;\n\n      const common = { cellH: m.cellH, cellW: m.cellW, stageH, stageW };\n\n      particles.entrance = buildEntrance(grids.btc, {\n        ...common,\n        frameX: leftX,\n        frameY,\n        rngSeed: 90_210,\n      });\n      particles.t1out = buildOutgoing(grids.btc, {\n        ...common,\n        frameX: leftX,\n        frameY,\n        rngSeed: 1337,\n        windDir: 1,\n      });\n      particles.t1in = buildIncoming(grids.gold, {\n        ...common,\n        frameX: rightX,\n        frameY,\n        rngSeed: 2024,\n        windDir: 1,\n      });\n      particles.t2out = buildOutgoing(grids.gold, {\n        ...common,\n        frameX: rightX,\n        frameY,\n        rngSeed: 4242,\n        windDir: -1,\n      });\n      particles.t2in = buildIncoming(grids.stats, {\n        ...common,\n        frameX: leftX,\n        frameY,\n        rngSeed: 8675,\n        windDir: -1,\n      });\n\n      S.lastDrawnP = -1;\n    };\n\n    const draw = () => {\n      ctx.clearRect(0, 0, layout.w, layout.h);\n      ctx.fillStyle = S.fg;\n\n      if (!S.entranceDone) {\n        renderParticles(ctx, particles.entrance, S.entranceT, ENTRANCE_OPTS);\n        return;\n      }\n\n      const p = S.smoothP;\n      const { frameX1, frameX2, frameX3, frameY, cellW, cellH } = layout;\n\n      if (p <= HOLD1_END) {\n        renderGrid(ctx, grids.btc, frameX1, frameY, cellW, cellH);\n      } else if (p <= TRANS1_END) {\n        const local = (p - HOLD1_END) / (TRANS1_END - HOLD1_END);\n        renderParticles(ctx, particles.t1out, local, TRANS_OPTS);\n        renderParticles(ctx, particles.t1in, local, TRANS_OPTS);\n      } else if (p <= HOLD2_END) {\n        renderGrid(ctx, grids.gold, frameX2, frameY, cellW, cellH);\n      } else if (p <= TRANS2_END) {\n        const local = (p - HOLD2_END) / (TRANS2_END - HOLD2_END);\n        renderParticles(ctx, particles.t2out, local, TRANS_OPTS);\n        renderParticles(ctx, particles.t2in, local, TRANS_OPTS);\n      } else {\n        renderGrid(ctx, grids.stats, frameX3, frameY, cellW, cellH);\n      }\n    };\n\n    // The scalar moves every frame but the chrome derived from it mostly does\n    // not: palette, phase and fade all sit still for hundreds of frames at a\n    // time. Everything below writes to the DOM only when its own value\n    // actually changed, so a steady-state frame costs one transform.\n    let shownFg = \"\";\n    let shownBg = \"\";\n    let shownPhase = 0;\n    let shownValueNow = -1;\n    let shownFade = -1;\n\n    const applyPalette = (p: number) => {\n      const { fg, bg } = paletteAt(p);\n      S.fg = fg;\n      if (fg !== shownFg) {\n        shownFg = fg;\n        root.style.setProperty(\"--terminal-fg\", fg);\n      }\n      if (bg !== shownBg) {\n        const el = bgRef.current;\n        if (el) {\n          shownBg = bg;\n          el.style.background = bg;\n        }\n      }\n    };\n\n    const applyChrome = (p: number, fade: number) => {\n      const phase = phaseAt(p);\n      const valueNow = Math.round(clamp01(p) * 100);\n\n      if (valueNow !== shownValueNow) {\n        shownValueNow = valueNow;\n        root.setAttribute(\"aria-valuenow\", String(valueNow));\n      }\n      if (phase !== shownPhase) {\n        shownPhase = phase;\n        root.setAttribute(\"aria-valuetext\", `screen ${phase} of 3`);\n        if (chipRef.current) {\n          chipRef.current.textContent = `terminal · 0${phase} / 03`;\n        }\n      }\n      if (fade !== shownFade) {\n        shownFade = fade;\n        if (chipRef.current) {\n          chipRef.current.style.opacity = String(0.55 * fade);\n        }\n        if (hintRef.current) {\n          hintRef.current.style.opacity = String(0.4 * fade);\n        }\n        if (railTrackRef.current) {\n          railTrackRef.current.style.opacity = String(0.18 * fade);\n        }\n      }\n      if (railFillRef.current) {\n        railFillRef.current.style.transform = `scaleY(${clamp01(p)})`;\n      }\n    };\n\n    // --- Driver: pointer drag + keys, with autoplay whenever the reader idles -\n    // Deliberately no wheel handler: this lives inside a card in a scrolling\n    // feed, and swallowing wheel events would trap the reader.\n    const DRAG_SLOP_PX = 3;\n    const IDLE_RESUME_MS = 4000;\n    const KEY_STEP = 0.02;\n    const PAGE_STEP = 0.1;\n\n    let dragId: number | null = null;\n    let lastX = 0;\n    let lastY = 0;\n    let idleTimer = 0;\n\n    const travel = () => Math.max(420, Math.min(layout.w, layout.h));\n\n    // Taking over is always temporary. A bare tap or a click that lands on the\n    // card must never be able to strand the component on a frozen frame, so\n    // control returns to autoplay once the reader has been still for a while —\n    // re-seeded at wherever they left the scalar, not snapped back to zero.\n    const takeOver = () => {\n      S.interacted = true;\n      if (idleTimer) {\n        window.clearTimeout(idleTimer);\n      }\n      idleTimer = 0;\n      // A held pointer that has stopped moving is still a reader holding the\n      // scrubber, not an idle one — so the countdown is armed at release\n      // instead, or the terminal would run away under a stationary cursor.\n      if (dragId !== null) {\n        return;\n      }\n      idleTimer = window.setTimeout(() => {\n        idleTimer = 0;\n        S.autoElapsed = clamp01(S.targetP) * SWEEP_MS;\n        S.interacted = false;\n      }, IDLE_RESUME_MS);\n    };\n\n    const onPointerDown = (e: PointerEvent) => {\n      if (dragId !== null) {\n        return;\n      }\n      dragId = e.pointerId;\n      lastX = e.clientX;\n      lastY = e.clientY;\n      // Not a take-over: a press that never moves must leave autoplay alone.\n      // It only has to hold off a resume that an earlier drag already armed.\n      if (idleTimer) {\n        window.clearTimeout(idleTimer);\n      }\n      idleTimer = 0;\n      root.style.cursor = \"grabbing\";\n      root.setPointerCapture(e.pointerId);\n    };\n\n    const onPointerMove = (e: PointerEvent) => {\n      if (dragId !== e.pointerId) {\n        return;\n      }\n      const dx = e.clientX - lastX;\n      const dy = e.clientY - lastY;\n      lastX = e.clientX;\n      lastY = e.clientY;\n      // Only real movement counts as a grab; a jittery tap stays autoplaying.\n      if (S.interacted || Math.abs(dx) + Math.abs(dy) > DRAG_SLOP_PX) {\n        takeOver();\n      }\n      // Dragging left or up advances, matching both carousel and scroll habits.\n      S.targetP = clamp01(S.targetP + (-dx - dy) / travel());\n    };\n\n    const stepFor = (key: string): number => {\n      if (key === \"ArrowRight\" || key === \"ArrowDown\") {\n        return KEY_STEP;\n      }\n      if (key === \"ArrowLeft\" || key === \"ArrowUp\") {\n        return -KEY_STEP;\n      }\n      if (key === \"PageDown\") {\n        return PAGE_STEP;\n      }\n      if (key === \"PageUp\") {\n        return -PAGE_STEP;\n      }\n      if (key === \"End\") {\n        return 1;\n      }\n      if (key === \"Home\") {\n        return -1;\n      }\n      return 0;\n    };\n\n    const onKeyDown = (e: KeyboardEvent) => {\n      const step = stepFor(e.key);\n      if (step === 0) {\n        return;\n      }\n      e.preventDefault();\n      takeOver();\n      S.targetP = clamp01(S.targetP + step);\n    };\n\n    const endDrag = (e: PointerEvent) => {\n      if (dragId !== e.pointerId) {\n        return;\n      }\n      dragId = null;\n      root.style.cursor = \"grab\";\n      if (root.hasPointerCapture(e.pointerId)) {\n        root.releasePointerCapture(e.pointerId);\n      }\n      // Release is where \"still\" starts counting. A press that never scrubbed\n      // left `interacted` false and must not hand autoplay a fresh countdown.\n      if (S.interacted) {\n        takeOver();\n      }\n    };\n\n    // --- Loop ---------------------------------------------------------------\n    let raf = 0;\n    let running = false;\n    let ready = false;\n    let onScreen = true;\n    let cancelled = false;\n\n    const tick = (now: number) => {\n      if (!running) {\n        return;\n      }\n\n      const dt = S.lastNow === null ? 16.7 : Math.min(64, now - S.lastNow);\n      S.lastNow = now;\n\n      if (!S.entranceDone) {\n        S.entranceT = Math.min(1, S.entranceT + dt / ENTRANCE_MS);\n        const fade = S.entranceT < 0.5 ? 0 : (S.entranceT - 0.5) / 0.5;\n        applyPalette(0);\n        applyChrome(0, fade);\n        draw();\n        if (S.entranceT >= 1) {\n          S.entranceDone = true;\n          // Adopt whatever the driver accumulated during the entrance, so a\n          // reader who scrubbed early doesn't watch the transitions replay.\n          S.smoothP = S.targetP;\n        }\n        raf = requestAnimationFrame(tick);\n        return;\n      }\n\n      if (!S.interacted && !reduce) {\n        S.autoElapsed = (S.autoElapsed + dt) % CYCLE_MS;\n        const u = Math.max(0, S.autoElapsed);\n        S.targetP = autoTargetAt(u);\n      }\n\n      // Critically-damped follow. Without it the disintegration reads as a\n      // jump-cut instead of a burn.\n      let sp = S.smoothP + (S.targetP - S.smoothP) * 0.12;\n      if (Math.abs(S.targetP - sp) < 0.0002) {\n        sp = S.targetP;\n      }\n      S.smoothP = sp;\n\n      if (Math.abs(sp - S.lastDrawnP) > 0.00005) {\n        applyPalette(sp);\n        applyChrome(sp, 1);\n        draw();\n        S.lastDrawnP = sp;\n      }\n\n      raf = requestAnimationFrame(tick);\n    };\n\n    const startLoop = () => {\n      if (running || cancelled || !ready) {\n        return;\n      }\n      running = true;\n      S.lastNow = null;\n      raf = requestAnimationFrame(tick);\n    };\n\n    const stopLoop = () => {\n      running = false;\n      if (raf) {\n        cancelAnimationFrame(raf);\n      }\n      raf = 0;\n    };\n\n    const syncLoop = () => {\n      if (onScreen && document.visibilityState !== \"hidden\") {\n        startLoop();\n      } else {\n        stopLoop();\n      }\n    };\n\n    const begin = () => {\n      if (cancelled || ready) {\n        return;\n      }\n      const rect = root.getBoundingClientRect();\n      if (rect.width < 1 || rect.height < 1) {\n        return;\n      }\n      computeLayout(rect.width, rect.height);\n      applyPalette(0);\n      applyChrome(0, 0);\n      ready = true;\n      syncLoop();\n    };\n\n    // A freshly-mounted iframe reports a 0x0 box first; ignore that callback.\n    const ro = new ResizeObserver((entries) => {\n      const [entry] = entries;\n      if (!entry) {\n        return;\n      }\n      const { width, height } = entry.contentRect;\n      if (width < 1 || height < 1) {\n        return;\n      }\n      if (ready) {\n        computeLayout(width, height);\n      } else {\n        begin();\n      }\n    });\n    ro.observe(root);\n\n    const io = new IntersectionObserver((entries) => {\n      const [entry] = entries;\n      if (!entry) {\n        return;\n      }\n      onScreen = entry.isIntersecting;\n      syncLoop();\n    });\n    io.observe(root);\n\n    // Measure against the real font rather than a fallback, but never wait\n    // forever for it.\n    let fontTimer = 0;\n    if (document.fonts?.ready) {\n      // oxlint-disable-next-line promise/avoid-new -- a cancellable timeout in the browser; there is no promise-returning timer to await here\n      const timeout = new Promise<void>((resolve) => {\n        fontTimer = window.setTimeout(resolve, 800);\n      });\n      const beginAfterFonts = async () => {\n        try {\n          await Promise.race([document.fonts.ready, timeout]);\n        } catch {\n          // A failed font load still gets a first paint.\n        }\n        begin();\n      };\n      void beginAfterFonts();\n    } else {\n      begin();\n    }\n\n    root.addEventListener(\"pointerdown\", onPointerDown);\n    root.addEventListener(\"pointermove\", onPointerMove);\n    root.addEventListener(\"pointerup\", endDrag);\n    root.addEventListener(\"pointercancel\", endDrag);\n    root.addEventListener(\"keydown\", onKeyDown);\n    document.addEventListener(\"visibilitychange\", syncLoop);\n\n    return () => {\n      cancelled = true;\n      stopLoop();\n      if (fontTimer) {\n        window.clearTimeout(fontTimer);\n      }\n      if (idleTimer) {\n        window.clearTimeout(idleTimer);\n      }\n      ro.disconnect();\n      io.disconnect();\n      root.removeEventListener(\"pointerdown\", onPointerDown);\n      root.removeEventListener(\"pointermove\", onPointerMove);\n      root.removeEventListener(\"pointerup\", endDrag);\n      root.removeEventListener(\"pointercancel\", endDrag);\n      root.removeEventListener(\"keydown\", onKeyDown);\n      document.removeEventListener(\"visibilitychange\", syncLoop);\n    };\n  }, []);\n\n  return (\n    <div\n      ref={rootRef}\n      // A scrubbable scalar, not a picture: drag or arrow-key it through the\n      // three screens. The label carries what the canvas shows.\n      // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- a range input cannot host the canvas; the keyboard and aria-value contract is the same\n      role=\"slider\"\n      tabIndex={0}\n      aria-label=\"An ASCII trading terminal whose screen disintegrates into embers and reassembles as the next screen\"\n      aria-valuemin={0}\n      aria-valuemax={100}\n      aria-valuenow={0}\n      style={{\n        background: BG_LIGHT,\n        color: \"var(--terminal-fg, #1c5f27)\",\n        cursor: \"grab\",\n        height: \"100%\",\n        overflow: \"hidden\",\n        position: \"relative\",\n        // Same reasoning as the missing wheel handler: this lives in a\n        // scrolling feed. The browser keeps vertical pans, horizontal drags\n        // still reach the scrubber.\n        touchAction: \"pan-y\",\n        userSelect: \"none\",\n        width: \"100%\",\n      }}\n    >\n      <div\n        ref={bgRef}\n        aria-hidden\n        style={{\n          background: BG_LIGHT,\n          inset: 0,\n          pointerEvents: \"none\",\n          position: \"absolute\",\n          zIndex: 0,\n        }}\n      />\n\n      <div\n        aria-hidden\n        style={{\n          background: \"radial-gradient(ellipse at center, transparent 35%, rgba(0,0,0,0.06) 95%)\",\n          inset: 0,\n          mixBlendMode: \"multiply\",\n          pointerEvents: \"none\",\n          position: \"absolute\",\n          zIndex: 1,\n        }}\n      />\n\n      <canvas\n        ref={canvasRef}\n        aria-hidden\n        style={{ inset: 0, pointerEvents: \"none\", position: \"absolute\", zIndex: 2 }}\n      />\n\n      <div\n        ref={chipRef}\n        aria-hidden\n        style={{\n          fontFamily: MONO_FAMILY,\n          fontSize: 11,\n          left: 22,\n          letterSpacing: \"0.25em\",\n          opacity: 0,\n          pointerEvents: \"none\",\n          position: \"absolute\",\n          textTransform: \"uppercase\",\n          top: 16,\n          zIndex: 4,\n        }}\n      >\n        terminal · 01 / 03\n      </div>\n\n      <div\n        ref={hintRef}\n        aria-hidden\n        style={{\n          bottom: 16,\n          fontFamily: MONO_FAMILY,\n          fontSize: 10,\n          left: 22,\n          letterSpacing: \"0.3em\",\n          opacity: 0,\n          pointerEvents: \"none\",\n          position: \"absolute\",\n          textTransform: \"uppercase\",\n          zIndex: 4,\n        }}\n      >\n        drag · disintegrate · resolve\n      </div>\n\n      <div\n        ref={railTrackRef}\n        aria-hidden\n        style={{\n          background: \"currentColor\",\n          bottom: \"7%\",\n          left: 0,\n          opacity: 0,\n          pointerEvents: \"none\",\n          position: \"absolute\",\n          top: \"7%\",\n          width: 2,\n          zIndex: 4,\n        }}\n      />\n\n      <div\n        ref={railFillRef}\n        aria-hidden\n        style={{\n          background: \"currentColor\",\n          height: \"86%\",\n          left: 0,\n          pointerEvents: \"none\",\n          position: \"absolute\",\n          top: \"7%\",\n          transform: \"scaleY(0)\",\n          transformOrigin: \"top\",\n          width: 2,\n          zIndex: 4,\n        }}\n      />\n    </div>\n  );\n};\n","path":"/terminal-disintegrate.tsx","target":"uicapsule/terminal-disintegrate/terminal-disintegrate.tsx","type":"registry:file"},{"content":"import type { Grid } from \"./terminal-grid\";\n\nimport { ASKS, BEAR_CURVE, BIDS, CANDLES, GREEKS, OPTION_CHAIN, RISK } from \"./terminal-data\";\nimport {\n  drawBar,\n  drawBigNumber,\n  drawBox,\n  drawHRule,\n  drawText,\n  drawTextRight,\n  INNER_COLS,\n  INNER_ROWS,\n  makeGrid,\n  OUTER_COLS,\n  OUTER_ROWS,\n  pasteInto,\n  SCREEN_X,\n  SCREEN_Y,\n  setCell,\n  vbarChar,\n} from \"./terminal-grid\";\n\n// ---------------------------------------------------------------------------\n// Phone shell — bezel rails, rounded stubs, side buttons, Dynamic Island\n// ---------------------------------------------------------------------------\n\nconst drawPhoneBezel = (g: Grid) => {\n  const cols = OUTER_COLS;\n  const rows = OUTER_ROWS;\n  const L_OUTER = 1;\n  const L_INNER = 2;\n  const R_INNER = cols - 3;\n  const R_OUTER = cols - 2;\n\n  // Top edge with rounded-corner stubs\n  setCell(g, 0, L_OUTER, \"▗\", 1);\n  setCell(g, 0, R_OUTER, \"▖\", 1);\n  for (let c = L_OUTER + 1; c < R_OUTER; c += 1) {\n    setCell(g, 0, c, \"▀\", 1);\n  }\n\n  // Side rails\n  for (let r = 1; r < rows - 1; r += 1) {\n    setCell(g, r, L_OUTER, \"▌\", 1);\n    setCell(g, r, L_INNER, \"█\", 1);\n    setCell(g, r, R_INNER, \"█\", 1);\n    setCell(g, r, R_OUTER, \"▐\", 1);\n  }\n\n  // Bottom edge with mirror stubs\n  setCell(g, rows - 1, L_OUTER, \"▝\", 1);\n  setCell(g, rows - 1, R_OUTER, \"▘\", 1);\n  for (let c = L_OUTER + 1; c < R_OUTER; c += 1) {\n    setCell(g, rows - 1, c, \"▄\", 1);\n  }\n\n  // Side buttons — left rail action + volume rockers, right rail sleep\n  const leftBtn = (r0: number, r1: number) => {\n    for (let r = r0; r <= r1; r += 1) {\n      setCell(g, r, 0, \"▌\", 1);\n    }\n  };\n  const rightBtn = (r0: number, r1: number) => {\n    for (let r = r0; r <= r1; r += 1) {\n      setCell(g, r, cols - 1, \"▐\", 1);\n    }\n  };\n\n  leftBtn(8, 9);\n  leftBtn(13, 15);\n  leftBtn(17, 19);\n  rightBtn(14, 19);\n\n  // Dynamic Island — pill with camera + Face ID cut-outs\n  const diW = 14;\n  const diStart = Math.floor((cols - diW) / 2);\n\n  for (let c = 0; c < diW; c += 1) {\n    setCell(g, 1, diStart + c, \"▄\", 1);\n  }\n  for (let c = 0; c < diW; c += 1) {\n    if (c === 2) {\n      continue;\n    }\n    if (c === diW - 3) {\n      continue;\n    }\n    setCell(g, 2, diStart + c, \"█\", 1);\n  }\n  for (let c = 0; c < diW; c += 1) {\n    setCell(g, 3, diStart + c, \"▀\", 1);\n  }\n};\n\nconst statusBarTop = (g: Grid) => {\n  drawText(g, 2, 5, \"9:41\", 0.85);\n  drawText(g, 2, 38, \"●●●\", 0.55);\n  drawText(g, 2, 42, \"78%\", 0.8);\n  setCell(g, 2, 46, \"█\", 0.85);\n};\n\nconst TABS = [\n  { iconCol: 3, label: \"home\", labelCol: 1 },\n  { iconCol: 11, label: \"trade\", labelCol: 8 },\n  { iconCol: 19, label: \"book\", labelCol: 17 },\n  { iconCol: 27, label: \"acct\", labelCol: 25 },\n  { iconCol: 35, label: \"more\", labelCol: 33 },\n] as const;\n\nconst drawTabBar = (inner: Grid, activeIndex: number) => {\n  drawHRule(inner, 49, 0, INNER_COLS, \"─\", 0.22);\n\n  for (const [i, tab] of TABS.entries()) {\n    const active = i === activeIndex;\n    setCell(inner, 50, tab.iconCol, active ? \"◆\" : \"◇\", active ? 0.9 : 0.45);\n    drawText(inner, 51, tab.labelCol, tab.label, active ? 0.8 : 0.45);\n  }\n\n  drawHRule(inner, 53, 16, 8, \"▬\", 0.6);\n};\n\n// ---------------------------------------------------------------------------\n// Phase 1 — BTC spot trade\n// ---------------------------------------------------------------------------\n\nconst drawCandles = (inner: Grid) => {\n  const top = 9;\n  const bottom = 23;\n  const h = bottom - top;\n  const x0 = 5;\n  const cw = 2;\n\n  let min = Infinity;\n  let max = -Infinity;\n  for (const k of CANDLES) {\n    min = Math.min(min, k.l);\n    max = Math.max(max, k.h);\n  }\n  const span = max - min || 1;\n  const yOf = (price: number) => top + Math.round((1 - (price - min) / span) * h);\n\n  drawText(inner, top, 0, \"108K\", 0.45);\n  drawText(inner, top + Math.round(h / 2), 0, \"107K\", 0.45);\n  drawText(inner, bottom, 0, \"106K\", 0.45);\n\n  for (const [i, k] of CANDLES.entries()) {\n    const cx = x0 + i * cw;\n    const bull = k.c >= k.o;\n    const bodyChar = bull ? \"█\" : \"▒\";\n    const bodyAlpha = bull ? 0.98 : 0.55;\n    const hi = yOf(k.h);\n    const lo = yOf(k.l);\n    const bodyTop = Math.min(yOf(k.o), yOf(k.c));\n    const bodyBot = Math.max(yOf(k.o), yOf(k.c));\n\n    for (let r = hi; r <= lo; r += 1) {\n      setCell(inner, r, cx, \"│\", 0.5);\n    }\n    for (let r = bodyTop; r <= bodyBot; r += 1) {\n      for (let x = 0; x < cw; x += 1) {\n        setCell(inner, r, cx + x, bodyChar, bodyAlpha);\n      }\n    }\n  }\n\n  const last = CANDLES.at(-1);\n  if (!last) {\n    return;\n  }\n\n  const lastRow = yOf(last.c);\n  for (let c = x0; c < 39; c += 1) {\n    setCell(inner, lastRow, c, \"┄\", 0.3);\n  }\n  // Alpha 1 is load-bearing: the last-price row crosses the final candles and\n  // `setCell` drops any write that a brighter cell already occupies, so\n  // anything at or below the 0.98 bull body would come out half-eaten.\n  drawText(inner, lastRow, 33, \"107.4K\", 1);\n};\n\nconst drawOrderBook = (inner: Grid) => {\n  drawText(inner, 27, 0, \"ORDER BOOK\", 0.6);\n  drawTextRight(inner, 27, 39, \"depth 4.32 BTC\", 0.5);\n  drawHRule(inner, 28, 0, INNER_COLS, \"─\", 0.22);\n\n  const maxSize = Math.max(...ASKS.map((a) => a.size), ...BIDS.map((b) => b.size));\n  const barCol = 13;\n  const barMax = 11;\n\n  for (const [i, a] of ASKS.entries()) {\n    const r = 29 + i;\n    drawText(inner, r, 0, `ASK ${a.price}`, 0.7);\n    drawBar(inner, r, barCol, Math.max(1, Math.round((a.size / maxSize) * barMax)), \"▒\", 0.55);\n    drawTextRight(inner, r, 34, a.size.toFixed(3), 0.7);\n    setCell(inner, r, 37, \"↑\", 0.55);\n  }\n\n  drawText(inner, 33, 9, \"── spread $4.00 ──\", 0.4);\n\n  for (const [i, b] of BIDS.entries()) {\n    const r = 34 + i;\n    drawText(inner, r, 0, `BID ${b.price}`, 0.7);\n    drawBar(inner, r, barCol, Math.max(1, Math.round((b.size / maxSize) * barMax)), \"▓\", 0.7);\n    drawTextRight(inner, r, 34, b.size.toFixed(3), 0.7);\n    setCell(inner, r, 37, \"↓\", 0.55);\n  }\n};\n\nexport const buildBtcFrame = (): Grid => {\n  const outer = makeGrid(OUTER_COLS, OUTER_ROWS);\n  drawPhoneBezel(outer);\n  statusBarTop(outer);\n\n  const inner = makeGrid(INNER_COLS, INNER_ROWS);\n\n  drawText(inner, 0, 0, \"←  BTC · USD\", 0.9);\n  setCell(inner, 0, 38, \"…\", 0.6);\n  drawText(inner, 1, 3, \"spot · usdt-margined\", 0.5);\n  setCell(inner, 1, 38, \"◆\", 0.7);\n\n  drawText(inner, 3, 0, \"$107,432.18\", 0.98);\n  drawText(inner, 4, 0, \"▲ +2.47%   +$2,584.42 today\", 0.7);\n  drawTextRight(inner, 4, 39, \"live ●\", 0.75);\n\n  const tfs = [\"1H\", \"1D\", \"1W\", \"1M\", \"3M\", \"1Y\"];\n  for (const [i, t] of tfs.entries()) {\n    drawText(inner, 6, i * 6, t, i === 3 ? 0.9 : 0.45);\n  }\n  drawText(inner, 7, 18, \"──\", 0.8);\n\n  drawCandles(inner);\n\n  const maxV = Math.max(...CANDLES.map((k) => k.v));\n  for (const [i, k] of CANDLES.entries()) {\n    const cx = 5 + i * 2;\n    const ch = vbarChar(k.v / maxV);\n    setCell(inner, 25, cx, ch, 0.5);\n    setCell(inner, 25, cx + 1, ch, 0.5);\n  }\n\n  drawOrderBook(inner);\n\n  drawText(inner, 39, 0, \"wallet\", 0.55);\n  drawText(inner, 40, 0, \"0.4218 BTC\", 0.85);\n  drawTextRight(inner, 40, 39, \"≈ $45,318.86\", 0.85);\n\n  drawBox(inner, 42, 0, 19, 4, 0.95);\n  drawText(inner, 43, 8, \"BUY\", 0.95);\n  drawText(inner, 44, 8, \"BUY\", 0.95);\n  drawBox(inner, 42, 20, 19, 4, 0.55);\n  drawText(inner, 43, 27, \"SELL\", 0.55);\n  drawText(inner, 44, 27, \"SELL\", 0.55);\n\n  drawTabBar(inner, 1);\n\n  pasteInto(outer, inner, SCREEN_Y, SCREEN_X);\n  return outer;\n};\n\n// ---------------------------------------------------------------------------\n// Phase 2 — XAU options chain\n// ---------------------------------------------------------------------------\n\nconst drawPayoff = (inner: Grid) => {\n  const top = 25;\n  const lossRow = 33;\n  const zero = 31;\n  const strikeCol = 14;\n\n  drawHRule(inner, zero, 1, 38, \"┈\", 0.16);\n  for (let c = 1; c <= strikeCol; c += 1) {\n    setCell(inner, lossRow, c, \"─\", 0.6);\n  }\n\n  let prevRow = lossRow;\n  for (let k = 1; k <= 24; k += 1) {\n    const col = strikeCol + k;\n    if (col > 39) {\n      break;\n    }\n    const row = Math.max(top, Math.round(lossRow - k * 0.62));\n    setCell(inner, row, col, \"╱\", 0.72);\n    if (prevRow - row > 1) {\n      for (let rr = row + 1; rr < prevRow; rr += 1) {\n        setCell(inner, rr, col, \"╱\", 0.5);\n      }\n    }\n    prevRow = row;\n  }\n\n  const beCol = strikeCol + 6;\n  for (let r = top; r <= lossRow; r += 1) {\n    setCell(inner, r, beCol, \"┊\", 0.22);\n  }\n  // Sits on the clamped plateau of the ramp, so it has to outrank the 0.72\n  // slashes to read as a label rather than a stray character.\n  drawTextRight(inner, top, 39, \"+max\", 0.8);\n};\n\nexport const buildGoldFrame = (): Grid => {\n  const outer = makeGrid(OUTER_COLS, OUTER_ROWS);\n  drawPhoneBezel(outer);\n  statusBarTop(outer);\n\n  const inner = makeGrid(INNER_COLS, INNER_ROWS);\n\n  drawText(inner, 0, 0, \"←  XAU · OPTIONS\", 0.9);\n  setCell(inner, 0, 38, \"…\", 0.6);\n  drawText(inner, 1, 3, \"26 dec · opt · usd\", 0.5);\n  setCell(inner, 1, 38, \"●\", 0.7);\n\n  drawText(inner, 3, 0, \"$2,684.50\", 0.98);\n  drawText(inner, 4, 0, \"▼ -0.32%   -$8.65 today\", 0.7);\n  drawTextRight(inner, 4, 39, \"iv30 13.8\", 0.6);\n\n  const exps = [\"NOV\", \"DEC\", \"JAN\", \"MAR\", \"JUN\", \"LEAP\"];\n  let ec = 0;\n  for (const [i, e] of exps.entries()) {\n    const active = i === 1;\n    drawText(inner, 6, ec, active ? `${e}*` : e, active ? 0.9 : 0.45);\n    ec += e.length + (active ? 1 : 0) + 2;\n  }\n  drawText(inner, 7, 5, \"──\", 0.8);\n\n  const cCallBid = 6;\n  const cCallIv = 12;\n  const cK = 17;\n  const cPutIv = 23;\n  const cPutBid = 29;\n\n  drawText(inner, 9, 0, \"CALL\", 0.55);\n  drawText(inner, 9, cCallBid, \"bid\", 0.45);\n  drawText(inner, 9, cCallIv, \"iv\", 0.45);\n  drawText(inner, 9, cK + 1, \"K\", 0.55);\n  drawText(inner, 9, cPutIv, \"iv\", 0.45);\n  drawText(inner, 9, cPutBid, \"bid\", 0.45);\n  drawTextRight(inner, 9, 39, \"PUT\", 0.55);\n  drawHRule(inner, 10, 0, INNER_COLS, \"─\", 0.22);\n\n  for (const [i, row] of OPTION_CHAIN.entries()) {\n    const r = 11 + i;\n    if (row.highlight) {\n      for (let c = 0; c < INNER_COLS; c += 1) {\n        setCell(inner, r, c, \"·\", 0.14);\n      }\n    }\n    drawText(inner, r, cCallBid, row.callBid.toFixed(2), 0.82);\n    drawText(inner, r, cCallIv, row.callIv.toFixed(1), 0.45);\n    drawText(inner, r, cK, String(row.strike), row.highlight ? 0.98 : 0.7);\n    drawText(inner, r, cPutIv, row.putIv.toFixed(1), 0.45);\n    drawText(inner, r, cPutBid, row.putBid.toFixed(2), 0.82);\n    if (row.highlight) {\n      // Hugging the strike: cK-2 lands on the last digit of the call IV and\n      // cK+4 is the only free column on the put side, so the pair has to sit\n      // tight against the 4-digit strike rather than one cell out.\n      setCell(inner, r, cK - 1, \"►\", 0.9);\n      setCell(inner, r, cK + 4, \"◄\", 0.9);\n    }\n  }\n\n  drawText(inner, 20, 0, \"GREEKS\", 0.6);\n  drawText(inner, 20, 9, `delta ${GREEKS.delta.toFixed(2)}`, 0.7);\n  drawText(inner, 20, 22, `gamma ${GREEKS.gamma.toFixed(3)}`, 0.7);\n  drawText(inner, 21, 0, `theta ${GREEKS.theta.toFixed(2)}`, 0.7);\n  drawText(inner, 21, 13, `vega ${GREEKS.vega.toFixed(2)}`, 0.7);\n  drawText(inner, 21, 25, `rho ${GREEKS.rho.toFixed(2)}`, 0.7);\n\n  drawText(inner, 23, 0, \"P/L AT EXPIRY\", 0.6);\n  drawTextRight(inner, 23, 39, \"long call ►\", 0.5);\n  drawHRule(inner, 24, 0, INNER_COLS, \"─\", 0.22);\n  drawPayoff(inner);\n  drawText(inner, 35, 12, \"2680\", 0.55);\n  drawText(inner, 35, 18, \"BE 2697\", 0.45);\n\n  drawBox(inner, 39, 0, 12, 3, 0.95);\n  drawText(inner, 40, 4, \"LONG\", 0.95);\n  drawBox(inner, 39, 13, 12, 3, 0.55);\n  drawText(inner, 40, 16, \"SHORT\", 0.55);\n  drawBox(inner, 39, 26, 14, 3, 0.55);\n  drawText(inner, 40, 29, \"SPREAD\", 0.55);\n\n  drawText(inner, 43, 0, \"size  1×  100 oz\", 0.7);\n  drawTextRight(inner, 43, 39, \"cost  $2,605\", 0.7);\n  drawHRule(inner, 44, 0, INNER_COLS, \"─\", 0.22);\n  drawText(inner, 45, 0, \"notional  $268,450\", 0.7);\n  drawTextRight(inner, 45, 39, \"margin 12 %\", 0.6);\n\n  drawTabBar(inner, 2);\n\n  pasteInto(outer, inner, SCREEN_Y, SCREEN_X);\n  return outer;\n};\n\n// ---------------------------------------------------------------------------\n// Phase 3 — Drawdown stats card\n// ---------------------------------------------------------------------------\n\n// Line segment glyph for a step from row `pr` to row `r` (rows grow downward).\nconst slopeChar = (r: number, pr: number): string => {\n  if (r < pr) {\n    return \"╱\";\n  }\n  if (r > pr) {\n    return \"╲\";\n  }\n  return \"─\";\n};\n\n// Area fill under the curve, thinning with distance `d` (0..1) below the line.\nconst fillChar = (d: number): string => {\n  if (d < 0.4) {\n    return \"▒\";\n  }\n  if (d < 0.75) {\n    return \"░\";\n  }\n  return \"·\";\n};\n\nconst drawBearChart = (inner: Grid) => {\n  const top = 32;\n  const bottom = 43;\n  const h = bottom - top;\n  const x0 = 5;\n  const x1 = 39;\n  const w = x1 - x0;\n  const vals = BEAR_CURVE;\n\n  const [first] = vals;\n  if (first === undefined) {\n    return;\n  }\n\n  let min = Infinity;\n  let max = -Infinity;\n  for (const v of vals) {\n    min = Math.min(min, v);\n    max = Math.max(max, v);\n  }\n  const span = max - min || 1;\n  const colOf = (i: number) => x0 + Math.round((i / (vals.length - 1)) * w);\n  const rowOf = (v: number) => top + Math.round((1 - (v - min) / span) * h);\n\n  drawText(inner, top, 0, \"131\", 0.45);\n  drawText(inner, top + Math.round(h / 2), 0, \"107\", 0.45);\n  drawText(inner, bottom, 0, \"83\", 0.45);\n\n  let peakI = 0;\n  let peakV = first;\n  let troughI = 0;\n  let troughV = first;\n  for (const [i, v] of vals.entries()) {\n    if (v > peakV) {\n      peakV = v;\n      peakI = i;\n    }\n    if (v < troughV) {\n      troughV = v;\n      troughI = i;\n    }\n  }\n\n  for (const [i, v] of vals.entries()) {\n    const c = colOf(i);\n    const r = rowOf(v);\n\n    const prev = vals[i - 1];\n    if (i > 0 && prev !== undefined) {\n      const pc = colOf(i - 1);\n      const pr = rowOf(prev);\n      const steps = Math.max(1, c - pc);\n      for (let s = 1; s < steps; s += 1) {\n        const ix = pc + s;\n        const iy = Math.round(pr + ((r - pr) * s) / steps);\n        setCell(inner, iy, ix, slopeChar(r, pr), 0.5);\n      }\n    }\n\n    setCell(inner, r, c, \"▪\", 0.85);\n    for (let rr = r + 1; rr <= bottom; rr += 1) {\n      const d = (rr - r) / Math.max(1, bottom - r);\n      setCell(inner, rr, c, fillChar(d), 0.18);\n    }\n  }\n\n  setCell(inner, rowOf(peakV) - 1, colOf(peakI), \"▼\", 0.6);\n  setCell(inner, rowOf(troughV) + 1, colOf(troughI), \"▲\", 0.6);\n\n  // One row below the trough marker, not level with it: the trough is by\n  // definition the chart floor, so its ▲ always lands on `bottom + 1` and would\n  // swallow whichever tick shares that column.\n  const years = [\"21\", \"22\", \"23\", \"24\", \"25\"];\n  for (const [i, y] of years.entries()) {\n    const c = x0 + Math.round((i / (years.length - 1)) * w);\n    setCell(inner, bottom + 2, c, \"┴\", 0.35);\n    drawText(inner, bottom + 3, Math.min(36, c), `'${y}`, 0.4);\n  }\n};\n\nexport const buildStatsFrame = (): Grid => {\n  const outer = makeGrid(OUTER_COLS, OUTER_ROWS);\n  drawPhoneBezel(outer);\n  statusBarTop(outer);\n\n  const inner = makeGrid(INNER_COLS, INNER_ROWS);\n\n  drawText(inner, 1, 0, \"DRAWDOWN PROFILE\", 0.85);\n  drawText(inner, 2, 0, \"five-year tape · marked to market\", 0.45);\n  drawHRule(inner, 4, 0, INNER_COLS, \"─\", 0.22);\n\n  const leftX = 0;\n  const rightX = 21;\n\n  drawBigNumber(inner, 6, leftX, RISK.sharpe, 0.95, 0);\n  drawText(inner, 12, leftX, \"SHARPE RATIO\", 0.6);\n  drawText(inner, 13, leftX, \"paid for the risk\", 0.4);\n  drawText(inner, 14, leftX, \"you took\", 0.4);\n\n  drawBigNumber(inner, 6, rightX, RISK.maxDrawdown, 0.95, 0);\n  drawText(inner, 12, rightX, \"MAX DRAWDOWN\", 0.6);\n  drawText(inner, 13, rightX, \"the year you'd\", 0.4);\n  drawText(inner, 14, rightX, \"rather forget\", 0.4);\n\n  drawBigNumber(inner, 17, leftX, RISK.vol, 0.95, 0);\n  drawText(inner, 23, leftX, \"ANNUAL VOL\", 0.6);\n  drawText(inner, 24, leftX, \"one σ of monthly\", 0.4);\n  drawText(inner, 25, leftX, \"log returns\", 0.4);\n\n  drawBigNumber(inner, 17, rightX, RISK.cagr, 0.95, 0);\n  drawText(inner, 23, rightX, \"CAGR · 5Y\", 0.6);\n  drawText(inner, 24, rightX, \"compounded since\", 0.4);\n  drawText(inner, 25, rightX, \"inception\", 0.4);\n\n  drawHRule(inner, 29, 0, INNER_COLS, \"─\", 0.22);\n  drawText(inner, 30, 0, \"EQUITY CURVE · INDEXED TO 100\", 0.6);\n  drawTextRight(inner, 30, 39, \"5Y · USD\", 0.45);\n\n  drawBearChart(inner);\n\n  drawHRule(inner, 49, 0, INNER_COLS, \"─\", 0.22);\n  drawText(inner, 51, 0, \"marked daily · 1,260 obs\", 0.5);\n  drawTextRight(inner, 51, 39, \"self · usd\", 0.4);\n\n  pasteInto(outer, inner, SCREEN_Y, SCREEN_X);\n  return outer;\n};\n","path":"/terminal-frames.ts","target":"uicapsule/terminal-disintegrate/terminal-frames.ts","type":"registry:file"},{"content":"// The ASCII art is a grid of single characters, each carrying an alpha.\n// Every frame builder mutates a Grid in place through these pure primitives.\n\nexport const OUTER_COLS = 50;\nexport const OUTER_ROWS = 60;\nexport const INNER_COLS = 40;\nexport const INNER_ROWS = 54;\nexport const SCREEN_X = 5;\nexport const SCREEN_Y = 4;\n\nexport interface Cell {\n  char: string;\n  alpha: number;\n}\nexport type Grid = Cell[][];\n\nexport const makeGrid = (cols: number, rows: number): Grid => {\n  const g: Grid = [];\n\n  for (let r = 0; r < rows; r += 1) {\n    const row: Cell[] = [];\n    for (let c = 0; c < cols; c += 1) {\n      row.push({ alpha: 0, char: \" \" });\n    }\n    g.push(row);\n  }\n\n  return g;\n};\n\n// Higher-alpha writes win on overlap; a space never erases an existing glyph.\nexport const setCell = (g: Grid, r: number, c: number, char: string, alpha: number) => {\n  const row = g[r];\n  if (!row) {\n    return;\n  }\n\n  const cur = row[c];\n  if (!cur) {\n    return;\n  }\n\n  if (char === \" \" && cur.char !== \" \") {\n    return;\n  }\n  if (cur.alpha > alpha && cur.char !== \" \") {\n    return;\n  }\n\n  cur.char = char;\n  cur.alpha = alpha;\n};\n\nexport const drawText = (g: Grid, r: number, c: number, text: string, alpha: number) => {\n  for (let i = 0; i < text.length; i += 1) {\n    setCell(g, r, c + i, text.charAt(i), alpha);\n  }\n};\n\nexport const drawTextRight = (g: Grid, r: number, cRight: number, text: string, alpha: number) => {\n  drawText(g, r, cRight - text.length + 1, text, alpha);\n};\n\nexport const drawHRule = (g: Grid, r: number, c: number, w: number, char = \"─\", alpha = 0.25) => {\n  for (let i = 0; i < w; i += 1) {\n    setCell(g, r, c + i, char, alpha);\n  }\n};\n\nexport const drawBar = (g: Grid, r: number, c: number, w: number, char = \"▒\", alpha = 0.55) => {\n  for (let i = 0; i < w; i += 1) {\n    setCell(g, r, c + i, char, alpha);\n  }\n};\n\nexport const drawBox = (g: Grid, r: number, c: number, w: number, h: number, alpha = 0.7) => {\n  setCell(g, r, c, \"╭\", alpha);\n  setCell(g, r, c + w - 1, \"╮\", alpha);\n  setCell(g, r + h - 1, c, \"╰\", alpha);\n  setCell(g, r + h - 1, c + w - 1, \"╯\", alpha);\n\n  for (let x = 1; x < w - 1; x += 1) {\n    setCell(g, r, c + x, \"─\", alpha);\n    setCell(g, r + h - 1, c + x, \"─\", alpha);\n  }\n\n  for (let y = 1; y < h - 1; y += 1) {\n    setCell(g, r + y, c, \"│\", alpha);\n    setCell(g, r + y, c + w - 1, \"│\", alpha);\n  }\n};\n\nconst VBARS = [\"▁\", \"▂\", \"▃\", \"▄\", \"▅\", \"▆\", \"▇\", \"█\"] as const;\n\nexport const vbarChar = (v01: number): string => {\n  const v = v01 < 0 ? 0 : Math.min(1, v01);\n  return VBARS[Math.min(VBARS.length - 1, Math.floor(v * VBARS.length))] ?? \"█\";\n};\n\nexport const pasteInto = (outer: Grid, inner: Grid, rOff: number, cOff: number) => {\n  for (let r = 0; r < inner.length; r += 1) {\n    const row = inner[r];\n    if (!row) {\n      continue;\n    }\n\n    for (let c = 0; c < row.length; c += 1) {\n      const cell = row[c];\n      if (!cell || cell.char === \" \" || cell.alpha <= 0) {\n        continue;\n      }\n      setCell(outer, rOff + r, cOff + c, cell.char, cell.alpha);\n    }\n  }\n};\n\n// 5-row block-digit font for the Phase 3 hero stats.\ntype BigGlyph = readonly [string, string, string, string, string];\n\nconst BLANK_GLYPH: BigGlyph = [\"   \", \"   \", \"   \", \"   \", \"   \"];\n\nconst BIG_DIGITS = new Map<string, BigGlyph>(\n  Object.entries({\n    \" \": BLANK_GLYPH,\n    $: [\" ╭╴\", \"╶┼╮\", \" │ \", \"╶┼╯\", \" ╰╴\"],\n    \"%\": [\"▪ ╱\", \" ╱ \", \" ╱ \", \"╱  \", \"╱ ▪\"],\n    \"+\": [\"   \", \" │ \", \"╶┼╴\", \" │ \", \"   \"],\n    // Period and comma are drawn 2 columns wide (see `drawBigNumber`), so their\n    // art has to live in the first two columns — a third column is never read.\n    \",\": [\"   \", \"   \", \"   \", \" ╷ \", \"╶╯ \"],\n    \"-\": [\"   \", \"   \", \"╶─╴\", \"   \", \"   \"],\n    \".\": [\"   \", \"   \", \"   \", \"   \", \" ▪ \"],\n    \"0\": [\"╭─╮\", \"│ │\", \"│ │\", \"│ │\", \"╰─╯\"],\n    \"1\": [\"╶┐ \", \" │ \", \" │ \", \" │ \", \"╶┴╴\"],\n    \"2\": [\"╭─╮\", \"  │\", \"╭─╯\", \"│  \", \"╰─╴\"],\n    \"3\": [\"╭─╮\", \"  │\", \" ─┤\", \"  │\", \"╰─╯\"],\n    \"4\": [\"╷ ╷\", \"│ │\", \"╰─┤\", \"  │\", \"  ╵\"],\n    \"5\": [\"╭─╴\", \"│  \", \"╰─╮\", \"  │\", \"╰─╯\"],\n    \"6\": [\"╭─╴\", \"│  \", \"├─╮\", \"│ │\", \"╰─╯\"],\n    \"7\": [\"╶─╮\", \"  │\", \"  │\", \"  │\", \"  ╵\"],\n    \"8\": [\"╭─╮\", \"│ │\", \"├─┤\", \"│ │\", \"╰─╯\"],\n    \"9\": [\"╭─╮\", \"│ │\", \"╰─┤\", \"  │\", \"╶─╯\"],\n  } satisfies Record<string, BigGlyph>),\n);\n\n// Walks a string, writing each block glyph. Period and comma consume 2 cols\n// so the number reads as one tight typographic unit. `gap` is the inter-glyph\n// spacing in cells.\nexport const drawBigNumber = (\n  g: Grid,\n  r: number,\n  c: number,\n  text: string,\n  alpha: number,\n  gap = 1,\n) => {\n  let col = c;\n\n  for (const ch of text) {\n    const glyph = BIG_DIGITS.get(ch) ?? BLANK_GLYPH;\n    const w = ch === \".\" || ch === \",\" ? 2 : 3;\n\n    for (let row = 0; row < 5; row += 1) {\n      const line = glyph[row];\n      if (line === undefined) {\n        continue;\n      }\n\n      for (let x = 0; x < w; x += 1) {\n        const gch = line.charAt(x);\n        if (gch && gch !== \" \") {\n          setCell(g, r + row, col + x, gch, alpha);\n        }\n      }\n    }\n\n    col += w + gap;\n  }\n};\n","path":"/terminal-grid.ts","target":"uicapsule/terminal-disintegrate/terminal-grid.ts","type":"registry:file"},{"content":"import type { Cell, Grid } from \"./terminal-grid\";\n\nimport { OUTER_COLS, OUTER_ROWS } from \"./terminal-grid\";\n\n// ---------------------------------------------------------------------------\n// Math helpers — all pure and deterministic\n// ---------------------------------------------------------------------------\n\nconst lerp = (a: number, b: number, t: number): number => a + (b - a) * t;\nexport const clamp01 = (t: number): number => (t < 0 ? 0 : Math.min(1, t));\n\nexport const smoothstep = (t: number): number => {\n  const x = clamp01(t);\n  return x * x * (3 - 2 * x);\n};\n\nconst easeInOutCubic = (t: number): number => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);\n\n// Mulberry32 — seeded, so scrubbing forwards/backwards is identical every time.\nconst seededRng = (seed: number): (() => number) => {\n  /* oxlint-disable no-bitwise, unicorn/prefer-math-trunc -- Mulberry32 is int32 bit math; `| 0` is the wrap Math.trunc would drop */\n  let s = seed | 0 || 1;\n  return () => {\n    s = (s + 0x6d_2b_79_f5) | 0;\n    let t = s;\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n    return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296;\n  };\n  /* oxlint-enable no-bitwise, unicorn/prefer-math-trunc */\n};\n\nconst curl = (seed: number, t: number, axis: number): number => {\n  const base = seed * 0.0173 + axis * 7.91;\n  return Math.sin(base + t * 5.21) * 0.6 + Math.sin(base * 1.6 + t * 11.4) * 0.4;\n};\n\n// Curl is a free-flight wobble, so it has to vanish at both ends of the flight.\n// `curl` itself is ~uniform in [-1,1] at local 0 and 1, but the renderer snaps\n// to the exact endpoints outside [begin, end] — leaving the offset in place put\n// a whole cloud's worth of glyphs up to a full `curlAmp` away from where they\n// land, so they teleported on the frame they arrived. Half a sine window keeps\n// the mid-flight amplitude (the part that is the tuning) and removes the jump.\nconst curlWindow = (local: number): number => Math.sin(Math.PI * local);\n\n// ---------------------------------------------------------------------------\n// Cell pixel sizing — the aspect ratio that makes the grid read as an iPhone\n// ---------------------------------------------------------------------------\n\n// A monospace cell is roughly 0.572 as wide as it is tall. Holding that ratio\n// is what turns a 50x60 character grid into a phone-shaped object.\nconst CELL_ASPECT = 0.572;\n\nexport interface CellMetrics {\n  cellW: number;\n  cellH: number;\n  canvasW: number;\n  canvasH: number;\n}\n\n// Sized against the stage box, not the viewport: the component lives in an\n// arbitrarily-sized frame and must never clip its own bezel.\nexport const cellMetrics = (stageW: number, stageH: number): CellMetrics => {\n  const fitH = (stageH * 0.9) / OUTER_ROWS;\n  const fitW = (stageW * 0.92) / (OUTER_COLS * CELL_ASPECT);\n\n  const raw = Math.min(fitH, fitW);\n  const cellH = Math.max(2.4, Math.min(16, Math.round(raw * 100) / 100));\n  const cellW = Math.round(cellH * CELL_ASPECT * 100) / 100;\n\n  return {\n    canvasH: cellH * OUTER_ROWS,\n    canvasW: cellW * OUTER_COLS,\n    cellH,\n    cellW,\n  };\n};\n\n// ---------------------------------------------------------------------------\n// Particles\n// ---------------------------------------------------------------------------\n\nexport interface Particle {\n  char: string;\n  x0: number;\n  y0: number;\n  x1: number;\n  y1: number;\n  a0: number;\n  a1: number;\n  begin: number;\n  end: number;\n  seed: number;\n}\n\nexport interface BuildOpts {\n  cellW: number;\n  cellH: number;\n  frameX: number;\n  frameY: number;\n  windDir: number;\n  rngSeed: number;\n  stageW: number;\n  stageH: number;\n}\n\nexport interface RenderOpts {\n  curlAmp: number;\n  scorchAmp: number;\n}\n\n// Deliberately narrowed to glyphs every monospace fallback ships, so a scorched\n// character can never render as a tofu box inside an otherwise perfect grid.\nconst NOISE = [\".\", \":\", \"·\", \"*\", \"+\", \"◦\", \"▪\", \"▫\", \"°\"];\n\n// A cell is \"lit\" when it would actually paint. All three builders walk exactly\n// this set in exactly this order — the seeded RNG draws once per lit cell, so\n// the traversal order is part of the deterministic output, not an incidental.\nconst LIT_ALPHA_MIN = 0.04;\n\nconst forEachLitCell = (\n  grid: Grid,\n  visit: (cell: Cell, r: number, c: number, cols: number) => void,\n) => {\n  for (let r = 0; r < grid.length; r += 1) {\n    const row = grid[r];\n    if (!row) {\n      continue;\n    }\n\n    for (let c = 0; c < row.length; c += 1) {\n      const cell = row[c];\n      if (!cell || cell.alpha < LIT_ALPHA_MIN || cell.char === \" \") {\n        continue;\n      }\n      visit(cell, r, c, row.length);\n    }\n  }\n};\n\n// Outgoing — every lit source cell lifts off toward a shared convergence zone\n// near the centre of the stage. The wind-facing edge crumbles first.\nexport const buildOutgoing = (grid: Grid, opts: BuildOpts): Particle[] => {\n  const { cellW, cellH, frameX, frameY, windDir, rngSeed, stageW, stageH } = opts;\n  const rng = seededRng(rngSeed);\n\n  const convergeX = stageW * 0.5 + windDir * 60;\n  const convergeY = stageH * 0.5;\n\n  const out: Particle[] = [];\n\n  forEachLitCell(grid, (cell, r, c, cols) => {\n    const x0 = frameX + c * cellW;\n    const y0 = frameY + r * cellH;\n\n    const spreadX = 60 + rng() * 110;\n    const spreadY = (rng() - 0.5) * 180;\n    const x1 = convergeX + windDir * spreadX * (rng() < 0.5 ? -0.4 : 1);\n    const y1 = convergeY + spreadY;\n\n    const colNorm = c / Math.max(1, cols - 1);\n    const stagger = windDir > 0 ? (1 - colNorm) * 0.55 : colNorm * 0.55;\n    const begin = stagger + rng() * 0.04;\n    const duration = 0.28 + rng() * 0.1;\n\n    out.push({\n      a0: cell.alpha,\n      a1: 0,\n      begin,\n      char: cell.char,\n      end: Math.min(0.72, begin + duration),\n      seed: Math.floor(rng() * 1e7),\n      x0,\n      x1,\n      y0,\n      y1,\n    });\n  });\n\n  return out;\n};\n\n// Incoming — mirror of outgoing. Spawns in the convergence zone on the\n// opposite side of centre so the two clouds pass through each other.\nexport const buildIncoming = (grid: Grid, opts: BuildOpts): Particle[] => {\n  const { cellW, cellH, frameX, frameY, windDir, rngSeed, stageW, stageH } = opts;\n  const rng = seededRng(rngSeed);\n\n  const convergeX = stageW * 0.5 - windDir * 60;\n  const convergeY = stageH * 0.5;\n\n  const out: Particle[] = [];\n\n  forEachLitCell(grid, (cell, r, c, cols) => {\n    const x1 = frameX + c * cellW;\n    const y1 = frameY + r * cellH;\n\n    const spreadX = 60 + rng() * 110;\n    const spreadY = (rng() - 0.5) * 180;\n    const x0 = convergeX - windDir * spreadX * (rng() < 0.5 ? -0.4 : 1);\n    const y0 = convergeY + spreadY;\n\n    const colNorm = c / Math.max(1, cols - 1);\n    const stagger = windDir > 0 ? colNorm * 0.5 : (1 - colNorm) * 0.5;\n    const begin = 0.35 + stagger + rng() * 0.04;\n    const duration = 0.28 + rng() * 0.1;\n\n    out.push({\n      a0: 0,\n      a1: cell.alpha,\n      begin,\n      char: cell.char,\n      end: Math.min(0.96, begin + duration),\n      seed: Math.floor(rng() * 1e7),\n      x0,\n      x1,\n      y0,\n      y1,\n    });\n  });\n\n  return out;\n};\n\n// Entrance — every cell spawns in a wide ring around the destination and\n// drifts inward. Outer cells settle first, the centre resolves last.\nexport const buildEntrance = (grid: Grid, opts: Omit<BuildOpts, \"windDir\">): Particle[] => {\n  const { cellW, cellH, frameX, frameY, rngSeed, stageW, stageH } = opts;\n  const rng = seededRng(rngSeed);\n\n  const rows = grid.length;\n  const cols = grid[0]?.length ?? 0;\n  const centerX = frameX + (cols * cellW) / 2;\n  const centerY = frameY + (rows * cellH) / 2;\n  const maxR = Math.hypot(cols * cellW, rows * cellH) / 2;\n\n  const out: Particle[] = [];\n\n  forEachLitCell(grid, (cell, r, c) => {\n    const x1 = frameX + c * cellW;\n    const y1 = frameY + r * cellH;\n\n    const angle = rng() * Math.PI * 2;\n    const distance = Math.min(stageW, stageH) * 0.5 + rng() * 320;\n    const burstX = centerX + (rng() - 0.5) * 220;\n    const burstY = stageH * 0.5 + (rng() - 0.5) * 180;\n    const x0 = burstX + Math.cos(angle) * distance;\n    const y0 = burstY + Math.sin(angle) * distance;\n\n    const dx = x1 - centerX;\n    const dy = y1 - centerY;\n    const distNorm = Math.min(1, Math.hypot(dx, dy) / maxR);\n    const stagger = (1 - distNorm) * 0.5 + rng() * 0.1;\n    const duration = 0.34 + rng() * 0.16;\n\n    out.push({\n      a0: 0,\n      a1: cell.alpha,\n      begin: stagger,\n      char: cell.char,\n      end: Math.min(0.98, stagger + duration),\n      seed: Math.floor(rng() * 1e7),\n      x0,\n      x1,\n      y0,\n      y1,\n    });\n  });\n\n  return out;\n};\n\n// ---------------------------------------------------------------------------\n// Renderers — fillStyle + font are set by the caller before invoking these\n// ---------------------------------------------------------------------------\n\nexport const renderGrid = (\n  ctx: CanvasRenderingContext2D,\n  grid: Grid,\n  frameX: number,\n  frameY: number,\n  cellW: number,\n  cellH: number,\n) => {\n  for (let r = 0; r < grid.length; r += 1) {\n    const row = grid[r];\n    if (!row) {\n      continue;\n    }\n\n    for (let c = 0; c < row.length; c += 1) {\n      const cell = row[c];\n      if (!cell || cell.char === \" \" || cell.alpha <= 0) {\n        continue;\n      }\n      ctx.globalAlpha = cell.alpha;\n      ctx.fillText(cell.char, frameX + c * cellW, frameY + r * cellH);\n    }\n  }\n  ctx.globalAlpha = 1;\n};\n\nexport const renderParticles = (\n  ctx: CanvasRenderingContext2D,\n  particles: Particle[],\n  t: number,\n  opts: RenderOpts,\n) => {\n  const { curlAmp, scorchAmp } = opts;\n\n  for (const p of particles) {\n    if (t <= p.begin) {\n      if (p.a0 > 0) {\n        ctx.globalAlpha = p.a0;\n        ctx.fillText(p.char, p.x0, p.y0);\n      }\n      continue;\n    }\n\n    if (t >= p.end) {\n      if (p.a1 > 0) {\n        ctx.globalAlpha = p.a1;\n        ctx.fillText(p.char, p.x1, p.y1);\n      }\n      continue;\n    }\n\n    const local = (t - p.begin) / (p.end - p.begin);\n    const eased = easeInOutCubic(local);\n\n    const sway = curlWindow(local);\n    const x = lerp(p.x0, p.x1, eased) + curl(p.seed, local, 0) * curlAmp * sway;\n    const y = lerp(p.y0, p.y1, eased) + curl(p.seed + 1, local, 1) * curlAmp * 0.7 * sway;\n\n    let alpha: number;\n    if (p.a1 === 0) {\n      const fadeStart = 0.7;\n      alpha = local < fadeStart ? p.a0 : p.a0 * (1 - (local - fadeStart) / (1 - fadeStart));\n    } else {\n      const fadeIn = 0.25;\n      alpha = local < fadeIn ? p.a1 * (local / fadeIn) : p.a1;\n    }\n\n    // Mid-flight scorch: a bit-mixed per-particle hash decides whether this\n    // glyph burns into noise, and only inside its own 0.32-0.72 window.\n    let ch = p.char;\n    if (scorchAmp > 0 && local > 0.32 && local < 0.72) {\n      /* oxlint-disable no-bitwise -- Knuth multiplicative hash: `>>> 0` is the uint32 wrap, `& 0xff` the byte mask */\n      const pick = (p.seed * 2_654_435_761) >>> 0;\n      const burns = ((pick >>> 8) & 0xff) / 255 < scorchAmp;\n      /* oxlint-enable no-bitwise */\n      if (burns) {\n        ch = NOISE[(pick + Math.floor(local * 64)) % NOISE.length] ?? p.char;\n      }\n    }\n\n    ctx.globalAlpha = alpha;\n    ctx.fillText(ch, x, y);\n  }\n\n  ctx.globalAlpha = 1;\n};\n","path":"/terminal-particles.ts","target":"uicapsule/terminal-disintegrate/terminal-particles.ts","type":"registry:file"}],"homepage":"https://www.uicapsule.com/ui/terminal-disintegrate","name":"terminal-disintegrate","registryDependencies":[],"title":"Terminal Disintegrate","type":"registry:block"}