# Terminal Disintegrate

> 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.

A shadcn/ui block from the [@uicapsule](https://uicapsule.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uicapsule/terminal-disintegrate).

- Page, with a live preview: https://shadcn.exchange/r/uicapsule/terminal-disintegrate
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uicapsule/terminal-disintegrate-light-a02d156601ac045e.png
- Registry item: https://uicapsule.com/r/terminal-disintegrate.json
- Categories: effects, pixel-art
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://uicapsule.com/r/terminal-disintegrate.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Files

### `/preview.tsx`

```tsx
"use client";

import { TerminalDisintegrate } from "./terminal-disintegrate";

const Preview = () => (
  <main className="h-dvh w-full overflow-hidden">
    <TerminalDisintegrate />
  </main>
);

export default Preview;
```

### `/terminal-data.ts`

```ts
// Static datasets for the three phases. No network, no randomness — every
// figure here gets drawn onto the canvas as characters.

export interface Candle {
  o: number;
  h: number;
  l: number;
  c: number;
  v: number;
}
export interface DepthRow {
  price: string;
  size: number;
}
export interface OptionRow {
  strike: number;
  callBid: number;
  callIv: number;
  putBid: number;
  putIv: number;
  highlight?: boolean;
}

// Phase 1 — a bullish BTC breakout from ~106K to a 107.4K close with one pullback.
export const CANDLES: Candle[] = [
  { c: 106_140, h: 106_180, l: 105_920, o: 106_020, v: 0.42 },
  { c: 106_280, h: 106_320, l: 106_080, o: 106_140, v: 0.55 },
  { c: 106_260, h: 106_400, l: 106_210, o: 106_280, v: 0.38 },
  { c: 106_480, h: 106_520, l: 106_230, o: 106_260, v: 0.61 },
  { c: 106_650, h: 106_690, l: 106_440, o: 106_480, v: 0.72 },
  { c: 106_560, h: 106_720, l: 106_500, o: 106_650, v: 0.49 },
  { c: 106_380, h: 106_600, l: 106_320, o: 106_560, v: 0.83 },
  { c: 106_510, h: 106_540, l: 106_350, o: 106_380, v: 0.44 },
  { c: 106_740, h: 106_780, l: 106_480, o: 106_510, v: 0.67 },
  { c: 106_920, h: 106_960, l: 106_700, o: 106_740, v: 0.78 },
  { c: 107_040, h: 107_080, l: 106_870, o: 106_920, v: 0.69 },
  { c: 106_990, h: 107_120, l: 106_960, o: 107_040, v: 0.51 },
  { c: 107_220, h: 107_260, l: 106_960, o: 106_990, v: 0.88 },
  { c: 107_350, h: 107_380, l: 107_180, o: 107_220, v: 0.74 },
  { c: 107_300, h: 107_420, l: 107_280, o: 107_350, v: 0.46 },
  { c: 107_440, h: 107_480, l: 107_270, o: 107_300, v: 0.81 },
  { c: 107_432, h: 107_460, l: 107_360, o: 107_440, v: 0.58 },
];

export const ASKS: DepthRow[] = [
  { price: "107,448", size: 0.184 },
  { price: "107,446", size: 0.092 },
  { price: "107,440", size: 0.451 },
  { price: "107,436", size: 0.218 },
];

export const BIDS: DepthRow[] = [
  { price: "107,432", size: 0.238 },
  { price: "107,430", size: 0.117 },
  { price: "107,428", size: 0.892 },
  { price: "107,424", size: 0.314 },
];

// Phase 2 — a XAU options chain, 26 DEC expiry, ATM at the 2680 strike.
export const OPTION_CHAIN: OptionRow[] = [
  {
    callBid: 56.4,
    callIv: 14.2,
    putBid: 3.05,
    putIv: 13.8,
    strike: 2640,
  },
  {
    callBid: 48.2,
    callIv: 13.9,
    putBid: 5.2,
    putIv: 13.5,
    strike: 2650,
  },
  {
    callBid: 40.4,
    callIv: 13.6,
    putBid: 8.1,
    putIv: 13.4,
    strike: 2660,
  },
  {
    callBid: 32.9,
    callIv: 13.3,
    putBid: 12.3,
    putIv: 13.6,
    strike: 2670,
  },
  {
    callBid: 25.85,
    callIv: 13.4,
    highlight: true,
    putBid: 16.95,
    putIv: 13.9,
    strike: 2680,
  },
  {
    callBid: 19.4,
    callIv: 13.7,
    putBid: 22.5,
    putIv: 14.3,
    strike: 2690,
  },
  {
    callBid: 14.05,
    callIv: 14.2,
    putBid: 29.1,
    putIv: 14.8,
    strike: 2700,
  },
  {
    callBid: 9.6,
    callIv: 14.8,
    putBid: 36.95,
    putIv: 15.4,
    strike: 2710,
  },
];

export const GREEKS = {
  delta: 0.52,
  gamma: 0.018,
  rho: 0.34,
  theta: -0.45,
  vega: 1.2,
};

// Phase 3 — a bearish equity curve indexed to 100, peak ~131, trough ~83.
export const BEAR_CURVE: number[] = [
  100, 104, 108, 113, 119, 124, 122, 127, 131, 128, 122, 116, 118, 121, 119, 113, 106, 99, 95, 91,
  88, 86, 83, 87, 92, 95, 93, 96, 99, 102,
];

export const RISK = {
  cagr: "23.8%",
  maxDrawdown: "-17.8%",
  sharpe: "2.14",
  vol: "14.2%",
};
```

### `/terminal-disintegrate.tsx`

```tsx
"use client";

import type { Particle } from "./terminal-particles";

import { useEffect, useRef } from "react";

import { buildBtcFrame, buildGoldFrame, buildStatsFrame } from "./terminal-frames";
import {
  buildEntrance,
  buildIncoming,
  buildOutgoing,
  cellMetrics,
  clamp01,
  renderGrid,
  renderParticles,
  smoothstep,
} from "./terminal-particles";

// --- Choreography -----------------------------------------------------------
// A single 0..1 scalar drives everything: hold, disintegrate, hold,
// disintegrate, hold. The palette inverts inside the second transition.
const HOLD1_END = 0.03;
const TRANS1_END = 0.36;
const HOLD2_END = 0.55;
const TRANS2_END = 0.85;
const FLIP_START = 0.78;
const FLIP_END = 0.86;

// Derived, so retuning the choreography can never desync the phase readout.
const PHASE_2_AT = (HOLD1_END + TRANS1_END) / 2;
const PHASE_3_AT = (HOLD2_END + TRANS2_END) / 2;

// --- Animation tunables -----------------------------------------------------
const ENTRANCE_MS = 2000;
const CURL_AMP_PX = 22;
const SCORCH_AMP = 0.45;

// The entrance is deliberately wilder than the transitions.
const TRANS_OPTS = { curlAmp: CURL_AMP_PX, scorchAmp: SCORCH_AMP };
const ENTRANCE_OPTS = { curlAmp: CURL_AMP_PX * 2.4, scorchAmp: SCORCH_AMP * 1.3 };

// Idle autoplay ping-pongs 0 -> 1 -> 0 until the first drag. Snapping 1 back to
// 0 would replay both transitions in half a second and hard-flip the palette,
// so it reverses through them instead.
const SWEEP_MS = 8000;
const HOLD_MS = 900;
const CYCLE_MS = SWEEP_MS * 2 + HOLD_MS * 2;

// --- Palette ----------------------------------------------------------------
const BG_LIGHT = "#cff060";
const FG_LIGHT = "#1c5f27";
const BG_DARK = "#1c5f27";
const FG_DARK = "#cff060";

// Pinned, never inherited: every pixel here is a box-drawing or block-element
// glyph, so the stack has to lead with faces that actually ship U+2500/U+2580.
const MONO_FAMILY =
  '"JetBrains Mono", "Cascadia Mono", "DejaVu Sans Mono", Menlo, Consolas, "Liberation Mono", "Noto Sans Mono", monospace';

const hexToRgb = (hex: string) => {
  const h = hex.replace("#", "");
  return {
    b: Number.parseInt(h.slice(4, 6), 16),
    g: Number.parseInt(h.slice(2, 4), 16),
    r: Number.parseInt(h.slice(0, 2), 16),
  };
};

const mixHex = (a: string, b: string, t: number): string => {
  const ca = hexToRgb(a);
  const cb = hexToRgb(b);
  const ch = (x: number, y: number) =>
    Math.round(x + (y - x) * t)
      .toString(16)
      .padStart(2, "0");
  return `#${ch(ca.r, cb.r)}${ch(ca.g, cb.g)}${ch(ca.b, cb.b)}`;
};

const paletteAt = (p: number) => {
  if (p <= FLIP_START) {
    return { bg: BG_LIGHT, fg: FG_LIGHT };
  }
  if (p >= FLIP_END) {
    return { bg: BG_DARK, fg: FG_DARK };
  }
  const t = smoothstep((p - FLIP_START) / (FLIP_END - FLIP_START));
  return { bg: mixHex(BG_LIGHT, BG_DARK, t), fg: mixHex(FG_LIGHT, FG_DARK, t) };
};

interface Layout {
  w: number;
  h: number;
  cellW: number;
  cellH: number;
  canvasW: number;
  canvasH: number;
  frameY: number;
  frameX1: number;
  frameX2: number;
  frameX3: number;
}

interface ParticleSets {
  entrance: Particle[];
  t1out: Particle[];
  t1in: Particle[];
  t2out: Particle[];
  t2in: Particle[];
}

interface Runtime {
  smoothP: number;
  targetP: number;
  lastDrawnP: number;
  entranceDone: boolean;
  entranceT: number;
  autoElapsed: number;
  interacted: boolean;
  lastNow: number | null;
  fg: string;
}

const phaseAt = (p: number): 1 | 2 | 3 => {
  if (p < PHASE_2_AT) {
    return 1;
  }
  if (p < PHASE_3_AT) {
    return 2;
  }
  return 3;
};

/* The idle cycle: sweep forward, hold, sweep back, rest. */
const autoTargetAt = (u: number): number => {
  if (u < SWEEP_MS) {
    return u / SWEEP_MS;
  }
  if (u < SWEEP_MS + HOLD_MS) {
    return 1;
  }
  if (u < SWEEP_MS * 2 + HOLD_MS) {
    return 1 - (u - SWEEP_MS - HOLD_MS) / SWEEP_MS;
  }
  return 0;
};

export const TerminalDisintegrate = () => {
  const rootRef = useRef<HTMLDivElement | null>(null);
  const bgRef = useRef<HTMLDivElement | null>(null);
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const chipRef = useRef<HTMLDivElement | null>(null);
  const hintRef = useRef<HTMLDivElement | null>(null);
  const railTrackRef = useRef<HTMLDivElement | null>(null);
  const railFillRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    const canvasEl = canvasRef.current;
    const rootEl = rootRef.current;
    if (!canvasEl || !rootEl) {
      return;
    }

    const ctx2d = canvasEl.getContext("2d");
    if (!ctx2d) {
      return;
    }

    // Re-bound as non-nullable so the hoisted helpers below close over a
    // narrowed type without needing assertions.
    const canvas: HTMLCanvasElement = canvasEl;
    const root: HTMLDivElement = rootEl;
    const ctx: CanvasRenderingContext2D = ctx2d;

    // The three screen grids are pure data — built once, never rebuilt.
    const grids = {
      btc: buildBtcFrame(),
      gold: buildGoldFrame(),
      stats: buildStatsFrame(),
    };

    const layout: Layout = {
      canvasH: 0,
      canvasW: 0,
      cellH: 0,
      cellW: 0,
      frameX1: 0,
      frameX2: 0,
      frameX3: 0,
      frameY: 0,
      h: 0,
      w: 0,
    };

    const particles: ParticleSets = {
      entrance: [],
      t1in: [],
      t1out: [],
      t2in: [],
      t2out: [],
    };

    const S: Runtime = {
      // Negative so the assembled first screen holds before it starts burning;
      // `u` clamps at 0 until the lead-in is spent.
      autoElapsed: -HOLD_MS,
      entranceDone: false,
      entranceT: 0,
      fg: FG_LIGHT,
      interacted: false,
      lastDrawnP: -1,
      lastNow: null,
      smoothP: 0,
      targetP: 0,
    };

    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    if (reduce) {
      S.entranceDone = true;
      S.entranceT = 1;
    }

    const computeLayout = (stageW: number, stageH: number) => {
      const m = cellMetrics(stageW, stageH);

      const dpr = Math.min(2.5, window.devicePixelRatio || 1);
      canvas.width = Math.round(stageW * dpr);
      canvas.height = Math.round(stageH * dpr);
      canvas.style.width = `${stageW}px`;
      canvas.style.height = `${stageH}px`;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      // Resizing the backing store wipes the whole 2d state, so the font and
      // baseline are (re)set here — the only place either can change — rather
      // than being re-parsed on every frame of the loop.
      ctx.textBaseline = "top";
      ctx.font = `500 ${m.cellH * 0.92}px ${MONO_FAMILY}`;

      // The three frames deliberately sit at two different X positions. The
      // disintegration is a stage traversal — left, right, left — which is what
      // makes the outgoing and incoming clouds visibly cross mid-flight rather
      // than shimmer in place.
      // The 8px slack reserve is what keeps the left frame off the stage edge;
      // the progress rail is pinned at left:0 so the two can never coincide.
      const slack = Math.max(0, stageW - m.canvasW);
      const offset = Math.max(0, Math.min(slack / 2 - 8, m.canvasW * 0.55));
      const leftX = stageW / 2 - offset - m.canvasW / 2;
      const rightX = stageW / 2 + offset - m.canvasW / 2;
      const frameY = Math.max(0, (stageH - m.canvasH) / 2);

      layout.w = stageW;
      layout.h = stageH;
      layout.cellW = m.cellW;
      layout.cellH = m.cellH;
      layout.canvasW = m.canvasW;
      layout.canvasH = m.canvasH;
      layout.frameY = frameY;
      layout.frameX1 = leftX;
      layout.frameX2 = rightX;
      layout.frameX3 = leftX;

      const common = { cellH: m.cellH, cellW: m.cellW, stageH, stageW };

      particles.entrance = buildEntrance(grids.btc, {
        ...common,
        frameX: leftX,
        frameY,
        rngSeed: 90_210,
      });
      particles.t1out = buildOutgoing(grids.btc, {
        ...common,
        frameX: leftX,
        frameY,
        rngSeed: 1337,
        windDir: 1,
      });
      particles.t1in = buildIncoming(grids.gold, {
        ...common,
        frameX: rightX,
        frameY,
        rngSeed: 2024,
        windDir: 1,
      });
      particles.t2out = buildOutgoing(grids.gold, {
        ...common,
        frameX: rightX,
        frameY,
        rngSeed: 4242,
        windDir: -1,
      });
      particles.t2in = buildIncoming(grids.stats, {
        ...common,
        frameX: leftX,
        frameY,
        rngSeed: 8675,
        windDir: -1,
      });

      S.lastDrawnP = -1;
    };

    const draw = () => {
      ctx.clearRect(0, 0, layout.w, layout.h);
      ctx.fillStyle = S.fg;

      if (!S.entranceDone) {
        renderParticles(ctx, particles.entrance, S.entranceT, ENTRANCE_OPTS);
        return;
      }

      const p = S.smoothP;
      const { frameX1, frameX2, frameX3, frameY, cellW, cellH } = layout;

      if (p <= HOLD1_END) {
        renderGrid(ctx, grids.btc, frameX1, frameY, cellW, cellH);
      } else if (p <= TRANS1_END) {
        const local = (p - HOLD1_END) / (TRANS1_END - HOLD1_END);
        renderParticles(ctx, particles.t1out, local, TRANS_OPTS);
        renderParticles(ctx, particles.t1in, local, TRANS_OPTS);
      } else if (p <= HOLD2_END) {
        renderGrid(ctx, grids.gold, frameX2, frameY, cellW, cellH);
      } else if (p <= TRANS2_END) {
        const local = (p - HOLD2_END) / (TRANS2_END - HOLD2_END);
        renderParticles(ctx, particles.t2out, local, TRANS_OPTS);
        renderParticles(ctx, particles.t2in, local, TRANS_OPTS);
      } else {
        renderGrid(ctx, grids.stats, frameX3, frameY, cellW, cellH);
      }
    };

    // The scalar moves every frame but the chrome derived from it mostly does
    // not: palette, phase and fade all sit still for hundreds of frames at a
    // time. Everything below writes to the DOM only when its own value
    // actually changed, so a steady-state frame costs one transform.
    let shownFg = "";
    let shownBg = "";
    let shownPhase = 0;
    let shownValueNow = -1;
    let shownFade = -1;

    const applyPalette = (p: number) => {
      const { fg, bg } = paletteAt(p);
      S.fg = fg;
      if (fg !== shownFg) {
        shownFg = fg;
        root.style.setProperty("--terminal-fg", fg);
      }
      if (bg !== shownBg) {
        const el = bgRef.current;
        if (el) {
          shownBg = bg;
          el.style.background = bg;
        }
      }
    };

    const applyChrome = (p: number, fade: number) => {
      const phase = phaseAt(p);
      const valueNow = Math.round(clamp01(p) * 100);

      if (valueNow !== shownValueNow) {
        shownValueNow = valueNow;
        root.setAttribute("aria-valuenow", String(valueNow));
      }
      if (phase !== shownPhase) {
        shownPhase = phase;
        root.setAttribute("aria-valuetext", `screen ${phase} of 3`);
        if (chipRef.current) {
          chipRef.current.textContent = `terminal · 0${phase} / 03`;
        }
      }
      if (fade !== shownFade) {
        shownFade = fade;
        if (chipRef.current) {
          chipRef.current.style.opacity = String(0.55 * fade);
        }
        if (hintRef.current) {
          hintRef.current.style.opacity = String(0.4 * fade);
        }
        if (railTrackRef.current) {
          railTrackRef.current.style.opacity = String(0.18 * fade);
        }
      }
      if (railFillRef.current) {
        railFillRef.current.style.transform = `scaleY(${clamp01(p)})`;
      }
    };

    // --- Driver: pointer drag + keys, with autoplay whenever the reader idles -
    // Deliberately no wheel handler: this lives inside a card in a scrolling
    // feed, and swallowing wheel events would trap the reader.
    const DRAG_SLOP_PX = 3;
    const IDLE_RESUME_MS = 4000;
    const KEY_STEP = 0.02;
    const PAGE_STEP = 0.1;

    let dragId: number | null = null;
    let lastX = 0;
    let lastY = 0;
    let idleTimer = 0;

    const travel = () => Math.max(420, Math.min(layout.w, layout.h));

    // Taking over is always temporary. A bare tap or a click that lands on the
    // card must never be able to strand the component on a frozen frame, so
    // control returns to autoplay once the reader has been still for a while —
    // re-seeded at wherever they left the scalar, not snapped back to zero.
    const takeOver = () => {
      S.interacted = true;
      if (idleTimer) {
        window.clearTimeout(idleTimer);
      }
      idleTimer = 0;
      // A held pointer that has stopped moving is still a reader holding the
      // scrubber, not an idle one — so the countdown is armed at release
      // instead, or the terminal would run away under a stationary cursor.
      if (dragId !== null) {
        return;
      }
      idleTimer = window.setTimeout(() => {
        idleTimer = 0;
        S.autoElapsed = clamp01(S.targetP) * SWEEP_MS;
        S.interacted = false;
      }, IDLE_RESUME_MS);
    };

    const onPointerDown = (e: PointerEvent) => {
      if (dragId !== null) {
        return;
      }
      dragId = e.pointerId;
      lastX = e.clientX;
      lastY = e.clientY;
      // Not a take-over: a press that never moves must leave autoplay alone.
      // It only has to hold off a resume that an earlier drag already armed.
      if (idleTimer) {
        window.clearTimeout(idleTimer);
      }
      idleTimer = 0;
      root.style.cursor = "grabbing";
      root.setPointerCapture(e.pointerId);
    };

    const onPointerMove = (e: PointerEvent) => {
      if (dragId !== e.pointerId) {
        return;
      }
      const dx = e.clientX - lastX;
      const dy = e.clientY - lastY;
      lastX = e.clientX;
      lastY = e.clientY;
      // Only real movement counts as a grab; a jittery tap stays autoplaying.
      if (S.interacted || Math.abs(dx) + Math.abs(dy) > DRAG_SLOP_PX) {
        takeOver();
      }
      // Dragging left or up advances, matching both carousel and scroll habits.
      S.targetP = clamp01(S.targetP + (-dx - dy) / travel());
    };

    const stepFor = (key: string): number => {
      if (key === "ArrowRight" || key === "ArrowDown") {
        return KEY_STEP;
      }
      if (key === "ArrowLeft" || key === "ArrowUp") {
        return -KEY_STEP;
      }
      if (key === "PageDown") {
        return PAGE_STEP;
      }
      if (key === "PageUp") {
        return -PAGE_STEP;
      }
      if (key === "End") {
        return 1;
      }
      if (key === "Home") {
        return -1;
      }
      return 0;
    };

    const onKeyDown = (e: KeyboardEvent) => {
      const step = stepFor(e.key);
      if (step === 0) {
        return;
      }
      e.preventDefault();
      takeOver();
      S.targetP = clamp01(S.targetP + step);
    };

    const endDrag = (e: PointerEvent) => {
      if (dragId !== e.pointerId) {
        return;
      }
      dragId = null;
      root.style.cursor = "grab";
      if (root.hasPointerCapture(e.pointerId)) {
        root.releasePointerCapture(e.pointerId);
      }
      // Release is where "still" starts counting. A press that never scrubbed
      // left `interacted` false and must not hand autoplay a fresh countdown.
      if (S.interacted) {
        takeOver();
      }
    };

    // --- Loop ---------------------------------------------------------------
    let raf = 0;
    let running = false;
    let ready = false;
    let onScreen = true;
    let cancelled = false;

    const tick = (now: number) => {
      if (!running) {
        return;
      }

      const dt = S.lastNow === null ? 16.7 : Math.min(64, now - S.lastNow);
      S.lastNow = now;

      if (!S.entranceDone) {
        S.entranceT = Math.min(1, S.entranceT + dt / ENTRANCE_MS);
        const fade = S.entranceT < 0.5 ? 0 : (S.entranceT - 0.5) / 0.5;
        applyPalette(0);
        applyChrome(0, fade);
        draw();
        if (S.entranceT >= 1) {
          S.entranceDone = true;
          // Adopt whatever the driver accumulated during the entrance, so a
          // reader who scrubbed early doesn't watch the transitions replay.
          S.smoothP = S.targetP;
        }
        raf = requestAnimationFrame(tick);
        return;
      }

      if (!S.interacted && !reduce) {
        S.autoElapsed = (S.autoElapsed + dt) % CYCLE_MS;
        const u = Math.max(0, S.autoElapsed);
        S.targetP = autoTargetAt(u);
      }

      // Critically-damped follow. Without it the disintegration reads as a
      // jump-cut instead of a burn.
      let sp = S.smoothP + (S.targetP - S.smoothP) * 0.12;
      if (Math.abs(S.targetP - sp) < 0.0002) {
        sp = S.targetP;
      }
      S.smoothP = sp;

      if (Math.abs(sp - S.lastDrawnP) > 0.00005) {
        applyPalette(sp);
        applyChrome(sp, 1);
        draw();
        S.lastDrawnP = sp;
      }

      raf = requestAnimationFrame(tick);
    };

    const startLoop = () => {
      if (running || cancelled || !ready) {
        return;
      }
      running = true;
      S.lastNow = null;
      raf = requestAnimationFrame(tick);
    };

    const stopLoop = () => {
      running = false;
      if (raf) {
        cancelAnimationFrame(raf);
      }
      raf = 0;
    };

    const syncLoop = () => {
      if (onScreen && document.visibilityState !== "hidden") {
        startLoop();
      } else {
        stopLoop();
      }
    };

    const begin = () => {
      if (cancelled || ready) {
        return;
      }
      const rect = root.getBoundingClientRect();
      if (rect.width < 1 || rect.height < 1) {
        return;
      }
      computeLayout(rect.width, rect.height);
      applyPalette(0);
      applyChrome(0, 0);
      ready = true;
      syncLoop();
    };

    // A freshly-mounted iframe reports a 0x0 box first; ignore that callback.
    const ro = new ResizeObserver((entries) => {
      const [entry] = entries;
      if (!entry) {
        return;
      }
      const { width, height } = entry.contentRect;
      if (width < 1 || height < 1) {
        return;
      }
      if (ready) {
        computeLayout(width, height);
      } else {
        begin();
      }
    });
    ro.observe(root);

    const io = new IntersectionObserver((entries) => {
      const [entry] = entries;
      if (!entry) {
        return;
      }
      onScreen = entry.isIntersecting;
      syncLoop();
    });
    io.observe(root);

    // Measure against the real font rather than a fallback, but never wait
    // forever for it.
    let fontTimer = 0;
    if (document.fonts?.ready) {
      // oxlint-disable-next-line promise/avoid-new -- a cancellable timeout in the browser; there is no promise-returning timer to await here
      const timeout = new Promise<void>((resolve) => {
        fontTimer = window.setTimeout(resolve, 800);
      });
      const beginAfterFonts = async () => {
        try {
          await Promise.race([document.fonts.ready, timeout]);
        } catch {
          // A failed font load still gets a first paint.
        }
        begin();
      };
      void beginAfterFonts();
    } else {
      begin();
    }

    root.addEventListener("pointerdown", onPointerDown);
    root.addEventListener("pointermove", onPointerMove);
    root.addEventListener("pointerup", endDrag);
    root.addEventListener("pointercancel", endDrag);
    root.addEventListener("keydown", onKeyDown);
    document.addEventListener("visibilitychange", syncLoop);

    return () => {
      cancelled = true;
      stopLoop();
      if (fontTimer) {
        window.clearTimeout(fontTimer);
      }
      if (idleTimer) {
        window.clearTimeout(idleTimer);
      }
      ro.disconnect();
      io.disconnect();
      root.removeEventListener("pointerdown", onPointerDown);
      root.removeEventListener("pointermove", onPointerMove);
      root.removeEventListener("pointerup", endDrag);
      root.removeEventListener("pointercancel", endDrag);
      root.removeEventListener("keydown", onKeyDown);
      document.removeEventListener("visibilitychange", syncLoop);
    };
  }, []);

  return (
    <div
      ref={rootRef}
      // A scrubbable scalar, not a picture: drag or arrow-key it through the
      // three screens. The label carries what the canvas shows.
      // 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
      role="slider"
      tabIndex={0}
      aria-label="An ASCII trading terminal whose screen disintegrates into embers and reassembles as the next screen"
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={0}
      style={{
        background: BG_LIGHT,
        color: "var(--terminal-fg, #1c5f27)",
        cursor: "grab",
        height: "100%",
        overflow: "hidden",
        position: "relative",
        // Same reasoning as the missing wheel handler: this lives in a
        // scrolling feed. The browser keeps vertical pans, horizontal drags
        // still reach the scrubber.
        touchAction: "pan-y",
        userSelect: "none",
        width: "100%",
      }}
    >
      <div
        ref={bgRef}
        aria-hidden
        style={{
          background: BG_LIGHT,
          inset: 0,
          pointerEvents: "none",
          position: "absolute",
          zIndex: 0,
        }}
      />

      <div
        aria-hidden
        style={{
          background: "radial-gradient(ellipse at center, transparent 35%, rgba(0,0,0,0.06) 95%)",
          inset: 0,
          mixBlendMode: "multiply",
          pointerEvents: "none",
          position: "absolute",
          zIndex: 1,
        }}
      />

      <canvas
        ref={canvasRef}
        aria-hidden
        style={{ inset: 0, pointerEvents: "none", position: "absolute", zIndex: 2 }}
      />

      <div
        ref={chipRef}
        aria-hidden
        style={{
          fontFamily: MONO_FAMILY,
          fontSize: 11,
          left: 22,
          letterSpacing: "0.25em",
          opacity: 0,
          pointerEvents: "none",
          position: "absolute",
          textTransform: "uppercase",
          top: 16,
          zIndex: 4,
        }}
      >
        terminal · 01 / 03
      </div>

      <div
        ref={hintRef}
        aria-hidden
        style={{
          bottom: 16,
          fontFamily: MONO_FAMILY,
          fontSize: 10,
          left: 22,
          letterSpacing: "0.3em",
          opacity: 0,
          pointerEvents: "none",
          position: "absolute",
          textTransform: "uppercase",
          zIndex: 4,
        }}
      >
        drag · disintegrate · resolve
      </div>

      <div
        ref={railTrackRef}
        aria-hidden
        style={{
          background: "currentColor",
          bottom: "7%",
          left: 0,
          opacity: 0,
          pointerEvents: "none",
          position: "absolute",
          top: "7%",
          width: 2,
          zIndex: 4,
        }}
      />

      <div
        ref={railFillRef}
        aria-hidden
        style={{
          background: "currentColor",
          height: "86%",
          left: 0,
          pointerEvents: "none",
          position: "absolute",
          top: "7%",
          transform: "scaleY(0)",
          transformOrigin: "top",
          width: 2,
          zIndex: 4,
        }}
      />
    </div>
  );
};
```

### `/terminal-frames.ts`

```ts
import type { Grid } from "./terminal-grid";

import { ASKS, BEAR_CURVE, BIDS, CANDLES, GREEKS, OPTION_CHAIN, RISK } from "./terminal-data";
import {
  drawBar,
  drawBigNumber,
  drawBox,
  drawHRule,
  drawText,
  drawTextRight,
  INNER_COLS,
  INNER_ROWS,
  makeGrid,
  OUTER_COLS,
  OUTER_ROWS,
  pasteInto,
  SCREEN_X,
  SCREEN_Y,
  setCell,
  vbarChar,
} from "./terminal-grid";

// ---------------------------------------------------------------------------
// Phone shell — bezel rails, rounded stubs, side buttons, Dynamic Island
// ---------------------------------------------------------------------------

const drawPhoneBezel = (g: Grid) => {
  const cols = OUTER_COLS;
  const rows = OUTER_ROWS;
  const L_OUTER = 1;
  const L_INNER = 2;
  const R_INNER = cols - 3;
  const R_OUTER = cols - 2;

  // Top edge with rounded-corner stubs
  setCell(g, 0, L_OUTER, "▗", 1);
  setCell(g, 0, R_OUTER, "▖", 1);
  for (let c = L_OUTER + 1; c < R_OUTER; c += 1) {
    setCell(g, 0, c, "▀", 1);
  }

  // Side rails
  for (let r = 1; r < rows - 1; r += 1) {
    setCell(g, r, L_OUTER, "▌", 1);
    setCell(g, r, L_INNER, "█", 1);
    setCell(g, r, R_INNER, "█", 1);
    setCell(g, r, R_OUTER, "▐", 1);
  }

  // Bottom edge with mirror stubs
  setCell(g, rows - 1, L_OUTER, "▝", 1);
  setCell(g, rows - 1, R_OUTER, "▘", 1);
  for (let c = L_OUTER + 1; c < R_OUTER; c += 1) {
    setCell(g, rows - 1, c, "▄", 1);
  }

  // Side buttons — left rail action + volume rockers, right rail sleep
  const leftBtn = (r0: number, r1: number) => {
    for (let r = r0; r <= r1; r += 1) {
      setCell(g, r, 0, "▌", 1);
    }
  };
  const rightBtn = (r0: number, r1: number) => {
    for (let r = r0; r <= r1; r += 1) {
      setCell(g, r, cols - 1, "▐", 1);
    }
  };

  leftBtn(8, 9);
  leftBtn(13, 15);
  leftBtn(17, 19);
  rightBtn(14, 19);

  // Dynamic Island — pill with camera + Face ID cut-outs
  const diW = 14;
  const diStart = Math.floor((cols - diW) / 2);

  for (let c = 0; c < diW; c += 1) {
    setCell(g, 1, diStart + c, "▄", 1);
  }
  for (let c = 0; c < diW; c += 1) {
    if (c === 2) {
      continue;
    }
    if (c === diW - 3) {
      continue;
    }
    setCell(g, 2, diStart + c, "█", 1);
  }
  for (let c = 0; c < diW; c += 1) {
    setCell(g, 3, diStart + c, "▀", 1);
  }
};

const statusBarTop = (g: Grid) => {
  drawText(g, 2, 5, "9:41", 0.85);
  drawText(g, 2, 38, "●●●", 0.55);
  drawText(g, 2, 42, "78%", 0.8);
  setCell(g, 2, 46, "█", 0.85);
};

const TABS = [
  { iconCol: 3, label: "home", labelCol: 1 },
  { iconCol: 11, label: "trade", labelCol: 8 },
  { iconCol: 19, label: "book", labelCol: 17 },
  { iconCol: 27, label: "acct", labelCol: 25 },
  { iconCol: 35, label: "more", labelCol: 33 },
] as const;

const drawTabBar = (inner: Grid, activeIndex: number) => {
  drawHRule(inner, 49, 0, INNER_COLS, "─", 0.22);

  for (const [i, tab] of TABS.entries()) {
    const active = i === activeIndex;
    setCell(inner, 50, tab.iconCol, active ? "◆" : "◇", active ? 0.9 : 0.45);
    drawText(inner, 51, tab.labelCol, tab.label, active ? 0.8 : 0.45);
  }

  drawHRule(inner, 53, 16, 8, "▬", 0.6);
};

// ---------------------------------------------------------------------------
// Phase 1 — BTC spot trade
// ---------------------------------------------------------------------------

const drawCandles = (inner: Grid) => {
  const top = 9;
  const bottom = 23;
  const h = bottom - top;
  const x0 = 5;
  const cw = 2;

  let min = Infinity;
  let max = -Infinity;
  for (const k of CANDLES) {
    min = Math.min(min, k.l);
    max = Math.max(max, k.h);
  }
  const span = max - min || 1;
  const yOf = (price: number) => top + Math.round((1 - (price - min) / span) * h);

  drawText(inner, top, 0, "108K", 0.45);
  drawText(inner, top + Math.round(h / 2), 0, "107K", 0.45);
  drawText(inner, bottom, 0, "106K", 0.45);

  for (const [i, k] of CANDLES.entries()) {
    const cx = x0 + i * cw;
    const bull = k.c >= k.o;
    const bodyChar = bull ? "█" : "▒";
    const bodyAlpha = bull ? 0.98 : 0.55;
    const hi = yOf(k.h);
    const lo = yOf(k.l);
    const bodyTop = Math.min(yOf(k.o), yOf(k.c));
    const bodyBot = Math.max(yOf(k.o), yOf(k.c));

    for (let r = hi; r <= lo; r += 1) {
      setCell(inner, r, cx, "│", 0.5);
    }
    for (let r = bodyTop; r <= bodyBot; r += 1) {
      for (let x = 0; x < cw; x += 1) {
        setCell(inner, r, cx + x, bodyChar, bodyAlpha);
      }
    }
  }

  const last = CANDLES.at(-1);
  if (!last) {
    return;
  }

  const lastRow = yOf(last.c);
  for (let c = x0; c < 39; c += 1) {
    setCell(inner, lastRow, c, "┄", 0.3);
  }
  // Alpha 1 is load-bearing: the last-price row crosses the final candles and
  // `setCell` drops any write that a brighter cell already occupies, so
  // anything at or below the 0.98 bull body would come out half-eaten.
  drawText(inner, lastRow, 33, "107.4K", 1);
};

const drawOrderBook = (inner: Grid) => {
  drawText(inner, 27, 0, "ORDER BOOK", 0.6);
  drawTextRight(inner, 27, 39, "depth 4.32 BTC", 0.5);
  drawHRule(inner, 28, 0, INNER_COLS, "─", 0.22);

  const maxSize = Math.max(...ASKS.map((a) => a.size), ...BIDS.map((b) => b.size));
  const barCol = 13;
  const barMax = 11;

  for (const [i, a] of ASKS.entries()) {
    const r = 29 + i;
    drawText(inner, r, 0, `ASK ${a.price}`, 0.7);
    drawBar(inner, r, barCol, Math.max(1, Math.round((a.size / maxSize) * barMax)), "▒", 0.55);
    drawTextRight(inner, r, 34, a.size.toFixed(3), 0.7);
    setCell(inner, r, 37, "↑", 0.55);
  }

  drawText(inner, 33, 9, "── spread $4.00 ──", 0.4);

  for (const [i, b] of BIDS.entries()) {
    const r = 34 + i;
    drawText(inner, r, 0, `BID ${b.price}`, 0.7);
    drawBar(inner, r, barCol, Math.max(1, Math.round((b.size / maxSize) * barMax)), "▓", 0.7);
    drawTextRight(inner, r, 34, b.size.toFixed(3), 0.7);
    setCell(inner, r, 37, "↓", 0.55);
  }
};

export const buildBtcFrame = (): Grid => {
  const outer = makeGrid(OUTER_COLS, OUTER_ROWS);
  drawPhoneBezel(outer);
  statusBarTop(outer);

  const inner = makeGrid(INNER_COLS, INNER_ROWS);

  drawText(inner, 0, 0, "←  BTC · USD", 0.9);
  setCell(inner, 0, 38, "…", 0.6);
  drawText(inner, 1, 3, "spot · usdt-margined", 0.5);
  setCell(inner, 1, 38, "◆", 0.7);

  drawText(inner, 3, 0, "$107,432.18", 0.98);
  drawText(inner, 4, 0, "▲ +2.47%   +$2,584.42 today", 0.7);
  drawTextRight(inner, 4, 39, "live ●", 0.75);

  const tfs = ["1H", "1D", "1W", "1M", "3M", "1Y"];
  for (const [i, t] of tfs.entries()) {
    drawText(inner, 6, i * 6, t, i === 3 ? 0.9 : 0.45);
  }
  drawText(inner, 7, 18, "──", 0.8);

  drawCandles(inner);

  const maxV = Math.max(...CANDLES.map((k) => k.v));
  for (const [i, k] of CANDLES.entries()) {
    const cx = 5 + i * 2;
    const ch = vbarChar(k.v / maxV);
    setCell(inner, 25, cx, ch, 0.5);
    setCell(inner, 25, cx + 1, ch, 0.5);
  }

  drawOrderBook(inner);

  drawText(inner, 39, 0, "wallet", 0.55);
  drawText(inner, 40, 0, "0.4218 BTC", 0.85);
  drawTextRight(inner, 40, 39, "≈ $45,318.86", 0.85);

  drawBox(inner, 42, 0, 19, 4, 0.95);
  drawText(inner, 43, 8, "BUY", 0.95);
  drawText(inner, 44, 8, "BUY", 0.95);
  drawBox(inner, 42, 20, 19, 4, 0.55);
  drawText(inner, 43, 27, "SELL", 0.55);
  drawText(inner, 44, 27, "SELL", 0.55);

  drawTabBar(inner, 1);

  pasteInto(outer, inner, SCREEN_Y, SCREEN_X);
  return outer;
};

// ---------------------------------------------------------------------------
// Phase 2 — XAU options chain
// ---------------------------------------------------------------------------

const drawPayoff = (inner: Grid) => {
  const top = 25;
  const lossRow = 33;
  const zero = 31;
  const strikeCol = 14;

  drawHRule(inner, zero, 1, 38, "┈", 0.16);
  for (let c = 1; c <= strikeCol; c += 1) {
    setCell(inner, lossRow, c, "─", 0.6);
  }

  let prevRow = lossRow;
  for (let k = 1; k <= 24; k += 1) {
    const col = strikeCol + k;
    if (col > 39) {
      break;
    }
    const row = Math.max(top, Math.round(lossRow - k * 0.62));
    setCell(inner, row, col, "╱", 0.72);
    if (prevRow - row > 1) {
      for (let rr = row + 1; rr < prevRow; rr += 1) {
        setCell(inner, rr, col, "╱", 0.5);
      }
    }
    prevRow = row;
  }

  const beCol = strikeCol + 6;
  for (let r = top; r <= lossRow; r += 1) {
    setCell(inner, r, beCol, "┊", 0.22);
  }
  // Sits on the clamped plateau of the ramp, so it has to outrank the 0.72
  // slashes to read as a label rather than a stray character.
  drawTextRight(inner, top, 39, "+max", 0.8);
};

export const buildGoldFrame = (): Grid => {
  const outer = makeGrid(OUTER_COLS, OUTER_ROWS);
  drawPhoneBezel(outer);
  statusBarTop(outer);

  const inner = makeGrid(INNER_COLS, INNER_ROWS);

  drawText(inner, 0, 0, "←  XAU · OPTIONS", 0.9);
  setCell(inner, 0, 38, "…", 0.6);
  drawText(inner, 1, 3, "26 dec · opt · usd", 0.5);
  setCell(inner, 1, 38, "●", 0.7);

  drawText(inner, 3, 0, "$2,684.50", 0.98);
  drawText(inner, 4, 0, "▼ -0.32%   -$8.65 today", 0.7);
  drawTextRight(inner, 4, 39, "iv30 13.8", 0.6);

  const exps = ["NOV", "DEC", "JAN", "MAR", "JUN", "LEAP"];
  let ec = 0;
  for (const [i, e] of exps.entries()) {
    const active = i === 1;
    drawText(inner, 6, ec, active ? `${e}*` : e, active ? 0.9 : 0.45);
    ec += e.length + (active ? 1 : 0) + 2;
  }
  drawText(inner, 7, 5, "──", 0.8);

  const cCallBid = 6;
  const cCallIv = 12;
  const cK = 17;
  const cPutIv = 23;
  const cPutBid = 29;

  drawText(inner, 9, 0, "CALL", 0.55);
  drawText(inner, 9, cCallBid, "bid", 0.45);
  drawText(inner, 9, cCallIv, "iv", 0.45);
  drawText(inner, 9, cK + 1, "K", 0.55);
  drawText(inner, 9, cPutIv, "iv", 0.45);
  drawText(inner, 9, cPutBid, "bid", 0.45);
  drawTextRight(inner, 9, 39, "PUT", 0.55);
  drawHRule(inner, 10, 0, INNER_COLS, "─", 0.22);

  for (const [i, row] of OPTION_CHAIN.entries()) {
    const r = 11 + i;
    if (row.highlight) {
      for (let c = 0; c < INNER_COLS; c += 1) {
        setCell(inner, r, c, "·", 0.14);
      }
    }
    drawText(inner, r, cCallBid, row.callBid.toFixed(2), 0.82);
    drawText(inner, r, cCallIv, row.callIv.toFixed(1), 0.45);
    drawText(inner, r, cK, String(row.strike), row.highlight ? 0.98 : 0.7);
    drawText(inner, r, cPutIv, row.putIv.toFixed(1), 0.45);
    drawText(inner, r, cPutBid, row.putBid.toFixed(2), 0.82);
    if (row.highlight) {
      // Hugging the strike: cK-2 lands on the last digit of the call IV and
      // cK+4 is the only free column on the put side, so the pair has to sit
      // tight against the 4-digit strike rather than one cell out.
      setCell(inner, r, cK - 1, "►", 0.9);
      setCell(inner, r, cK + 4, "◄", 0.9);
    }
  }

  drawText(inner, 20, 0, "GREEKS", 0.6);
  drawText(inner, 20, 9, `delta ${GREEKS.delta.toFixed(2)}`, 0.7);
  drawText(inner, 20, 22, `gamma ${GREEKS.gamma.toFixed(3)}`, 0.7);
  drawText(inner, 21, 0, `theta ${GREEKS.theta.toFixed(2)}`, 0.7);
  drawText(inner, 21, 13, `vega ${GREEKS.vega.toFixed(2)}`, 0.7);
  drawText(inner, 21, 25, `rho ${GREEKS.rho.toFixed(2)}`, 0.7);

  drawText(inner, 23, 0, "P/L AT EXPIRY", 0.6);
  drawTextRight(inner, 23, 39, "long call ►", 0.5);
  drawHRule(inner, 24, 0, INNER_COLS, "─", 0.22);
  drawPayoff(inner);
  drawText(inner, 35, 12, "2680", 0.55);
  drawText(inner, 35, 18, "BE 2697", 0.45);

  drawBox(inner, 39, 0, 12, 3, 0.95);
  drawText(inner, 40, 4, "LONG", 0.95);
  drawBox(inner, 39, 13, 12, 3, 0.55);
  drawText(inner, 40, 16, "SHORT", 0.55);
  drawBox(inner, 39, 26, 14, 3, 0.55);
  drawText(inner, 40, 29, "SPREAD", 0.55);

  drawText(inner, 43, 0, "size  1×  100 oz", 0.7);
  drawTextRight(inner, 43, 39, "cost  $2,605", 0.7);
  drawHRule(inner, 44, 0, INNER_COLS, "─", 0.22);
  drawText(inner, 45, 0, "notional  $268,450", 0.7);
  drawTextRight(inner, 45, 39, "margin 12 %", 0.6);

  drawTabBar(inner, 2);

  pasteInto(outer, inner, SCREEN_Y, SCREEN_X);
  return outer;
};

// ---------------------------------------------------------------------------
// Phase 3 — Drawdown stats card
// ---------------------------------------------------------------------------

// Line segment glyph for a step from row `pr` to row `r` (rows grow downward).
const slopeChar = (r: number, pr: number): string => {
  if (r < pr) {
    return "╱";
  }
  if (r > pr) {
    return "╲";
  }
  return "─";
};

// Area fill under the curve, thinning with distance `d` (0..1) below the line.
const fillChar = (d: number): string => {
  if (d < 0.4) {
    return "▒";
  }
  if (d < 0.75) {
    return "░";
  }
  return "·";
};

const drawBearChart = (inner: Grid) => {
  const top = 32;
  const bottom = 43;
  const h = bottom - top;
  const x0 = 5;
  const x1 = 39;
  const w = x1 - x0;
  const vals = BEAR_CURVE;

  const [first] = vals;
  if (first === undefined) {
    return;
  }

  let min = Infinity;
  let max = -Infinity;
  for (const v of vals) {
    min = Math.min(min, v);
    max = Math.max(max, v);
  }
  const span = max - min || 1;
  const colOf = (i: number) => x0 + Math.round((i / (vals.length - 1)) * w);
  const rowOf = (v: number) => top + Math.round((1 - (v - min) / span) * h);

  drawText(inner, top, 0, "131", 0.45);
  drawText(inner, top + Math.round(h / 2), 0, "107", 0.45);
  drawText(inner, bottom, 0, "83", 0.45);

  let peakI = 0;
  let peakV = first;
  let troughI = 0;
  let troughV = first;
  for (const [i, v] of vals.entries()) {
    if (v > peakV) {
      peakV = v;
      peakI = i;
    }
    if (v < troughV) {
      troughV = v;
      troughI = i;
    }
  }

  for (const [i, v] of vals.entries()) {
    const c = colOf(i);
    const r = rowOf(v);

    const prev = vals[i - 1];
    if (i > 0 && prev !== undefined) {
      const pc = colOf(i - 1);
      const pr = rowOf(prev);
      const steps = Math.max(1, c - pc);
      for (let s = 1; s < steps; s += 1) {
        const ix = pc + s;
        const iy = Math.round(pr + ((r - pr) * s) / steps);
        setCell(inner, iy, ix, slopeChar(r, pr), 0.5);
      }
    }

    setCell(inner, r, c, "▪", 0.85);
    for (let rr = r + 1; rr <= bottom; rr += 1) {
      const d = (rr - r) / Math.max(1, bottom - r);
      setCell(inner, rr, c, fillChar(d), 0.18);
    }
  }

  setCell(inner, rowOf(peakV) - 1, colOf(peakI), "▼", 0.6);
  setCell(inner, rowOf(troughV) + 1, colOf(troughI), "▲", 0.6);

  // One row below the trough marker, not level with it: the trough is by
  // definition the chart floor, so its ▲ always lands on `bottom + 1` and would
  // swallow whichever tick shares that column.
  const years = ["21", "22", "23", "24", "25"];
  for (const [i, y] of years.entries()) {
    const c = x0 + Math.round((i / (years.length - 1)) * w);
    setCell(inner, bottom + 2, c, "┴", 0.35);
    drawText(inner, bottom + 3, Math.min(36, c), `'${y}`, 0.4);
  }
};

export const buildStatsFrame = (): Grid => {
  const outer = makeGrid(OUTER_COLS, OUTER_ROWS);
  drawPhoneBezel(outer);
  statusBarTop(outer);

  const inner = makeGrid(INNER_COLS, INNER_ROWS);

  drawText(inner, 1, 0, "DRAWDOWN PROFILE", 0.85);
  drawText(inner, 2, 0, "five-year tape · marked to market", 0.45);
  drawHRule(inner, 4, 0, INNER_COLS, "─", 0.22);

  const leftX = 0;
  const rightX = 21;

  drawBigNumber(inner, 6, leftX, RISK.sharpe, 0.95, 0);
  drawText(inner, 12, leftX, "SHARPE RATIO", 0.6);
  drawText(inner, 13, leftX, "paid for the risk", 0.4);
  drawText(inner, 14, leftX, "you took", 0.4);

  drawBigNumber(inner, 6, rightX, RISK.maxDrawdown, 0.95, 0);
  drawText(inner, 12, rightX, "MAX DRAWDOWN", 0.6);
  drawText(inner, 13, rightX, "the year you'd", 0.4);
  drawText(inner, 14, rightX, "rather forget", 0.4);

  drawBigNumber(inner, 17, leftX, RISK.vol, 0.95, 0);
  drawText(inner, 23, leftX, "ANNUAL VOL", 0.6);
  drawText(inner, 24, leftX, "one σ of monthly", 0.4);
  drawText(inner, 25, leftX, "log returns", 0.4);

  drawBigNumber(inner, 17, rightX, RISK.cagr, 0.95, 0);
  drawText(inner, 23, rightX, "CAGR · 5Y", 0.6);
  drawText(inner, 24, rightX, "compounded since", 0.4);
  drawText(inner, 25, rightX, "inception", 0.4);

  drawHRule(inner, 29, 0, INNER_COLS, "─", 0.22);
  drawText(inner, 30, 0, "EQUITY CURVE · INDEXED TO 100", 0.6);
  drawTextRight(inner, 30, 39, "5Y · USD", 0.45);

  drawBearChart(inner);

  drawHRule(inner, 49, 0, INNER_COLS, "─", 0.22);
  drawText(inner, 51, 0, "marked daily · 1,260 obs", 0.5);
  drawTextRight(inner, 51, 39, "self · usd", 0.4);

  pasteInto(outer, inner, SCREEN_Y, SCREEN_X);
  return outer;
};
```

### `/terminal-grid.ts`

```ts
// The ASCII art is a grid of single characters, each carrying an alpha.
// Every frame builder mutates a Grid in place through these pure primitives.

export const OUTER_COLS = 50;
export const OUTER_ROWS = 60;
export const INNER_COLS = 40;
export const INNER_ROWS = 54;
export const SCREEN_X = 5;
export const SCREEN_Y = 4;

export interface Cell {
  char: string;
  alpha: number;
}
export type Grid = Cell[][];

export const makeGrid = (cols: number, rows: number): Grid => {
  const g: Grid = [];

  for (let r = 0; r < rows; r += 1) {
    const row: Cell[] = [];
    for (let c = 0; c < cols; c += 1) {
      row.push({ alpha: 0, char: " " });
    }
    g.push(row);
  }

  return g;
};

// Higher-alpha writes win on overlap; a space never erases an existing glyph.
export const setCell = (g: Grid, r: number, c: number, char: string, alpha: number) => {
  const row = g[r];
  if (!row) {
    return;
  }

  const cur = row[c];
  if (!cur) {
    return;
  }

  if (char === " " && cur.char !== " ") {
    return;
  }
  if (cur.alpha > alpha && cur.char !== " ") {
    return;
  }

  cur.char = char;
  cur.alpha = alpha;
};

export const drawText = (g: Grid, r: number, c: number, text: string, alpha: number) => {
  for (let i = 0; i < text.length; i += 1) {
    setCell(g, r, c + i, text.charAt(i), alpha);
  }
};

export const drawTextRight = (g: Grid, r: number, cRight: number, text: string, alpha: number) => {
  drawText(g, r, cRight - text.length + 1, text, alpha);
};

export const drawHRule = (g: Grid, r: number, c: number, w: number, char = "─", alpha = 0.25) => {
  for (let i = 0; i < w; i += 1) {
    setCell(g, r, c + i, char, alpha);
  }
};

export const drawBar = (g: Grid, r: number, c: number, w: number, char = "▒", alpha = 0.55) => {
  for (let i = 0; i < w; i += 1) {
    setCell(g, r, c + i, char, alpha);
  }
};

export const drawBox = (g: Grid, r: number, c: number, w: number, h: number, alpha = 0.7) => {
  setCell(g, r, c, "╭", alpha);
  setCell(g, r, c + w - 1, "╮", alpha);
  setCell(g, r + h - 1, c, "╰", alpha);
  setCell(g, r + h - 1, c + w - 1, "╯", alpha);

  for (let x = 1; x < w - 1; x += 1) {
    setCell(g, r, c + x, "─", alpha);
    setCell(g, r + h - 1, c + x, "─", alpha);
  }

  for (let y = 1; y < h - 1; y += 1) {
    setCell(g, r + y, c, "│", alpha);
    setCell(g, r + y, c + w - 1, "│", alpha);
  }
};

const VBARS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] as const;

export const vbarChar = (v01: number): string => {
  const v = v01 < 0 ? 0 : Math.min(1, v01);
  return VBARS[Math.min(VBARS.length - 1, Math.floor(v * VBARS.length))] ?? "█";
};

export const pasteInto = (outer: Grid, inner: Grid, rOff: number, cOff: number) => {
  for (let r = 0; r < inner.length; r += 1) {
    const row = inner[r];
    if (!row) {
      continue;
    }

    for (let c = 0; c < row.length; c += 1) {
      const cell = row[c];
      if (!cell || cell.char === " " || cell.alpha <= 0) {
        continue;
      }
      setCell(outer, rOff + r, cOff + c, cell.char, cell.alpha);
    }
  }
};

// 5-row block-digit font for the Phase 3 hero stats.
type BigGlyph = readonly [string, string, string, string, string];

const BLANK_GLYPH: BigGlyph = ["   ", "   ", "   ", "   ", "   "];

const BIG_DIGITS = new Map<string, BigGlyph>(
  Object.entries({
    " ": BLANK_GLYPH,
    $: [" ╭╴", "╶┼╮", " │ ", "╶┼╯", " ╰╴"],
    "%": ["▪ ╱", " ╱ ", " ╱ ", "╱  ", "╱ ▪"],
    "+": ["   ", " │ ", "╶┼╴", " │ ", "   "],
    // Period and comma are drawn 2 columns wide (see `drawBigNumber`), so their
    // art has to live in the first two columns — a third column is never read.
    ",": ["   ", "   ", "   ", " ╷ ", "╶╯ "],
    "-": ["   ", "   ", "╶─╴", "   ", "   "],
    ".": ["   ", "   ", "   ", "   ", " ▪ "],
    "0": ["╭─╮", "│ │", "│ │", "│ │", "╰─╯"],
    "1": ["╶┐ ", " │ ", " │ ", " │ ", "╶┴╴"],
    "2": ["╭─╮", "  │", "╭─╯", "│  ", "╰─╴"],
    "3": ["╭─╮", "  │", " ─┤", "  │", "╰─╯"],
    "4": ["╷ ╷", "│ │", "╰─┤", "  │", "  ╵"],
    "5": ["╭─╴", "│  ", "╰─╮", "  │", "╰─╯"],
    "6": ["╭─╴", "│  ", "├─╮", "│ │", "╰─╯"],
    "7": ["╶─╮", "  │", "  │", "  │", "  ╵"],
    "8": ["╭─╮", "│ │", "├─┤", "│ │", "╰─╯"],
    "9": ["╭─╮", "│ │", "╰─┤", "  │", "╶─╯"],
  } satisfies Record<string, BigGlyph>),
);

// Walks a string, writing each block glyph. Period and comma consume 2 cols
// so the number reads as one tight typographic unit. `gap` is the inter-glyph
// spacing in cells.
export const drawBigNumber = (
  g: Grid,
  r: number,
  c: number,
  text: string,
  alpha: number,
  gap = 1,
) => {
  let col = c;

  for (const ch of text) {
    const glyph = BIG_DIGITS.get(ch) ?? BLANK_GLYPH;
    const w = ch === "." || ch === "," ? 2 : 3;

    for (let row = 0; row < 5; row += 1) {
      const line = glyph[row];
      if (line === undefined) {
        continue;
      }

      for (let x = 0; x < w; x += 1) {
        const gch = line.charAt(x);
        if (gch && gch !== " ") {
          setCell(g, r + row, col + x, gch, alpha);
        }
      }
    }

    col += w + gap;
  }
};
```

### `/terminal-particles.ts`

```ts
import type { Cell, Grid } from "./terminal-grid";

import { OUTER_COLS, OUTER_ROWS } from "./terminal-grid";

// ---------------------------------------------------------------------------
// Math helpers — all pure and deterministic
// ---------------------------------------------------------------------------

const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
export const clamp01 = (t: number): number => (t < 0 ? 0 : Math.min(1, t));

export const smoothstep = (t: number): number => {
  const x = clamp01(t);
  return x * x * (3 - 2 * x);
};

const easeInOutCubic = (t: number): number => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);

// Mulberry32 — seeded, so scrubbing forwards/backwards is identical every time.
const seededRng = (seed: number): (() => number) => {
  /* oxlint-disable no-bitwise, unicorn/prefer-math-trunc -- Mulberry32 is int32 bit math; `| 0` is the wrap Math.trunc would drop */
  let s = seed | 0 || 1;
  return () => {
    s = (s + 0x6d_2b_79_f5) | 0;
    let t = s;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296;
  };
  /* oxlint-enable no-bitwise, unicorn/prefer-math-trunc */
};

const curl = (seed: number, t: number, axis: number): number => {
  const base = seed * 0.0173 + axis * 7.91;
  return Math.sin(base + t * 5.21) * 0.6 + Math.sin(base * 1.6 + t * 11.4) * 0.4;
};

// Curl is a free-flight wobble, so it has to vanish at both ends of the flight.
// `curl` itself is ~uniform in [-1,1] at local 0 and 1, but the renderer snaps
// to the exact endpoints outside [begin, end] — leaving the offset in place put
// a whole cloud's worth of glyphs up to a full `curlAmp` away from where they
// land, so they teleported on the frame they arrived. Half a sine window keeps
// the mid-flight amplitude (the part that is the tuning) and removes the jump.
const curlWindow = (local: number): number => Math.sin(Math.PI * local);

// ---------------------------------------------------------------------------
// Cell pixel sizing — the aspect ratio that makes the grid read as an iPhone
// ---------------------------------------------------------------------------

// A monospace cell is roughly 0.572 as wide as it is tall. Holding that ratio
// is what turns a 50x60 character grid into a phone-shaped object.
const CELL_ASPECT = 0.572;

export interface CellMetrics {
  cellW: number;
  cellH: number;
  canvasW: number;
  canvasH: number;
}

// Sized against the stage box, not the viewport: the component lives in an
// arbitrarily-sized frame and must never clip its own bezel.
export const cellMetrics = (stageW: number, stageH: number): CellMetrics => {
  const fitH = (stageH * 0.9) / OUTER_ROWS;
  const fitW = (stageW * 0.92) / (OUTER_COLS * CELL_ASPECT);

  const raw = Math.min(fitH, fitW);
  const cellH = Math.max(2.4, Math.min(16, Math.round(raw * 100) / 100));
  const cellW = Math.round(cellH * CELL_ASPECT * 100) / 100;

  return {
    canvasH: cellH * OUTER_ROWS,
    canvasW: cellW * OUTER_COLS,
    cellH,
    cellW,
  };
};

// ---------------------------------------------------------------------------
// Particles
// ---------------------------------------------------------------------------

export interface Particle {
  char: string;
  x0: number;
  y0: number;
  x1: number;
  y1: number;
  a0: number;
  a1: number;
  begin: number;
  end: number;
  seed: number;
}

export interface BuildOpts {
  cellW: number;
  cellH: number;
  frameX: number;
  frameY: number;
  windDir: number;
  rngSeed: number;
  stageW: number;
  stageH: number;
}

export interface RenderOpts {
  curlAmp: number;
  scorchAmp: number;
}

// Deliberately narrowed to glyphs every monospace fallback ships, so a scorched
// character can never render as a tofu box inside an otherwise perfect grid.
const NOISE = [".", ":", "·", "*", "+", "◦", "▪", "▫", "°"];

// A cell is "lit" when it would actually paint. All three builders walk exactly
// this set in exactly this order — the seeded RNG draws once per lit cell, so
// the traversal order is part of the deterministic output, not an incidental.
const LIT_ALPHA_MIN = 0.04;

const forEachLitCell = (
  grid: Grid,
  visit: (cell: Cell, r: number, c: number, cols: number) => void,
) => {
  for (let r = 0; r < grid.length; r += 1) {
    const row = grid[r];
    if (!row) {
      continue;
    }

    for (let c = 0; c < row.length; c += 1) {
      const cell = row[c];
      if (!cell || cell.alpha < LIT_ALPHA_MIN || cell.char === " ") {
        continue;
      }
      visit(cell, r, c, row.length);
    }
  }
};

// Outgoing — every lit source cell lifts off toward a shared convergence zone
// near the centre of the stage. The wind-facing edge crumbles first.
export const buildOutgoing = (grid: Grid, opts: BuildOpts): Particle[] => {
  const { cellW, cellH, frameX, frameY, windDir, rngSeed, stageW, stageH } = opts;
  const rng = seededRng(rngSeed);

  const convergeX = stageW * 0.5 + windDir * 60;
  const convergeY = stageH * 0.5;

  const out: Particle[] = [];

  forEachLitCell(grid, (cell, r, c, cols) => {
    const x0 = frameX + c * cellW;
    const y0 = frameY + r * cellH;

    const spreadX = 60 + rng() * 110;
    const spreadY = (rng() - 0.5) * 180;
    const x1 = convergeX + windDir * spreadX * (rng() < 0.5 ? -0.4 : 1);
    const y1 = convergeY + spreadY;

    const colNorm = c / Math.max(1, cols - 1);
    const stagger = windDir > 0 ? (1 - colNorm) * 0.55 : colNorm * 0.55;
    const begin = stagger + rng() * 0.04;
    const duration = 0.28 + rng() * 0.1;

    out.push({
      a0: cell.alpha,
      a1: 0,
      begin,
      char: cell.char,
      end: Math.min(0.72, begin + duration),
      seed: Math.floor(rng() * 1e7),
      x0,
      x1,
      y0,
      y1,
    });
  });

  return out;
};

// Incoming — mirror of outgoing. Spawns in the convergence zone on the
// opposite side of centre so the two clouds pass through each other.
export const buildIncoming = (grid: Grid, opts: BuildOpts): Particle[] => {
  const { cellW, cellH, frameX, frameY, windDir, rngSeed, stageW, stageH } = opts;
  const rng = seededRng(rngSeed);

  const convergeX = stageW * 0.5 - windDir * 60;
  const convergeY = stageH * 0.5;

  const out: Particle[] = [];

  forEachLitCell(grid, (cell, r, c, cols) => {
    const x1 = frameX + c * cellW;
    const y1 = frameY + r * cellH;

    const spreadX = 60 + rng() * 110;
    const spreadY = (rng() - 0.5) * 180;
    const x0 = convergeX - windDir * spreadX * (rng() < 0.5 ? -0.4 : 1);
    const y0 = convergeY + spreadY;

    const colNorm = c / Math.max(1, cols - 1);
    const stagger = windDir > 0 ? colNorm * 0.5 : (1 - colNorm) * 0.5;
    const begin = 0.35 + stagger + rng() * 0.04;
    const duration = 0.28 + rng() * 0.1;

    out.push({
      a0: 0,
      a1: cell.alpha,
      begin,
      char: cell.char,
      end: Math.min(0.96, begin + duration),
      seed: Math.floor(rng() * 1e7),
      x0,
      x1,
      y0,
      y1,
    });
  });

  return out;
};

// Entrance — every cell spawns in a wide ring around the destination and
// drifts inward. Outer cells settle first, the centre resolves last.
export const buildEntrance = (grid: Grid, opts: Omit<BuildOpts, "windDir">): Particle[] => {
  const { cellW, cellH, frameX, frameY, rngSeed, stageW, stageH } = opts;
  const rng = seededRng(rngSeed);

  const rows = grid.length;
  const cols = grid[0]?.length ?? 0;
  const centerX = frameX + (cols * cellW) / 2;
  const centerY = frameY + (rows * cellH) / 2;
  const maxR = Math.hypot(cols * cellW, rows * cellH) / 2;

  const out: Particle[] = [];

  forEachLitCell(grid, (cell, r, c) => {
    const x1 = frameX + c * cellW;
    const y1 = frameY + r * cellH;

    const angle = rng() * Math.PI * 2;
    const distance = Math.min(stageW, stageH) * 0.5 + rng() * 320;
    const burstX = centerX + (rng() - 0.5) * 220;
    const burstY = stageH * 0.5 + (rng() - 0.5) * 180;
    const x0 = burstX + Math.cos(angle) * distance;
    const y0 = burstY + Math.sin(angle) * distance;

    const dx = x1 - centerX;
    const dy = y1 - centerY;
    const distNorm = Math.min(1, Math.hypot(dx, dy) / maxR);
    const stagger = (1 - distNorm) * 0.5 + rng() * 0.1;
    const duration = 0.34 + rng() * 0.16;

    out.push({
      a0: 0,
      a1: cell.alpha,
      begin: stagger,
      char: cell.char,
      end: Math.min(0.98, stagger + duration),
      seed: Math.floor(rng() * 1e7),
      x0,
      x1,
      y0,
      y1,
    });
  });

  return out;
};

// ---------------------------------------------------------------------------
// Renderers — fillStyle + font are set by the caller before invoking these
// ---------------------------------------------------------------------------

export const renderGrid = (
  ctx: CanvasRenderingContext2D,
  grid: Grid,
  frameX: number,
  frameY: number,
  cellW: number,
  cellH: number,
) => {
  for (let r = 0; r < grid.length; r += 1) {
    const row = grid[r];
    if (!row) {
      continue;
    }

    for (let c = 0; c < row.length; c += 1) {
      const cell = row[c];
      if (!cell || cell.char === " " || cell.alpha <= 0) {
        continue;
      }
      ctx.globalAlpha = cell.alpha;
      ctx.fillText(cell.char, frameX + c * cellW, frameY + r * cellH);
    }
  }
  ctx.globalAlpha = 1;
};

export const renderParticles = (
  ctx: CanvasRenderingContext2D,
  particles: Particle[],
  t: number,
  opts: RenderOpts,
) => {
  const { curlAmp, scorchAmp } = opts;

  for (const p of particles) {
    if (t <= p.begin) {
      if (p.a0 > 0) {
        ctx.globalAlpha = p.a0;
        ctx.fillText(p.char, p.x0, p.y0);
      }
      continue;
    }

    if (t >= p.end) {
      if (p.a1 > 0) {
        ctx.globalAlpha = p.a1;
        ctx.fillText(p.char, p.x1, p.y1);
      }
      continue;
    }

    const local = (t - p.begin) / (p.end - p.begin);
    const eased = easeInOutCubic(local);

    const sway = curlWindow(local);
    const x = lerp(p.x0, p.x1, eased) + curl(p.seed, local, 0) * curlAmp * sway;
    const y = lerp(p.y0, p.y1, eased) + curl(p.seed + 1, local, 1) * curlAmp * 0.7 * sway;

    let alpha: number;
    if (p.a1 === 0) {
      const fadeStart = 0.7;
      alpha = local < fadeStart ? p.a0 : p.a0 * (1 - (local - fadeStart) / (1 - fadeStart));
    } else {
      const fadeIn = 0.25;
      alpha = local < fadeIn ? p.a1 * (local / fadeIn) : p.a1;
    }

    // Mid-flight scorch: a bit-mixed per-particle hash decides whether this
    // glyph burns into noise, and only inside its own 0.32-0.72 window.
    let ch = p.char;
    if (scorchAmp > 0 && local > 0.32 && local < 0.72) {
      /* oxlint-disable no-bitwise -- Knuth multiplicative hash: `>>> 0` is the uint32 wrap, `& 0xff` the byte mask */
      const pick = (p.seed * 2_654_435_761) >>> 0;
      const burns = ((pick >>> 8) & 0xff) / 255 < scorchAmp;
      /* oxlint-enable no-bitwise */
      if (burns) {
        ch = NOISE[(pick + Math.floor(local * 64)) % NOISE.length] ?? p.char;
      }
    }

    ctx.globalAlpha = alpha;
    ctx.fillText(ch, x, y);
  }

  ctx.globalAlpha = 1;
};
```
