Skip to main content
Back to discovery

Preview supplied by Arlan Marat; live previews were recorded from Arlan's Vault when no published video file was available. Preview: creator supplied · rights cleared.

Midjourney Medical's ASCII

Thousands of live text characters spin through a WebGL canvas and gradually resolve into a crisp ASCII-style wordmark.

VSource LandVault

Why it stands out

The canvas reuses cached glyphs to animate thousands of characters efficiently. Letters near the center spin quickly while outer letters move more slowly, then each character transforms toward its destination in the logo under scanlines, chromatic edges, and a warm CRT treatment.

Prompt

You are given the complete source for a self-contained web visual: "Midjourney Medical's ASCII".

What it is:
- This is the [Midjourney Medical](https://www.midjourney.com) page. When it loads, a bunch of letters spin around and slowly form the Midjourney name. The cool part is that it's not a video or an image. It's all real text that the code moves around live.
- It all runs on one canvas. Each letter is drawn once and saved, then reused thousands of times, so it stays fast. Every frame the code takes text from a list of prompts and spins each letter around the center. The letters in the middle spin fast and the ones on the edges barely move, so it looks like a whirlpool. When a letter reaches the logo, it slowly turns into the right letter of the name.
- After the text is drawn, they add an old TV effect on top. The screen bends a little, the colors split at the edges, there are soft scanlines, the corners get darker, and everything gets a warm tone.

How to use this code:
- It is a React + TypeScript component tree (Next.js App Router) that renders to an HTML canvas; the entry point is the playground component.
- Drop the files into a project under the same relative paths, mount the playground component, and it runs as-is. No external assets beyond what's inline.
- To adapt it: change the resolved word/logo, the color controls, or the experiment parameters. Ask me to modify, explain, or port any part.
- Original inspiration: https://www.midjourney.com/medical

The full source follows, one file per block:

### swirl/playground.tsx
```tsx
"use client";

// An interactive rebuild of Midjourney Medical's ASCII intro, taken apart and
// re-authored as our own modules (see ./swirl): a vortex field of monospace
// cells that spins and condenses into a word, finished with a CRT post pass.
// The renderer reads a mutable config ref every frame, so controls update the
// effect live — no re-init. Below the main rebuild are a few EXPERIMENTS that
// push the same engine somewhere the original never went.

import { useEffect, useRef, useState } from "react";
import { Slider, ColorControl, SegmentedControl, GhostButton } from "./controls";
import { SectionLabel } from "../section-label";
import { useSwirlStage, DEFAULT_STAGE, type StageConfig } from "./use-swirl-stage";
import { VARIANTS } from "./resolver";
import { DEFAULT_TEXT } from "./default-text";
import { SwirlExperiments } from "./experiments";

export function SwirlPlayground() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [variantId, setVariantId] = useState(VARIANTS[0].id);
  const [ink, setInk] = useState(VARIANTS[0].ink);
  const [logoColor, setLogoColor] = useState(VARIANTS[0].logo);
  const [bg, setBg] = useState(VARIANTS[0].bg);
  const [rows, setRows] = useState<string[]>(VARIANTS[0].rows);
  const [scanlines, setScanlines] = useState(0.4);
  const [aberration, setAberration] = useState(1);
  const [curvature, setCurvature] = useState(1);
  const [zoom, setZoom] = useState(VARIANTS[0].zoom);
  const [failed, setFailed] = useState(false);

  // The background prompt text is fixed; it is not user-editable.
  const cfg = useRef<StageConfig>({
    ...DEFAULT_STAGE,
    rows,
    inkStops: [ink],
    logoColor,
    bg,
    text: DEFAULT_TEXT,
    scanlines,
    aberration,
    curvature,
    zoom,
  });
  useEffect(() => {
    cfg.current = {
      ...DEFAULT_STAGE,
      rows,
      inkStops: [ink],
      logoColor,
      bg,
      text: DEFAULT_TEXT,
      scanlines,
      aberration,
      curvature,
      zoom,
    };
  }, [rows, ink, logoColor, bg, scanlines, aberration, curvature, zoom]);

  const stage = useSwirlStage(canvasRef, cfg, () => setFailed(true));

  const pickVariant = (id: string) => {
    const v = VARIANTS.find((x) => x.id === id) ?? VARIANTS[0];
    setVariantId(v.id);
    setInk(v.ink);
    setLogoColor(v.logo);
    setBg(v.bg);
    setRows(v.rows);
    setZoom(v.zoom);
    stage.current.replay();
  };

  return (
    <>
      <section className="flex min-w-0 flex-col gap-3">
        <SectionLabel
          action={!failed && <GhostButton onClick={() => stage.current.replay()}>Replay</GhostButton>}
        >
          Implementation
        </SectionLabel>

        <div
          className="relative z-10 overflow-hidden rounded-xl border border-[var(--border-line)]"
          style={{ backgroundColor: bg }}
        >
          {failed ? (
            <div className="flex aspect-[16/10] w-full items-center justify-center px-6 text-center text-[13px] text-[var(--text-tertiary)]">
              Your browser doesn&apos;t support WebGL2, so the live playground can&apos;t run here.
            </div>
          ) : (
            <canvas ref={canvasRef} className="block aspect-[16/10] w-full" />
          )}
        </div>

        {!failed && (
          <div className="-mt-5 flex min-w-0 flex-col gap-3 rounded-b-xl border border-t-0 border-[var(--border-line)] bg-[var(--bg-surface)] p-3 pt-5">
            <div className="grid grid-cols-1 items-center gap-x-4 gap-y-3 sm:grid-cols-2">
              <SegmentedControl
                options={VARIANTS.map((v) => ({ id: v.id, label: v.label }))}
                activeId={variantId}
                onPick={pickVariant}
              />
              <div className="grid grid-cols-[1fr_1fr_1.4fr] gap-0.5">
                <ColorControl label="Letters" value={ink} onChange={setInk} />
                <ColorControl label="Logo" value={logoColor} onChange={setLogoColor} />
                <ColorControl label="Background" value={bg} onChange={setBg} />
              </div>
            </div>

            <div className="grid grid-cols-1 gap-x-4 gap-y-2.5 sm:grid-cols-2">
              <Slider label="Zoom" value={zoom} min={0.5} max={1.6} format={(v) => v.toFixed(2) + "x"} onChange={setZoom} />
              <Slider label="Scanlines" value={scanlines} min={0} max={1} format={(v) => (v * 100).toFixed(0) + "%"} onChange={setScanlines} />
              <Slider label="Aberration" value={aberration} min={0} max={3} onChange={setAberration} />
              <Slider label="Curvature" value={curvature} min={0} max={1} format={(v) => (v * 100).toFixed(0) + "%"} onChange={setCurvature} />
            </div>
          </div>
        )}
      </section>

      <SwirlExperiments />
    </>
  );
}

```

### swirl/experiments.tsx
```tsx
"use client";

// Experiments: the same vortex engine pushed past the faithful rebuild. Each is
// a small stage plus a short note on what I was trying. Always visible. The
// control panel under each stage tucks UNDER the canvas with the exact styling
// of the main playground panel (see swirl-playground.tsx): the canvas wrapper
// carries the shadow + full rounding, the panel pulls up with -mt-5 / border-t-0
// / no top radius / pt-8 so they read as one piece.

import { useEffect, useMemo, useRef, useState } from "react";
import { SectionLabel } from "../section-label";
import { SegmentedControl, Slider, ColorControl, GhostButton } from "./controls";
import { ExperimentStage } from "./experiment-stage";
import { DEFAULT_STAGE, type StageConfig, type StageEvents } from "./use-swirl-stage";
import type { WavePattern } from "./vortex-field";
import { DEFAULT_TEXT } from "./default-text";
import { renderWord, type FontStyle } from "./block-font";
import { swirlFormation, swirlChurn, swirlMove, swirlClick } from "../../lib/sound";

const BASE: StageConfig = {
  ...DEFAULT_STAGE,
  inkStops: ["#d8d8d8"],
  logoColor: "#ffffff",
  bg: "#0c0c0d",
  rows: undefined,
  word: "ARLAN",
  style: "slant",
  text: DEFAULT_TEXT,
  zoom: 0.62,
};

function Experiment({
  title,
  action,
  children,
}: {
  title: string;
  /** Optional control on the right of the title row (e.g. a Replay button). */
  action?: React.ReactNode;
  children: React.ReactNode;
}) {
  return (
    <div className="flex min-w-0 flex-col gap-3">
      <div className="flex items-center justify-between gap-3">
        <h3 className="font-semibold text-[var(--text-primary)]">{title}</h3>
        {action}
      </div>
      {children}
    </div>
  );
}

// Control panel that tucks under the canvas, matching the main playground panel.
function Panel({ children }: { children: React.ReactNode }) {
  return (
    <div className="-mt-5 flex min-w-0 flex-col gap-3 rounded-b-xl border border-t-0 border-[var(--border-line)] bg-[var(--bg-surface)] p-3 pt-5">
      {children}
    </div>
  );
}

/* ---------- 1. Logo generator ---------- */
const STYLES: { id: FontStyle; label: string }[] = [
  { id: "slant", label: "Slant" },
  { id: "standard", label: "Standard" },
  { id: "ogre", label: "Ogre" },
  { id: "doom", label: "Doom" },
  { id: "big", label: "Big" },
  { id: "speed", label: "Speed" },
  { id: "stop", label: "Stop" },
  { id: "subzero", label: "Sub-Zero" },
  { id: "banner", label: "Banner" },
];

// Wider/taller faces get pulled back a little so the word still fits the frame.
const STYLE_ZOOM: Partial<Record<FontStyle, number>> = {
  subzero: 0.42,
  big: 0.46,
  doom: 0.46,
  stop: 0.46,
  speed: 0.46,
};

const THEMES = [
  { id: "dark", label: "Dark" },
  { id: "light", label: "Light" },
];

// Each font carries its own accent in dark mode, so switching the face also
// switches the palette. [letters, logo]. Light mode overrides to dark-on-white.
const FONT_ACCENT: Record<FontStyle, [string, string]> = {
  slant: ["#e7e3da", "#ffffff"], // warm white
  standard: ["#8fd0ff", "#eaf6ff"], // sky blue
  ogre: ["#7fe3a3", "#e9fff0"], // green
  doom: ["#ff7a6e", "#ffeae7"], // red
  big: ["#9b8cff", "#efeaff"], // indigo
  speed: ["#ffd166", "#fff6e0"], // gold
  stop: ["#ff9ed2", "#ffeaf6"], // pink
  subzero: ["#5fe0e0", "#e6ffff"], // cyan
  banner: ["#c2f06b", "#f4ffe0"], // lime
};

function LogoGenerator() {
  const [word, setWord] = useState("MINERVA");
  const [style, setStyle] = useState<FontStyle>("slant");
  const [theme, setTheme] = useState("dark");
  const light = theme === "light";
  const [accent, logoAccent] = FONT_ACCENT[style];
  const cfg: StageConfig = {
    ...BASE,
    rows: renderWord(word || " ", style),
    zoom: STYLE_ZOOM[style] ?? 0.5,
    // light: dark text + dark logo on a near-white field; dark: the font's accent
    bg: light ? "#fcfcfc" : "#0a0a0c",
    inkStops: [light ? "#9a9a9a" : accent],
    logoColor: light ? "#1b1b1b" : logoAccent,
  };

  return (
    <Experiment
      title="Type your own logo"
    >
      <ExperimentStage config={cfg} />
      <Panel>
        <div className="flex items-stretch gap-2">
          <input
            value={word}
            onChange={(e) => setWord(e.target.value.slice(0, 14))}
            spellCheck={false}
            aria-label="Logo word"
            placeholder="type a word"
            className="h-8 min-w-0 flex-1 rounded-lg border border-[var(--border-line)] bg-[var(--bg-page)] px-3 text-[13px] uppercase tracking-wide text-[var(--text-body)] outline-none transition-colors duration-150 focus:border-[var(--border-ring)]"
          />
          <div className="w-44 shrink-0">
            <SegmentedControl options={THEMES} activeId={theme} onPick={setTheme} />
          </div>
        </div>
        <SegmentedControl options={STYLES} activeId={style} onPick={(id) => setStyle(id as FontStyle)} fill />
      </Panel>
    </Experiment>
  );
}

/* ---------- 2. Sound ---------- */
// The same engine, but wired for AUDIO: a layered synth cue rides the entrance
// (a rising sweep + a tap cascade that lands as the word forms + a low lock
// thud), and a soft churn/CRT-static drone plays while the canvas is on screen.
// All synthesized live (Web Audio, no files), gated on the global mute, first
// user gesture, and reduced-motion. See lib/sound.ts.
function SoundSwirl() {
  const cfg: StageConfig = {
    ...BASE,
    word: "SOUND",
    zoom: 0.62,
    inkStops: ["#cfe0ff"],
    logoColor: "#ffffff",
    bg: "#070a12",
  };

  // the churn drone, started/stopped by visibility; kept in a ref so re-renders
  // don't restart it
  const churn = useRef<{ stop: () => void } | null>(null);
  const reduced = useRef(false);
  useEffect(() => {
    reduced.current =
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    return () => churn.current?.stop();
  }, []);

  const restartChurn = () => {
    churn.current?.stop();
    churn.current = reduced.current ? null : swirlChurn();
  };

  const events: StageEvents = useMemo(
    () => ({
      onFormationStart: () => {
        if (!reduced.current) swirlFormation();
      },
      onVisible: (visible) => {
        if (visible && !reduced.current) {
          if (!churn.current) churn.current = swirlChurn();
        } else {
          churn.current?.stop();
          churn.current = null;
        }
      },
      // cursor over the letters → a soft tick that pitches up with speed
      onPointerMove: (speed) => {
        if (!reduced.current) swirlMove(speed);
      },
      // click / shockwave → an impact thud
      onPointerDown: () => {
        if (!reduced.current) swirlClick();
      },
    }),
    [],
  );

  // bumping this replays the entrance (and re-fires the entrance sound, since
  // onFormationStart runs on every (re)start)
  const [replay, setReplay] = useState(0);
  const onReplay = () => {
    setReplay((n) => n + 1); // restarts the visual + the entrance sound
    restartChurn(); // and the ambient bed, so EVERYTHING restarts together
  };

  return (
    <Experiment
      title="The same swirl, with sound"
      action={<GhostButton onClick={onReplay}>Replay</GhostButton>}
    >
      <ExperimentStage
        config={cfg}
        events={events}
        replayKey={replay}
        trackPointer
        burstOnClick
      />
    </Experiment>
  );
}

/* ---------- 3. Cursor trail ---------- */
function CursorTrail() {
  // its own warm-coral palette; dragging lights a hot white-gold trail that
  // churns the soup into the vortex and lingers
  const cfg: StageConfig = {
    ...BASE,
    trail: true,
    trailStrength: 1.6,
    trailFlare: "#fff1c2",
    word: "TRACE",
    zoom: 0.62,
    inkStops: ["#ff7a55"],
    logoColor: "#fff0ea",
    bg: "#160604",
  };
  return (
    <Experiment
      title="Drag a trail through the letters"
    >
      <ExperimentStage config={cfg} trackPointer />
    </Experiment>
  );
}

/* ---------- 4. Flowing multi-stop gradient ---------- */
// Default is ONE color living through its own shades: a single hue ramped from
// deep to bright and back, so it reads as a rich, breathing teal rather than a
// rainbow. The per-letter drift + speed brightness already baked into the field
// turn that one hue into something with real depth.
const MONO_RAMP = ["#0a3d3a", "#13807a", "#2fd4c4", "#9ff5ec", "#13807a"];

function GradientInk() {
  const [stops, setStops] = useState<string[]>(MONO_RAMP);
  const [angle, setAngle] = useState(0.4);
  const [flow, setFlow] = useState(0.06);

  const setStop = (i: number, v: string) =>
    setStops((s) => s.map((c, k) => (k === i ? v : c)));

  const cfg: StageConfig = {
    ...BASE,
    word: "FLOW",
    zoom: 0.55,
    gradient: true,
    inkStops: stops,
    gradientAngle: angle * Math.PI * 2,
    gradientFlow: flow,
    bg: "#08080b",
  };

  return (
    <Experiment
      title="Letters that flow through colors"
    >
      <ExperimentStage config={cfg} />
      <Panel>
        <div className="grid grid-cols-5 gap-1">
          {stops.map((c, i) => (
            <ColorControl key={i} label={`${i + 1}`} value={c} onChange={(v) => setStop(i, v)} />
          ))}
        </div>
        <div className="grid grid-cols-1 gap-x-4 gap-y-2.5 sm:grid-cols-2">
          <Slider label="Angle" value={angle} min={0} max={1} format={(v) => (v * 360).toFixed(0) + "°"} onChange={setAngle} />
          <Slider label="Flow" value={flow} min={0} max={0.4} format={(v) => (v * 100).toFixed(0) + "%"} onChange={setFlow} />
        </div>
      </Panel>
    </Experiment>
  );
}

/* ---------- 5. Shockwaves + sustained wave patterns ---------- */
const PATTERNS = [
  { id: "wavefront", label: "Wavefront" },
  { id: "ripples", label: "Ripples" },
  { id: "flow", label: "Flow" },
  { id: "cloth", label: "Cloth" },
];

// Each pattern gets its own bold palette so switching feels like a mood change.
const WAVE_PALETTE: Record<WavePattern, { ink: string; logo: string; bg: string }> = {
  wavefront: { ink: "#7fb8ff", logo: "#eaf3ff", bg: "#040a1a" }, // ocean blue
  ripples: { ink: "#4fe3d0", logo: "#e6fffb", bg: "#021414" }, // teal
  flow: { ink: "#d98cff", logo: "#f7ebff", bg: "#10031a" }, // magenta / violet
  cloth: { ink: "#ffc266", logo: "#fff4e0", bg: "#1a0f02" }, // amber
};

function ClickShock() {
  const [turbulence, setTurbulence] = useState(0.35);
  const [pattern, setPattern] = useState<WavePattern>("wavefront");
  const pal = WAVE_PALETTE[pattern];
  const cfg: StageConfig = {
    ...BASE,
    shock: true,
    turbulence,
    wavePattern: pattern,
    word: "WAVES",
    zoom: 0.62,
    inkStops: [pal.ink],
    logoColor: pal.logo,
    bg: pal.bg,
  };
  return (
    <Experiment
      title="Click for a wave, or leave it churning"
    >
      <ExperimentStage config={cfg} burstOnClick replayKey={pattern} />
      <Panel>
        <SegmentedControl options={PATTERNS} activeId={pattern} onPick={(id) => setPattern(id as WavePattern)} />
        <Slider label="Strength" value={turbulence} min={0} max={1} format={(v) => (v * 100).toFixed(0) + "%"} onChange={setTurbulence} />
      </Panel>
    </Experiment>
  );
}

export function SwirlExperiments() {
  return (
    <section className="mt-12 flex min-w-0 flex-col gap-6">
      <SectionLabel>Experiments</SectionLabel>
      <p className="-mt-3 text-[15px] leading-[1.7] text-[var(--text-secondary)]">
        A few things I added on top, just to see how far the same idea would go.
      </p>
      {/* each experiment after the first is set off by a hairline divider with
          generous spacing, so they read as distinct sections */}
      <div className="flex min-w-0 flex-col [&>*:not(:first-child)]:mt-14 [&>*:not(:first-child)]:border-t [&>*:not(:first-child)]:border-[var(--border-line)] [&>*:not(:first-child)]:pt-14">
        <LogoGenerator />
        <SoundSwirl />
        <CursorTrail />
        <GradientInk />
        <ClickShock />
      </div>
    </section>
  );
}

```

### swirl/controls.tsx
```tsx
"use client";

// Minimalist, site-styled controls for the swirl playground. Native color
// dialog / range chrome is replaced with our own so the panel matches the rest
// of the page (thin lines, soft grays, the site font) and reads as one system.
// The color control's anatomy (swatch trigger -> popover with a hue slider, a
// hex field and quick presets) is adapted from HeroUI's ColorPicker.

import {
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type ReactNode,
} from "react";
import { hapticTap } from "../../lib/haptics";
import { hoverLink } from "../../lib/sound";

const LABEL = "text-[12px] text-[var(--text-tertiary)]";

// ── Shared playground shell ─────────────────────────────────────────────────
// One connected card: a fully-rounded preview box with the control panel tucked
// underneath via a deep negative-margin overlap (the panel slides up under the
// preview's rounded bottom so they read as a single card). Matched to the vector
// editor; used by every Vault playground so their roundings are identical.
// PANEL_PAD covers the overlap so the first control row clears the seam.
export const PG_PREVIEW =
  "relative z-10 overflow-hidden rounded-xl border border-[var(--border-line)] bg-[var(--bg-hover)]";
export const PG_PANEL =
  "-mt-5 flex min-w-0 flex-col gap-4 rounded-b-xl border border-t-0 border-[var(--border-line)] bg-[var(--bg-surface)] p-4 pt-8";

/* ---------- Segmented control (variant picker) ---------- */
// One pill container with a sliding white indicator behind the active segment —
// the same sliding-pill language as the code tabs, so the panel reads cohesive.
export function SegmentedControl({
  options,
  activeId,
  onPick,
  fill = false,
}: {
  options: { id: string; label: string }[];
  activeId: string;
  onPick: (id: string) => void;
  /** Distribute segments to fill the row evenly when there's space, but never
   *  shrink a label below its text — so on a narrow screen the row scrolls
   *  horizontally instead of forcing the page wider. */
  fill?: boolean;
}) {
  const rowRef = useRef<HTMLDivElement>(null);
  const segRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const [pill, setPill] = useState<{ x: number; w: number } | null>(null);
  const activeIndex = Math.max(
    0,
    options.findIndex((o) => o.id === activeId),
  );

  useLayoutEffect(() => {
    const el = segRefs.current[activeIndex];
    if (!el) return;
    setPill({ x: el.offsetLeft, w: el.offsetWidth });
  }, [activeIndex, options.length]);

  useEffect(() => {
    const row = rowRef.current;
    if (!row || typeof ResizeObserver === "undefined") return;
    const ro = new ResizeObserver(() => {
      const el = segRefs.current[activeIndex];
      if (el) setPill({ x: el.offsetLeft, w: el.offsetWidth });
    });
    ro.observe(row);
    return () => ro.disconnect();
  }, [activeIndex]);

  return (
    <div
      ref={rowRef}
      role="tablist"
      aria-label="Variant"
      className="swirl-tabscroll relative flex h-8 w-full items-stretch gap-0 overflow-x-auto rounded-lg border border-[var(--border-line)] bg-[var(--bg-page)] p-0.5"
    >
      {pill && (
        <span
          aria-hidden="true"
          className="pointer-events-none absolute inset-y-0.5 rounded-md border border-[var(--border-line)] bg-[var(--bg-hover)]"
          style={{
            transform: `translateX(${pill.x - 2}px)`,
            width: pill.w,
            transition:
              "transform 0.34s var(--ease-out), width 0.34s var(--ease-out)",
          }}
        />
      )}
      {options.map((o, i) => {
        const active = o.id === activeId;
        return (
          <button
            key={o.id}
            ref={(el) => {
              segRefs.current[i] = el;
            }}
            type="button"
            role="tab"
            aria-selected={active}
            onClick={() => {
              hapticTap();
              onPick(o.id);
            }}
            onPointerEnter={hoverLink}
            className={`relative z-10 flex items-center justify-center whitespace-nowrap rounded-md px-2 py-1 text-[12px] transition-colors duration-150 ease-[var(--ease-out)] ${
              fill ? "flex-1 basis-0 [min-width:fit-content]" : "flex-1"
            } ${
              active
                ? "text-[var(--text-primary)]"
                : "text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]"
            }`}
          >
            {o.label}
          </button>
        );
      })}
    </div>
  );
}

/* ---------- Select (dropdown) ---------- */
// A site-styled select: a pill trigger (same border / radius / 12px text as the
// segmented control and theme toggle) with a chevron, opening a popover of
// options that share the sliding-pill's hover + active treatment. Used where a
// segmented row would be too many options to fit (e.g. the 9 fonts).
export function Select({
  options,
  activeId,
  onPick,
  ariaLabel,
}: {
  options: { id: string; label: string }[];
  activeId: string;
  onPick: (id: string) => void;
  ariaLabel?: string;
}) {
  const [open, setOpen] = useState(false);
  const wrapRef = useRef<HTMLDivElement>(null);
  const active = options.find((o) => o.id === activeId);

  // close on outside click / Escape (same pattern as ColorControl)
  useEffect(() => {
    if (!open) return;
    const onDoc = (e: MouseEvent) => {
      if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false);
    };
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
    document.addEventListener("mousedown", onDoc);
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("mousedown", onDoc);
      document.removeEventListener("keydown", onKey);
    };
  }, [open]);

  return (
    <div ref={wrapRef} className="relative flex shrink-0">
      <button
        type="button"
        role="combobox"
        aria-expanded={open}
        aria-label={ariaLabel}
        onClick={() => {
          hapticTap();
          setOpen((o) => !o);
        }}
        onPointerEnter={hoverLink}
        className="flex h-8 w-full items-center justify-between gap-1.5 rounded-lg border border-[var(--border-line)] bg-[var(--bg-page)] pl-3 pr-2.5 text-[12px] text-[var(--text-primary)] transition-colors duration-150 ease-[var(--ease-out)] hover:border-[var(--border-ring)]"
      >
        <span className="whitespace-nowrap">{active?.label ?? "Select"}</span>
        <svg
          width="10"
          height="10"
          viewBox="0 0 10 10"
          aria-hidden="true"
          className={`shrink-0 text-[var(--text-tertiary)] transition-transform duration-150 ease-[var(--ease-out)] ${open ? "rotate-180" : ""}`}
        >
          <path d="M2 3.5 5 6.5 8 3.5" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
        </svg>
      </button>

      {open && (
        <div
          role="listbox"
          // Matches the trigger's width instead of a fixed w-40: the trigger
          // stretches to whatever cell it sits in, so a fixed popover only lined
          // up at one particular size. min-w-40 keeps it usable when the trigger
          // is narrow, and it stays right-anchored so a wide list never runs off
          // the panel edge.
          className="absolute right-0 top-full z-50 mt-2 flex max-h-64 w-full min-w-40 flex-col gap-0.5 overflow-y-auto rounded-xl border border-[var(--border-ring)] bg-[var(--bg-surface)] p-1 shadow-[0_1px_2px_rgba(17,24,39,0.06),0_8px_24px_rgba(17,24,39,0.08)]"
          style={{ animation: "swirl-pop 0.16s var(--ease-expo)" }}
        >
          {options.map((o) => {
            const isActive = o.id === activeId;
            return (
              <button
                key={o.id}
                type="button"
                role="option"
                aria-selected={isActive}
                onClick={() => {
                  hapticTap();
                  onPick(o.id);
                  setOpen(false);
                }}
                onPointerEnter={hoverLink}
                className={`rounded-md px-2.5 py-1.5 text-left text-[12px] transition-colors duration-150 ease-[var(--ease-out)] ${
                  isActive
                    ? "bg-[var(--bg-page)] text-[var(--text-primary)]"
                    : "text-[var(--text-secondary)] hover:bg-[var(--bg-page)] hover:text-[var(--text-primary)]"
                }`}
              >
                {o.label}
              </button>
            );
          })}
        </div>
      )}
    </div>
  );
}

/* ---------- Slider ---------- */
// Row slider: the whole row is the track (fill grows from the left), label sits
// left + value right, with a thin handle riding the fill edge. Drag anywhere on
// the row, click to snap, or arrow-key when focused.
const CLICK_THRESHOLD = 3; // px — distinguishes a click from a drag

export function Slider({
  label,
  value,
  min,
  max,
  step = 0.01,
  format,
  onChange,
}: {
  label: string;
  value: number;
  min: number;
  max: number;
  step?: number;
  format?: (v: number) => string;
  onChange: (v: number) => void;
}) {
  const rowRef = useRef<HTMLDivElement>(null);
  const [dragging, setDragging] = useState(false);
  const down = useRef<{ x: number; moved: boolean } | null>(null);

  const pct = max === min ? 0 : ((value - min) / (max - min)) * 100;
  const snap = (v: number) => {
    const snapped = Math.round(v / step) * step;
    const clamped = Math.min(Math.max(snapped, min), max);
    // round float dust from the step division
    return parseFloat(clamped.toPrecision(12));
  };
  const valueFromX = (clientX: number) => {
    const r = rowRef.current!.getBoundingClientRect();
    const t = (clientX - r.left) / r.width;
    return snap(min + t * (max - min));
  };

  const onPointerDown = (e: React.PointerEvent) => {
    (e.target as HTMLElement).setPointerCapture?.(e.pointerId);
    down.current = { x: e.clientX, moved: false };
    setDragging(true);
    hapticTap();
  };
  const onPointerMove = (e: React.PointerEvent) => {
    if (!dragging || !down.current) return;
    if (Math.abs(e.clientX - down.current.x) > CLICK_THRESHOLD) down.current.moved = true;
    onChange(valueFromX(e.clientX));
  };
  const endDrag = (e: React.PointerEvent) => {
    if (!down.current) return;
    if (!down.current.moved) onChange(valueFromX(e.clientX)); // click-to-snap
    down.current = null;
    setDragging(false);
  };

  const onKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
      e.preventDefault();
      onChange(snap(value - step));
    } else if (e.key === "ArrowRight" || e.key === "ArrowUp") {
      e.preventDefault();
      onChange(snap(value + step));
    }
  };

  return (
    <label className="flex min-w-[9rem] flex-1 flex-col">
      <div
        ref={rowRef}
        role="slider"
        tabIndex={0}
        aria-valuemin={min}
        aria-valuemax={max}
        aria-valuenow={value}
        aria-label={label}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={endDrag}
        onPointerCancel={endDrag}
        onPointerEnter={hoverLink}
        onKeyDown={onKeyDown}
        className="relative flex h-8 w-full cursor-pointer touch-none select-none items-center overflow-hidden rounded-lg border border-[var(--border-line)] bg-[var(--bg-page)] outline-none ring-[var(--border-ring)] focus-visible:ring-1"
      >
        {/* fill — a light gray wash so the level reads against the white track */}
        <span
          className="pointer-events-none absolute inset-y-0 left-0 bg-[var(--bg-hover)]"
          style={{ width: `${pct}%` }}
        />
        {/* handle on the fill edge */}
        <span
          className={`pointer-events-none absolute top-1/2 h-4 w-[3px] -translate-y-1/2 rounded-full bg-[var(--text-primary)] transition-opacity duration-150 ${
            dragging ? "opacity-90" : "opacity-40"
          }`}
          style={{ left: `max(3px, calc(${pct}% - 1.5px))` }}
        />
        <span className="pointer-events-none relative z-10 pl-3 text-[12px] text-[var(--text-secondary)]">
          {label}
        </span>
        <span className="pointer-events-none relative z-10 ml-auto mr-3 text-[12px] tabular-nums text-[var(--text-secondary)]">
          {format ? format(value) : value.toFixed(2)}
        </span>
      </div>
    </label>
  );
}

/* ---------- Color helpers ---------- */
function hexToHsl(hex: string): [number, number, number] {
  let h = hex.replace("#", "").trim();
  if (h.length === 3) h = h.split("").map((c) => c + c).join("");
  const r = parseInt(h.slice(0, 2), 16) / 255;
  const g = parseInt(h.slice(2, 4), 16) / 255;
  const b = parseInt(h.slice(4, 6), 16) / 255;
  const max = Math.max(r, g, b);
  const min = Math.min(r, g, b);
  const l = (max + min) / 2;
  let s = 0;
  let hue = 0;
  if (max !== min) {
    const d = max - min;
    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
    if (max === r) hue = (g - b) / d + (g < b ? 6 : 0);
    else if (max === g) hue = (b - r) / d + 2;
    else hue = (r - g) / d + 4;
    hue *= 60;
  }
  return [Math.round(hue), Math.round(s * 100), Math.round(l * 100)];
}
function hslToHex(h: number, s: number, l: number): string {
  s /= 100;
  l /= 100;
  const k = (n: number) => (n + h / 30) % 12;
  const a = s * Math.min(l, 1 - l);
  const f = (n: number) =>
    l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
  const to = (n: number) =>
    Math.round(255 * f(n))
      .toString(16)
      .padStart(2, "0");
  return `#${to(0)}${to(8)}${to(4)}`;
}
const isHex = (s: string) => /^#?[0-9a-fA-F]{6}$/.test(s.trim());
const normHex = (s: string) => {
  const v = s.trim().replace(/^#?/, "");
  return `#${v.toLowerCase()}`;
};

const PRESET_SWATCHES = [
  "#ffffff", "#d8d8d8", "#9b9b9b", "#1b1b1b", "#0b1733",
  "#e88f00", "#2ee06a", "#3b82f6", "#a855f7", "#f7d8e3",
];

/* ---------- Color control (swatch trigger + popover) ---------- */
export function ColorControl({
  label,
  value,
  onChange,
}: {
  label: string;
  value: string;
  onChange: (v: string) => void;
}) {
  const [open, setOpen] = useState(false);
  const wrapRef = useRef<HTMLDivElement>(null);
  const [h, s, l] = hexToHsl(value);
  const [hexDraft, setHexDraft] = useState(value);

  useEffect(() => setHexDraft(value), [value]);

  // close on outside click / Escape
  useEffect(() => {
    if (!open) return;
    const onDoc = (e: MouseEvent) => {
      if (wrapRef.current && !wrapRef.current.contains(e.target as Node))
        setOpen(false);
    };
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
    document.addEventListener("mousedown", onDoc);
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("mousedown", onDoc);
      document.removeEventListener("keydown", onKey);
    };
  }, [open]);

  const setHue = (hue: number) => onChange(hslToHex(hue, s || 70, l || 50));

  return (
    <div ref={wrapRef} className="relative flex w-full min-w-0">
      <button
        type="button"
        onClick={() => {
          hapticTap();
          setOpen((o) => !o);
        }}
        onPointerEnter={hoverLink}
        aria-label={`${label}: ${value}`}
        aria-expanded={open}
        className={`group flex h-8 w-full min-w-0 items-center gap-1.5 rounded-lg border border-[var(--border-line)] bg-[var(--bg-page)] px-2.5 transition-colors duration-150 ease-[var(--ease-out)] hover:border-[var(--border-ring)] ${
          label ? "" : "justify-center"
        }`}
      >
        {label && <span className={`${LABEL} truncate`}>{label}</span>}
        <span
          aria-hidden="true"
          className={`h-4 w-4 shrink-0 rounded-[5px] border border-[var(--border-ring)] transition-transform duration-150 ease-[var(--ease-out)] group-active:scale-[0.96] ${
            label ? "ml-auto" : ""
          }`}
          style={{ backgroundColor: value }}
        />
      </button>

      {open && (
        <div
          className="absolute left-0 top-full z-50 mt-2 flex w-56 flex-col gap-3 rounded-xl border border-[var(--border-ring)] bg-[var(--bg-surface)] p-3 shadow-[0_1px_2px_rgba(17,24,39,0.06),0_8px_24px_rgba(17,24,39,0.08)]"
          style={{ animation: "swirl-pop 0.16s var(--ease-expo)" }}
        >
          {/* hue slider */}
          <span className="hue-track relative flex h-4 items-center">
            <span
              className="pointer-events-none absolute h-4 w-4 -translate-x-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.15)]"
              style={{ left: `${(h / 360) * 100}%`, backgroundColor: value }}
            />
            <input
              type="range"
              min={0}
              max={360}
              step={1}
              value={h}
              onChange={(e) => setHue(+e.target.value)}
              onPointerDown={hapticTap}
              aria-label={`${label} hue`}
              className="swirl-range relative z-10 h-4 w-full cursor-pointer appearance-none bg-transparent"
            />
          </span>

          {/* hex field */}
          <input
            type="text"
            value={hexDraft}
            spellCheck={false}
            onChange={(e) => {
              setHexDraft(e.target.value);
              if (isHex(e.target.value)) onChange(normHex(e.target.value));
            }}
            onBlur={() => setHexDraft(value)}
            aria-label={`${label} hex`}
            className="w-full rounded-md border border-[var(--border-line)] bg-[var(--bg-page)] px-2 py-1 font-mono text-[12px] lowercase text-[var(--text-body)] outline-none transition-colors duration-150 focus:border-[var(--border-ring)]"
          />

          {/* preset swatches */}
          <div className="grid grid-cols-5 gap-1.5">
            {PRESET_SWATCHES.map((c) => (
              <button
                key={c}
                type="button"
                onClick={() => {
                  hapticTap();
                  onChange(c);
                }}
                onPointerEnter={hoverLink}
                aria-label={c}
                className={`h-6 w-full rounded-md border transition-transform duration-150 ease-[var(--ease-out)] active:scale-[0.96] ${
                  c.toLowerCase() === value.toLowerCase()
                    ? "border-[var(--text-primary)]"
                    : "border-[var(--border-ring)]"
                }`}
                style={{ backgroundColor: c }}
              />
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

/* ---------- Auto-growing prompt input ---------- */
export function PromptInput({
  label,
  value,
  onChange,
}: {
  label: string;
  value: string;
  onChange: (v: string) => void;
}) {
  const ref = useRef<HTMLTextAreaElement>(null);
  const [max] = useState(132);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    el.style.height = "auto";
    el.style.height = `${Math.min(el.scrollHeight, max)}px`;
  }, [value, max]);

  return (
    <label className="flex flex-col gap-1.5">
      <span className={LABEL}>{label}</span>
      <textarea
        ref={ref}
        value={value}
        onChange={(e) => onChange(e.target.value)}
        onPointerEnter={hoverLink}
        spellCheck={false}
        rows={2}
        className="w-full resize-none overflow-y-auto overscroll-none rounded-lg border border-[var(--border-line)] bg-[var(--bg-page)] px-3 py-2 text-[12px] leading-[1.55] text-[var(--text-body)] outline-none transition-colors duration-150 focus:border-[var(--border-ring)]"
        style={{ maxHeight: max } as CSSProperties}
      />
    </label>
  );
}

/* ---------- Ghost button (panel actions) ---------- */
// Matches the Copy button / code-tab pill language: hairline border, anchored
// surface, hover darkens the border (never dissolves into the panel).
export function GhostButton({
  onClick,
  children,
}: {
  onClick: () => void;
  children: ReactNode;
}) {
  return (
    <button
      type="button"
      onClick={() => {
        hapticTap();
        onClick();
      }}
      onPointerEnter={hoverLink}
      className="inline-flex h-7 shrink-0 items-center self-start rounded-lg border border-[var(--border-line)] bg-[var(--bg-surface)] px-3 text-[12px] font-medium text-[var(--text-secondary)] transition-colors duration-150 ease-[var(--ease-out)] hover:bg-[var(--bg-hover)] hover:border-[var(--border-ring)] hover:text-[var(--text-primary)] active:scale-[0.98]"
    >
      {children}
    </button>
  );
}

```

### swirl/use-swirl-stage.ts
```ts
// One React hook that mounts the whole pipeline onto a <canvas> and runs the
// frame loop, reading a mutable config ref every frame so controls update the
// effect live (no re-init). Both the main playground and each experiment use
// this — they just feed it a different StageConfig.
//
// Interaction state (the spring-smoothed cursor bend, the velocity smoothing,
// and the click shockwave pool) lives HERE, per frame; the per-cell math that
// consumes it lives in vortex-field. The split keeps the hot loop pure.

import { useEffect, useRef, type RefObject } from "react";
import { buildAtlas, type GlyphAtlas } from "./glyph-atlas";
import { createRenderer, type Renderer } from "./renderer";
import { hexToRgb01 } from "./color";
import {
  composeField,
  makeBuffers,
  makeTarget,
  type FieldBuffers,
  type FieldGrid,
  type InkMode,
  type InkPaint,
  type Shock,
  type Target,
  type WavePattern,
} from "./vortex-field";
import { depositTrail, makeTrailField, stepTrail } from "./trail-field";
import { renderWord, type FontStyle } from "./block-font";

export interface StageConfig {
  /** Pre-baked ASCII rows for the named presets (takes priority over word). */
  rows?: string[];
  /** Live word for the logo generator (used when `rows` is absent). */
  word?: string;
  style?: FontStyle;
  /** Gradient color stops (hex). One stop = flat fill. */
  inkStops: string[];
  /** Color of the resolved logo word (hex), independent of the swirl letters. */
  logoColor: string;
  gradient: boolean;
  gradientAngle: number; // radians
  /** Gradient band drift speed (cycles/sec). 0 = static gradient. */
  gradientFlow: number;
  /** How the stops map onto the field. Defaults to "rows" (color swirls with
   *  the letters); "axis" is the old flat screen-space gradient. */
  gradientMode?: InkMode;
  bg: string;
  text: string;
  scanlines: number;
  aberration: number;
  curvature: number;
  zoom: number;
  /** Enable the persistent cursor trail (wake + local swirl-up). */
  trail: boolean;
  /** Trail intensity multiplier (1 = default). */
  trailStrength?: number;
  /** Hex color hot trail cells glow toward; omit for no color flare. */
  trailFlare?: string;
  /** Enable click shockwaves. */
  shock: boolean;
  /** Sustained ambient ripple, 0..1 (a permanent churning wave). */
  turbulence: number;
  /** Which ambient wave shape to use. */
  wavePattern: WavePattern;
}

export const DEFAULT_STAGE: Omit<StageConfig, "rows" | "word" | "style" | "bg" | "zoom" | "inkStops" | "logoColor"> = {
  gradient: false,
  gradientAngle: 0,
  gradientFlow: 0,
  text: "",
  scanlines: 0.4,
  aberration: 1,
  curvature: 1,
  trail: false,
  shock: false,
  turbulence: 0,
  wavePattern: "wavefront",
};

// Rows of glyphs down the canvas = BASE_ROWS / zoom. Smaller zoom → more, smaller
// cells → more words fit. Driven by the Zoom control.
const BASE_ROWS = 22;
// spring rate for the bend (1/time-constant); ~100ms half-life
// velocity smoothing rate
const VEL_SMOOTH = 5.0;
// shockwaves older than this (seconds) are retired
const SHOCK_LIFE = 2.4;

export interface StageHandle {
  /** Restart the formation so the word condenses from scratch. */
  replay: () => void;
  /** Set the pointer in normalized coords (-1..1); null = pointer left. */
  setPointer: (p: { x: number; y: number } | null) => void;
  /** Spawn a click shockwave at normalized coords (-1..1). */
  burst: (x: number, y: number) => void;
}

function resolveTarget(c: StageConfig): Target {
  if (c.rows && c.rows.length) return makeTarget(c.rows);
  return makeTarget(renderWord(c.word ?? " ", c.style ?? "slant"));
}

function targetKey(c: StageConfig): string {
  return c.rows ? "rows:" + c.rows.join("|") : `word:${c.word}:${c.style}`;
}

export interface StageEvents {
  /** The entrance (re)started — soup begins condensing into the word. */
  onFormationStart?: () => void;
  /** The word has fully formed (formation reached ~1). Fires once per entrance. */
  onSettle?: () => void;
  /** Visibility changed (canvas entered/left the on-screen play band). */
  onVisible?: (visible: boolean) => void;
  /** The very first frame has been drawn (canvas now has real content). */
  onFirstFrame?: () => void;
  /** Pointer moved over the canvas; `speed` is 0..1 (normalized units/frame). */
  onPointerMove?: (speed: number) => void;
  /** Pointer pressed on the canvas (e.g. for a click sound). */
  onPointerDown?: () => void;
}

export function useSwirlStage(
  canvasRef: RefObject<HTMLCanvasElement | null>,
  cfgRef: RefObject<StageConfig>,
  onFail: () => void,
  eventsRef?: RefObject<StageEvents>,
): RefObject<StageHandle> {
  const handle = useRef<StageHandle>({ replay: () => {}, setPointer: () => {}, burst: () => {} });

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const r: Renderer | null = createRenderer(canvas);
    if (!r) {
      onFail();
      return;
    }
    const { gl } = r;
    const { round, max, floor } = Math;

    // Glyph set: printable ASCII + whatever box/block chars the fonts use.
    const glyphs: string[] = [];
    const lookup: Record<string, number> = Object.create(null);
    const addGlyph = (cc: string) => {
      if (lookup[cc] !== undefined) return;
      lookup[cc] = glyphs.length;
      glyphs.push(cc);
    };
    // printable ASCII covers most styles + anything the user types; the full
    // block is for the Banner face
    for (let code = 32; code <= 126; code++) addGlyph(String.fromCharCode(code));
    for (const cc of "█▓") addGlyph(cc);
    addGlyph(" ");
    const spaceSlot = lookup[" "];
    const slotOf = (cc: string, weight: number) => {
      const i = lookup[cc];
      return weight * glyphs.length + (i === undefined ? spaceSlot : i);
    };

    let source: string[] = cfgRef.current.text.split("\n").map((e) => e.replace(/\t/g, "    "));
    let lastText = cfgRef.current.text;

    let target: Target = resolveTarget(cfgRef.current);
    let lastTargetKey = targetKey(cfgRef.current);
    let lastZoom = cfgRef.current.zoom;

    let atlas: GlyphAtlas | null = null;
    let grid: FieldGrid | null = null;
    let buffers: FieldBuffers | null = null;
    let lastCw = -1;
    let lastCh = -1;

    let ro: ResizeObserver | null = null;
    if (typeof ResizeObserver !== "undefined") {
      ro = new ResizeObserver(() => {
        lastCw = -1;
        lastCh = -1;
      });
      ro.observe(canvas);
    }

    // ── play only while roughly centered on screen ──
    // The rAF loop runs only when the canvas is near the vertical middle of the
    // viewport; offscreen it stops entirely (zero per-frame work). Each time it
    // re-enters, the entrance replays so the word condenses in as you scroll to
    // it. rootMargin shrinks the trigger band to the middle ~30% of the screen.
    let visible = true;
    let io: IntersectionObserver | null = null;

    // ── interaction state (per frame) ──
    const pointer = { x: 0, y: 0, active: false };
    const trail = makeTrailField();
    let velX = 0; // smoothed pointer velocity (normalized units / sec)
    let velY = 0;
    let prevPx = 0;
    let prevPy = 0;
    let havePrev = false;
    const shocks: Shock[] = [];

    function rebuild(cw: number, ch: number) {
      const c = cfgRef.current;
      const rows = max(8, round(BASE_ROWS / max(0.3, c.zoom)));
      atlas = buildAtlas(gl, r!.glyphTex, r!.scratch, glyphs, max(8, round(ch / rows)));
      const gridRows = max(target.rows.length, Math.ceil(ch / atlas.inkSize) + 1);
      const contentH = gridRows * atlas.inkSize;
      const cols = floor(cw / atlas.advance);
      grid = {
        cols,
        rows: gridRows,
        inkSize: atlas.inkSize,
        vOffset: round((ch - contentH) / 2),
        targetX: max(0, round((cols - (target.rows[0]?.length ?? 0)) / 2)),
        targetY: max(0, round((gridRows - target.rows.length) / 2)),
      };
      buffers = makeBuffers(gridRows * cols);
      r!.allocCells(buffers);
      r!.resizeTargets(cw, ch);
    }

    let startTime = 0;
    let prevTime = 0;
    let raf = 0;
    let settled = false; // has onSettle fired for the current entrance
    let firstFramePainted = false; // has the canvas drawn its first frame
    // seconds until the word is fully formed — matches FORMATION_SEC in
    // vortex-field (the formation easeOutQuad is ~done by then)
    const FORMATION_SETTLE_SEC = 1.8;

    // (re)start the loop, resetting timing so the first frame after a pause
    // doesn't compute a giant dt from the time spent offscreen.
    function startLoop() {
      if (raf !== 0) return;
      prevTime = 0;
      raf = requestAnimationFrame(frame);
    }
    function stopLoop() {
      if (raf !== 0) {
        cancelAnimationFrame(raf);
        raf = 0;
      }
    }

    handle.current = {
      replay: () => {
        startTime = 0;
      },
      setPointer: (p) => {
        if (p) {
          pointer.x = p.x;
          pointer.y = p.y;
          pointer.active = true;
        } else {
          pointer.active = false;
        }
      },
      burst: (x, y) => {
        shocks.push({ x, y, age: 0 });
        if (shocks.length > 4) shocks.shift();
      },
    };

    function frame(time: number) {
      const c = cfgRef.current;
      if (c.text !== lastText) {
        lastText = c.text;
        source = c.text.split("\n").map((e) => e.replace(/\t/g, "    "));
        lastCw = -1;
      }
      const tk = targetKey(c);
      if (tk !== lastTargetKey) {
        lastTargetKey = tk;
        target = resolveTarget(c);
        lastCw = -1;
        startTime = 0; // re-run the entrance so the new word condenses in
      }
      if (c.zoom !== lastZoom) {
        lastZoom = c.zoom;
        lastCw = -1;
      }

      const rect = canvas!.getBoundingClientRect();
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      const cw = round(rect.width * dpr);
      const ch = round(rect.height * dpr);
      if (cw > 0 && ch > 0 && (cw !== lastCw || ch !== lastCh)) {
        rebuild(cw, ch);
        lastCw = cw;
        lastCh = ch;
      }

      if (grid && atlas && buffers) {
        if (startTime === 0) {
          startTime = time;
          settled = false; // a fresh entrance is beginning
          eventsRef?.current?.onFormationStart?.();
        }
        if (prevTime === 0) prevTime = time; // first frame after a (re)start
        const elapsed = time - startTime;
        const dt = Math.min(0.05, Math.max(0.001, (time - prevTime) / 1000));
        prevTime = time;

        // fire onSettle once the word has fully formed (formation ~1)
        if (!settled && elapsed * 0.001 >= FORMATION_SETTLE_SEC) {
          settled = true;
          eventsRef?.current?.onSettle?.();
        }

        // ── persistent cursor trail: deposit at the pointer, then decay+diffuse ──
        if (c.trail) {
          if (pointer.active) {
            const vSp = 1 - Math.exp(-VEL_SMOOTH * dt);
            if (havePrev) {
              const instVx = (pointer.x - prevPx) / dt;
              const instVy = (pointer.y - prevPy) / dt;
              velX += (instVx - velX) * vSp;
              velY += (instVy - velY) * vSp;
            }
            prevPx = pointer.x;
            prevPy = pointer.y;
            havePrev = true;
            depositTrail(trail, pointer.x, pointer.y, velX, velY, dt);
          } else {
            havePrev = false;
            velX *= 1 - (1 - Math.exp(-VEL_SMOOTH * dt));
            velY *= 1 - (1 - Math.exp(-VEL_SMOOTH * dt));
          }
          stepTrail(trail, dt);
        }

        // ── age + retire shockwaves ──
        if (shocks.length) {
          for (let i = 0; i < shocks.length; i++) shocks[i].age += dt;
          for (let i = shocks.length - 1; i >= 0; i--) {
            if (shocks[i].age > SHOCK_LIFE) shocks.splice(i, 1);
          }
        }

        const stops = (c.gradient ? c.inkStops : c.inkStops.slice(0, 1)).map(hexToRgb01);
        const paint: InkPaint = {
          stops: stops.length ? (stops as [number, number, number][]) : [[1, 1, 1]],
          angle: c.gradientAngle,
          flow: c.gradient ? (elapsed * 0.001 * c.gradientFlow) : 0,
          gradient: c.gradient && stops.length > 1,
          mode: c.gradientMode ?? "rows",
        };
        const bg = hexToRgb01(c.bg);
        const count = composeField({
          grid,
          atlas,
          buffers,
          source: source.length ? source : [""],
          target,
          elapsed,
          paint,
          logo: hexToRgb01(c.logoColor),
          slotOf,
          trail: c.trail ? trail : undefined,
          trailStrength: c.trailStrength,
          trailFlare: c.trailFlare ? hexToRgb01(c.trailFlare) : undefined,
          shocks: c.shock && shocks.length ? shocks : undefined,
          turbulence: c.turbulence,
          wavePattern: c.wavePattern,
        });
        r!.drawField(count, grid, buffers, bg);
        r!.drawCrt(elapsed * 0.001, cw, ch, {
          scanline: c.scanlines,
          aberration: c.aberration,
          curvature: c.curvature,
          bg,
        });
        if (!firstFramePainted) {
          firstFramePainted = true;
          eventsRef?.current?.onFirstFrame?.();
        }
      }
      raf = 0;
      if (visible) raf = requestAnimationFrame(frame); // stop entirely if offscreen
    }

    if (typeof IntersectionObserver !== "undefined") {
      io = new IntersectionObserver(
        ([entry]) => {
          const nowVisible = entry.isIntersecting;
          if (nowVisible && !visible) {
            visible = true;
            startTime = 0; // replay the entrance each time it scrolls into the middle
            startLoop();
            eventsRef?.current?.onVisible?.(true);
          } else if (!nowVisible && visible) {
            visible = false;
            stopLoop();
            eventsRef?.current?.onVisible?.(false);
          }
        },
        // only "in view" when the canvas is near the vertical middle of the
        // screen: shrink the viewport's effective top/bottom by 35% each.
        { threshold: 0, rootMargin: "-35% 0px -35% 0px" },
      );
      io.observe(canvas);
      // Start hidden until the observer reports; the first callback fires async.
      visible = false;
    } else {
      startLoop();
    }

    return () => {
      stopLoop();
      ro?.disconnect();
      io?.disconnect();
      r.dispose();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return handle;
}

```

### swirl/renderer.ts
```ts
// The WebGL2 plumbing: compile both programs, allocate the buffers and the
// offscreen framebuffer, and expose two draw calls — drawField (instanced text
// into the FBO) and drawCrt (the post pass to the screen). The React component
// drives this; none of the per-frame math lives here.

import { CRT_VERT, CRT_FRAG, TEXT_VERT, TEXT_FRAG } from "./crt-pass";
import type { FieldBuffers, FieldGrid } from "./vortex-field";

export interface Renderer {
  gl: WebGL2RenderingContext;
  glyphTex: WebGLTexture;
  scratch: HTMLCanvasElement;
  resizeTargets: (cw: number, ch: number) => void;
  allocCells: (buffers: FieldBuffers) => void;
  drawField: (
    count: number,
    grid: FieldGrid,
    buffers: FieldBuffers,
    bg: [number, number, number],
  ) => void;
  drawCrt: (
    elapsedSec: number,
    cw: number,
    ch: number,
    uniforms: { scanline: number; aberration: number; curvature: number; bg: [number, number, number] },
  ) => void;
  dispose: () => void;
}

/** Returns null if WebGL2 / 2D context / shader compile fails. */
export function createRenderer(canvas: HTMLCanvasElement): Renderer | null {
  const gl = canvas.getContext("webgl2", { alpha: true, antialias: false, depth: false });
  if (!gl) return null;
  gl.disable(gl.DEPTH_TEST);

  const scratch = document.createElement("canvas");
  if (!scratch.getContext("2d")) return null;

  let ok = true;
  const compile = (type: number, src: string) => {
    const s = gl.createShader(type)!;
    gl.shaderSource(s, src);
    gl.compileShader(s);
    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
      console.error("swirl shader compile failed:", gl.getShaderInfoLog(s));
      ok = false;
    }
    return s;
  };
  const link = (vs: WebGLShader, fs: WebGLShader) => {
    const p = gl.createProgram()!;
    gl.attachShader(p, vs);
    gl.attachShader(p, fs);
    gl.linkProgram(p);
    if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
      console.error("swirl program link failed:", gl.getProgramInfoLog(p));
      ok = false;
    }
    return p;
  };

  const crtProg = link(compile(gl.VERTEX_SHADER, CRT_VERT), compile(gl.FRAGMENT_SHADER, CRT_FRAG));
  const textProg = link(compile(gl.VERTEX_SHADER, TEXT_VERT), compile(gl.FRAGMENT_SHADER, TEXT_FRAG));
  if (!ok) return null;

  const C = {
    aPos: gl.getAttribLocation(crtProg, "aPos"),
    aUv: gl.getAttribLocation(crtProg, "aUv"),
    uTex: gl.getUniformLocation(crtProg, "uTex"),
    uTime: gl.getUniformLocation(crtProg, "uTime"),
    uRes: gl.getUniformLocation(crtProg, "uRes"),
    uScanline: gl.getUniformLocation(crtProg, "uScanline"),
    uAberration: gl.getUniformLocation(crtProg, "uAberration"),
    uCurvature: gl.getUniformLocation(crtProg, "uCurvature"),
    uBg: gl.getUniformLocation(crtProg, "uBg"),
  };
  const T = {
    aCorner: gl.getAttribLocation(textProg, "aCorner"),
    aBounds: gl.getAttribLocation(textProg, "aBounds"),
    aGlyphUv: gl.getAttribLocation(textProg, "aGlyphUv"),
    aColor: gl.getAttribLocation(textProg, "aColor"),
    uTarget: gl.getUniformLocation(textProg, "uTarget"),
    uAtlas: gl.getUniformLocation(textProg, "uAtlas"),
  };

  const posBuf = gl.createBuffer()!;
  const uvBuf = gl.createBuffer()!;
  const cornerBuf = gl.createBuffer()!;
  const boundsBuf = gl.createBuffer()!;
  const glyphUvBuf = gl.createBuffer()!;
  const colorBuf = gl.createBuffer()!;
  const glyphTex = gl.createTexture()!;
  const textTex = gl.createTexture()!;
  const fbo = gl.createFramebuffer()!;

  gl.bindBuffer(gl.ARRAY_BUFFER, posBuf);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, 1, 1, 1, -1, -1, 1, -1]), gl.STATIC_DRAW);
  gl.bindBuffer(gl.ARRAY_BUFFER, uvBuf);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 1, 1, 1, 0, 0, 1, 0]), gl.STATIC_DRAW);
  gl.bindBuffer(gl.ARRAY_BUFFER, cornerBuf);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), gl.STATIC_DRAW);

  const configureTex = (tex: WebGLTexture) => {
    gl.bindTexture(gl.TEXTURE_2D, tex);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  };
  configureTex(glyphTex);
  configureTex(textTex);

  const allocStream = (buffer: WebGLBuffer, data: Float32Array) => {
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
    gl.bufferData(gl.ARRAY_BUFFER, data.byteLength, gl.DYNAMIC_DRAW);
  };
  const streamAttr = (buffer: WebGLBuffer, data: Float32Array, loc: number) => {
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
    gl.bufferSubData(gl.ARRAY_BUFFER, 0, data);
    gl.vertexAttribPointer(loc, 4, gl.FLOAT, false, 0, 0);
    gl.enableVertexAttribArray(loc);
    gl.vertexAttribDivisor(loc, 1);
  };

  return {
    gl,
    glyphTex,
    scratch,
    resizeTargets(cw, ch) {
      canvas.width = cw;
      canvas.height = ch;
      gl.bindTexture(gl.TEXTURE_2D, textTex);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, cw, ch, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
      gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
      gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, textTex, 0);
      gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    },
    allocCells(buffers) {
      allocStream(boundsBuf, buffers.bounds);
      allocStream(glyphUvBuf, buffers.glyphUvs);
      allocStream(colorBuf, buffers.colors);
    },
    drawField(count, grid, buffers, bg) {
      void grid;
      gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
      gl.viewport(0, 0, canvas.width, canvas.height);
      gl.disable(gl.DEPTH_TEST);
      gl.clearColor(bg[0], bg[1], bg[2], 1);
      gl.clear(gl.COLOR_BUFFER_BIT);
      gl.enable(gl.BLEND);
      gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
      gl.useProgram(textProg);
      gl.activeTexture(gl.TEXTURE0);
      gl.bindTexture(gl.TEXTURE_2D, glyphTex);
      gl.uniform1i(T.uAtlas, 0);
      gl.uniform2f(T.uTarget, canvas.width, canvas.height);
      gl.bindBuffer(gl.ARRAY_BUFFER, cornerBuf);
      gl.vertexAttribPointer(T.aCorner, 2, gl.FLOAT, false, 0, 0);
      gl.enableVertexAttribArray(T.aCorner);
      gl.vertexAttribDivisor(T.aCorner, 0);
      streamAttr(boundsBuf, buffers.bounds.subarray(0, count * 4), T.aBounds);
      streamAttr(glyphUvBuf, buffers.glyphUvs.subarray(0, count * 4), T.aGlyphUv);
      streamAttr(colorBuf, buffers.colors.subarray(0, count * 4), T.aColor);
      gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, count);
      gl.vertexAttribDivisor(T.aBounds, 0);
      gl.vertexAttribDivisor(T.aGlyphUv, 0);
      gl.vertexAttribDivisor(T.aColor, 0);
    },
    drawCrt(elapsedSec, cw, ch, u) {
      gl.bindFramebuffer(gl.FRAMEBUFFER, null);
      gl.viewport(0, 0, cw, ch);
      gl.disable(gl.BLEND);
      gl.clearColor(0, 0, 0, 0);
      gl.clear(gl.COLOR_BUFFER_BIT);
      gl.useProgram(crtProg);
      gl.activeTexture(gl.TEXTURE0);
      gl.bindTexture(gl.TEXTURE_2D, textTex);
      gl.bindBuffer(gl.ARRAY_BUFFER, posBuf);
      gl.vertexAttribPointer(C.aPos, 2, gl.FLOAT, false, 0, 0);
      gl.enableVertexAttribArray(C.aPos);
      gl.bindBuffer(gl.ARRAY_BUFFER, uvBuf);
      gl.vertexAttribPointer(C.aUv, 2, gl.FLOAT, false, 0, 0);
      gl.enableVertexAttribArray(C.aUv);
      gl.uniform1i(C.uTex, 0);
      gl.uniform1f(C.uTime, elapsedSec);
      gl.uniform2f(C.uRes, cw, ch);
      gl.uniform1f(C.uScanline, u.scanline);
      gl.uniform1f(C.uAberration, u.aberration);
      gl.uniform1f(C.uCurvature, u.curvature);
      gl.uniform3f(C.uBg, u.bg[0], u.bg[1], u.bg[2]);
      gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
    },
    dispose() {
      // Intentionally NOT calling WEBGL_lose_context().loseContext(): under
      // React Strict Mode the effect mounts → cleans up → remounts on the same
      // <canvas>, and force-losing the context left the remount with a dead one
      // (the swirl only appeared after a hard reload). Let GC reclaim it.
    },
  };
}

```

### swirl/glyph-atlas.ts
```ts
// Glyph atlas: we rasterize every character we might draw ONCE into a hidden 2D
// canvas, then hand that canvas to the GPU as a single texture. After that,
// drawing N letters is N UV lookups into one image instead of N canvas writes —
// which is the whole reason the field can be thousands of cells and still hold
// 60fps.
//
// Two weights are baked (regular + bold) so the resolving word can fade in a
// bolder stroke over the background swirl without a second atlas.

export const WEIGHT_REGULAR = 0;
export const WEIGHT_BOLD = 1;
export const WEIGHT_COUNT = 2;

export interface GlyphAtlas {
  /** Rasterization pixel size (also the cell font-size). */
  inkSize: number;
  /** Advance width of one monospace cell. */
  advance: number;
  cellW: number;
  cellH: number;
  baseline: number;
  pad: number;
  /** [u0,v0,u1,v1] per (weight*glyphCount + glyphIndex). */
  uvs: number[][];
  canvas: HTMLCanvasElement;
}

/**
 * Build the atlas for a given ink size over the supplied glyph list, uploading
 * it into `tex`. The caller owns the GL context and the glyph ordering.
 */
export function buildAtlas(
  gl: WebGL2RenderingContext,
  tex: WebGLTexture,
  scratch: HTMLCanvasElement,
  glyphs: string[],
  inkSize: number,
): GlyphAtlas {
  const { max, ceil, sqrt, floor } = Math;
  const ctx = scratch.getContext("2d")!;
  const baseFont = `${inkSize}px monospace`;
  ctx.font = baseFont;
  ctx.textBaseline = "alphabetic";
  ctx.textAlign = "left";
  const advance = ctx.measureText("M").width;

  // measure the tallest ascent/descent across both weights so every cell fits
  let asc = 0;
  let desc = 0;
  for (let w = 0; w < WEIGHT_COUNT; w++) {
    ctx.font = w === WEIGHT_BOLD ? `bold ${baseFont}` : baseFont;
    for (const g of glyphs) {
      const m = ctx.measureText(g);
      asc = max(asc, m.actualBoundingBoxAscent || inkSize * 0.8);
      desc = max(desc, m.actualBoundingBoxDescent || inkSize * 0.25);
    }
  }

  const pad = max(2, ceil(inkSize * 0.18));
  const cellW = max(1, ceil(advance + pad * 2));
  const cellH = max(1, ceil(asc + desc + pad * 2));
  const baseline = pad + ceil(asc);

  const count = glyphs.length * WEIGHT_COUNT;
  const cols = ceil(sqrt(count));
  const rows = ceil(count / cols);
  scratch.width = cols * cellW;
  scratch.height = rows * cellH;
  ctx.clearRect(0, 0, scratch.width, scratch.height);
  ctx.fillStyle = "#ffffff";
  ctx.textBaseline = "alphabetic";
  ctx.textAlign = "left";

  const uvs: number[][] = new Array(count);
  for (let w = 0; w < WEIGHT_COUNT; w++) {
    ctx.font = w === WEIGHT_BOLD ? `bold ${baseFont}` : baseFont;
    for (let i = 0; i < glyphs.length; i++) {
      const idx = w * glyphs.length + i;
      const cx = (idx % cols) * cellW;
      const cy = floor(idx / cols) * cellH;
      ctx.fillText(glyphs[i], cx + pad, cy + baseline);
      uvs[idx] = [
        cx / scratch.width,
        cy / scratch.height,
        (cx + cellW) / scratch.width,
        (cy + cellH) / scratch.height,
      ];
    }
  }

  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
  gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
  gl.bindTexture(gl.TEXTURE_2D, tex);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, scratch);

  return { inkSize, advance, cellW, cellH, baseline, pad, uvs, canvas: scratch };
}

```

### swirl/vortex-field.ts
```ts
// The vortex field: a dense grid of monospace cells. Every frame each cell's
// position is rotated around the center by a twist that grows toward the middle
// (fast core, still rim — that gradient is the whirlpool), then the source text
// is sampled at the rotated coordinate. Where a cell lands inside the target
// word's stencil, its character is pulled toward the word so the name condenses
// out of the soup.
//
// Renamed and re-derived from a study of Midjourney Medical's ASCII intro; the
// twist falloff here is our own smoothstep core rather than a raw 1/dist.

import {
  WEIGHT_REGULAR,
  WEIGHT_BOLD,
  type GlyphAtlas,
} from "./glyph-atlas";
import { sampleTrail, type TrailField } from "./trail-field";

// ---- tuning ----
/** Base angular speed; the falloff below scales it up toward the core. */
export const TWIST_RATE = 0.1;
/** Floor on the radius so the very center doesn't spin to infinity. */
export const CORE_FLOOR = 0.1;
/** Seconds for the word to fully condense out of the field. Long enough that the
 *  crossfade entrance clearly reads, short enough not to drag on each keystroke. */
export const FORMATION_SEC = 1.8;
/** How wide the letter-body halo is when carving the stencil. */
export const STENCIL_HALO = 4;
/** Sampling stays in [-1,1] so columns map cleanly onto the grid width. */
export const FIELD_EXTENT = 1.0;

const SPACE = " ";

const easeOutQuad = (t: number) => t * (2 - t);
const mix = (a: number, b: number, t: number) => a * (1 - t) + b * t;
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
// The twist a cell receives, given its distance from the center: strong at the
// core, fading toward the rim. Expressed as TWIST_RATE over the (floored)
// radius — the floor stops the center spinning to infinity.
const twistAt = (spin: number, dist: number) =>
  (spin * TWIST_RATE) / Math.max(CORE_FLOOR, dist);

/**
 * The target word laid out as 5+ rows of ASCII, plus a stencil marking which
 * cells belong to the letter bodies (so the morph knows what to carve).
 */
export interface Target {
  rows: string[];
  stencil: boolean[][];
}

/** Build the body stencil around a word's glyph cells (incl. interior counters). */
export function carveStencil(rows: string[]): boolean[][] {
  const w = Math.max(0, ...rows.map((l) => l.length));
  return rows.map((line) => {
    const isInk = (x: number) => x >= 0 && x < w && line[x] !== undefined && line[x] !== " ";
    return Array.from({ length: w }, (_u, x) => {
      if (isInk(x) || isInk(x - 1) || isInk(x + 1)) return true;
      // a cell sitting inside a letter counter (ink on both sides) still belongs
      let left = false;
      let right = false;
      for (let d = 1; d <= STENCIL_HALO; d++) {
        if (isInk(x - d)) left = true;
        if (isInk(x + d)) right = true;
      }
      return left && right;
    });
  });
}

export function makeTarget(rows: string[]): Target {
  const w = Math.max(0, ...rows.map((l) => l.length));
  const padded = rows.map((l) => l.padEnd(w, " "));
  return { rows: padded, stencil: carveStencil(padded) };
}

/** A reusable cell buffer the composer fills each frame. */
export interface FieldBuffers {
  bounds: Float32Array;
  glyphUvs: Float32Array;
  colors: Float32Array;
}

export function makeBuffers(maxCells: number): FieldBuffers {
  return {
    bounds: new Float32Array(maxCells * 4),
    glyphUvs: new Float32Array(maxCells * 4),
    colors: new Float32Array(maxCells * 4),
  };
}

export interface FieldGrid {
  cols: number;
  rows: number;
  inkSize: number;
  vOffset: number;
  targetX: number;
  targetY: number;
}

/** Per-cell ink color. Flat color, or a gradient sampled across the field. */
// ---- ink: a flowing multi-stop gradient sampled per cell ----
/**
 * How the stops are mapped onto the field:
 *  - "rows":  hue follows the SOURCE row each character was pulled from, so a
 *             letter carries its color as the vortex flings it around — the
 *             rainbow spirals with the swirl instead of sitting still.
 *  - "axis":  the legacy flat screen-space gradient (a fixed sheet the letters
 *             slide across). Kept for the simple look.
 */
export type InkMode = "rows" | "axis";

export interface InkPaint {
  /** Ordered color stops in 0..1 RGB. One stop = flat fill. */
  stops: [number, number, number][];
  /** Gradient axis direction in radians (0 = left→right). Used by "axis" mode. */
  angle: number;
  /** Phase the bands have drifted (animated by the caller). */
  flow: number;
  /** When false, every cell is stops[0]. */
  gradient: boolean;
  /** Which mapping to use (defaults to "rows" when gradient is on). */
  mode: InkMode;
}

// ---- depth layers stacked on top of the base hue ----
/** Max ± hue offset a single cell can drift, in stop-space (so neighbours
 *  shimmer between adjacent colors instead of matching exactly). */
const HUE_DRIFT = 0.12;
/** How fast each cell's drift cycles. */
const HUE_DRIFT_SPEED = 0.6;
/** Brightness floor for the slowest (rim) cells; the fast core reaches 1. */
const SPEED_DIM = 0.55;

// sample the stop ramp as a closed loop at position t (any real; wrapped to 0..1)
function rampAt(stops: [number, number, number][], t: number): [number, number, number] {
  t = t - Math.floor(t); // 0..1 wrapped
  const seg = t * stops.length;
  const i = Math.floor(seg) % stops.length;
  const j = (i + 1) % stops.length;
  const f = seg - Math.floor(seg);
  const a = stops[i];
  const b = stops[j];
  return [mix(a[0], b[0], f), mix(a[1], b[1], f), mix(a[2], b[2], f)];
}

/** A click shockwave: an expanding ring that displaces the cells it crosses. */
export interface Shock {
  /** Origin in normalized field units (-1..1). */
  x: number;
  y: number;
  /** Seconds since the click (the caller ages this). */
  age: number;
}

// cursor-trail tuning. The trail is a persistent heat+flow field (see
// trail-field.ts); here we turn a cell's local heat into two layers:
//   1. a directional WAKE — push the cell along the remembered flow direction
//   2. a local SWIRL-UP — add to the cell's twist so hot regions spin faster
// Both scale per cell by heat, so a faint lingering trail keeps nudging and
// churning the soup long after the cursor has gone.
const WAKE_PUSH = 0.34; // max wake displacement at full heat (half-field units)
const SWIRL_GAIN = 4.5; // extra spin multiplier per unit heat (strong churn)
const TRAIL_NOISE = 0.9; // per-cell variation so the wake isn't a uniform slab
const FLARE_GAIN = 2.4; // how hard heat drives the color flare (clamped to 1)
// shockwave tuning
const SHOCK_SPEED = 1.4; // ring radius growth per second (field units)
const SHOCK_WIDTH = 0.13; // ring thickness
const SHOCK_PUSH = 0.12; // radial shove at the ring crest (gentle)
const SHOCK_FADE = 1.9; // exp decay per second

// deterministic per-cell hash → 0..1 (so a cell's noise is stable frame to frame)
const cellHash = (col: number, row: number) => {
  const n = Math.sin(col * 127.1 + row * 311.7) * 43758.5453;
  return n - Math.floor(n);
};

// Where this cell sits on the color ramp BEFORE the depth layers. "rows" keys
// off the source row (color travels with the letter); "axis" is the old
// screen-space projection.
interface PaintSample {
  /** Final screen position (-1..1), for the legacy axis mode. */
  fx: number;
  fy: number;
  /** Source row this character came from, normalized 0..1 (for "rows"). */
  srcRow: number;
  /** Stable per-cell hash 0..1, for the per-letter hue drift. */
  jitter: number;
  /** Swirl speed at this cell 0..1 (1 = fast core), for speed brightness. */
  speed: number;
  /** Animation phase in seconds, for the drift cycle. */
  time: number;
}

function paintAt(paint: InkPaint, s: PaintSample): [number, number, number] {
  const stops = paint.stops;
  if (!paint.gradient || stops.length < 2) return stops[0];

  // ── base hue position on the ramp ──
  let t: number;
  if (paint.mode === "axis") {
    const ca = Math.cos(paint.angle);
    const sa = Math.sin(paint.angle);
    t = (s.fx * ca + s.fy * sa) * 0.5 + 0.5 + paint.flow;
  } else {
    // rows: each source line maps to a point on the loop, drifting by flow
    t = s.srcRow + paint.flow;
  }

  // ── layer 1: per-letter hue drift — a slow ± wobble unique to each cell, so
  //    neighbours shimmer between adjacent colors instead of matching exactly ──
  t += Math.sin(s.time * HUE_DRIFT_SPEED + s.jitter * Math.PI * 2) * HUE_DRIFT * s.jitter;

  const rgb = rampAt(stops, t);

  // ── layer 2: speed brightness — the fast-spinning core renders full, the slow
  //    rim dims toward SPEED_DIM, so the swirl's energy reads in the color ──
  const bright = mix(SPEED_DIM, 1, s.speed);
  return [rgb[0] * bright, rgb[1] * bright, rgb[2] * bright];
}

function pushCell(
  buf: FieldBuffers,
  atlas: GlyphAtlas,
  glyphSlot: number,
  x: number,
  baseline: number,
  rgb: [number, number, number],
  alpha: number,
  state: { count: number },
) {
  if (alpha <= 0) return;
  const o = state.count * 4;
  const uv = atlas.uvs[glyphSlot];
  buf.bounds[o] = x - atlas.pad;
  buf.bounds[o + 1] = baseline - atlas.baseline;
  buf.bounds[o + 2] = x - atlas.pad + atlas.cellW;
  buf.bounds[o + 3] = baseline - atlas.baseline + atlas.cellH;
  buf.glyphUvs[o] = uv[0];
  buf.glyphUvs[o + 1] = uv[1];
  buf.glyphUvs[o + 2] = uv[2];
  buf.glyphUvs[o + 3] = uv[3];
  buf.colors[o] = rgb[0];
  buf.colors[o + 1] = rgb[1];
  buf.colors[o + 2] = rgb[2];
  buf.colors[o + 3] = alpha;
  state.count++;
}

export interface ComposeArgs {
  grid: FieldGrid;
  atlas: GlyphAtlas;
  buffers: FieldBuffers;
  source: string[];
  target: Target;
  /** ms since the formation (re)started. */
  elapsed: number;
  paint: InkPaint;
  /** Color of the resolved logo word in 0..1 RGB (independent of the swirl ink). */
  logo: [number, number, number];
  /** Glyph index lookup (resolves a character to its atlas slot, per weight). */
  slotOf: (ch: string, weight: number) => number;
  /** Persistent cursor trail: drives the directional wake + local swirl-up. */
  trail?: TrailField;
  /** Overall trail intensity multiplier (1 = default). */
  trailStrength?: number;
  /** Color hot trail cells flare toward (0..1 RGB); omit for no color flare. */
  trailFlare?: [number, number, number];
  /** Active click shockwaves (optional). */
  shocks?: Shock[];
  /** Sustained ambient wave distortion, 0..1 (a permanent rippling). */
  turbulence?: number;
  /** Which ambient wave shape to use (defaults to "wavefront"). */
  wavePattern?: WavePattern;
}

/**
 * Fill the buffers for this frame and return how many cells were written. The
 * caller uploads `buffers` and issues one instanced draw of `count` quads.
 *
 * The swirl sampling is unchanged from the faithful rebuild; the cursor bend and
 * click shockwaves are applied AFTER, as a positional displacement on each
 * cell's screen position — so the letters physically shove around the pointer
 * and ripple out from a click, rather than the whole field shifting.
 */
// ── ambient wave: a sustained displacement pattern over the whole field. The
// strength is `turbulence` (0..1); the shape is `wavePattern`. ──
export type WavePattern = "wavefront" | "ripples" | "flow" | "cloth";

const WAVE_AMP = 0.16; // max displacement at turbulence = 1
const WAVE_FREQ = 2.4; // spatial frequency
const WAVE_SPEED = 0.9; // travel speed
const WAVE_DIR = Math.PI * 0.15; // wavefront travel direction (radians)

// cheap value noise (hash-lerp) for the "flow" pattern — smooth, scrolling,
// non-repeating. Not Perlin, but plenty for a soft drift.
const vnoise = (x: number, y: number): number => {
  const xi = Math.floor(x);
  const yi = Math.floor(y);
  const xf = x - xi;
  const yf = y - yi;
  const h = (a: number, b: number) => {
    const n = Math.sin(a * 127.1 + b * 311.7) * 43758.5453;
    return n - Math.floor(n);
  };
  const u = xf * xf * (3 - 2 * xf);
  const v = yf * yf * (3 - 2 * yf);
  const a = h(xi, yi);
  const b = h(xi + 1, yi);
  const c = h(xi, yi + 1);
  const d = h(xi + 1, yi + 1);
  return (a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v) * 2 - 1; // -1..1
};

export function composeField(args: ComposeArgs): number {
  const { grid, atlas, buffers, source, target, elapsed, paint, logo, slotOf, trail, shocks, turbulence } = args;
  const wavePattern = args.wavePattern ?? "wavefront";
  const trailStrength = args.trailStrength ?? 1;
  const flare = args.trailFlare;
  const { sin, cos, sqrt, floor, round, exp, max, PI } = Math;
  const spin = elapsed * 0.001;
  const turbOn = !!turbulence && turbulence > 0.001;
  const tWave = elapsed * 0.001 * WAVE_SPEED;
  const waveDirX = cos(WAVE_DIR);
  const waveDirY = sin(WAVE_DIR);
  const formation = easeOutQuad(clamp01((elapsed * 0.001) / FORMATION_SEC));
  // Highlight tracks the logo color instead of being hardcoded white: nudge it
  // toward whichever end is "further" so a dark logo gets a soft sheen and a
  // light logo a faint lift — never a white wash that breaks on light bgs.
  const lum = 0.299 * logo[0] + 0.587 * logo[1] + 0.114 * logo[2];
  const hi: [number, number, number] = lum < 0.5
    ? [mix(logo[0], 1, 0.5), mix(logo[1], 1, 0.5), mix(logo[2], 1, 0.5)] // lighten dark logo
    : [mix(logo[0], 0, 0.35), mix(logo[1], 0, 0.35), mix(logo[2], 0, 0.35)]; // darken light logo
  const lines = source;
  const tw = target.rows;
  const tWidth = tw[0]?.length ?? 0;
  const state = { count: 0 };

  const trailOn = !!trail;
  const ts = { heat: 0, fx: 0, fy: 0 }; // reused per-cell trail sample
  const shockOn = !!shocks && shocks.length > 0;
  // px per normalized HALF-unit, per axis (the canvas is not square, so x and y
  // get their own scale or the push would skew on one axis).
  const halfW = (grid.cols * atlas.advance) / 2;
  const halfH = (grid.rows * grid.inkSize) / 2;

  for (let row = 0; row < grid.rows; row++) {
    const y = (1 - (row * 2) / grid.rows) * FIELD_EXTENT;
    const baseline = grid.vOffset + row * grid.inkSize;
    // screen-normalized y (-1..1, y-down) for displacement math
    const sny = (row + 0.5) / grid.rows * 2 - 1;
    for (let col = 0; col < grid.cols; col++) {
      const x = ((col * 2) / grid.cols - 1) * FIELD_EXTENT;
      const snx = (col + 0.5) / grid.cols * 2 - 1; // screen-normalized x (y-down)
      const dist = sqrt(x * x + y * y);

      // Sample the persistent cursor trail once (heat + remembered flow). The
      // heat locally spins the whirlpool up so a touched region keeps churning.
      let heat = 0;
      if (trailOn) {
        sampleTrail(trail!, snx, sny, ts);
        heat = ts.heat;
      }
      const twist = twistAt(spin, dist) * (1 + SWIRL_GAIN * heat * trailStrength);
      const s = sin(twist);
      const cse = cos(twist);
      const rx = x * cse + y * s;
      const ry = x * s - y * cse;

      // Sample the source: rows cycle through the prompt lines; the column
      // indexes DIRECTLY into the line so where a line runs out the cell is
      // blank — that ragged edge is what keeps the field open and airy.
      const sampleCol = floor(((rx + 1) / 2) * grid.cols);
      const sampleRow = floor(((ry + 1) / 2) * grid.rows);
      const srcLine = lines[((sampleRow % lines.length) + lines.length) % lines.length] ?? "";
      let ch = sampleCol >= 0 && sampleCol < srcLine.length ? srcLine[sampleCol] ?? SPACE : SPACE;

      // The name reads by NEGATIVE space: inside the logo footprint (the
      // stencil) the soup is cleared so letters never show through the word, and
      // the figlet's own glyph cells morph from the soup char toward the target
      // char as the formation ramps in (the entrance).
      let resolved = SPACE;
      const tx = col - grid.targetX;
      const ty = row - grid.targetY;
      const inTarget = tx >= 0 && tx < tWidth && ty >= 0 && ty < tw.length;
      // The whole logo footprint (every stencil cell, not just glyph cells) is
      // held rigid: no displacement reaches it, so the name never warps while the
      // soup around it bends, shocks and churns.
      const inLogo = inTarget && !!target.stencil[ty]?.[tx];
      if (inLogo) {
        const wordChar = tw[ty][tx];
        if (wordChar && wordChar !== " ") {
          ch = String.fromCharCode(
            round(mix(ch.charCodeAt(0), wordChar.charCodeAt(0), formation)),
          );
          resolved = ch;
        } else if (formation > 0.5) {
          ch = SPACE; // counter / halo cell clears once the word has formed
        }
      }
      if (ch === SPACE && resolved === SPACE) continue;

      // ── positional displacement: cursor wake + click shockwaves ──
      // Worked entirely in SCREEN-normalized space (-1..1, y-down) so the trail /
      // click coordinates line up exactly with where the letters are.
      let dx = 0;
      let dy = 0;
      // Directional WAKE: push the cell along the trail's remembered flow,
      // scaled by heat, with per-cell noise so it isn't a uniform slab. Because
      // the trail lingers, this keeps nudging long after the cursor has left.
      if (trailOn && !inLogo && heat > 0.001) {
        const noise = 1 + (cellHash(col, row) - 0.5) * TRAIL_NOISE;
        const push = WAKE_PUSH * heat * trailStrength * noise;
        dx += ts.fx * push;
        dy += ts.fy * push;
        // a swirl curl: also nudge perpendicular to the flow so the wake twists
        // into the vortex instead of just streaking straight — more alive
        dx += -ts.fy * push * 0.5;
        dy += ts.fx * push * 0.5;
      }
      if (shockOn && !inLogo) {
        for (let k = 0; k < shocks!.length; k++) {
          const sh = shocks![k];
          const ox = snx - sh.x;
          const oy = sny - sh.y;
          const r = sqrt(ox * ox + oy * oy);
          const ringR = sh.age * SHOCK_SPEED;
          const d = (r - ringR) / SHOCK_WIDTH;
          const crest = exp(-d * d) * exp(-sh.age * SHOCK_FADE);
          if (crest > 0.002) {
            const inv = 1 / max(0.0001, r);
            const push = SHOCK_PUSH * crest;
            dx += ox * inv * push;
            dy += oy * inv * push;
          }
        }
      }
      if (turbOn && !inLogo) {
        const a = WAVE_AMP * turbulence!;
        if (wavePattern === "wavefront") {
          // a directional crest sweeps across the field; cells lift along the
          // travel direction as the front passes (water / flag)
          const phase = (snx * waveDirX + sny * waveDirY) * WAVE_FREQ * PI - tWave * PI;
          const w = sin(phase);
          dx += a * w * waveDirX;
          dy += a * w * waveDirY;
        } else if (wavePattern === "ripples") {
          // endless concentric rings radiating from the center (pond breathing)
          const r = sqrt(snx * snx + sny * sny);
          const w = sin(r * WAVE_FREQ * PI * 1.6 - tWave * PI);
          const inv = 1 / max(0.08, r);
          dx += a * w * snx * inv;
          dy += a * w * sny * inv;
        } else if (wavePattern === "flow") {
          // drift each cell along a scrolling noise field (ink / smoke)
          const nx = vnoise(snx * 1.6 + tWave * 0.4, sny * 1.6);
          const ny = vnoise(sny * 1.6 - tWave * 0.4 + 7.3, snx * 1.6 + 3.1);
          dx += a * 1.4 * nx;
          dy += a * 1.4 * ny;
        } else {
          // cloth: columns sway side-to-side, phase increasing down the rows, so
          // the field ripples like a banner in a breeze
          dx += a * 1.3 * sin(sny * WAVE_FREQ * PI * 0.9 + tWave * PI * 1.2);
          dy += a * 0.35 * sin(snx * WAVE_FREQ * PI + tWave * PI);
        }
      }

      const px = col * atlas.advance + dx * halfW;
      const by = baseline + dy * halfH;
      const fx = (col * 2) / grid.cols - 1;
      const fy = 1 - (row * 2) / grid.rows;
      // color by the SOURCE row (so it travels with the letter), shimmered by a
      // per-cell drift and brightened by how fast this cell is spinning.
      const srcRow = floor(((ry + 1) / 2) * grid.rows);
      const rgb = paintAt(paint, {
        fx,
        fy,
        srcRow: (((srcRow % grid.rows) + grid.rows) % grid.rows) / grid.rows,
        jitter: cellHash(col, row),
        speed: clamp01(1 - dist),
        time: spin,
      });
      // COLOR FLARE: hot trail cells glow toward the flare color, so dragging
      // lights up a visible, lingering path (the trail reads as light + heat).
      let cellRgb = rgb;
      if (flare && heat > 0.001 && !inLogo) {
        const t = clamp01(heat * FLARE_GAIN * trailStrength);
        cellRgb = [mix(rgb[0], flare[0], t), mix(rgb[1], flare[1], t), mix(rgb[2], flare[2], t)];
      }
      if (ch !== SPACE) {
        pushCell(buffers, atlas, slotOf(ch, WEIGHT_REGULAR), px, by, cellRgb, 1, state);
      }
      if (resolved !== SPACE) {
        // the forming letter fades in over the morphing char, in the LOGO color
        // plus a sheen derived from it (not pure white, so light themes read)
        pushCell(buffers, atlas, slotOf(resolved, WEIGHT_BOLD), px, by, logo, formation, state);
        pushCell(buffers, atlas, slotOf(resolved, WEIGHT_BOLD), px, by, hi, formation * 0.5, state);
      }
    }
  }
  return state.count;
}

```

### swirl/trail-field.ts
```ts
// A persistent "trail" the cursor leaves on the swirl. It is a low-res grid that
// remembers, per cell, how much the pointer has disturbed that spot (heat) and
// which way it was moving when it did (a flow direction). Heat barely fades, so
// a path you trace lingers almost indefinitely — but each deposit is tiny, so it
// stays a faint, slowly-churning trace rather than a bright smear.
//
// The grid is a fixed resolution in normalized field space (-1..1 on both axes),
// independent of the canvas size, so it survives resizes. composeField samples it
// per cell to drive a directional wake (push along the remembered flow) and a
// local swirl-up (extra spin where it is hot).

export const TRAIL_W = 72;
export const TRAIL_H = 40;

// ── tuning ── kept deliberately weak: the trail is near-permanent, so a small
// per-frame deposit accumulates into a faint, lingering trace rather than a
// bright smear. HEAT_MAX caps how strong a hammered spot can ever get.
const DEPOSIT_RADIUS = 0.15; // Gaussian radius of a single deposit (field units)
const DEPOSIT_BASE = 0.05; // heat laid down per second just by hovering
const DEPOSIT_SPEED = 0.8; // extra heat scaled by pointer speed
const HEAT_MAX = 0.7; // clamp: headroom for a strong, visible trail
const DECAY_PER_SEC = 0.025; // near-permanent: ~2.5% of the heat fades per second
const DIFFUSE = 0.12; // per-frame blur so trails soften into pools
const FLOW_BLEND = 0.25; // how fast a cell adopts the latest pointer direction

export interface TrailField {
  heat: Float32Array; // TRAIL_W * TRAIL_H, 0..HEAT_MAX
  flowX: Float32Array; // remembered pointer direction per cell, -1..1
  flowY: Float32Array;
  tmp: Float32Array; // scratch for diffusion
}

export function makeTrailField(): TrailField {
  const n = TRAIL_W * TRAIL_H;
  return {
    heat: new Float32Array(n),
    flowX: new Float32Array(n),
    flowY: new Float32Array(n),
    tmp: new Float32Array(n),
  };
}

export function clearTrail(t: TrailField) {
  t.heat.fill(0);
  t.flowX.fill(0);
  t.flowY.fill(0);
}

// normalized (-1..1) → grid index helpers
const toGX = (nx: number) => ((nx + 1) / 2) * (TRAIL_W - 1);
const toGY = (ny: number) => ((ny + 1) / 2) * (TRAIL_H - 1);

/**
 * Deposit heat + flow at the pointer. `nx,ny` are normalized field coords
 * (-1..1, y-down to match screen). `vx,vy` is the pointer velocity (same units
 * per second); its magnitude scales the deposit and its direction is stored.
 */
export function depositTrail(
  t: TrailField,
  nx: number,
  ny: number,
  vx: number,
  vy: number,
  dt: number,
) {
  const speed = Math.min(3, Math.hypot(vx, vy));
  const amount = (DEPOSIT_BASE + DEPOSIT_SPEED * speed) * dt;
  const dirLen = Math.hypot(vx, vy) || 1;
  const dirX = vx / dirLen;
  const dirY = vy / dirLen;

  const gx = toGX(nx);
  const gy = toGY(ny);
  const rx = DEPOSIT_RADIUS * (TRAIL_W / 2);
  const ry = DEPOSIT_RADIUS * (TRAIL_H / 2);
  const x0 = Math.max(0, Math.floor(gx - rx * 2));
  const x1 = Math.min(TRAIL_W - 1, Math.ceil(gx + rx * 2));
  const y0 = Math.max(0, Math.floor(gy - ry * 2));
  const y1 = Math.min(TRAIL_H - 1, Math.ceil(gy + ry * 2));
  for (let y = y0; y <= y1; y++) {
    for (let x = x0; x <= x1; x++) {
      const ddx = (x - gx) / rx;
      const ddy = (y - gy) / ry;
      const fall = Math.exp(-(ddx * ddx + ddy * ddy));
      if (fall < 0.01) continue;
      const i = y * TRAIL_W + x;
      t.heat[i] = Math.min(HEAT_MAX, t.heat[i] + amount * fall);
      // ease the stored flow toward the current direction, weighted by deposit
      const w = FLOW_BLEND * fall;
      t.flowX[i] += (dirX - t.flowX[i]) * w;
      t.flowY[i] += (dirY - t.flowY[i]) * w;
    }
  }
}

/** Decay (near-permanent) + a light box blur so heat diffuses into soft pools. */
export function stepTrail(t: TrailField, dt: number) {
  const keep = Math.exp(-DECAY_PER_SEC * dt);
  const { heat, tmp } = t;
  // 4-neighbour blur into tmp, then lerp back by DIFFUSE and apply decay
  for (let y = 0; y < TRAIL_H; y++) {
    for (let x = 0; x < TRAIL_W; x++) {
      const i = y * TRAIL_W + x;
      const l = x > 0 ? heat[i - 1] : heat[i];
      const rr = x < TRAIL_W - 1 ? heat[i + 1] : heat[i];
      const u = y > 0 ? heat[i - TRAIL_W] : heat[i];
      const d = y < TRAIL_H - 1 ? heat[i + TRAIL_W] : heat[i];
      tmp[i] = (l + rr + u + d) * 0.25;
    }
  }
  for (let i = 0; i < heat.length; i++) {
    heat[i] = (heat[i] + (tmp[i] - heat[i]) * DIFFUSE) * keep;
  }
}

/** Bilinear-sample heat + flow at normalized coords. Returns into `out`. */
export function sampleTrail(
  t: TrailField,
  nx: number,
  ny: number,
  out: { heat: number; fx: number; fy: number },
) {
  const gx = toGX(nx);
  const gy = toGY(ny);
  const x0 = Math.max(0, Math.min(TRAIL_W - 1, Math.floor(gx)));
  const y0 = Math.max(0, Math.min(TRAIL_H - 1, Math.floor(gy)));
  const x1 = Math.min(TRAIL_W - 1, x0 + 1);
  const y1 = Math.min(TRAIL_H - 1, y0 + 1);
  const tx = gx - x0;
  const ty = gy - y0;
  const i00 = y0 * TRAIL_W + x0;
  const i10 = y0 * TRAIL_W + x1;
  const i01 = y1 * TRAIL_W + x0;
  const i11 = y1 * TRAIL_W + x1;
  const lerp = (a: number, b: number, f: number) => a + (b - a) * f;
  const bi = (arr: Float32Array) =>
    lerp(lerp(arr[i00], arr[i10], tx), lerp(arr[i01], arr[i11], tx), ty);
  out.heat = bi(t.heat);
  out.fx = bi(t.flowX);
  out.fy = bi(t.flowY);
}

```

### swirl/crt-pass.ts
```ts
// The CRT post pass and the instanced-text pass, as GLSL strings + the named
// constants that tune them. Keeping the shaders here (instead of inline in the
// React component) means the playground file reads as orchestration, and the
// "feel" numbers live in one place where they're documented as decisions.

// ---- tuning constants (named so they read as choices, not copied dust) ----
/** How hard the barrel bulge pushes at full curvature. */
export const BARREL_GAIN = 0.1;
/** Inward pinch that pairs with the bulge to keep the frame square-ish. */
export const BARREL_PINCH = 0.085;
/** Edge-weighted term so the bulge grows toward the corners. */
export const BARREL_EDGE = 0.05;
/** RGB split distance per aberration unit, in UV. */
export const ABERRATION_UV = 0.0028;
/** Vignette darkening at the corners (0 = none). */
export const VIGNETTE = 0.7;
/** Filmic exposure before the tonemap; >1 lifts midtones. */
export const TONEMAP_EXPOSURE = 2.2;
/** Seconds for the curvature to ease in from flat at load. */
export const CURVE_RAMP_SEC = 3.0;

// ---- fullscreen CRT pass over the rendered-text texture ----
export const CRT_FRAG = `#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif
varying vec2 vUv;
uniform sampler2D uTex;
uniform float uTime;
uniform vec2 uRes;
uniform float uScanline;
uniform float uAberration;
uniform float uCurvature;
uniform vec3 uBg;
float easeOutQuad(float t){ return t*(2.0-t); }
void main(){
  float prog = min(uTime / ${CRT_NUM(CURVE_RAMP_SEC)}, 1.0);
  float curve = easeOutQuad(prog) * uCurvature;
  vec2 c = vUv * 2.0 - 1.0;
  c *= 1.0 + ${CRT_NUM(BARREL_GAIN)} * curve;
  c *= 1.0 - ${CRT_NUM(BARREL_PINCH)} * curve + ${CRT_NUM(BARREL_EDGE)} * curve * pow(abs(c.yx), vec2(2.0));
  c = c * 0.5 + 0.5;
  if (c.x < 0.0 || c.x > 1.0 || c.y < 0.0 || c.y > 1.0){ gl_FragColor = vec4(uBg, 1.0); return; }
  float d = uAberration * ${CRT_NUM(ABERRATION_UV)};
  float r = texture2D(uTex, vec2(c.x + d, c.y)).r;
  float g = texture2D(uTex, c).g;
  float b = texture2D(uTex, vec2(c.x - d, c.y)).b;
  vec3 col = vec3(r, g, b);

  // How light the background is (0 = black, 1 = white). The CRT darkening tricks
  // (vignette toward black, filmic tonemap) are tuned for a dark tube and leave
  // a grey halo on a light field, so we fade them out as the bg gets lighter.
  float bgLum = dot(uBg, vec3(0.299, 0.587, 0.114));
  float darkMode = 1.0 - smoothstep(0.4, 0.8, bgLum);

  float scan = max(0.0, sin((c.y + uTime * 0.0005) * uRes.y)) * 0.5;
  col = mix(col, col - vec3(scan), uScanline);

  // Vignette fades the edges toward the ACTUAL background (not black), so on a
  // white field the corners stay white instead of going grey.
  float vig = length(c - 0.5) * ${CRT_NUM(VIGNETTE)};
  col = mix(col, uBg, clamp(vig, 0.0, 1.0) * darkMode);

  // Filmic glow/tonemap only on dark backgrounds; on light it would dull white.
  vec3 toned = 1.0 - exp(-col * ${CRT_NUM(TONEMAP_EXPOSURE)});
  col = mix(col, toned, darkMode);

  gl_FragColor = vec4(col, 1.0);
}`;

export const CRT_VERT = `attribute vec4 aPos; attribute vec2 aUv; varying vec2 vUv;
void main(){ gl_Position = aPos; vUv = aUv; }`;

// ---- instanced text pass: one quad per visible glyph cell ----
export const TEXT_VERT = `attribute vec2 aCorner; attribute vec4 aBounds; attribute vec4 aGlyphUv; attribute vec4 aColor;
uniform vec2 uTarget; varying vec2 vGlyphUv; varying vec4 vColor;
void main(){
  vec2 px = mix(aBounds.xy, aBounds.zw, aCorner);
  vec2 clip = vec2((px.x / uTarget.x) * 2.0 - 1.0, 1.0 - (px.y / uTarget.y) * 2.0);
  gl_Position = vec4(clip, 0.0, 1.0);
  vGlyphUv = mix(aGlyphUv.xy, aGlyphUv.zw, aCorner);
  vColor = aColor;
}`;

export const TEXT_FRAG = `precision mediump float; varying vec2 vGlyphUv; varying vec4 vColor; uniform sampler2D uAtlas;
void main(){ float a = texture2D(uAtlas, vGlyphUv).a; if(a <= 0.001) discard; gl_FragColor = vec4(vColor.rgb, vColor.a * a); }`;

// GLSL needs a decimal point on float literals; this stamps our JS constants in.
function CRT_NUM(n: number): string {
  const s = String(n);
  return s.includes(".") ? s : s + ".0";
}

```

### swirl/block-font.ts
```ts
// The logo-generator font engine. You give it a word and a style; it returns
// rows of monospace ASCII art that the vortex resolves into.
//
// Eight styles come straight from figlet (patorjk/figlet.js), converted to our
// row format in figlet-fonts.ts — real display faces, hand-picked to stay
// legible once the swirl shrinks them (short, sparse line-art). A ninth, Banner,
// is our own block face kept for the chunky poster look. Whatever the source,
// everything flows through one renderWord, so each font automatically inherits
// the entrance morph and all the experiment behaviours.

import { FIGLET_FONTS } from "./figlet-fonts";

export type FontStyle =
  | "slant"
  | "standard"
  | "ogre"
  | "doom"
  | "big"
  | "speed"
  | "stop"
  | "subzero"
  | "banner";

// ── Banner: our own block face. Bitmaps (1 = filled) outlined into hollow
//    letters so they read at small scale instead of merging into a blob. ──
const BANNER_BITS: Record<string, string[]> = {
  A: ["01110", "10001", "11111", "10001", "10001"],
  B: ["11110", "10001", "11110", "10001", "11110"],
  C: ["01111", "10000", "10000", "10000", "01111"],
  D: ["11110", "10001", "10001", "10001", "11110"],
  E: ["11111", "10000", "11110", "10000", "11111"],
  F: ["11111", "10000", "11110", "10000", "10000"],
  G: ["01111", "10000", "10011", "10001", "01111"],
  H: ["10001", "10001", "11111", "10001", "10001"],
  I: ["111", "010", "010", "010", "111"],
  J: ["00111", "00010", "00010", "10010", "01100"],
  K: ["10001", "10010", "11100", "10010", "10001"],
  L: ["10000", "10000", "10000", "10000", "11111"],
  M: ["10001", "11011", "10101", "10001", "10001"],
  N: ["10001", "11001", "10101", "10011", "10001"],
  O: ["01110", "10001", "10001", "10001", "01110"],
  P: ["11110", "10001", "11110", "10000", "10000"],
  Q: ["01110", "10001", "10101", "10010", "01101"],
  R: ["11110", "10001", "11110", "10010", "10001"],
  S: ["01111", "10000", "01110", "00001", "11110"],
  T: ["11111", "00100", "00100", "00100", "00100"],
  U: ["10001", "10001", "10001", "10001", "01110"],
  V: ["10001", "10001", "10001", "01010", "00100"],
  W: ["10001", "10001", "10101", "11011", "10001"],
  X: ["10001", "01010", "00100", "01010", "10001"],
  Y: ["10001", "01010", "00100", "00100", "00100"],
  Z: ["11111", "00010", "00100", "01000", "11111"],
  "0": ["01110", "10011", "10101", "11001", "01110"],
  "1": ["00100", "01100", "00100", "00100", "01110"],
  "2": ["11110", "00001", "01110", "10000", "11111"],
  "3": ["11110", "00001", "01110", "00001", "11110"],
  "4": ["10010", "10010", "11111", "00010", "00010"],
  "5": ["11111", "10000", "11110", "00001", "11110"],
  "6": ["01111", "10000", "11110", "10001", "01110"],
  "7": ["11111", "00010", "00100", "01000", "01000"],
  "8": ["01110", "10001", "01110", "10001", "01110"],
  "9": ["01110", "10001", "01111", "00001", "11110"],
  "!": ["1", "1", "1", "0", "1"],
  "?": ["11110", "00001", "00110", "00000", "00100"],
  ".": ["0", "0", "0", "0", "1"],
  "-": ["000", "000", "111", "000", "000"],
  " ": ["00", "00", "00", "00", "00"],
};

// A filled cell becomes a block only on the EDGE of the shape; interiors go
// blank. Hollow letters read; solid blocks merge into a mass when shrunk.
function bitsToOutline(bits: string[]): string[] {
  const h = bits.length;
  const w = Math.max(0, ...bits.map((r) => r.length));
  const on = (r: number, c: number) =>
    r >= 0 && r < h && c >= 0 && c < w && bits[r]?.[c] === "1";
  return bits.map((row, r) => {
    let line = "";
    for (let c = 0; c < w; c++) {
      if (!on(r, c)) { line += " "; continue; }
      const edge = !on(r - 1, c) || !on(r + 1, c) || !on(r, c - 1) || !on(r, c + 1);
      line += edge ? "█" : " ";
    }
    return line;
  });
}

const BANNER: Record<string, string[]> = Object.fromEntries(
  Object.entries(BANNER_BITS).map(([k, v]) => [k, bitsToOutline(v)]),
);

const TABLES: Record<FontStyle, Record<string, string[]>> = {
  slant: FIGLET_FONTS.slant,
  standard: FIGLET_FONTS.standard,
  ogre: FIGLET_FONTS.ogre,
  doom: FIGLET_FONTS.doom,
  big: FIGLET_FONTS.big,
  speed: FIGLET_FONTS.speed,
  stop: FIGLET_FONTS.stop,
  subzero: FIGLET_FONTS.subzero,
  banner: BANNER,
};

// Banner letters are solid blocks with no built-in gutter, so they need an
// explicit gap; the figlet faces carry their own spacing and smush together.
const GAP_COLS: Partial<Record<FontStyle, number>> = { banner: 1 };

// Pad every row of a glyph to its own widest row, so an uneven glyph never
// shears the letters after it on that row.
function squareGlyph(rows: string[]): string[] {
  const w = Math.max(0, ...rows.map((r) => r.length));
  return rows.map((r) => r.padEnd(w, " "));
}

// How far a glyph can slide left into the canvas without any of its non-space
// cells overlapping one already there. This is figlet "kerning": the blank
// gutters each letter carries get nested instead of stacked, so pairs like V+A
// or M+I sit at a natural spacing rather than far apart.
function overlapAllowed(canvas: string[], glyph: string[], leftEdge: number, minGap: number): number {
  const gw = Math.max(0, ...glyph.map((r) => r.length));
  let shift = 0;
  for (let s = 1; s <= gw; s++) {
    let collides = false;
    for (let r = 0; r < canvas.length && !collides; r++) {
      const crow = canvas[r];
      const grow = glyph[r] ?? "";
      for (let gx = 0; gx < gw; gx++) {
        const gc = grow[gx] ?? " ";
        if (gc === " ") continue;
        const cx = leftEdge - s + gx;
        if (cx < 0) continue;
        // a forced gap reserves `minGap` blank columns between letters
        for (let g = 0; g <= minGap; g++) {
          if ((crow[cx + g] ?? " ") !== " ") { collides = true; break; }
        }
        if (collides) break;
      }
    }
    if (collides) break;
    shift = s;
  }
  return shift;
}

/**
 * Render `word` in the given figlet style. Returns equal-length rows (right-
 * padded) so the caller gets a clean rectangle; row count is the font's height.
 */
export function renderWord(word: string, style: FontStyle): string[] {
  const table = TABLES[style];
  const minGap = GAP_COLS[style] ?? 0;
  const rowCount = table[" "].length;
  const glyphs = word
    .toUpperCase()
    .split("")
    .map((c) => squareGlyph(table[c] ?? table[" "]));
  if (glyphs.length === 0) return Array.from({ length: rowCount }, () => "");

  // Lay glyphs onto a row canvas, smushing each one leftward as far as it fits.
  let canvas: string[] = Array.from({ length: rowCount }, () => "");
  for (const glyph of glyphs) {
    const gw = Math.max(0, ...glyph.map((r) => r.length));
    const cw = Math.max(0, ...canvas.map((r) => r.length));
    const shift = cw > 0 ? overlapAllowed(canvas, glyph, cw, minGap) : 0;
    const at = cw - shift;
    const next: string[] = [];
    for (let r = 0; r < rowCount; r++) {
      const crow = (canvas[r] ?? "").padEnd(at, " ");
      const grow = (glyph[r] ?? "").padEnd(gw, " ");
      let merged = crow.slice(0, at);
      for (let gx = 0; gx < gw; gx++) {
        const cx = at + gx;
        const existing = (canvas[r] ?? "")[cx] ?? " ";
        const gc = grow[gx];
        merged += gc !== " " ? gc : existing;
      }
      next.push(merged);
    }
    canvas = next;
  }
  const w = Math.max(...canvas.map((r) => r.length));
  return canvas.map((r) => r.padEnd(w, " "));
}

```

### swirl/figlet-fonts.ts
```ts
// AUTO-GENERATED from figlet.js (patorjk/figlet.js) fonts, converted to our
// compact row format. Each glyph is N equal-height rows; squareGlyph in
// block-font.ts pads each to its own width before joining. Regenerate with
// the script in scripts/gen-figlet.mjs if you add fonts.

export type FigletFont = Record<string, string[]>;

export const FIGLET_FONTS: Record<string, FigletFont> = {
  slant: {
    "0": ["   ____ ", "  / __ \\", " / / / /", "/ /_/ / ", "\\____/  "],
    "1": ["   ___", "  <  /", "  / / ", " / /  ", "/_/   "],
    "2": ["   ___ ", "  |__ \\", "  __/ /", " / __/ ", "/____/ "],
    "3": ["   _____", "  |__  /", "   /_ < ", " ___/ / ", "/____/  "],
    "4": ["   __ __", "  / // /", " / // /_", "/__  __/", "  /_/   "],
    "5": ["    ______", "   / ____/", "  /___ \\  ", " ____/ /  ", "/_____/   "],
    "6": ["   _____", "  / ___/", " / __ \\ ", "/ /_/ / ", "\\____/  "],
    "7": [" _____", "/__  /", "  / / ", " / /  ", "/_/   "],
    "8": ["   ____ ", "  ( __ )", " / __  |", "/ /_/ / ", "\\____/  "],
    "9": ["   ____ ", "  / __ \\", " / /_/ /", " \\__, / ", "/____/  "],
    "A": ["    ___ ", "   /   |", "  / /| |", " / ___ |", "/_/  |_|"],
    "B": ["    ____ ", "   / __ )", "  / __  |", " / /_/ / ", "/_____/  "],
    "C": ["   ______", "  / ____/", " / /     ", "/ /___   ", "\\____/   "],
    "D": ["    ____ ", "   / __ \\", "  / / / /", " / /_/ / ", "/_____/  "],
    "E": ["    ______", "   / ____/", "  / __/   ", " / /___   ", "/_____/   "],
    "F": ["    ______", "   / ____/", "  / /_    ", " / __/    ", "/_/       "],
    "G": ["   ______", "  / ____/", " / / __  ", "/ /_/ /  ", "\\____/   "],
    "H": ["    __  __", "   / / / /", "  / /_/ / ", " / __  /  ", "/_/ /_/   "],
    "I": ["    ____", "   /  _/", "   / /  ", " _/ /   ", "/___/   "],
    "J": ["       __", "      / /", " __  / / ", "/ /_/ /  ", "\\____/   "],
    "K": ["    __ __", "   / //_/", "  / ,<   ", " / /| |  ", "/_/ |_|  "],
    "L": ["    __ ", "   / / ", "  / /  ", " / /___", "/_____/"],
    "M": ["    __  ___", "   /  |/  /", "  / /|_/ / ", " / /  / /  ", "/_/  /_/   "],
    "N": ["    _   __", "   / | / /", "  /  |/ / ", " / /|  /  ", "/_/ |_/   "],
    "O": ["   ____ ", "  / __ \\", " / / / /", "/ /_/ / ", "\\____/  "],
    "P": ["    ____ ", "   / __ \\", "  / /_/ /", " / ____/ ", "/_/      "],
    "Q": ["   ____ ", "  / __ \\", " / / / /", "/ /_/ / ", "\\___\\_\\ "],
    "R": ["    ____ ", "   / __ \\", "  / /_/ /", " / _, _/ ", "/_/ |_|  "],
    "S": ["   _____", "  / ___/", "  \\__ \\ ", " ___/ / ", "/____/  "],
    "T": ["  ______", " /_  __/", "  / /   ", " / /    ", "/_/     "],
    "U": ["   __  __", "  / / / /", " / / / / ", "/ /_/ /  ", "\\____/   "],
    "V": [" _    __", "| |  / /", "| | / / ", "| |/ /  ", "|___/   "],
    "W": [" _       __", "| |     / /", "| | /| / / ", "| |/ |/ /  ", "|__/|__/   "],
    "X": ["   _  __", "  | |/ /", "  |   / ", " /   |  ", "/_/|_|  "],
    "Y": ["__  __", "\\ \\/ /", " \\  / ", " / /  ", "/_/   "],
    "Z": [" _____", "/__  /", "  / / ", " / /__", "/____/"],
    " ": ["         ", "         ", "         ", "         ", "         "],
    "!": ["    __", "   / /", "  / / ", " /_/  ", "(_)   "],
    "?": ["  ___ ", " /__ \\", "  / _/", " /_/  ", "(_)   "],
    ".": ["   ", "   ", "   ", " _ ", "(_)"],
    "-": ["       ", "       ", " ______", "/_____/", "       "],
  },
  standard: {
    "0": ["   ___  ", "  / _ \\ ", " | | | |", " | |_| |", "  \\___/ "],
    "1": ["  _ ", " / |", " | |", " | |", " |_|"],
    "2": ["  ____  ", " |___ \\ ", "   __) |", "  / __/ ", " |_____|"],
    "3": ["  _____ ", " |___ / ", "   |_ \\ ", "  ___) |", " |____/ "],
    "4": ["  _  _   ", " | || |  ", " | || |_ ", " |__   _|", "    |_|  "],
    "5": ["  ____  ", " | ___| ", " |___ \\ ", "  ___) |", " |____/ "],
    "6": ["   __   ", "  / /_  ", " | '_ \\ ", " | (_) |", "  \\___/ "],
    "7": ["  _____ ", " |___  |", "    / / ", "   / /  ", "  /_/   "],
    "8": ["   ___  ", "  ( _ ) ", "  / _ \\ ", " | (_) |", "  \\___/ "],
    "9": ["   ___  ", "  / _ \\ ", " | (_) |", "  \\__, |", "    /_/ "],
    "A": ["     _    ", "    / \\   ", "   / _ \\  ", "  / ___ \\ ", " /_/   \\_\\"],
    "B": ["  ____  ", " | __ ) ", " |  _ \\ ", " | |_) |", " |____/ "],
    "C": ["   ____ ", "  / ___|", " | |    ", " | |___ ", "  \\____|"],
    "D": ["  ____  ", " |  _ \\ ", " | | | |", " | |_| |", " |____/ "],
    "E": ["  _____ ", " | ____|", " |  _|  ", " | |___ ", " |_____|"],
    "F": ["  _____ ", " |  ___|", " | |_   ", " |  _|  ", " |_|    "],
    "G": ["   ____ ", "  / ___|", " | |  _ ", " | |_| |", "  \\____|"],
    "H": ["  _   _ ", " | | | |", " | |_| |", " |  _  |", " |_| |_|"],
    "I": ["  ___ ", " |_ _|", "  | | ", "  | | ", " |___|"],
    "J": ["      _ ", "     | |", "  _  | |", " | |_| |", "  \\___/ "],
    "K": ["  _  __", " | |/ /", " | ' / ", " | . \\ ", " |_|\\_\\"],
    "L": ["  _     ", " | |    ", " | |    ", " | |___ ", " |_____|"],
    "M": ["  __  __ ", " |  \\/  |", " | |\\/| |", " | |  | |", " |_|  |_|"],
    "N": ["  _   _ ", " | \\ | |", " |  \\| |", " | |\\  |", " |_| \\_|"],
    "O": ["   ___  ", "  / _ \\ ", " | | | |", " | |_| |", "  \\___/ "],
    "P": ["  ____  ", " |  _ \\ ", " | |_) |", " |  __/ ", " |_|    "],
    "Q": ["   ___  ", "  / _ \\ ", " | | | |", " | |_| |", "  \\__\\_\\"],
    "R": ["  ____  ", " |  _ \\ ", " | |_) |", " |  _ < ", " |_| \\_\\"],
    "S": ["  ____  ", " / ___| ", " \\___ \\ ", "  ___) |", " |____/ "],
    "T": ["  _____ ", " |_   _|", "   | |  ", "   | |  ", "   |_|  "],
    "U": ["  _   _ ", " | | | |", " | | | |", " | |_| |", "  \\___/ "],
    "V": [" __     __", " \\ \\   / /", "  \\ \\ / / ", "   \\ V /  ", "    \\_/   "],
    "W": [" __        __", " \\ \\      / /", "  \\ \\ /\\ / / ", "   \\ V  V /  ", "    \\_/\\_/   "],
    "X": [" __  __", " \\ \\/ /", "  \\  / ", "  /  \\ ", " /_/\\_\\"],
    "Y": [" __   __", " \\ \\ / /", "  \\ V / ", "   | |  ", "   |_|  "],
    "Z": ["  _____", " |__  /", "   / / ", "  / /_ ", " /____|"],
    " ": ["   ", "   ", "   ", "   ", "   "],
    "!": ["  _ ", " | |", " | |", " |_|", " (_)"],
    "?": ["  ___ ", " |__ \\", "   / /", "  |_| ", "  (_) "],
    ".": ["    ", "    ", "    ", "  _ ", " (_)"],
    "-": ["        ", "        ", "  _____ ", " |_____|", "        "],
  },
  ogre: {
    "0": ["  ___  ", " / _ \\ ", "| | | |", "| |_| |", " \\___/ "],
    "1": [" _ ", "/ |", "| |", "| |", "|_|"],
    "2": [" ____  ", "|___ \\ ", "  __) |", " / __/ ", "|_____|"],
    "3": [" _____ ", "|___ / ", "  |_ \\ ", " ___) |", "|____/ "],
    "4": [" _  _   ", "| || |  ", "| || |_ ", "|__   _|", "   |_|  "],
    "5": [" ____  ", "| ___| ", "|___ \\ ", " ___) |", "|____/ "],
    "6": ["  __   ", " / /_  ", "| '_ \\ ", "| (_) |", " \\___/ "],
    "7": [" _____ ", "|___  |", "   / / ", "  / /  ", " /_/   "],
    "8": ["  ___  ", " ( _ ) ", " / _ \\ ", "| (_) |", " \\___/ "],
    "9": ["  ___  ", " / _ \\ ", "| (_) |", " \\__, |", "   /_/ "],
    "A": ["   _   ", "  /_\\  ", " //_\\\\ ", "/  _  \\", "\\_/ \\_/"],
    "B": ["   ___ ", "  / __\\", " /__\\//", "/ \\/  \\", "\\_____/"],
    "C": ["   ___ ", "  / __\\", " / /   ", "/ /___ ", "\\____/ "],
    "D": ["    ___ ", "   /   \\", "  / /\\ /", " / /_// ", "/___,'  "],
    "E": ["   __ ", "  /__\\", " /_\\  ", "//__  ", "\\__/  "],
    "F": ["   ___ ", "  / __\\", " / _\\  ", "/ /    ", "\\/     "],
    "G": ["   ___ ", "  / _ \\", " / /_\\/", "/ /_\\\\ ", "\\____/ "],
    "H": ["        ", "  /\\  /\\", " / /_/ /", "/ __  / ", "\\/ /_/  "],
    "I": ["  _____ ", "  \\_   \\", "   / /\\/", "/\\/ /_  ", "\\____/  "],
    "J": ["   __  ", "   \\ \\ ", "    \\ \\", " /\\_/ /", " \\___/ "],
    "K": ["       ", "  /\\ /\\", " / //_/", "/ __ \\ ", "\\/  \\/ "],
    "L": ["   __  ", "  / /  ", " / /   ", "/ /___ ", "\\____/ "],
    "M": ["        ", "  /\\/\\  ", " /    \\ ", "/ /\\/\\ \\", "\\/    \\/"],
    "N": ["     __ ", "  /\\ \\ \\", " /  \\/ /", "/ /\\  / ", "\\_\\ \\/  "],
    "O": ["   ___ ", "  /___\\", " //  //", "/ \\_// ", "\\___/  "],
    "P": ["   ___ ", "  / _ \\", " / /_)/", "/ ___/ ", "\\/     "],
    "Q": ["   ____ ", "  /___ \\", " //  / /", "/ \\_/ / ", "\\___,_\\ "],
    "R": ["   __  ", "  /__\\ ", " / \\// ", "/ _  \\ ", "\\/ \\_/ "],
    "S": [" __    ", "/ _\\   ", "\\ \\    ", "_\\ \\   ", "\\__/   "],
    "T": [" _____ ", "/__   \\", "  / /\\/", " / /   ", " \\/    "],
    "U": ["       ", " /\\ /\\ ", "/ / \\ \\", "\\ \\_/ /", " \\___/ "],
    "V": ["         ", " /\\   /\\ ", " \\ \\ / / ", "  \\ V /  ", "   \\_/   "],
    "W": [" __    __ ", "/ / /\\ \\ \\", "\\ \\/  \\/ /", " \\  /\\  / ", "  \\/  \\/  "],
    "X": ["__  __", "\\ \\/ /", " \\  / ", " /  \\ ", "/_/\\_\\"],
    "Y": ["     ", "/\\_/\\", "\\_ _/", " / \\ ", " \\_/ "],
    "Z": [" _____", "/ _  /", "\\// / ", " / //\\", "/____/"],
    " ": ["  ", "  ", "  ", "  ", "  "],
    "!": ["   _ ", "  / \\", " /  /", "/\\_/ ", "\\/   "],
    "?": [" ___ ", "/ _ \\", "\\// /", "  \\/ ", "  () "],
    ".": ["   ", "   ", "   ", " _ ", "(_)"],
    "-": ["       ", "       ", " _____ ", "|_____|", "       "],
  },
  doom: {
    "0": [" _____ ", "|  _  |", "| |/' |", "|  /| |", "\\ |_/ /", " \\___/ "],
    "1": [" __  ", "/  | ", "`| | ", " | | ", "_| |_", "\\___/"],
    "2": [" _____ ", "/ __  \\", "`' / /'", "  / /  ", "./ /___", "\\_____/"],
    "3": [" _____ ", "|____ |", "    / /", "    \\ \\", ".___/ /", "\\____/ "],
    "4": ["   ___ ", "  /   |", " / /| |", "/ /_| |", "\\___  |", "    |_/"],
    "5": [" _____ ", "|  ___|", "|___ \\ ", "    \\ \\", "/\\__/ /", "\\____/ "],
    "6": ["  ____ ", " / ___|", "/ /___ ", "| ___ \\", "| \\_/ |", "\\_____/"],
    "7": [" ______", "|___  /", "   / / ", "  / /  ", "./ /   ", "\\_/    "],
    "8": [" _____ ", "|  _  |", " \\ V / ", " / _ \\ ", "| |_| |", "\\_____/"],
    "9": [" _____ ", "|  _  |", "| |_| |", "\\____ |", ".___/ /", "\\____/ "],
    "A": ["  ___  ", " / _ \\ ", "/ /_\\ \\", "|  _  |", "| | | |", "\\_| |_/"],
    "B": ["______ ", "| ___ \\", "| |_/ /", "| ___ \\", "| |_/ /", "\\____/ "],
    "C": [" _____ ", "/  __ \\", "| /  \\/", "| |    ", "| \\__/\\", " \\____/"],
    "D": ["______ ", "|  _  \\", "| | | |", "| | | |", "| |/ / ", "|___/  "],
    "E": [" _____ ", "|  ___|", "| |__  ", "|  __| ", "| |___ ", "\\____/ "],
    "F": ["______ ", "|  ___|", "| |_   ", "|  _|  ", "| |    ", "\\_|    "],
    "G": [" _____ ", "|  __ \\", "| |  \\/", "| | __ ", "| |_\\ \\", " \\____/"],
    "H": [" _   _ ", "| | | |", "| |_| |", "|  _  |", "| | | |", "\\_| |_/"],
    "I": [" _____ ", "|_   _|", "  | |  ", "  | |  ", " _| |_ ", " \\___/ "],
    "J": ["   ___ ", "  |_  |", "    | |", "    | |", "/\\__/ /", "\\____/ "],
    "K": [" _   __", "| | / /", "| |/ / ", "|    \\ ", "| |\\  \\", "\\_| \\_/"],
    "L": [" _     ", "| |    ", "| |    ", "| |    ", "| |____", "\\_____/"],
    "M": ["___  ___", "|  \\/  |", "| .  . |", "| |\\/| |", "| |  | |", "\\_|  |_/"],
    "N": [" _   _ ", "| \\ | |", "|  \\| |", "| . ` |", "| |\\  |", "\\_| \\_/"],
    "O": [" _____ ", "|  _  |", "| | | |", "| | | |", "\\ \\_/ /", " \\___/ "],
    "P": ["______ ", "| ___ \\", "| |_/ /", "|  __/ ", "| |    ", "\\_|    "],
    "Q": [" _____ ", "|  _  |", "| | | |", "| | | |", "\\ \\/' /", " \\_/\\_\\"],
    "R": ["______ ", "| ___ \\", "| |_/ /", "|    / ", "| |\\ \\ ", "\\_| \\_|"],
    "S": [" _____ ", "/  ___|", "\\ `--. ", " `--. \\", "/\\__/ /", "\\____/ "],
    "T": [" _____ ", "|_   _|", "  | |  ", "  | |  ", "  | |  ", "  \\_/  "],
    "U": [" _   _ ", "| | | |", "| | | |", "| | | |", "| |_| |", " \\___/ "],
    "V": [" _   _ ", "| | | |", "| | | |", "| | | |", "\\ \\_/ /", " \\___/ "],
    "W": [" _    _ ", "| |  | |", "| |  | |", "| |/\\| |", "\\  /\\  /", " \\/  \\/ "],
    "X": ["__   __", "\\ \\ / /", " \\ V / ", " /   \\ ", "/ /^\\ \\", "\\/   \\/"],
    "Y": ["__   __", "\\ \\ / /", " \\ V / ", "  \\ /  ", "  | |  ", "  \\_/  "],
    "Z": [" ______", "|___  /", "   / / ", "  / /  ", "./ /___", "\\_____/"],
    " ": ["  ", "  ", "  ", "  ", "  ", "  "],
    "!": [" _ ", "| |", "| |", "| |", "|_|", "(_)"],
    "?": [" ___  ", "|__ \\ ", "   ) |", "  / / ", " |_|  ", " (_)  "],
    ".": ["   ", "   ", "   ", "   ", " _ ", "(_)"],
    "-": ["        ", "        ", " ______ ", "|______|", "        ", "        "],
  },
  big: {
    "0": ["   ___  ", "  / _ \\ ", " | | | |", " | | | |", " | |_| |", "  \\___/ "],
    "1": ["  __ ", " /_ |", "  | |", "  | |", "  | |", "  |_|"],
    "2": ["  ___  ", " |__ \\ ", "    ) |", "   / / ", "  / /_ ", " |____|"],
    "3": ["  ____  ", " |___ \\ ", "   __) |", "  |__ < ", "  ___) |", " |____/ "],
    "4": ["  _  _   ", " | || |  ", " | || |_ ", " |__   _|", "    | |  ", "    |_|  "],
    "5": ["  _____ ", " | ____|", " | |__  ", " |___ \\ ", "  ___) |", " |____/ "],
    "6": ["    __  ", "   / /  ", "  / /_  ", " | '_ \\ ", " | (_) |", "  \\___/ "],
    "7": ["  ______ ", " |____  |", "     / / ", "    / /  ", "   / /   ", "  /_/    "],
    "8": ["   ___  ", "  / _ \\ ", " | (_) |", "  > _ < ", " | (_) |", "  \\___/ "],
    "9": ["   ___  ", "  / _ \\ ", " | (_) |", "  \\__, |", "    / / ", "   /_/  "],
    "A": ["           ", "     /\\    ", "    /  \\   ", "   / /\\ \\  ", "  / ____ \\ ", " /_/    \\_\\"],
    "B": ["  ____  ", " |  _ \\ ", " | |_) |", " |  _ < ", " | |_) |", " |____/ "],
    "C": ["   _____ ", "  / ____|", " | |     ", " | |     ", " | |____ ", "  \\_____|"],
    "D": ["  _____  ", " |  __ \\ ", " | |  | |", " | |  | |", " | |__| |", " |_____/ "],
    "E": ["  ______ ", " |  ____|", " | |__   ", " |  __|  ", " | |____ ", " |______|"],
    "F": ["  ______ ", " |  ____|", " | |__   ", " |  __|  ", " | |     ", " |_|     "],
    "G": ["   _____ ", "  / ____|", " | |  __ ", " | | |_ |", " | |__| |", "  \\_____|"],
    "H": ["  _    _ ", " | |  | |", " | |__| |", " |  __  |", " | |  | |", " |_|  |_|"],
    "I": ["  _____ ", " |_   _|", "   | |  ", "   | |  ", "  _| |_ ", " |_____|"],
    "J": ["       _ ", "      | |", "      | |", "  _   | |", " | |__| |", "  \\____/ "],
    "K": ["  _  __", " | |/ /", " | ' / ", " |  <  ", " | . \\ ", " |_|\\_\\"],
    "L": ["  _      ", " | |     ", " | |     ", " | |     ", " | |____ ", " |______|"],
    "M": ["  __  __ ", " |  \\/  |", " | \\  / |", " | |\\/| |", " | |  | |", " |_|  |_|"],
    "N": ["  _   _ ", " | \\ | |", " |  \\| |", " | . ` |", " | |\\  |", " |_| \\_|"],
    "O": ["   ____  ", "  / __ \\ ", " | |  | |", " | |  | |", " | |__| |", "  \\____/ "],
    "P": ["  _____  ", " |  __ \\ ", " | |__) |", " |  ___/ ", " | |     ", " |_|     "],
    "Q": ["   ____  ", "  / __ \\ ", " | |  | |", " | |  | |", " | |__| |", "  \\___\\_\\"],
    "R": ["  _____  ", " |  __ \\ ", " | |__) |", " |  _  / ", " | | \\ \\ ", " |_|  \\_\\"],
    "S": ["   _____ ", "  / ____|", " | (___  ", "  \\___ \\ ", "  ____) |", " |_____/ "],
    "T": ["  _______ ", " |__   __|", "    | |   ", "    | |   ", "    | |   ", "    |_|   "],
    "U": ["  _    _ ", " | |  | |", " | |  | |", " | |  | |", " | |__| |", "  \\____/ "],
    "V": [" __      __", " \\ \\    / /", "  \\ \\  / / ", "   \\ \\/ /  ", "    \\  /   ", "     \\/    "],
    "W": [" __          __", " \\ \\        / /", "  \\ \\  /\\  / / ", "   \\ \\/  \\/ /  ", "    \\  /\\  /   ", "     \\/  \\/    "],
    "X": [" __   __", " \\ \\ / /", "  \\ V / ", "   > <  ", "  / . \\ ", " /_/ \\_\\"],
    "Y": [" __     __", " \\ \\   / /", "  \\ \\_/ / ", "   \\   /  ", "    | |   ", "    |_|   "],
    "Z": ["  ______", " |___  /", "    / / ", "   / /  ", "  / /__ ", " /_____|"],
    " ": ["   ", "   ", "   ", "   ", "   ", "   "],
    "!": ["  _ ", " | |", " | |", " | |", " |_|", " (_)"],
    "?": ["  ___  ", " |__ \\ ", "    ) |", "   / / ", "  |_|  ", "  (_)  "],
    ".": ["    ", "    ", "    ", "    ", "  _ ", " (_)"],
    "-": ["         ", "         ", "  ______ ", " |______|", "         ", "         "],
  },
  speed: {
    "0": ["_______ ", "__  __ \\", "_  / / /", "/ /_/ / ", "\\____/  "],
    "1": ["______", "__<  /", "__  / ", "_  /  ", "/_/   "],
    "2": ["______ ", "__|__ \\", "____/ /", "_  __/ ", "/____/ "],
    "3": ["________", "__|__  /", "___/_ < ", "____/ / ", "/____/  "],
    "4": ["_____ __", "__  // /", "_  // /_", "/__  __/", "  /_/   "],
    "5": ["__________", "___  ____/", "______ \\  ", " ____/ /  ", "/_____/   "],
    "6": ["________", "__  ___/", "_  __ \\ ", "/ /_/ / ", "\\____/  "],
    "7": ["______", "/__  /", "__  / ", "_  /  ", "/_/   "],
    "8": ["_______ ", "__( __ )", "_  __  |", "/ /_/ / ", "\\____/  "],
    "9": ["_______ ", "__  __ \\", "_  /_/ /", "_\\__, / ", "/____/  "],
    "A": ["_______ ", "___    |", "__  /| |", "_  ___ |", "/_/  |_|"],
    "B": ["________ ", "___  __ )", "__  __  |", "_  /_/ / ", "/_____/  "],
    "C": ["_________", "__  ____/", "_  /     ", "/ /___   ", "\\____/   "],
    "D": ["________ ", "___  __ \\", "__  / / /", "_  /_/ / ", "/_____/  "],
    "E": ["__________", "___  ____/", "__  __/   ", "_  /___   ", "/_____/   "],
    "F": ["__________", "___  ____/", "__  /_    ", "_  __/    ", "/_/       "],
    "G": ["_________", "__  ____/", "_  / __  ", "/ /_/ /  ", "\\____/   "],
    "H": ["______  __", "___  / / /", "__  /_/ / ", "_  __  /  ", "/_/ /_/   "],
    "I": ["________", "____  _/", " __  /  ", "__/ /   ", "/___/   "],
    "J": ["_________", "______  /", "___ _  / ", "/ /_/ /  ", "\\____/   "],
    "K": ["______ __", "___  //_/", "__  ,<   ", "_  /| |  ", "/_/ |_|  "],
    "L": ["______ ", "___  / ", "__  /  ", "_  /___", "/_____/"],
    "M": ["______  ___", "___   |/  /", "__  /|_/ / ", "_  /  / /  ", "/_/  /_/   "],
    "N": ["_____   __", "___  | / /", "__   |/ / ", "_  /|  /  ", "/_/ |_/   "],
    "O": ["_______ ", "__  __ \\", "_  / / /", "/ /_/ / ", "\\____/  "],
    "P": ["________ ", "___  __ \\", "__  /_/ /", "_  ____/ ", "/_/      "],
    "Q": ["_______ ", "__  __ \\", "_  / / /", "/ /_/ / ", "\\___\\_\\ "],
    "R": ["________ ", "___  __ \\", "__  /_/ /", "_  _, _/ ", "/_/ |_|  "],
    "S": ["________", "__  ___/", "_____ \\ ", "____/ / ", "/____/  "],
    "T": ["________", "___  __/", "__  /   ", "_  /    ", "/_/     "],
    "U": ["_____  __", "__  / / /", "_  / / / ", "/ /_/ /  ", "\\____/   "],
    "V": ["___    __", "__ |  / /", "__ | / / ", "__ |/ /  ", "_____/   "],
    "W": ["___       __", "__ |     / /", "__ | /| / / ", "__ |/ |/ /  ", "____/|__/   "],
    "X": ["____  __", "__  |/ /", "__    / ", "_    |  ", "/_/|_|  "],
    "Y": ["__  __", "_ \\/ /", "__  / ", "_  /  ", "/_/   "],
    "Z": ["______", "___  /", "__  / ", "_  /__", "/____/"],
    " ": ["         ", "         ", "         ", "         ", "         "],
    "!": ["______", "___  /", "__  / ", " /_/  ", "(_)   "],
    "?": ["_____ ", "_ __ \\", "__/ _/", "_/_/  ", "(_)   "],
    ".": ["    ", "    ", "    ", "___ ", "_(_)"],
    "-": ["        ", "        ", "________", "_/_____/", "        "],
  },
  stop: {
    "0": ["  ______ ", " / __   |", "| | //| |", "| |// | |", "|  /__| |", " \\_____/ "],
    "1": ["  __ ", " /  |", "/_/ |", "  | |", "  | |", "  |_|"],
    "2": [" ______  ", "(_____ \\ ", "  ____) )", " /_____/ ", " _______ ", "(_______)"],
    "3": [" ________", "(_______/", "   ____  ", "  (___ \\ ", " _____) )", "(______/ "],
    "4": ["   __    ", "  / /    ", " / /____ ", "|___   _)", "    | |  ", "    |_|  "],
    "5": [" _______ ", "(_______)", " ______  ", "(_____ \\ ", " _____) )", "(______/ "],
    "6": ["    __  ", "   / /  ", "  / /_  ", " / __ \\ ", "( (__) )", " \\____/ "],
    "7": [" _______ ", "(_______)", "      _  ", "     / ) ", "    / /  ", "   (_/   "],
    "8": ["  _____  ", " / ___ \\ ", "( (   ) )", " > > < < ", "( (___) )", " \\_____/ "],
    "9": ["  ____  ", " / __ \\ ", "( (__) )", " \\__  / ", "   / /  ", "  /_/   "],
    "A": ["        ", "   /\\   ", "  /  \\  ", " / /\\ \\ ", "| |__| |", "|______|"],
    "B": [" ______  ", "(____  \\ ", " ____)  )", "|  __  ( ", "| |__)  )", "|______/ "],
    "C": ["  ______ ", " / _____)", "| /      ", "| |      ", "| \\_____ ", " \\______)"],
    "D": [" _____   ", "(____ \\  ", " _   \\ \\ ", "| |   | |", "| |__/ / ", "|_____/  "],
    "E": [" _______ ", "(_______)", " _____   ", "|  ___)  ", "| |_____ ", "|_______)"],
    "F": [" _______ ", "(_______)", " _____   ", "|  ___)  ", "| |      ", "|_|      "],
    "G": ["  ______ ", " / _____)", "| /  ___ ", "| | (___)", "| \\____/|", " \\_____/ "],
    "H": [" _     _ ", "| |   | |", "| |__ | |", "|  __)| |", "| |   | |", "|_|   |_|"],
    "I": [" _____ ", "(_____)", "   _   ", "  | |  ", " _| |_ ", "(_____)"],
    "J": ["   _____ ", "  (_____)", "     _   ", "    | |  ", " ___| |  ", "(____/   "],
    "K": [" _    _ ", "| |  / )", "| | / / ", "| |< <  ", "| | \\ \\ ", "|_|  \\_)"],
    "L": [" _       ", "| |      ", "| |      ", "| |      ", "| |_____ ", "|_______)"],
    "M": [" ______  ", "|  ___ \\ ", "| | _ | |", "| || || |", "| || || |", "|_||_||_|"],
    "N": [" ______  ", "|  ___ \\ ", "| |   | |", "| |   | |", "| |   | |", "|_|   |_|"],
    "O": ["  _____  ", " / ___ \\ ", "| |   | |", "| |   | |", "| |___| |", " \\_____/ "],
    "P": [" ______  ", "(_____ \\ ", " _____) )", "|  ____/ ", "| |      ", "|_|      "],
    "Q": ["  _____  ", " / ___ \\ ", "| |   | |", "| |   |_|", " \\ \\____ ", "  \\_____)"],
    "R": [" ______  ", "(_____ \\ ", " _____) )", "(_____ ( ", "      | |", "      |_|"],
    "S": ["    _    ", "   | |   ", "    \\ \\  ", "     \\ \\ ", " _____) )", "(______/ "],
    "T": [" _______ ", "(_______)", " _       ", "| |      ", "| |_____ ", " \\______)"],
    "U": [" _     _ ", "| |   | |", "| |   | |", "| |   | |", "| |___| |", " \\______|"],
    "V": [" _    _ ", "| |  | |", "| |  | |", " \\ \\/ / ", "  \\  /  ", "   \\/   "],
    "W": [" _  _  _ ", "| || || |", "| || || |", "| ||_|| |", "| |___| |", " \\______|"],
    "X": [" _    _ ", "\\ \\  / /", " \\ \\/ / ", "  )  (  ", " / /\\ \\ ", "/_/  \\_\\"],
    "Y": [" _     _ ", "| |   | |", "| |___| |", " \\_____/ ", "   ___   ", "  (___)  "],
    "Z": [" _______ ", "(_______)", "   __    ", "  / /    ", " / /____ ", "(_______)"],
    " ": ["    ", "    ", "    ", "    ", "    ", "    "],
    "!": [" _ ", "| |", "| |", "|_|", " _ ", "|_|"],
    "?": [" ____  ", "(___ \\ ", "    ) )", "   /_/ ", "   _   ", "  (_)  "],
    ".": ["   ", "   ", "   ", "   ", " _ ", "(_)"],
    "-": ["     ", "     ", " ___ ", "(___)", "     ", "     "],
  },
  subzero: {
    "0": ["", "", "", "", ""],
    "1": ["", "", "", "", ""],
    "2": ["", "", "", "", ""],
    "3": ["", "", "", "", ""],
    "4": ["", "", "", "", ""],
    "5": ["", "", "", "", ""],
    "6": ["", "", "", "", ""],
    "7": ["", "", "", "", ""],
    "8": ["", "", "", "", ""],
    "9": ["", "", "", "", ""],
    "A": [" ______    ", "/\\  __ \\   ", "\\ \\  __ \\  ", " \\ \\_\\ \\_\\ ", "  \\/_/\\/_/ "],
    "B": [" ______    ", "/\\  == \\   ", "\\ \\  __<   ", " \\ \\_____\\ ", "  \\/_____/ "],
    "C": [" ______    ", "/\\  ___\\   ", "\\ \\ \\____  ", " \\ \\_____\\ ", "  \\/_____/ "],
    "D": [" _____    ", "/\\  __-.  ", "\\ \\ \\/\\ \\ ", " \\ \\____- ", "  \\/____/ "],
    "E": [" ______    ", "/\\  ___\\   ", "\\ \\  __\\   ", " \\ \\_____\\ ", "  \\/_____/ "],
    "F": [" ______  ", "/\\  ___\\ ", "\\ \\  __\\ ", " \\ \\_\\   ", "  \\/_/   "],
    "G": [" ______    ", "/\\  ___\\   ", "\\ \\ \\__ \\  ", " \\ \\_____\\ ", "  \\/_____/ "],
    "H": [" __  __    ", "/\\ \\_\\ \\   ", "\\ \\  __ \\  ", " \\ \\_\\ \\_\\ ", "  \\/_/\\/_/ "],
    "I": [" __    ", "/\\ \\   ", "\\ \\ \\  ", " \\ \\_\\ ", "  \\/_/ "],
    "J": ["   __    ", "  /\\ \\   ", " _\\_\\ \\  ", "/\\_____\\ ", "\\/_____/ "],
    "K": [" __  __    ", "/\\ \\/ /    ", "\\ \\  _\"-.  ", " \\ \\_\\ \\_\\ ", "  \\/_/\\/_/ "],
    "L": [" __        ", "/\\ \\       ", "\\ \\ \\____  ", " \\ \\_____\\ ", "  \\/_____/ "],
    "M": [" __    __    ", "/\\ \"-./  \\   ", "\\ \\ \\-./\\ \\  ", " \\ \\_\\ \\ \\_\\ ", "  \\/_/  \\/_/ "],
    "N": [" __   __    ", "/\\ \"-.\\ \\   ", "\\ \\ \\-.  \\  ", " \\ \\_\\\\\"\\_\\ ", "  \\/_/ \\/_/ "],
    "O": [" ______    ", "/\\  __ \\   ", "\\ \\ \\/\\ \\  ", " \\ \\_____\\ ", "  \\/_____/ "],
    "P": [" ______  ", "/\\  == \\ ", "\\ \\  _-/ ", " \\ \\_\\   ", "  \\/_/   "],
    "Q": [" ______    ", "/\\  __ \\   ", "\\ \\ \\/\\_\\  ", " \\ \\___\\_\\ ", "  \\/___/_/ "],
    "R": [" ______    ", "/\\  == \\   ", "\\ \\  __<   ", " \\ \\_\\ \\_\\ ", "  \\/_/ /_/ "],
    "S": [" ______    ", "/\\  ___\\   ", "\\ \\___  \\  ", " \\/\\_____\\ ", "  \\/_____/ "],
    "T": [" ______  ", "/\\__  _\\ ", "\\/_/\\ \\/ ", "   \\ \\_\\ ", "    \\/_/ "],
    "U": [" __  __    ", "/\\ \\/\\ \\   ", "\\ \\ \\_\\ \\  ", " \\ \\_____\\ ", "  \\/_____/ "],
    "V": [" __   __  ", "/\\ \\ / /  ", "\\ \\ \\'/   ", " \\ \\__|   ", "  \\/_/    "],
    "W": [" __     __    ", "/\\ \\  _ \\ \\   ", "\\ \\ \\/ \".\\ \\  ", " \\ \\__/\".~\\_\\ ", "  \\/_/   \\/_/ "],
    "X": [" __  __    ", "/\\_\\_\\_\\   ", "\\/_/\\_\\/_  ", "  /\\_\\/\\_\\ ", "  \\/_/\\/_/ "],
    "Y": [" __  __    ", "/\\ \\_\\ \\   ", "\\ \\____ \\  ", " \\/\\_____\\ ", "  \\/_____/ "],
    "Z": [" ______    ", "/\\___  \\   ", "\\/_/  /__  ", "  /\\_____\\ ", "  \\/_____/ "],
    " ": ["      ", "      ", "      ", "      ", "      "],
    "!": ["", "", "", "", ""],
    "?": ["", "", "", "", ""],
    ".": ["", "", "", "", ""],
    "-": ["", "", "", "", ""],
  },
};

```

### swirl/resolver.ts
```ts
// Variants the swirl resolves into. The three named presets carry hand-set
// figlet "slant" ASCII (the look we want for the real names — crisp, italic,
// hollow letterforms). The free-text logo generator renders ITS word live via
// block-font; that path is the only one that touches the generated fonts.

// figlet "slant" — the original logos, kept verbatim.
const SLANT_ARLAN = [
  "    ___         __          ",
  "   /   |  _____/ /___ _____ ",
  "  / /| | / ___/ / __ `/ __ \\",
  " / ___ |/ /  / / /_/ / / / /",
  "/_/  |_/_/  /_/\\__,_/_/ /_/ ",
];
const SLANT_HUMAN_DELTA = [
  "    __  __                               ____       ____       ",
  "   / / / /_  ______ ___  ____ _____     / __ \\___  / / /_____ _",
  "  / /_/ / / / / __ `__ \\/ __ `/ __ \\   / / / / _ \\/ / __/ __ `/",
  " / __  / /_/ / / / / / / /_/ / / / /  / /_/ /  __/ / /_/ /_/ / ",
  "/_/ /_/\\__,_/_/ /_/ /_/\\__,_/_/ /_/  /_____/\\___/_/\\__/\\__,_/  ",
];
const SLANT_KAMILA = [
  "    __ __                _ __     ",
  "   / //_/___ _____ ___  (_) /___ _",
  "  / ,< / __ `/ __ `__ \\/ / / __ `/",
  " / /| / /_/ / / / / / / / / /_/ / ",
  "/_/ |_\\__,_/_/ /_/ /_/_/_/\\__,_/  ",
];

export interface Variant {
  id: string;
  label: string;
  /** Pre-baked ASCII rows for the named presets. */
  rows: string[];
  /** Swirl-letter color. */
  ink: string;
  /** Resolved-logo color (independent of the swirl letters). */
  logo: string;
  /** Canvas background. */
  bg: string;
  /** Starting zoom (smaller = more cells, fits a wider word). */
  zoom: number;
}

export const VARIANTS: Variant[] = [
  { id: "arlan", label: "Arlan", rows: SLANT_ARLAN, ink: "#d8d8d8", logo: "#ffffff", bg: "#0c0c0d", zoom: 0.62 },
  { id: "human-delta", label: "Human Delta", rows: SLANT_HUMAN_DELTA, ink: "#dfe6f5", logo: "#ffffff", bg: "#0b1733", zoom: 0.62 },
  { id: "kamila", label: "Kamila", rows: SLANT_KAMILA, ink: "#f7d8e3", logo: "#ffffff", bg: "#2a0f1c", zoom: 0.62 },
];

```

### swirl/color.ts
```ts
// Hex → linear-ish 0..1 RGB for shader uniforms / per-cell colors.
export function hexToRgb01(hex: string): [number, number, number] {
  let h = hex.replace("#", "").trim();
  if (h.length === 3) h = h.split("").map((c) => c + c).join("");
  const n = parseInt(h, 16);
  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}

```

### swirl/default-text.ts
```ts
// Placeholder field text. Lines should be LONG and VARIED in length: rows cycle
// through them and each column samples the line directly, so a long line fills
// the width while a short one leaves an airy gap, and that mix is the look.
export const DEFAULT_TEXT = `design is deciding what to leave out, then defending that emptiness against every well meaning request to fill it back in
clarity beats cleverness
the best interfaces feel inevitable, as if no other arrangement of the same pixels could have been correct
ship small
constraints are where the good ideas hide, so a boring problem usually means you have not found the right constraint yet
make it work, then make it right, then make it fast
taste is patience
a product is a long sequence of small decisions made visible, and the work is to keep making them after everyone got tired
the demo is the spec everyone actually reads
delete more than you add
most complexity is a missing decision wearing a costume, a question pushed downstream until it hardened into architecture
read the code before you replace it
motion should explain something true, where a thing came from or where it went, otherwise it is just expensive confetti
small loops beat big plans
the edges are where the craft shows, the empty states and the errors and the quiet moment right after a click
write it down or it did not happen
build for the person in front of you, not the imaginary average user who wants whatever you already built
opinions are cheap, working software is not
the boring solution is usually the correct one, and the urge to make it interesting is your ego asking for a stage
finish something today`;

```

### swirl/experiment-stage.tsx
```tsx
"use client";

// A compact swirl canvas for the experiments below the main rebuild. Same
// engine, smaller frame. The wrapper matches the main playground's canvas box
// (z-10, shadow, full rounding) so a control Panel can tuck under it. It
// forwards pointer moves (cursor bend) and clicks (shockwave) to the stage.

import { useEffect, useRef, useState, type PointerEvent, type ReactNode } from "react";
import { useSwirlStage, type StageConfig, type StageEvents } from "./use-swirl-stage";

export function ExperimentStage({
  config,
  trackPointer = false,
  burstOnClick = false,
  onClick,
  replayKey,
  events,
  children,
}: {
  config: StageConfig;
  /** Forward normalized pointer (-1..1) to the stage each move (cursor bend). */
  trackPointer?: boolean;
  /** Spawn a shockwave on pointer down at the click point. */
  burstOnClick?: boolean;
  /** Extra click callback (e.g. bump a counter). */
  onClick?: () => void;
  /** Replay the entrance whenever this value changes (e.g. a pattern switch). */
  replayKey?: string | number;
  /** Lifecycle hooks (entrance start / settle / visibility) — e.g. for sound. */
  events?: StageEvents;
  /** Overlay (e.g. a hint line) rendered above the canvas. */
  children?: ReactNode;
}) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const cfg = useRef<StageConfig>(config);
  // Sync live config (colors, pattern, sliders) into the ref the frame loop
  // reads every frame, so panel controls update the effect without re-init.
  cfg.current = config;
  const [failed, setFailed] = useState(false);
  // The canvas fades in (opacity only) once it has painted AND is in view, and
  // fades back out when scrolled off the play band, so the idle state shows the
  // dashed placeholder rather than a frozen frame.
  const [painted, setPainted] = useState(false);
  const [inView, setInView] = useState(false);

  // Merge the caller's events with our reveal tracking; keep in a ref so the
  // stage reads the latest without re-initializing.
  const eventsRef = useRef<StageEvents>({});
  eventsRef.current = {
    ...events,
    onFirstFrame: () => {
      setPainted(true);
      events?.onFirstFrame?.();
    },
    onVisible: (v) => {
      setInView(v);
      events?.onVisible?.(v);
    },
  };

  const stage = useSwirlStage(canvasRef, cfg, () => setFailed(true), eventsRef);

  // Restart the formation when replayKey changes (the word condenses again).
  useEffect(() => {
    if (replayKey !== undefined) stage.current.replay();
  }, [replayKey, stage]);

  const norm = (e: PointerEvent) => {
    const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
    return {
      x: ((e.clientX - rect.left) / rect.width) * 2 - 1,
      y: ((e.clientY - rect.top) / rect.height) * 2 - 1,
    };
  };

  // last pointer position (normalized) for a cheap speed estimate, used to drive
  // the cursor-move sound
  const prevPt = useRef<{ x: number; y: number } | null>(null);
  const handleMove = (e: PointerEvent) => {
    const p = norm(e);
    if (trackPointer) stage.current.setPointer(p);
    const onMove = eventsRef.current.onPointerMove;
    if (onMove) {
      const prev = prevPt.current;
      const speed = prev ? Math.min(1, Math.hypot(p.x - prev.x, p.y - prev.y) * 6) : 0;
      onMove(speed);
    }
    prevPt.current = p;
  };
  const wantsMove = trackPointer || !!events?.onPointerMove;
  const wantsDown = burstOnClick || !!events?.onPointerDown;

  return (
    <div
      // Idle backdrop reads like the Vault "more soon" cards: a light page-bg
      // panel with a dashed hairline border. Before the swirl paints / when it's
      // scrolled off the play band you see that, not a black box. Once the canvas
      // fades in it covers the panel; the solid border returns under it.
      className={`relative z-10 overflow-hidden rounded-xl ${
        painted && inView
          ? "border border-[var(--border-line)] bg-[color-mix(in_srgb,var(--bg-page)_97%,#000)]"
          : "border border-dashed border-[var(--border-line)] bg-[var(--bg-page)]"
      }`}
    >
      {failed ? (
        <div className="flex aspect-[16/9] w-full items-center justify-center px-6 text-center text-[12px] text-[var(--text-tertiary)]">
          WebGL2 unavailable here.
        </div>
      ) : (
        <canvas
          ref={canvasRef}
          style={{ opacity: painted && inView ? 1 : 0 }}
          className={`block aspect-[16/9] w-full transition-opacity duration-500 ease-[var(--ease-out)] motion-reduce:transition-none ${burstOnClick ? "cursor-pointer" : ""}`}
          onPointerMove={wantsMove ? handleMove : undefined}
          onPointerLeave={
            wantsMove
              ? () => {
                  if (trackPointer) stage.current.setPointer(null);
                  prevPt.current = null;
                }
              : undefined
          }
          onPointerDown={
            wantsDown
              ? (e) => {
                  const p = norm(e);
                  if (burstOnClick) stage.current.burst(p.x, p.y);
                  eventsRef.current.onPointerDown?.();
                  onClick?.();
                }
              : undefined
          }
        />
      )}
      {children}
    </div>
  );
}

```

Discovery vocabulary

Related by governed terms

Continue comparing

More from Vault