{
 "_ai": {
  "schema": 2,
  "what": "Prism terminal catalog: animated and static terminal facets (scenes, widgets, spinners, progress, charts, text, TUI, diagrams, ops) rendered with the zero-dependency prism-term engine.",
  "howToUse": "Pick a facet by gallery/category/tags. Run it with `usage.cli` (or `usage.npx` without installing), or import `prism-term` and call play(findFacet(id)). To read or rebuild a facet, take its code from `sources[facet.module]`; the modules in its `imports` are relative to `module`, and the \"_\" gallery helpers among them are in `sources` too (same \"prism-term/src/facets/…\" keys). The browser engine is in `sources` too, under the same repo-path keys: prism-term/src/web/index.js (TermCanvas, mountFacet, FacetPlayer, parseAnsi), prism-term/src/palettes.js and every engine module they or any facet import statically (color.js, canvas.js, buffer.js, widgets/*, text/* …), so the island alone can render any facet in a browser; Node-only terminal I/O (screen.js, loop.js, loaded with dynamic import() under Node only) is not included — use the package for the CLI. Facets are deterministic for a seed and accept `params` via --set key=value.",
  "fields": {
   "id": "stable unique id",
   "gallery": "gallery id (see galleries[])",
   "category": "category within the gallery",
   "kind": "scene|widget|spinner|progress|text|chart|tui|diagram|art",
   "size": "preferred size in terminal cells {w,h}",
   "minSize": "smallest size it reads at",
   "animated": "false = still frame",
   "loop": "seconds per cycle",
   "glyphs": "minimum glyph support: ascii|unicode|braille|sextant",
   "color": "minimum color depth: mono|16|256|truecolor",
   "params": "tunable parameters {key:{default,min,max,options,label}}",
   "module": "repo path of the facet's ES module; also the key into top-level `sources` for its code",
   "imports": "import specifiers in the module, relative to `module`: \"_\" gallery helpers and browser engine modules, both with their code in `sources`",
   "preview": "plain-text frame (no color) at up to 60×16"
  },
  "sources": "top-level `sources`: { \"<repo path>\": \"<full ES module source>\" } for every facet module (`facet.module`) and every \"_\" gallery helper they import (listed in `imports`, including helpers other helpers re-export, across galleries), plus the browser engine closure: every prism-term/src/*.js module reachable through static imports from prism-term/src/web/index.js, prism-term/src/palettes.js and the facet modules (Node-only screen.js / loop.js excluded); all sorted by path. Several facets can share one module.",
  "count": 435,
  "island": "<script type=\"application/json\" id=\"prism-terminal-catalog\">"
 },
 "engine": {
  "package": "prism-term",
  "version": "0.2.0",
  "repo": "https://github.com/crazy54/Prism/tree/main/prism-term",
  "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term",
  "curl": "curl -sL https://prism.jeremyfhall.com/tty",
  "web": "https://prism.jeremyfhall.com/terminal/"
 },
 "galleries": [
  {
   "id": "fx",
   "title": "FX Store",
   "icon": "✺",
   "blurb": "Full-field effects: plasma, fire, fractals, water and light.",
   "count": 24,
   "categories": [
    {
     "name": "Fields",
     "count": 7
    },
    {
     "name": "Fractals",
     "count": 4
    },
    {
     "name": "Fire & Water",
     "count": 5
    },
    {
     "name": "Light & Sky",
     "count": 4
    },
    {
     "name": "Glitch & Noise",
     "count": 4
    }
   ]
  },
  {
   "id": "objects",
   "title": "Animated Objects",
   "icon": "◈",
   "blurb": "3D solids, wireframes, globes and mechanical things that move.",
   "count": 19,
   "categories": [
    {
     "name": "3D Solids",
     "count": 5
    },
    {
     "name": "Wireframes",
     "count": 3
    },
    {
     "name": "Globes & Planets",
     "count": 3
    },
    {
     "name": "Mechanical",
     "count": 4
    },
    {
     "name": "Icons & Logos",
     "count": 4
    }
   ]
  },
  {
   "id": "sim",
   "title": "Particles & Simulations",
   "icon": "✦",
   "blurb": "Particles, cellular automata, physics, flocking and weather.",
   "count": 18,
   "categories": [
    {
     "name": "Particles",
     "count": 4
    },
    {
     "name": "Cellular Automata",
     "count": 4
    },
    {
     "name": "Physics",
     "count": 5
    },
    {
     "name": "Life & Flocking",
     "count": 3
    },
    {
     "name": "Weather",
     "count": 2
    }
   ]
  },
  {
   "id": "retro",
   "title": "Retro & Demoscene",
   "icon": "▚",
   "blurb": "Cracktro scrollers, copper bars, BBS-era ANSI art and chiptune visuals.",
   "count": 24,
   "categories": [
    {
     "name": "Intros & Scrollers",
     "count": 8
    },
    {
     "name": "Raster & Copper",
     "count": 4
    },
    {
     "name": "BBS & ANSI Art",
     "count": 5
    },
    {
     "name": "Chiptune Visuals",
     "count": 4
    },
    {
     "name": "Outrun",
     "count": 3
    }
   ]
  },
  {
   "id": "text",
   "title": "Text Effects",
   "icon": "Aa",
   "blurb": "Banners, bitmap fonts, reveals, glitches and color on type.",
   "count": 29,
   "categories": [
    {
     "name": "Banners & Fonts",
     "count": 9
    },
    {
     "name": "Reveal & Type",
     "count": 8
    },
    {
     "name": "Glitch & Distort",
     "count": 3
    },
    {
     "name": "Color & Light",
     "count": 5
    },
    {
     "name": "Motion",
     "count": 4
    }
   ]
  },
  {
   "id": "loaders",
   "title": "Spinners & Loaders",
   "icon": "⠿",
   "blurb": "Every way to say \"working…\" in one or two cells.",
   "count": 64,
   "categories": [
    {
     "name": "Classic Spinners",
     "count": 15
    },
    {
     "name": "Braille Spinners",
     "count": 10
    },
    {
     "name": "Bars & Pulses",
     "count": 15
    },
    {
     "name": "Orbits & Shapes",
     "count": 11
    },
    {
     "name": "Truecolor Spinners",
     "count": 13
    }
   ]
  },
  {
   "id": "progress",
   "title": "Progress & Counters",
   "icon": "▰",
   "blurb": "Progress bars, multi-step pipelines, counters, timers and transfer meters.",
   "count": 41,
   "categories": [
    {
     "name": "Progress Bars",
     "count": 9
    },
    {
     "name": "Steps & Pipelines",
     "count": 10
    },
    {
     "name": "Counters & Odometers",
     "count": 6
    },
    {
     "name": "Timers & Countdowns",
     "count": 6
    },
    {
     "name": "Transfers & ETA",
     "count": 10
    }
   ]
  },
  {
   "id": "charts",
   "title": "Charts & Metrics",
   "icon": "▁▃▆",
   "blurb": "Sparklines, line and bar charts, gauges, heatmaps and KPI tiles.",
   "count": 30,
   "categories": [
    {
     "name": "Sparklines",
     "count": 4
    },
    {
     "name": "Line & Area",
     "count": 7
    },
    {
     "name": "Bars & Histograms",
     "count": 6
    },
    {
     "name": "Gauges & Meters",
     "count": 6
    },
    {
     "name": "Heatmaps & Matrices",
     "count": 4
    },
    {
     "name": "KPI Tiles",
     "count": 3
    }
   ]
  },
  {
   "id": "ai",
   "title": "AI Working",
   "icon": "✳",
   "blurb": "Thinking states, token streams, tool calls and agent pipelines.",
   "count": 18,
   "categories": [
    {
     "name": "Thinking States",
     "count": 4
    },
    {
     "name": "Token Streams",
     "count": 4
    },
    {
     "name": "Tool Calls",
     "count": 4
    },
    {
     "name": "Agents & Pipelines",
     "count": 3
    },
    {
     "name": "Model Internals",
     "count": 3
    }
   ]
  },
  {
   "id": "status",
   "title": "Notifications & Status",
   "icon": "●",
   "blurb": "Toasts, status lines, badges, health indicators and log lines.",
   "count": 18,
   "categories": [
    {
     "name": "Toasts & Alerts",
     "count": 5
    },
    {
     "name": "Status Lines",
     "count": 3
    },
    {
     "name": "Badges & Pills",
     "count": 3
    },
    {
     "name": "Health & Uptime",
     "count": 4
    },
    {
     "name": "Log Lines",
     "count": 3
    }
   ]
  },
  {
   "id": "ops",
   "title": "Linux & Ops",
   "icon": "$_",
   "blurb": "What you see on a real box: monitors, disks, network, services, containers and logins.",
   "count": 39,
   "categories": [
    {
     "name": "System Monitors",
     "count": 6
    },
    {
     "name": "Disk & Storage",
     "count": 4
    },
    {
     "name": "Network",
     "count": 6
    },
    {
     "name": "Processes & Services",
     "count": 4
    },
    {
     "name": "Containers & Clusters",
     "count": 5
    },
    {
     "name": "Logs & Journals",
     "count": 4
    },
    {
     "name": "Login & MOTD",
     "count": 4
    },
    {
     "name": "Shell Prompts",
     "count": 6
    }
   ]
  },
  {
   "id": "diagrams",
   "title": "Architecture Diagrams",
   "icon": "┌┐",
   "blurb": "Box-drawing flows, trees, topologies, sequences and git graphs.",
   "count": 16,
   "categories": [
    {
     "name": "Boxes & Flows",
     "count": 4
    },
    {
     "name": "Trees & Hierarchies",
     "count": 3
    },
    {
     "name": "Networks & Topologies",
     "count": 3
    },
    {
     "name": "Sequence & Timelines",
     "count": 3
    },
    {
     "name": "Git Graphs",
     "count": 3
    }
   ]
  },
  {
   "id": "callouts",
   "title": "Callouts & Layout",
   "icon": "▌",
   "blurb": "Panels, borders, admonitions, tables, dividers and key-value blocks.",
   "count": 15,
   "categories": [
    {
     "name": "Panels & Borders",
     "count": 5
    },
    {
     "name": "Admonitions",
     "count": 3
    },
    {
     "name": "Tables",
     "count": 3
    },
    {
     "name": "Dividers & Headers",
     "count": 2
    },
    {
     "name": "Key-Value",
     "count": 2
    }
   ]
  },
  {
   "id": "input",
   "title": "Input & Menus",
   "icon": "❯",
   "blurb": "TUI controls: selects, checkboxes, text fields, palettes, dialogs and tab bars.",
   "count": 23,
   "categories": [
    {
     "name": "Selects & Lists",
     "count": 3
    },
    {
     "name": "Toggles & Checkboxes",
     "count": 4
    },
    {
     "name": "Text Fields",
     "count": 4
    },
    {
     "name": "Command Palettes",
     "count": 3
    },
    {
     "name": "Dialogs & Prompts",
     "count": 5
    },
    {
     "name": "Tabs & Bars",
     "count": 4
    }
   ]
  },
  {
   "id": "maps",
   "title": "Maps & Geo",
   "icon": "◍",
   "blurb": "World maps, radar sweeps, routes, arcs and floorplans in braille and blocks.",
   "count": 11,
   "categories": [
    {
     "name": "World Maps",
     "count": 3
    },
    {
     "name": "Radar & Sonar",
     "count": 2
    },
    {
     "name": "Routes & Arcs",
     "count": 4
    },
    {
     "name": "Grids & Floorplans",
     "count": 2
    }
   ]
  },
  {
   "id": "lab",
   "title": "Animation Lab",
   "icon": "∿",
   "blurb": "Motion studies: easing curves, springs, timing and the building blocks of animation.",
   "count": 17,
   "categories": [
    {
     "name": "Easing & Timing",
     "count": 6
    },
    {
     "name": "Springs & Physics",
     "count": 2
    },
    {
     "name": "Loops & Cycles",
     "count": 4
    },
    {
     "name": "Transitions",
     "count": 5
    }
   ]
  },
  {
   "id": "ascii",
   "title": "Pure ASCII",
   "icon": "#",
   "blurb": "7-bit only — no Unicode, no color required. Runs on a VT100, a serial console, anything.",
   "count": 29,
   "categories": [
    {
     "name": "ASCII Animations",
     "count": 11
    },
    {
     "name": "ASCII Art",
     "count": 5
    },
    {
     "name": "ASCII Spinners & Bars",
     "count": 4
    },
    {
     "name": "Classics",
     "count": 9
    }
   ]
  }
 ],
 "facets": [
  {
   "id": "plasma",
   "title": "Plasma",
   "gallery": "fx",
   "galleryTitle": "FX Store",
   "category": "Fields",
   "description": "Four interfering sine fields cycling through an Oklab palette — the demoscene classic.",
   "tags": [
    "plasma",
    "demoscene",
    "sine",
    "palette"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "prism",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/fx/plasma.js",
   "imports": [],
   "usage": {
    "cli": "prism-term play plasma",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play plasma",
    "snapshot": "prism-term snapshot plasma --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('plasma'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/plasma"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "########***+****###############################***+++**+++**\n######**++++++*#############******#############**+++*****++*\n#####*+++**++**############******################**++++++++*\n####**++**++**####***######***########*****#######**********\n####*++**++**###*****###############******##################\n####*+++++*###********#############**#######################\n####*+++**###***++*****######****#######################****\n*###*****###********************#######################***+*\n*####**####********+***********########################*****\n*#########*********+**********##########*********#######****\n*########**+*****************##########***+********#####****\n*########**+*****+*********######**####************######***\n*########***********###########*****###************#######**\n*#########********############******###*******+***##########\n*##########******#############*******###********#####***####\n*####**#####****#####******##*******######****#####******###"
   }
  },
  {
   "id": "tunnel",
   "title": "Tunnel",
   "gallery": "fx",
   "galleryTitle": "FX Store",
   "category": "Fields",
   "description": "Textured demoscene tunnel — precomputed angle/depth LUT, neon ribs, palette cycling and camera wobble.",
   "tags": [
    "tunnel",
    "demoscene",
    "texture",
    "lut"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "prism",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/fx/tunnel.js",
   "imports": [],
   "usage": {
    "cli": "prism-term play tunnel",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play tunnel",
    "snapshot": "prism-term snapshot tunnel --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('tunnel'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/tunnel"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": ".@=:::::....:::::+:....:::::-%....:::::::...................\n...-@:.......:::::+-==-::::#:.......:.......................\n.....:#*......=*:.#......:#-......:::.......................\n....:::::@-.==:...-.....=:.:==::::::::...................:=*\n...::::::::%#....::....-:.....%::::::::....:=*%@@%+=::::::::\n:::.::::::*...-::.:....:..:...-*%@%*=:.:::::::::::::::::::::\n:::.......#:...:..........:...:#.......:::::::::::::::::::::\n:::.......#:...:..........:...:#.......:::::::::::::::::::::\n:::.:=*%@%*-...:..:....:.::-...*::::::.:::::::::::::::::::::\n...::::::::%.....:-....::....#%::::::::......:::::::::::::::\n....::::::::==:.:=.....-...:==.-@:::::...............:::::::\n.....:::......-#:......#.:*=......*#:.......................\n.....:.......:#::::-==-+:::::.......:@-.....................\n.:::::::....%-:::::....:+:::::....:::::=@...................\n::::::::::-%::::::......@:::::::::::::::::#*................\n:::::::::%:...::::......@:::....:::::::::::::@-............."
   }
  },
  {
   "id": "metaballs",
   "title": "Metaballs",
   "gallery": "fx",
   "galleryTitle": "FX Store",
   "category": "Fields",
   "description": "Orbiting metaballs that merge with blended hues, a glowing iso-contour rim and soft halo.",
   "tags": [
    "metaballs",
    "blobs",
    "isosurface",
    "glow"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "candy",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/fx/metaballs.js",
   "imports": [],
   "usage": {
    "cli": "prism-term play metaballs",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play metaballs",
    "snapshot": "prism-term snapshot metaballs --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('metaballs'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/metaballs"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "................................................:::::.......\n..............................:::::..........:-*%%%%#=:.....\n...........................:-**#***+-::....::-*%%%%%%%+:....\n.........................:-+###*****#*::::::-*%%%%%%%%+:....\n........................:-+####*****#+-::::-*%%%%%%%%+:.....\n........................:=###########*-::::+%%%%%%%*-:......\n..........:::-==-:::...::-+########**-::.::-*%%%%%#-:.......\n.......::-*+******+-::..::-=*###*+=-:......::-+*+-:.........\n.....:=+***********+-:.....:::::::..........................\n....:+#************+::......................................\n...:=##*#*##*******::.......................................\n...:=####*#*****+-:.........................................\n....:=*#**####+-:...........................................\n......:-*++*=::.............................................\n............................................................\n............................................................"
   }
  },
  {
   "id": "interference",
   "title": "Interference",
   "gallery": "fx",
   "galleryTitle": "FX Store",
   "category": "Fields",
   "description": "A ripple tank: three orbiting sources whose concentric waves reinforce and cancel into sweeping moiré nodal lines.",
   "tags": [
    "interference",
    "waves",
    "moire",
    "ripple tank",
    "physics",
    "rings"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 20,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "ocean",
   "params": {
    "sources": {
     "default": 3,
     "min": 2,
     "max": 5,
     "label": "Sources"
    },
    "wavelength": {
     "default": 1,
     "min": 0.4,
     "max": 3,
     "label": "Wavelength"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/fx/interference.js",
   "imports": [
    "../retro/_kit.js"
   ],
   "usage": {
    "cli": "prism-term play interference",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play interference",
    "snapshot": "prism-term snapshot interference --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('interference'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/interference"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": ":#=:.:-%+.::.::..::##:.::..-=:....::::%@%+-*%%#:.::..-#%-:.:\n=+.:::#*..:.*-.:.+=..:.#+.:.:-.::.#%=...:...:::::......#%-..\n--.:.=*.::.##.:.#@#:.:*@#:.:=%-::-%-:...--.:**.::-%#....=.::\n-=.:.:=.:::*.:@:.+-::.#%....%%....+.:::%@*..+%+:..#%*..-%=:.\n+#.::.#=.:.=#:.:.+=.:.-=.::=%+:.:*%+::.+..@.:-=:...-....%%::\n#%:...%%....%%:..=:::.-*.:::+.::.%%....##...#@%.:.%%:..:--.:\n+%=:.:#%:::.:...:.:.:.=*.::.+=.:.-=.::.%%=.:+::...%%.:.*#...\n.#=.:.::..:%%-::.%%-::.-:.:.=+.::..=+=::...:.:::.=-:...%%:..\n.....--.:.:-*-..:*-:...=*:.:..::.....==...=%%#+*%%=:::.=....\n:.::.-*=...::::::::..:-:....::.=*-.:::..:=*=.:..:::.=%#....%\n-:...::.-##:.:*#+..-%%...:=%=.::.--::..:::::::.:-..:.++..::%\n..:##.:.:.:.:::#=:.:.-.:..#%+.::....:++::-+*=+%%%*.::.:-...:\n:.::%%.:.+#...::-..:.#%=....::.%%::.:........:@:::....=+:.:.\n=:.:.-.::=%%:..*%#-.::......::.#%:::.#*...:.:.::.+%#.....::.\n#:..-+.:.:...::..::...:.::%@+.:::::.=%%-..:%@%#+#*::.:.::..:\n..::.##=..........:.*%%.::.-:.:.=*::..:....:...:::::.=%#..:."
   }
  },
  {
   "id": "voronoi",
   "title": "Voronoi Cells",
   "gallery": "fx",
   "galleryTitle": "FX Store",
   "category": "Fields",
   "description": "Drifting seeds carve living Voronoi cells — bevelled stained-glass tiles with glowing borders where neighbors meet.",
   "tags": [
    "voronoi",
    "worley",
    "cells",
    "tiles",
    "stained glass",
    "procedural"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 24,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "prism",
   "params": {
    "cells": {
     "default": 1,
     "min": 0.3,
     "max": 3,
     "label": "Cell density"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/fx/voronoi.js",
   "imports": [
    "../retro/_kit.js"
   ],
   "usage": {
    "cli": "prism-term play voronoi",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play voronoi",
    "snapshot": "prism-term snapshot voronoi --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('voronoi'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/voronoi"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "---=...=-=.=-----===+++++++====---:::::::-...=-:::::::::::::\n-=...==---=..----====+++++====----:::::-...-::::::::::::::::\n..++======-=.....+++=========----::::-...=--::::::::::::::::\n*+++++++====..=++++..........+++===......=-:::::::::::::::::\n++++++++++==..=-===========+++.....=....==-:::::::::::::::::\n+*******+++=..=====+++++====--=..=-=+...+=------::::::::::::\n+***#***++++..====+++++++====+..====+...+=---------:::::::::\n++*****+++=+..====+++++++===+.+=====+...+=======-----:::::::\n+++++++++==+..=============..+=++++++*..++++++====----::::::\n===========+..---=========..+=++++*++*..++++++++===---::::--\n-=======---+..----------+..+=+++******..++***+++============\n-----------+..-::-----=...===++++***+*..++++++++++++........\n::::::::::-...-:::::-=..+=-===+++++++*......................\n::::::::::=...-::::-=..=----=======+..*+++***+++++++++++....\n:::::::::-=...-::-=...=-::----===+...++++*****+++====-------\n:::::::::-=...-:-=...=-:::::--==...++==+++++++++===----:::::"
   }
  },
  {
   "id": "lava-lamp",
   "title": "Lava Lamp",
   "gallery": "fx",
   "galleryTitle": "FX Store",
   "category": "Fields",
   "description": "Lava lamps on a shelf: metaball wax warms at the bulb, rises, stretches and sinks inside tapered glass lit from below.",
   "tags": [
    "lava lamp",
    "metaballs",
    "wax",
    "blobs",
    "retro",
    "1970s",
    "groovy"
   ],
   "kind": "scene",
   "size": {
    "w": 64,
    "h": 24
   },
   "minSize": {
    "w": 16,
    "h": 8
   },
   "animated": true,
   "loop": 30,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "magma",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/fx/lava-lamp.js",
   "imports": [
    "../retro/_kit.js"
   ],
   "usage": {
    "cli": "prism-term play lava-lamp",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play lava-lamp",
    "snapshot": "prism-term snapshot lava-lamp --size 64x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('lava-lamp'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/lava-lamp"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "........:=*:................:=*:................:=*:........\n........:+#:................:+#:................:+#:........\n........-+#-................-+#-................-+#-........\n........-:::................-+**-...............#%#:........\n.......:-::::...............-.==...............:+=:::.......\n.......:-::::...............-..................*%*:::.......\n.......::::::..................................-#%#-:.......\n.......::::::..................:...............:-*=::.......\n.......::=:::.................................::::::::......\n......:::-+*-:...............=+...............:*#*---:......\n......-**####+.............-..................-+#*#%*-......\n......+%#####+............:-::=*+:..........::-=---*--::....\n.....:+#-+%=*+:............:-+%=*=..........:.---+%==-.:....\n......:-=*#*=:............:-=*#*=:..........:::-=*#*=:::....\n.....:-=+*##*=-..........:-=+*##*=-.........::-=+*##*=-:....\n............................................................"
   }
  },
  {
   "id": "kaleidoscope",
   "title": "Kaleidoscope",
   "gallery": "fx",
   "galleryTitle": "FX Store",
   "category": "Fields",
   "description": "A turning kaleidoscope: a flowing field folded through mirrored wedges into a mandala, with an eyepiece vignette.",
   "tags": [
    "kaleidoscope",
    "mirror",
    "symmetry",
    "mandala",
    "psychedelic",
    "polar"
   ],
   "kind": "scene",
   "size": {
    "w": 72,
    "h": 24
   },
   "minSize": {
    "w": 16,
    "h": 6
   },
   "animated": true,
   "loop": 24,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "prism",
   "params": {
    "segments": {
     "default": 6,
     "min": 3,
     "max": 12,
     "label": "Mirrors"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/fx/kaleidoscope.js",
   "imports": [
    "../retro/_kit.js"
   ],
   "usage": {
    "cli": "prism-term play kaleidoscope",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play kaleidoscope",
    "snapshot": "prism-term snapshot kaleidoscope --size 72x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('kaleidoscope'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/kaleidoscope"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "........................:--:.-.-....:.......................\n....................:.:-.=+=.-.*=--+.::.....................\n.....................+=..=+-==+=.:+=.-.---:.................\n.................---++=+-.-=::*.-.*:=+*+-...................\n................--.=*:--:*=--++::+-.=---:-:.:...............\n..............:-:..=:-+-.:+=:=*-*=-::.:=**+:................\n.............:-=++=.=::=+*+++++*=-=*+:.:::+:................\n..............:--.+.:.-=--=*+++++====---+*+=:...............\n...............:=+*+---====+++++*=--=-.:.+.--:..............\n................:+:::.:+*=-=*+++++*+=::=.=++=-:.............\n................:+**=:.::-=*-*=:=+:.-+-:=..:-:..............\n...............:.:-:---=.-+::++--=*:--:*=.--................\n...................-+*+=:*.-.*::=-.-+=++---.................\n.................:---.-.=+:.=+==-+=..=+.....................\n.....................::.+--=*.-.=+=.-:.:....................\n.......................:....-.-.:--:........................"
   }
  },
  {
   "id": "mandelbrot",
   "title": "Mandelbrot Dive",
   "gallery": "fx",
   "galleryTitle": "FX Store",
   "category": "Fractals",
   "description": "Continuous zoom into Seahorse Valley with smooth iteration coloring; --set set=julia for a morphing Julia set.",
   "tags": [
    "mandelbrot",
    "julia",
    "fractal",
    "zoom"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "magma",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/fx/mandelbrot.js",
   "imports": [],
   "usage": {
    "cli": "prism-term play mandelbrot",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play mandelbrot",
    "snapshot": "prism-term snapshot mandelbrot --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('mandelbrot'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/mandelbrot"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "=====++++++++******###%@*+......................%%%###****++\n====++++++++******####%%%#......................:-%%%##****+\n===++++++++*****#####%%@#+-........................+=%##****\n===++++++******####%%%@@%-:.........................+*#%#***\n==++++++*****###%%%@@%%%#*...........................#%%##**\n=+++++++****#%@@@%%*==...-=..........................*%@%#**\n=++++++****##%@#*=.:..................................:=@##*\n++++++*****##%%@%*.................................=#%@@%#**\n+++++*****###%@%#*...........-....................-#%%%##***\n+++++****###%%%#-............*##=................:#%%%##****\n++++****##%%%%*==-:=.......=+%%%%%:=*+...=.-*=....:+%%##****\n+++***##%%@%=%%@%*%%##-==#*#@%%%%%@@%#=+#%%%%#===.+%%%#****+\n+++**##%%#%%%%%%%%%%%@+#%@%%%%####%%%@%%%%%%%%@%%%-%@%#***++\n+++**%%##############%%%%######################%%@*%##***+++\n+++++***************#######*******************######***+++++\n==+++++++********************************************+++++++"
   }
  },
  {
   "id": "burning-ship",
   "title": "Burning Ship",
   "gallery": "fx",
   "galleryTitle": "FX Store",
   "category": "Fractals",
   "description": "The Burning Ship fractal in flame colors, breathing from the whole hull down to the little ship on its antenna.",
   "tags": [
    "burning ship",
    "fractal",
    "escape time",
    "zoom",
    "fire"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 30,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "fire",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/fx/burning-ship.js",
   "imports": [],
   "usage": {
    "cli": "prism-term play burning-ship",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play burning-ship",
    "snapshot": "prism-term snapshot burning-ship --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('burning-ship'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/burning-ship"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "....................::::::::::-#@@**-:::::-.@*-:-...........\n...................::::::::%=+=#.*@@%%@+.@..................\n..................:::::::-#+=%+..*#%=@@.....................\n................:::::::::@@+=#@*.@........................%=\n.............::::::::::::-+%-%@.#........................#=-\n..........::::::::::::::==@@............................*=--\n........:::::::::::::::-.#.............................*=---\n.......:::::::::::--*.--..............................*=---:\n.....::::::::::::-%+@................................@+=--::\n....:::::::::::---...................................#=---::\n...:::::::::----=....................................*=--:::\n...::::---=@====.....................................*=--:::\n...::::::::::::::-------==+*.......##%.................=-:::\n......::::::::::::::::::-------------===++#.............--::\n..........:::::::::::::::::::::::::--------==*@.........=-::\n...............::::::::::::::::::::::::::-----==+.......--::"
   }
  },
  {
   "id": "newton-fractal",
   "title": "Newton Basins",
   "gallery": "fx",
   "galleryTitle": "FX Store",
   "category": "Fractals",
   "description": "Newton's method basins for a polynomial whose roots orbit and breathe — color by root, shade by speed.",
   "tags": [
    "newton",
    "fractal",
    "basins",
    "roots",
    "complex",
    "polynomial"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 24,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "prism",
   "params": {
    "roots": {
     "default": 3,
     "min": 3,
     "max": 6,
     "label": "Roots"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/fx/newton-fractal.js",
   "imports": [
    "../retro/_kit.js"
   ],
   "usage": {
    "cli": "prism-term play newton-fractal",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play newton-fractal",
    "snapshot": "prism-term snapshot newton-fractal --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('newton-fractal'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/newton-fractal"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "------================+++++++++++++============--:----:--.:-\n---:----==============+++++++++++++==========----::::-::----\n--:---..-=============++++++@+++++========--:----:----------\n==-----:---============++++++++++========----:==------------\n::--::::------:-==========+++++==========-:-:-:-------------\n-------::-==:---:==================----==-:-----------------\n----------:--:--:-==----==========-----==:-------=====------\n-----------------.==--=--:=======-:---:-----===============-\n------------------:--:---:-======--.:----===================\n------------------------::-=======-----=====================\n-------------====---------:=======-:---=====================\n----------==========------:=======-:--=======@==============\n---------=============----:-======-----=====================\n--------========@=====--------===------=====================\n--------==============-----::--==-------====================\n---------============-------:-====-:-----==================="
   }
  },
  {
   "id": "fractal-tree",
   "title": "Blossom Tree",
   "gallery": "fx",
   "galleryTitle": "FX Store",
   "category": "Fractals",
   "description": "A recursive blossom tree swaying in the wind — ten generations of branches, blooming tips and drifting petals.",
   "tags": [
    "tree",
    "fractal",
    "recursive",
    "l-system",
    "blossom",
    "sakura",
    "wind"
   ],
   "kind": "scene",
   "size": {
    "w": 64,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 8
   },
   "animated": true,
   "loop": 16,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": "candy",
   "params": {
    "depth": {
     "default": 10,
     "min": 5,
     "max": 12,
     "label": "Generations"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/fx/fractal-tree.js",
   "imports": [
    "../retro/_kit.js"
   ],
   "usage": {
    "cli": "prism-term play fractal-tree",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play fractal-tree",
    "snapshot": "prism-term snapshot fractal-tree --size 64x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('fractal-tree'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/fractal-tree"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "          %%%*%%***%+++%**+++%  ****%*%*%**%*%**\n         %***%+++%+ +=% **=  =+== %**+++%****%**%\n         *%**+  +++= ==  ===== ===*=  =%%+++%****%\n        ****+++++======   ==    ==  ===  *++*+%***\n        *****+++     ==== ==    == =======++*****   %\n         *%%**          ====    ====        *****\n         %%++             ===   ===        *%**\n                           === ===   *\n *                          =====\n                     %       ===  %\n             *               ===                           *\n                             ===*\n                             ===\n                             ===\n*---- - -  -   -     -  -- - === ----       -  * - --\n-------------%-----%----------*-----------------------------"
   }
  },
  {
   "id": "fire",
   "title": "Fire",
   "gallery": "fx",
   "galleryTitle": "FX Store",
   "category": "Fire & Water",
   "description": "Classic demoscene fire — heat rises, averages and cools through a scrolling noise map, throwing embers on the wind.",
   "tags": [
    "fire",
    "doom",
    "embers",
    "heat"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "fire",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/fx/fire.js",
   "imports": [
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play fire",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play fire",
    "snapshot": "prism-term snapshot fire --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('fire'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/fire"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "                                  %\n         %\n          %\n ..\n ..   ..                                     %....\n.........                 .....             .......\n..........             ..........          .............\n...........     ...........::......       ................\n..................:::::::::::::......   ....::.............\n..........:::::---====----:::::::.........:::::...:::-::....\n.......::-====+++++++++=-------::::.....::----:::-====--:...\n:-------=+************++==---------::::--========++*+++=-:..\n====+++**########**###**++========--:-==++++++++**###*++=-:.\n+****##%%%%%%%#####%%###***++++++==--==++*******##%%##*++=--\n***##%@@@@%%%%%%%%%%@%%%#%####**+==+++**###%%##%%%%%%##+++==\n*#%@@@@@@@@@@@@@@@@@@@@@%%#%%#+=+=+*****#%%%@%@@@@%####***++"
   }
  },
  {
   "id": "ripple",
   "title": "Ripple Pond",
   "gallery": "fx",
   "galleryTitle": "FX Store",
   "category": "Fire & Water",
   "description": "Rain on water — damped wave-equation ripples refracting a pebbled bed, with specular glints.",
   "tags": [
    "water",
    "ripple",
    "rain",
    "wave equation"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "ocean",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/fx/ripple.js",
   "imports": [],
   "usage": {
    "cli": "prism-term play ripple",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ripple",
    "snapshot": "prism-term snapshot ripple --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ripple'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ripple"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": ":::------:--:::::::::--------:-::::::::::::::---::::::..:--:\n:::-++:-:--::::::::-:--===-+:-:::::--:::.::::--::::::..-@@:.\n:-:-%::::-::::==+:=---=--::::::--::::::..:----:.:==:.:-@@:..\n:#:%::::::--:+=+:--:::::::::::::...:::.::::---:-*+:.:*@@:..:\n#::=-::::-=-----:::-::::::::::::::-:::::..::=--:::.:#@@:..:-\n:.-@:::::+-:::-::::::::::::::::::::.:-:.::-+#::..::*@@:..:+-\n:-%-::.:@+:::::.:-:::.::-=::::::.::.::.::-++::::-*#%%:..:#-:\n-=--:.:+-...::::-:.:::::--::::-:::.:::::--::.-*==+-=:..:=-::\n:-:::.::.:...:.::::::::-:::::--::.:--::::.::-@+--:--...::..:\n:%:::::::.::::::::-::::=--:-:--:-:-*:::::::=@*---.:...::::::\n#:.:-:::.:-:=:::::-:::--:::::-:---=:.:*-::%@=:.......:::::::\n:.:+:::.:@---:.:::::.:=::.::--:::...:*-::=@=....::::::::::::\n::::-:.:*::--::-:::::--::--::......:++=-:#+:...:++:::::::-::\n:::-:::::-:-:::-----::-=::::....::-@+=-:::::..:=%:.::::----:\n--::::::::::..:-:::-::::::::.::=+#@-::::::::::---..:::::-::-\n-:--:::::::::::::::::...:::::--=--:::::::::----:::::::::::::"
   }
  },
  {
   "id": "ocean-waves",
   "title": "Ocean at Sunset",
   "gallery": "fx",
   "galleryTitle": "FX Store",
   "category": "Fire & Water",
   "description": "Open sea at sunset in perspective: a height field of waves reflecting the sky, with a flickering sun-glitter path.",
   "tags": [
    "ocean",
    "sea",
    "waves",
    "sunset",
    "reflection",
    "glitter",
    "water"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 20,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "sunset",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/fx/ocean-waves.js",
   "imports": [
    "../retro/_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ocean-waves",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ocean-waves",
    "snapshot": "prism-term snapshot ocean-waves --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ocean-waves'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ocean-waves"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "....................::::::::::::::::::::....................\n...............:::::::----------------:::::::...............\n::::::::::::::::--------====%%%%====--------::::::::::::::::\n-----------------=========%%%%%%%%=========-----------------\n================+++++++++%%%%%#####++++++++++===============\n*************************##########*************************\n**###***##***##***##***##*+*%%%%**##*++*##*+*###*+*###*+*###\n#*+=-===+**##*+=-::::--+########+-:....:-+*#####*+==--==+*##\n:---...--::-***+####*###*===-:..:...::::---------------=+***\n++=---::---::.::---:::-+++=-=#***+==+**+=====-----::..:::...\n:...:::::---------===-::...:::..................::---===+**#\n+**=-:..:=*##*+=-::-+++++-:...:::::...............::----::::\n::::::...........................................:::---=====\n.::--::.....................................................\n:...........................................................\n.........:.................................................."
   }
  },
  {
   "id": "smoke",
   "title": "Incense Smoke",
   "gallery": "fx",
   "galleryTitle": "FX Store",
   "category": "Fire & Water",
   "description": "Incense smoke: a laminar ribbon leaves the glowing tip, wavers and breaks into curling turbulence as it rises.",
   "tags": [
    "smoke",
    "incense",
    "turbulence",
    "wisps",
    "calm",
    "ambient"
   ],
   "kind": "scene",
   "size": {
    "w": 48,
    "h": 24
   },
   "minSize": {
    "w": 12,
    "h": 8
   },
   "animated": true,
   "loop": 24,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "ice",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/fx/smoke.js",
   "imports": [
    "../retro/_kit.js"
   ],
   "usage": {
    "cli": "prism-term play smoke",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play smoke",
    "snapshot": "prism-term snapshot smoke --size 48x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('smoke'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/smoke"
   },
   "preview": {
    "w": 48,
    "h": 16,
    "text": "....................:-==-:......................\n....................:-----:.....................\n...................:---::--:....................\n...................::-----=-:...................\n...................::::-+*=:....................\n...................::::-++=:....................\n....................:--=+==-....................\n......................-***=:....................\n......................:###:.....................\n......................:##+......................\n......................:##-......................\n......................:##.......................\n.......................#=.......................\n.......................+#.......................\n........................-.......................\n..................::::::::::::.................."
   }
  },
  {
   "id": "caustics",
   "title": "Pool Caustics",
   "gallery": "fx",
   "galleryTitle": "FX Store",
   "category": "Fire & Water",
   "description": "A sunlit pool floor: tiles bent by the moving surface under a dancing network of bright caustic light.",
   "tags": [
    "caustics",
    "pool",
    "water",
    "light",
    "refraction",
    "summer",
    "tiles"
   ],
   "kind": "scene",
   "size": {
    "w": 72,
    "h": 24
   },
   "minSize": {
    "w": 16,
    "h": 6
   },
   "animated": true,
   "loop": 16,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "ocean",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/fx/caustics.js",
   "imports": [
    "../retro/_kit.js"
   ],
   "usage": {
    "cli": "prism-term play caustics",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play caustics",
    "snapshot": "prism-term snapshot caustics --size 72x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('caustics'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/caustics"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "--+#**%=----=*--:::.-+%+=-*#=---.--#--..:::::.:.:**=::......\n---=--..-----.=#=--:--==-.------.---+*:-:::::.::::--%=--..--\n-=+=--.------.=+:::.:::::.-----..------%=-:::.:::::.-*+--.--\n#*++-........:#-:::.--......:=-..::::.:#=:...........+=.....\n=--==::::::=%*=-:::.-----.=+---=#*++=-:+**+**.-----.-#=-:.::\n-----.--+=::.:-++-:.-----*------.:-=%=.:::::::+----.-+*=-=+:\n...--.-*-....:%=*-......:-........-%-.........-=......-**:..\n:::::.=*+--=%=--==-.:::::+-::::::#+---.-----..+=-::.:-=++:--\n=-:::.------.---*--.:::::.%-:::-#+----.-----.::=*#+=++=--.--\n-::....-----.-:%-..........%=--==*%%+-:......:::::..:-%-:.--\n------.-----..%=--:.:::::..-+#+=:-----.:---+%=-:::..--=#-.::\n.------.-----.=*=--.:::::..-*=-..-----.:::::.-+%#*:.--=#-.::\n..........---..-%-.........:-.-..--..........::----%+*+:...:\n.------.:::::.-**--.-----:#-:::.::::--:-----.------.--::::.:\n.----=+*%%%*++%=---.---==.:::::.:::-%=:--+=-.------.::::::.:\n.--+#*=:..:-=+=:.......%-.:::::.::::*+:---=+.----..........."
   }
  },
  {
   "id": "aurora",
   "title": "Aurora",
   "gallery": "fx",
   "galleryTitle": "FX Store",
   "category": "Light & Sky",
   "description": "Northern-lights curtains with drifting rays over a twinkling sky, mountains and a mirrored lake.",
   "tags": [
    "aurora",
    "northern lights",
    "sky",
    "noise"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "aurora",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/fx/aurora.js",
   "imports": [],
   "usage": {
    "cli": "prism-term play aurora",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play aurora",
    "snapshot": "prism-term snapshot aurora --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('aurora'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/aurora"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "............................................................\n...............+............................................\n............................................................\n.....................::.:.::.:.:::.:..::::.::.::.:...:..:...\n..:..:..:::::::::::::-::::-::-::--:-:::---:--:--:-:::-::-:::\n::::::::--:::::-:-:::---%:---------=:----=-======+=-=+=-==--\n-:::-:::---------=---==-=-=+====-#--:--=-==+*=+++#++*#*+#*=+\n=:-:--:--=---====+===+===-==---==-==-======+*=+++%#+*#*+#*==\n-:-:==:=-===-==+=+==+=+===++==-=+===:=-----==----=--=+=-=-:-\n..::+=-==*+===+++===+=+==-#+=-:::.::.:.....::....:...:.::...\n.....::--+=====+=--.:.:..:::................................\n............................................................\n............................................................\n......:::::::::::...........................................\n...:..::::::::::::.:::::.-:.................................\n..............:....:.:...::....:....................:......."
   }
  },
  {
   "id": "lightning",
   "title": "Thunderstorm",
   "gallery": "fx",
   "galleryTitle": "FX Store",
   "category": "Light & Sky",
   "description": "A thunderstorm: rolling clouds, slanting rain and forked bolts that flicker, lighting the clouds from inside.",
   "tags": [
    "lightning",
    "storm",
    "thunder",
    "rain",
    "clouds",
    "weather",
    "bolt"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 8
   },
   "animated": true,
   "loop": 24,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "ice",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/fx/lightning.js",
   "imports": [
    "../retro/_kit.js"
   ],
   "usage": {
    "cli": "prism-term play lightning",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play lightning",
    "snapshot": "prism-term snapshot lightning --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('lightning'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/lightning"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": ":::::::::::::::::....:::::-:::::::::...............:::..::::\n::::..:::::.-............::-:::::::................:::::...:\n::::::.:::..-......................................:::::::::\n::::::::.............................................:::::::\n::::::................................................::::::\n:::::......:::::::::::...................-.........:::::::::\n:::.........:::::::::....................-..........::::::::\n................................................-...........\n.-.:...............-.............................-..........\n..-................-................-.......................\n.........-..........................-.......................\n.........-...........................................-...-..\n......................................................-..-..\n.........-..............................................-...\n.........-..............................................-...\n............................................................"
   }
  },
  {
   "id": "nebula",
   "title": "Nebula",
   "gallery": "fx",
   "galleryTitle": "FX Store",
   "category": "Light & Sky",
   "description": "A domain-warped emission nebula with dust lanes, slowly turning, with twinkling stars and diffraction spikes.",
   "tags": [
    "nebula",
    "space",
    "stars",
    "cosmos",
    "galaxy",
    "deep field",
    "ambient"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 40,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "magma",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/fx/nebula.js",
   "imports": [
    "../retro/_kit.js"
   ],
   "usage": {
    "cli": "prism-term play nebula",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play nebula",
    "snapshot": "prism-term snapshot nebula --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('nebula'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/nebula"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "----:::.:..............................:::----:::::...-+%+-.\n::........................-...........::::----::::::...--...\n::.........#.....................-.:::::::::--:::::::-=%=-..\n:.............................::...:::::...::::::::::..+....\n:.....................*...:::......:::::.........::::+......\n...................................:::::..............::::::\n...++.....::::.::.::...............::::::..........+::::::::\n:........::::::::::::...........+:::::::::::::::::::::::::::\n:........:::::::::::::::........*::::-::::::::+:::::::::::::\n....:%:::::::::-#::...::......:::::--::---:::....:....::::+:\n:...:::::::::---::::::::..:...::::-::::::-::...........:::::\n::..:#-:::::::::---:::.........::::---::::::...........:::::\n::..::::::::::::::--:............-:::::...::.....:......::::\n-::.::::::::::.....::.....................:::.........::::::\n::::::.:::::::......:::.........:::....-...::........:::----\n::::::::::::::::.....:::.........:::................:::::::-"
   }
  },
  {
   "id": "god-rays",
   "title": "God Rays",
   "gallery": "fx",
   "galleryTitle": "FX Store",
   "category": "Light & Sky",
   "description": "Volumetric light: the low sun behind a swaying forest canopy, shafts fanning through the gaps and dust drifting in them.",
   "tags": [
    "god rays",
    "crepuscular",
    "volumetric",
    "light shafts",
    "forest",
    "sun",
    "haze"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 8
   },
   "animated": true,
   "loop": 20,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "gold",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/fx/god-rays.js",
   "imports": [
    "../retro/_kit.js"
   ],
   "usage": {
    "cli": "prism-term play god-rays",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play god-rays",
    "snapshot": "prism-term snapshot god-rays --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('god-rays'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/god-rays"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": ".............................................::..........:::\n......................................::====---::::....:::::\n.....................................::-++**+++=---:::::::::\n.....................................:::::::::::::::::...:::\n.....................................::::::-::::::::::...:::\n.................::..................::-:::----::::-::::....\n.................::..................::+=------:-::::::..:..\n.................:::::..:............:=++======:--:-::::....\n.................::::::...............:++======---:-::::.:::\n.................::::::................-=======---:-::::.:::\n.................::::...................::-=--=---:-::::.:::\n.................::.......................:---=---:-::::.:::\n..........................................:-:--::-::::::.:::\n...........................................:::::::::::::.:::\n.............................................:::::.:..:.....\n.................................................:.:..:....."
   }
  },
  {
   "id": "matrix",
   "title": "Digital Rain",
   "gallery": "fx",
   "galleryTitle": "FX Store",
   "category": "Glitch & Noise",
   "description": "Matrix digital rain in real halfwidth katakana — white-hot heads, per-column speeds and trail lengths, mutating glyphs.",
   "tags": [
    "matrix",
    "digital rain",
    "katakana",
    "hacker"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "ascii",
   "palette": "matrix",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/fx/matrix.js",
   "imports": [],
   "usage": {
    "cli": "prism-term play matrix",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play matrix",
    "snapshot": "prism-term snapshot matrix --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('matrix'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/matrix"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "  B HB 8 2C *Z%    >  Z T   8     G= 20 #SQ0    RA    Q  9W\n  32K* U 0D <BA    % JK P   98    PZXQF  9 A    +H    B  4J\n  +J2X6+ <J 5%S    J @3 P   %C    6 $WB  3 I    F     S  T\n  LMHF5=23G 5LJ    W 7  <    O    6 -6   R L    >     Z  F\n  %JFU*AUKV WJ=    2 % RR    <  G=+ <>   I V    H     2  4\n  -O5Y74<U9 3H     N # DY    -  %JA R3 S C            X  @\n  5Z@*N@2Q5  W     Z = J0    #  R4I X0 Q 1  7         U  >\n  6-$ZDLL5Q     R  % W CN    2  -7E T4 G >  4       A L  7\n  VAQX8CLN8     %  L Q 0*    3 J6<0 B? 1    2     0 Y    I\n  H25 06&D%     W  R9F 0W $  F N33  MG O    D     Z Q    0\n  2$  %S5*6     -  OO  DB E    -&%  P2 2    2     1 *  ? B\nS 8B  <L&%5     7 - J  ?>57    2X>   D E    $     YF*  =\nL5<@  TRKO=     %PS @  7UFH    D+T   K *    H  I  0OV  7\nB4=+    4       BRF G  A1S2Z   ##E   ? S    TQ I  5K5  7\n@O0V    Y       +U+ R  GLZ7N   %2@   G B    J9 A  $A3  *\nQL 0    5      O VE O  <+*2N P %6U   $ @    M?LD  29T  -"
   }
  },
  {
   "id": "vhs-glitch",
   "title": "VHS Tracking",
   "gallery": "fx",
   "galleryTitle": "FX Store",
   "category": "Glitch & Noise",
   "description": "Worn VHS color bars: line jitter, chroma bleed, a rolling tracking band, head-switch noise and the deck OSD.",
   "tags": [
    "vhs",
    "glitch",
    "tracking",
    "analog",
    "color bars",
    "vcr",
    "retro"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 24,
    "h": 8
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/fx/vhs-glitch.js",
   "imports": [
    "../retro/_kit.js"
   ],
   "usage": {
    "cli": "prism-term play vhs-glitch",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play vhs-glitch",
    "snapshot": "prism-term snapshot vhs-glitch --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('vhs-glitch'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/vhs-glitch"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "+*****+*++++++*++***+===========%%=...:.:::.........:...++..\n+*PLAY >+*+*++++++@@*++===%%========*+.:::**..:.:...**:.....\n*+*****++++++++*@@**@%=============...:.:::.:.**.:.:.:......\n******+*+*++++++++@@+++==============.+*:.:::.....**:::.....\n**#*#**##********#****+++*++++++++++...::::::..::::.:::.....\n#*##*######***#*#**##**+*+++++++++++...::::::.:..:..:::.....\n*####*##*#*#**#**###*++*+++++++++++..:::::::.....:.:::......\n#####@###*******##*##*+++*++++++++++..:::::::...:::::::%....\n##*#####*#*********#***+*++++++++++...::::::..::..::::......\n#######*#**#***#**##*+*++++++++++++...::::::::.:.:.:::......\n*****#*#*#*##*#**###*++++++++++++++..::::::..:...:::::......\n.....................:::::::.......++*+++++.........+**###*#\n...........##%@@@@@@@%::....................................\n...........##%@@@@@@@@::....................................\n..:-.:..:*#*#@%@@#*@:-.-:..-...=.:.:........--SP  0:12:37:..\n..:..-::..##*@@@@@%@@:=.....--...::..:....-:.....-...-..:::."
   }
  },
  {
   "id": "crt-static",
   "title": "CRT Static",
   "gallery": "fx",
   "galleryTitle": "FX Store",
   "category": "Glitch & Noise",
   "description": "Between channels on an old CRT: snow, a crawling hum bar and scanlines, until a test card rolls into lock.",
   "tags": [
    "static",
    "snow",
    "crt",
    "tv",
    "noise",
    "analog",
    "channel"
   ],
   "kind": "scene",
   "size": {
    "w": 64,
    "h": 20
   },
   "minSize": {
    "w": 16,
    "h": 6
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/fx/crt-static.js",
   "imports": [
    "../retro/_kit.js"
   ],
   "usage": {
    "cli": "prism-term play crt-static",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play crt-static",
    "snapshot": "prism-term snapshot crt-static --size 64x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('crt-static'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/crt-static"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "...::..:..-:..=:.=+..:...::..+..+..=..=.=.-::..::.....-.....\n..:-.--=.=.=-:.....::+-.+=..--+=:..:-.:.:=:.=.+.-:..:CH 04..\n:..:=--...+....#..-+=-.--.+#::=+*+.:#.:..+=.*=..=+:+.::.=.-.\n...-.:...::.=..==..:.....::...+-.-*:.-..+..:*-.:.=-:**..:=:-\n..:........:....*@....-.-#...=.=.+.:#:.=+=%:=..+=++.=.*...=.\n..+-.=:.=:*-:-*.+:.-*:-.=:.::--::.+:=::.*.=.-:*..+.:.=.=:...\n...==..:.#.+:.+..#-:..:+::.-:.+#*::.%@*.*..=.....-===%=:-=.*\n:.+..+.:..=:.:-.+=.=...:-+-..*.:..-..-*...-+-::.+*+--:..==..\n=:..*-:.:#.:..::...%-=#.#..=..:.=-..+...::=+:.#+..#..:+:..-=\n=...::..+=.+-..+-..=.-+.-.:.-=.-..+-...-*.#-=-.=.=::...:.=..\n.....:.==@+@+=*..-#:+..:-..=..-:.:::.+#++#...=.-.-+.#:-=...=\n..-=..:..*.....:.*:....=:...:#.:::+..*...:=....+.*...=.=....\n:..=:=-..-:...:=:...%..-.:+...=..-:*.*....:==+.*:#-#..*+-::+\n..=:.:=:...=-+=.*.-.-+=:..+=.=*=..#.....--**-*..*:..+:.....:\n:.-:.-.-.*-=.*-:=*::.==.:..::.-@#*.-:+.=.*.+.=.+.+:..-.--...\n:::...-..-=:.:...:..=.-.:+.+-.......=..:..==..=.-..-....:..."
   }
  },
  {
   "id": "pixel-sort",
   "title": "Pixel Sort",
   "gallery": "fx",
   "galleryTitle": "FX Store",
   "category": "Glitch & Noise",
   "description": "Glitch-art pixel sorting: a sunset whose rows sort by brightness inside a moving threshold, in drifting smeared bands.",
   "tags": [
    "pixel sort",
    "glitch",
    "databend",
    "melt",
    "sort",
    "glitch art"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 16,
    "h": 6
   },
   "animated": true,
   "loop": 16,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "sunset",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/fx/pixel-sort.js",
   "imports": [
    "../retro/_kit.js"
   ],
   "usage": {
    "cli": "prism-term play pixel-sort",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play pixel-sort",
    "snapshot": "prism-term snapshot pixel-sort --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('pixel-sort'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/pixel-sort"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "...................................:::......................\n...................................####.....................\n...............................############.................\n.............::::.............#####%%%%%####...............:\n:::::::::::::::::::::::::::::####%%%%%%%%%###:::::::::::::::\n::::::::::::::::::::::::-----###%%%%%%%%%%####::::::::::::::\n--------------------------===####%%%%%%%%%###---------------\n........=++++***********++****#.........#####-----------.---\n............................................................\n===============================#############================\n=================================#########==================\n--------------------------------**********------------------\n:::::::::::::::::::::::::::::::************:::::::::::::::::\n:::::::::::::::::::::::::::::::::*********::::::::::::::::::\n::::::::::::::::::::::::::::::::::++++++::::::::::::::::::::\n.................................++++++++..................."
   }
  },
  {
   "id": "shaded-cube",
   "title": "Shaded Cube",
   "gallery": "objects",
   "galleryTitle": "Animated Objects",
   "category": "3D Solids",
   "description": "A z-buffered cube with Lambert and Blinn-Phong lighting, palette-tinted faces and a soft contact shadow.",
   "tags": [
    "cube",
    "3d",
    "lighting",
    "z-buffer",
    "phong"
   ],
   "kind": "scene",
   "size": {
    "w": 60,
    "h": 22
   },
   "minSize": {
    "w": 18,
    "h": 7
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "prism",
   "params": {
    "speed": {
     "default": 1,
     "min": 0.1,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/objects/shaded-cube.js",
   "imports": [
    "../../math3d.js",
    "./_raster.js"
   ],
   "usage": {
    "cli": "prism-term play shaded-cube",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play shaded-cube",
    "snapshot": "prism-term snapshot shaded-cube --size 60x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('shaded-cube'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/shaded-cube"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "                              *\n                           +%%%%+\n                        -+%******%+-\n                      *%#**********#%+\n                   ++%****************%+-\n                  *##******************%%\n                    **#**************%**\n                     -+%***********%*:+\n                       **%#******%*::+\n                         ++%#**%*::+\n                          -+*%*:+-\n                            -%%-\n                   ......................\n                     ..................\n\n"
   }
  },
  {
   "id": "donut",
   "title": "Donut",
   "gallery": "objects",
   "galleryTitle": "Animated Objects",
   "category": "3D Solids",
   "description": "The donut.c torus, z-buffered and properly lit — frosted with sprinkles, or --set style=solid.",
   "tags": [
    "donut",
    "torus",
    "donut.c",
    "3d",
    "lighting"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "hires",
   "palette": "candy",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/objects/donut.js",
   "imports": [],
   "usage": {
    "cli": "prism-term play donut",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play donut",
    "snapshot": "prism-term snapshot donut --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('donut'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/donut"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n                        ++++++++++=\n                     *%%**++==========\n                   *@@#+=-------========-\n                 +*@@+=----::::---=======-\n                +*%#+=--:.......:--==*+==-:\n                +#%+=--:....    .:-==+*+==-.\n                +**+==-:..        -=+#%*+--.\n                =+++==--:.        ++*%@+=-:.\n                ==++====---      ++*@@+==-:.\n                =======+=====+++++*@#+==--:.\n                 --=========++++**++==---..\n                  :---============-----:..\n                    ::-----=*-------:.:.\n                      ..::::::::::....\n                            ...."
   }
  },
  {
   "id": "studio-sphere",
   "title": "Studio Sphere",
   "gallery": "objects",
   "galleryTitle": "Animated Objects",
   "category": "3D Solids",
   "description": "Per-pixel lit sphere with an orbiting key light, cool rim light, soft banding and a moving floor shadow.",
   "tags": [
    "sphere",
    "lighting",
    "phong",
    "rim light",
    "3d"
   ],
   "kind": "scene",
   "size": {
    "w": 56,
    "h": 22
   },
   "minSize": {
    "w": 16,
    "h": 7
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "ocean",
   "params": {
    "speed": {
     "default": 1,
     "min": 0.1,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/objects/studio-sphere.js",
   "imports": [
    "./_raster.js"
   ],
   "usage": {
    "cli": "prism-term play studio-sphere",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play studio-sphere",
    "snapshot": "prism-term snapshot studio-sphere --size 56x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('studio-sphere'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/studio-sphere"
   },
   "preview": {
    "w": 56,
    "h": 16,
    "text": "\n                ::===:\n                :=*%*=:*++++=====\n                ::===:****++===-----\n                   ++*#@%#+=---::::..\n                  *++****+=---:::....:\n                 +=====----:::........:\n                 +===----::::.........:\n                 +---::::.............-\n                  -:::...............:\n                   -:...............-\n                     -:..........:-\n                          -::-.........\n                    ............................\n                         ..................\n"
   }
  },
  {
   "id": "twisty-cube",
   "title": "Twisty Cube",
   "gallery": "objects",
   "galleryTitle": "Animated Objects",
   "category": "3D Solids",
   "description": "A 3×3×3 puzzle cube that scrambles itself with twelve eased quarter turns, then solves itself in reverse.",
   "tags": [
    "puzzle cube",
    "twisty puzzle",
    "cube",
    "3d",
    "scramble"
   ],
   "kind": "scene",
   "size": {
    "w": 56,
    "h": 24
   },
   "minSize": {
    "w": 18,
    "h": 8
   },
   "animated": true,
   "loop": 13.2,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "prism",
   "params": {
    "moves": {
     "default": 12,
     "min": 4,
     "max": 30,
     "label": "Scramble length"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/objects/twisty-cube.js",
   "imports": [
    "./_raster.js",
    "../../easing.js"
   ],
   "usage": {
    "cli": "prism-term play twisty-cube",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play twisty-cube",
    "snapshot": "prism-term snapshot twisty-cube --size 56x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('twisty-cube'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/twisty-cube"
   },
   "preview": {
    "w": 56,
    "h": 16,
    "text": "\n\n\n                      %.---++++.+++.\n                     ......-.--.....\n                  .%%%%%.+++++.%%%%%.\n                 .::...............%.-\n                  :::::.-----..:::::..\n                  :::::.-----..:::::.\n                  .----..++++.......\n                   ----..++++.:::::.\n                   -----..........:\n                   .----.++++.:::::\n                               ....\n\n"
   }
  },
  {
   "id": "dna",
   "title": "DNA Helix",
   "gallery": "objects",
   "galleryTitle": "Animated Objects",
   "category": "3D Solids",
   "description": "Rotating double helix in perspective — depth-sorted, shaded backbones, A–T/G–C base-pair rungs and drifting motes.",
   "tags": [
    "dna",
    "helix",
    "biology",
    "3d"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": "prism",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/objects/dna.js",
   "imports": [
    "../../math3d.js",
    "../../color.js",
    "../../palettes.js"
   ],
   "usage": {
    "cli": "prism-term play dna",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play dna",
    "snapshot": "prism-term snapshot dna --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('dna'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/dna"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n\n\n      =--:     -\n      =-     :--:     .+=       --                  -\n      -==     ==       -=      ::==     ---=\n      = -=   --==     -+++    -- -=    = --+=   =-=+==+#\n      ++-----:---==---- +=   --- ==+ -:= - +=+--= -====*#-=-\n     --= ---: -++ ::= - =+-=-- = ==+:-= -  + *+ =  =  *+**=-\n   ::::-==:: - -=::=  - ***--  = - =+ = - + = =+= =  *  ++##\n        -----------  -  -== - = -::++ = = --  =+= = ===***+#\n  :              -----::  ==+ =--- -==  =-:   +=+ ==      *+\n                   -       -=--     ==+=--     =+==        +\n                          - -        -=-       +=+\n         -                            --        =-         #\n                                                -          +"
   }
  },
  {
   "id": "torus-knot",
   "title": "Torus Knot",
   "gallery": "objects",
   "galleryTitle": "Animated Objects",
   "category": "Wireframes",
   "description": "A braille wire tube with true hidden-line removal, morphing through the (2,3), (2,5), (3,4) and (3,5) torus knots.",
   "tags": [
    "knot",
    "torus",
    "wireframe",
    "braille",
    "hidden-line",
    "morph"
   ],
   "kind": "scene",
   "size": {
    "w": 48,
    "h": 22
   },
   "minSize": {
    "w": 16,
    "h": 7
   },
   "animated": true,
   "loop": 18,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": "prism",
   "params": {
    "rings": {
     "default": 4,
     "min": 1,
     "max": 8,
     "label": "Ring spacing (segments)"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/objects/torus-knot.js",
   "imports": [
    "./_raster.js",
    "../../easing.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play torus-knot",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play torus-knot",
    "snapshot": "prism-term snapshot torus-knot --size 48x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('torus-knot'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/torus-knot"
   },
   "preview": {
    "w": 48,
    "h": 16,
    "text": "                    =+++++++\n                  =+++ + ++**+*\n                 ====++++++ *****\n                 ====      ***** *\n                 ====      +==*****\n          +*********++++++++++= * *\n         +*** *  * ***+++++====*****=\n         + +*=    =**++++      *** ===\n          +++=========  ====+ *****===\n           ==============+++******====\n               ======-=====+*****= ===\n                  ====         ======\n                  ==================\n                   ===============\n                       ========\n"
   }
  },
  {
   "id": "wireframe",
   "title": "Wireframe Solids",
   "gallery": "objects",
   "galleryTitle": "Animated Objects",
   "category": "Wireframes",
   "description": "Braille wireframes morphing cube → icosahedron → torus → octahedron with depth-cued edges and glowing vertices.",
   "tags": [
    "wireframe",
    "polyhedra",
    "3d",
    "braille"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": "prism",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/objects/wireframe.js",
   "imports": [
    "../../math3d.js",
    "../../easing.js",
    "../../palettes.js"
   ],
   "usage": {
    "cli": "prism-term play wireframe",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play wireframe",
    "snapshot": "prism-term snapshot wireframe --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('wireframe'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/wireframe"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "                           ***\n                          ++*=+\n                        ++    =+**\n                      ++       -  **#\n                    ++          :    ### @\n                *+:::.............:     @@@\n              ---:            ::  .....% @\n             ---*+=--      :::      %% ...\n             ==   +* -:: ::      %%%  :--=::\n             ==     *   ::     %%       === ::\n               ==    *    : %%%       -- =  --\n                 === #    ##       ---     ---\n                    ==#=##  :-    -      ==-\n                      @@=*==+=-=== = == =\n                       @      ===\n                               ="
   }
  },
  {
   "id": "geodesic",
   "title": "Geodesic Sphere",
   "gallery": "objects",
   "galleryTitle": "Animated Objects",
   "category": "Wireframes",
   "description": "A subdivided icosahedron in braille wire: far edges dotted, near edges depth-cued, pentagon vertices lit, a scan sweeping by.",
   "tags": [
    "geodesic",
    "icosahedron",
    "dome",
    "wireframe",
    "braille",
    "buckminster"
   ],
   "kind": "scene",
   "size": {
    "w": 44,
    "h": 22
   },
   "minSize": {
    "w": 14,
    "h": 7
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": "theme",
   "params": {
    "frequency": {
     "default": 2,
     "min": 1,
     "max": 6,
     "label": "Subdivision frequency"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/objects/geodesic.js",
   "imports": [
    "../../math3d.js",
    "./_raster.js"
   ],
   "usage": {
    "cli": "prism-term play geodesic",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play geodesic",
    "snapshot": "prism-term snapshot geodesic --size 44x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('geodesic'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/geodesic"
   },
   "preview": {
    "w": 44,
    "h": 16,
    "text": "                     ++\n              =====+++*++=====\n             +**++: : *  :++**+\n          ++*******************+++\n         = *: *     # *#     *  * +\n        + *: :*:  ## :: ##  :*: :* +\n        *#     #%#   ::   #%#     #*\n        %%%%%%%%%%%%%%%%%%%%%%%%%%%%\n       %#%  :: %@%::  : ::%@% ::  %#%\n        +*    #  :#      #   #    *+\n         +** *     #  : #  :  * **+\n         +++****:  :####:  :*****++\n          ++*** ******#***** ****+\n             +++:  ** :**  :+++\n               ++++++++++++++\n                      :"
   }
  },
  {
   "id": "ringed-planet",
   "title": "Ringed Planet",
   "gallery": "objects",
   "galleryTitle": "Animated Objects",
   "category": "Globes & Planets",
   "description": "A banded gas giant turning under a tilted ring system, with the planet shadowing its own rings.",
   "tags": [
    "planet",
    "saturn",
    "rings",
    "space",
    "3d"
   ],
   "kind": "scene",
   "size": {
    "w": 72,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 7
   },
   "animated": true,
   "loop": 20,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "gold",
   "params": {
    "tilt": {
     "default": 0.32,
     "min": 0.08,
     "max": 0.7,
     "label": "Ring tilt"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/objects/ringed-planet.js",
   "imports": [
    "./_raster.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play ringed-planet",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ringed-planet",
    "snapshot": "prism-term snapshot ringed-planet --size 72x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ringed-planet'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ringed-planet"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": ":                        :\n                                                       :\n               =\n                   .       =-.::.\n                      .-++==----::...\n               .-+*=.:###+=---::-:....:.=*+-.\n           -*=.=#+-=-*+:===:==---:.........:.....\n         -*--#+-=   ==###***+==--:......   ...:...-\n         -*--#+-=+  =**++=======-:......   =-+#--*-\n           -*=.=#+-=-------====--:.....-=-+#=.=*-\n               .-+*=.:+*#%%##***####+-:.=*+-.\n                       :=-:::::......\n                           :.....\n                                                       .\n              .                          .\n"
   }
  },
  {
   "id": "moon-phases",
   "title": "Moon Phases",
   "gallery": "objects",
   "galleryTitle": "Animated Objects",
   "category": "Globes & Planets",
   "description": "A cratered Moon cycling through its phases with earthshine on the dark side and the phase named below.",
   "tags": [
    "moon",
    "phases",
    "lunar",
    "craters",
    "space"
   ],
   "kind": "scene",
   "size": {
    "w": 48,
    "h": 22
   },
   "minSize": {
    "w": 16,
    "h": 8
   },
   "animated": true,
   "loop": 16,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "mono",
   "params": {
    "month": {
     "default": 16,
     "min": 4,
     "max": 60,
     "label": "Seconds per lunar month"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/objects/moon-phases.js",
   "imports": [
    "./_raster.js",
    "../../buffer.js"
   ],
   "usage": {
    "cli": "prism-term play moon-phases",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play moon-phases",
    "snapshot": "prism-term snapshot moon-phases --size 48x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('moon-phases'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/moon-phases"
   },
   "preview": {
    "w": 48,
    "h": 16,
    "text": ":\n                    :--:::::\n            -   .:==++++++----:.\n               -+==++-=======--::\n             .:===++=========-+-::.\n             :=++++**=========---::\n            :::=++***#========--+=-.\n            :::---=*##**#%%===----::\n             ::--***#*#*++++=-----:\n             .:--++++***###*+--==-:\n               ::====++++++++===-\n                .:---========--.            .\n           .        ::---::.\n\n\n              Full Moon . 100% lit"
   }
  },
  {
   "id": "globe",
   "title": "Data Globe",
   "gallery": "objects",
   "galleryTitle": "Animated Objects",
   "category": "Globes & Planets",
   "description": "Holographic dotted Earth in braille — graticule, atmosphere, pulsing hubs and packet-carrying great-circle arcs.",
   "tags": [
    "globe",
    "earth",
    "world",
    "arcs",
    "3d"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/objects/globe.js",
   "imports": [],
   "usage": {
    "cli": "prism-term play globe",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play globe",
    "snapshot": "prism-term snapshot globe --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('globe'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/globe"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "                     .           :\n                    ::::::-**=+*++++=  .\n                 .  ::=#*#---***..+++++=\n               .   :::::####-##*:::::::::::-.\n              .. ==:###:##:::=*:- +:*++:+:=  :\n              .  =::::::====..  -..:::::::==\n                =::####=##==..  --.-**+++:::=\n                =: ###  ===......-  *++++==-= .\n             :  =:   #   :........-  ++++===#\n                =:---- ###:*..... -   +--==:::\n              . =::  -****:=****------ - =::=\n                 =:: -  ***+:*..+ -    -++++\n                 ===:-:--***..-+..---:::++++\n                 .-===:::+++.+**:.::::::-=.\n                     ===::+=:....:::====.\n                      . =============:"
   }
  },
  {
   "id": "gears",
   "title": "Gear Train",
   "gallery": "objects",
   "galleryTitle": "Animated Objects",
   "category": "Mechanical",
   "description": "Three spur gears (16, 10 and 8 teeth) meshing at true ratios, anti-aliased, with spun-metal shading and drop shadows.",
   "tags": [
    "gears",
    "mechanical",
    "machine",
    "ratio",
    "clockwork"
   ],
   "kind": "scene",
   "size": {
    "w": 64,
    "h": 22
   },
   "minSize": {
    "w": 20,
    "h": 7
   },
   "animated": true,
   "loop": 6,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "gold",
   "params": {
    "speed": {
     "default": 1,
     "min": 0.1,
     "max": 5,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/objects/gears.js",
   "imports": [
    "./_raster.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play gears",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play gears",
    "snapshot": "prism-term snapshot gears --size 64x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('gears'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/gears"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n                                 .+-  +=   -.\n                  :   --          #*+=::-.--..\n                 -=+  --. ==: .*##-#*+::=:-.. :.\n             +#+ -==++--=---.. :.*..-##+-...--...\n          .   .++=....-:...:---.+---:=---=:-....\n           *=+++*==::-::....::.=::::.:=:..:.:=:.\n            .*...=--+*+-----:.---.--.:--.=+:.....\n         =+---...-=*+*=.:-........:...--*=::--..\n          ..:=---::==..--=:...:--...  ...=-=--.:::.\n         :=----....::---==--:--..... =---=++-::.....\n          ....--...::....:=::::=:.    ..--::---::..\n            ---.:--..... ::==-.....  ---.::.--......\n             ...--:.--..--...::       ...::..--.   .\n               .:....-....:   ..        ....  ...\n                ..   ..   ..             ...   ."
   }
  },
  {
   "id": "analog-clock",
   "title": "Analog Clock",
   "gallery": "objects",
   "galleryTitle": "Animated Objects",
   "category": "Mechanical",
   "description": "A braille wall clock with a bezel, ticks, numerals, tapered baton hands and a smoothly sweeping second hand.",
   "tags": [
    "clock",
    "time",
    "watch",
    "braille",
    "hands"
   ],
   "kind": "widget",
   "size": {
    "w": 40,
    "h": 20
   },
   "minSize": {
    "w": 14,
    "h": 7
   },
   "animated": true,
   "loop": 60,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": "prism",
   "params": {
    "rate": {
     "default": 1,
     "min": 1,
     "max": 600,
     "label": "Time multiplier"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/objects/analog-clock.js",
   "imports": [
    "../../buffer.js",
    "./_raster.js"
   ],
   "usage": {
    "cli": "prism-term play analog-clock",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play analog-clock",
    "snapshot": "prism-term snapshot analog-clock --size 40x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('analog-clock'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/analog-clock"
   },
   "preview": {
    "w": 40,
    "h": 16,
    "text": "\n              ===+=+%+=+===\n           =+%++++12%+=++++%+=\n         =+++ %           %=+++=\n        =++                 %%++=\n       =%%%               %%  %%+=\n      =++     %%       %%%      ++=\n      ++        %%%%%**%         ++\n      %%%9         ***        3%%%%=\n      ++         **%             ++\n      =++     ***               ++=\n       =+%% **                %%%=\n        =+**                  ++=\n         =+++ %     6     %=+++=\n           =+%++++=+%+=++++%+=\n              ===+=+%+=+==="
   }
  },
  {
   "id": "hourglass",
   "title": "Hourglass",
   "gallery": "objects",
   "galleryTitle": "Animated Objects",
   "category": "Mechanical",
   "description": "Sand drains through the neck into a growing heap, volume conserved; when the top runs dry the glass flips over.",
   "tags": [
    "hourglass",
    "sand",
    "timer",
    "waiting",
    "time"
   ],
   "kind": "scene",
   "size": {
    "w": 30,
    "h": 22
   },
   "minSize": {
    "w": 12,
    "h": 9
   },
   "animated": true,
   "loop": 9.4,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "gold",
   "params": {
    "duration": {
     "default": 8,
     "min": 2,
     "max": 60,
     "label": "Seconds per run"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/objects/hourglass.js",
   "imports": [
    "./_raster.js",
    "../../easing.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play hourglass",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play hourglass",
    "snapshot": "prism-term snapshot hourglass --size 30x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('hourglass'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/hourglass"
   },
   "preview": {
    "w": 30,
    "h": 16,
    "text": "      :--::-::--::--::-:\n      -==............=:-\n      -=..::..........=-\n      - *++#+....**#+* -\n      -  *******##+**  -\n      -=  :****+***:  :-\n      -     *++++*     -\n      -      :+*:      -\n      -      :*#:      -\n      -     =.*#.=     -\n      -=  :=..*...=:  :-\n      -  =:.:.#*...:=  -\n      - =..:..*#.....= -\n      -=..:*++**+**...=-\n      -=*+++**+*+**++*:-\n      :--::--::-::--::--"
   }
  },
  {
   "id": "newton-cradle",
   "title": "Newton's Cradle",
   "gallery": "objects",
   "galleryTitle": "Animated Objects",
   "category": "Mechanical",
   "description": "Five chrome balls on strings trading momentum on pendulum arcs, with strike glints and motion ghosts.",
   "tags": [
    "newton",
    "cradle",
    "pendulum",
    "momentum",
    "chrome",
    "desk toy"
   ],
   "kind": "scene",
   "size": {
    "w": 64,
    "h": 20
   },
   "minSize": {
    "w": 36,
    "h": 10
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "ice",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/objects/newton-cradle.js",
   "imports": [
    "./_raster.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play newton-cradle",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play newton-cradle",
    "snapshot": "prism-term snapshot newton-cradle --size 64x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('newton-cradle'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/newton-cradle"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n\n   =====+++====+++====++=====+=+=====++====+++====+++===-+\n   =             =      =     =     =     =             -\n   =            =       =     =     =     =             -\n   =           =        =     =     =     =             -\n   =           =        =     =     =     =             -\n   =          =         =     =     =     =             -\n   =     .   =          =     =     =     =             -\n   =   .-%%%#=         ++    ++    ++    =+             -\n   =   .::--::       +****==****==****+-****+           -\n   =     -++=        -=++=::=++=::=++=-.==+=-           -\n   =    ......       ........................           -\n   =---====---====---====---====---====---====---====----=\n   =------------------------------------------------------\n"
   }
  },
  {
   "id": "refraction",
   "title": "Refraction",
   "gallery": "objects",
   "galleryTitle": "Animated Objects",
   "category": "Icons & Logos",
   "description": "White light in, every color out: a beam strikes a glass prism and fans into a true-order spectrum (violet bends most) on a wavelength screen.",
   "tags": [
    "prism",
    "refraction",
    "spectrum",
    "light",
    "beam",
    "dispersion",
    "hero",
    "glass",
    "wavelength"
   ],
   "kind": "art",
   "size": {
    "w": 96,
    "h": 28
   },
   "minSize": {
    "w": 32,
    "h": 10
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "hires",
   "palette": "prism",
   "params": {
    "labels": {
     "default": "on",
     "options": [
      "on",
      "off"
     ],
     "label": "Instrument labels"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/objects/refraction.js",
   "imports": [
    "../../buffer.js",
    "../../color.js",
    "../../palettes.js",
    "./_raster.js"
   ],
   "usage": {
    "cli": "prism-term play refraction",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play refraction",
    "snapshot": "prism-term snapshot refraction --size 96x28",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('refraction'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/refraction"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n                        .:\n                        %:-\n                       #...=\n                     -=.....*\n                    *:.......*\n                   %..........*\n        .........:@:...........=..   EVERY COLOR OUT\n..........:::-*@@@*=-----------=#+-:.....\n..-++-::::......@::...............= .-=+++*++++==--==*  700\n...........   :*...................+    .--==+=++++*+%  600\n             =-.....................*       .:-==-===#\n            #........................*          .---=*  500\n           #--------------------------+             .*  400\n\n"
   }
  },
  {
   "id": "prism-logo",
   "title": "Prism",
   "gallery": "objects",
   "galleryTitle": "Animated Objects",
   "category": "Icons & Logos",
   "description": "Prism's mark: a glass prism turning in 3D, splitting a white beam into a smoothly blended, pulsing spectrum.",
   "tags": [
    "prism",
    "logo",
    "spectrum",
    "light",
    "refraction",
    "glass"
   ],
   "kind": "art",
   "size": {
    "w": 72,
    "h": 24
   },
   "minSize": {
    "w": 24,
    "h": 8
   },
   "animated": true,
   "loop": 14,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "prism",
   "params": {
    "label": {
     "default": "on",
     "options": [
      "on",
      "off"
     ],
     "label": "Wordmark"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/objects/prism-logo.js",
   "imports": [
    "./_raster.js",
    "../../buffer.js",
    "../../color.js",
    "../../palettes.js"
   ],
   "usage": {
    "cli": "prism-term play prism-logo",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play prism-logo",
    "snapshot": "prism-term snapshot prism-logo --size 72x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('prism-logo'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/prism-logo"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n\n                       --\n*=-:..                *:#----\n*#%@%#*=-:..         *:::#:---\n ..:-=+#%@@%*+-:..  *:::::#::-::\n        .::=+#%@@%#*+=-:::::#---:             +********+++++\n              ..:-+*###::::++#++=:=*##################*****+\n                  *::-+++:+:::#---::###################*****\n                 *:::::::::::::#----:##################*****\n                ::::::::::::::::######*#################****\n                                        *###############****\n                                          *###########******\n                                            *########*******\n\n                    P  R  I  S  M"
   }
  },
  {
   "id": "atom",
   "title": "Atom",
   "gallery": "objects",
   "galleryTitle": "Animated Objects",
   "category": "Icons & Logos",
   "description": "A lit nucleus of protons and neutrons inside three tilted orbits, with glowing electrons trailing light.",
   "tags": [
    "atom",
    "science",
    "orbit",
    "electron",
    "nucleus",
    "physics"
   ],
   "kind": "art",
   "size": {
    "w": 56,
    "h": 22
   },
   "minSize": {
    "w": 18,
    "h": 7
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "prism",
   "params": {
    "electrons": {
     "default": 3,
     "min": 1,
     "max": 6,
     "label": "Electrons"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/objects/atom.js",
   "imports": [
    "./_raster.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play atom",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play atom",
    "snapshot": "prism-term snapshot atom --size 56x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('atom'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/atom"
   },
   "preview": {
    "w": 56,
    "h": 16,
    "text": "\n                    .+*:        .::.\n                    .=@=      .:::-:\n                      :#-    .:.. =.\n                 ..... .*-  .:.  :=\n              :--========%+==:...=.\n              -:.      ..:@#=--=#*--:.\n              .:::...   #+:++=-=-.:-=++=:.\n                 ...:::.:.*=*:*+.     .-%@=.\n                      ..:--.:#*:.......:-+:\n                      .:.  .-++:..........\n                      :.  .=-.:+:\n                     .:  :=:   :+:\n                     :-:==.     :+.\n                    .#@%-.       ::\n                      ."
   }
  },
  {
   "id": "spinning-coin",
   "title": "Spinning Coin",
   "gallery": "objects",
   "galleryTitle": "Animated Objects",
   "category": "Icons & Logos",
   "description": "A ray-cast gold coin spinning on edge: embossed faces, a reeded rim, studio reflections and a soft shadow.",
   "tags": [
    "coin",
    "gold",
    "money",
    "spin",
    "metal",
    "raycast"
   ],
   "kind": "scene",
   "size": {
    "w": 40,
    "h": 20
   },
   "minSize": {
    "w": 12,
    "h": 6
   },
   "animated": true,
   "loop": 6,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "gold",
   "params": {
    "speed": {
     "default": 1,
     "min": 0.2,
     "max": 4,
     "label": "Spin speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/objects/spinning-coin.js",
   "imports": [
    "../../color.js",
    "./_raster.js"
   ],
   "usage": {
    "cli": "prism-term play spinning-coin",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play spinning-coin",
    "snapshot": "prism-term snapshot spinning-coin --size 40x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('spinning-coin'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/spinning-coin"
   },
   "preview": {
    "w": 40,
    "h": 16,
    "text": "\n                 .=++++:\n                :+++++++\n                ++++++++-\n               -+++++++++\n               -*++++++++\n               #%++++++++\n               ==++++++++\n               ==+==++===\n               --=+=====:\n               :-======+\n               .--====+:\n                --==++-\n               ..:::::...\n           ..................\n            ................"
   }
  },
  {
   "id": "falling-sand",
   "title": "Falling Sand",
   "gallery": "sim",
   "galleryTitle": "Particles & Simulations",
   "category": "Particles",
   "description": "Sweeping spouts pour color-drifting grains that pile into strata over ledges, then drain through a trapdoor.",
   "tags": [
    "sand",
    "falling sand",
    "automaton",
    "granular",
    "pile",
    "sand art"
   ],
   "kind": "scene",
   "size": {
    "w": 60,
    "h": 22
   },
   "minSize": {
    "w": 20,
    "h": 8
   },
   "animated": true,
   "loop": 24,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "prism",
   "params": {
    "rate": {
     "default": 1,
     "min": 0.2,
     "max": 3,
     "label": "Pour rate"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/sim/falling-sand.js",
   "imports": [
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play falling-sand",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play falling-sand",
    "snapshot": "prism-term snapshot falling-sand --size 60x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('falling-sand'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/falling-sand"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "    ***    ########\n       %%%%++++++++#  ###                 ####\n           %%%%%++++##***                #****#\n                %%%%%****#               ******#\n                     %%%%+##           ##*******#\n                         %%%#         #**********#\n                                     #************%\n                           ###      #**************% %%%\n                          #***#    #*************%%%%\n                         #**+**#  #*********%%%%%\n                        #*******##*****%%%%%\n                       #*****+*****%%%%\n                      #****++++*%%%\n                     #****++++++\n                    #****+++++++#\n                   #*+*+++++++++*#"
   }
  },
  {
   "id": "fireworks",
   "title": "Fireworks",
   "gallery": "sim",
   "galleryTitle": "Particles & Simulations",
   "category": "Particles",
   "description": "Rockets and four shell types (peony, ring, willow, crackle) with gravity, drag, additive glow trails and a lake reflection.",
   "tags": [
    "fireworks",
    "particles",
    "celebration",
    "glow"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": "prism",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/sim/fireworks.js",
   "imports": [
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play fireworks",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play fireworks",
    "snapshot": "prism-term snapshot fireworks --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('fireworks'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/fireworks"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "=:                  -=                   %%%%%%%\n.                    .=                +%=:..:=%  # ##\n              ++      .=              %%:.@@:=%%###.#*-#*\n        :%%%%===@%%    .=            %%:::%*#%%+#.#.#-#:###=\n      %%*       ..=%   .-            +%%=%%%##:. .#####:####\n    %%.  .=.    ..=%   .=              +% ##:.*:.#:# @*#.:##\n  +%..   @@@#  ..=#+  ..-                ##-#:.-#**.##:...-#\n +%..    -@@*...=#+   .-.                 ####*#:...- +-*-#-\n.%=..     ...::=++  ....                  =###--#-.##..-##+#\n.%=:......::.:#+  ....:       @            =####*#--##-#-#==\n.%%-=====%#:++.. .: :.        %               *# ###- ##==\n  :+%*+++ .. .. .: .          .=                  ===\n     . :: :: :: .\n--:-::::--.::-:::...... =  = -.  =  =  = :---------------:-.\n.::-:::::---:.:..::-.....     -      ::::::::::::::::-::::::\n:::..  ::::.::::...::::.....            ::::::::::::::::::::"
   }
  },
  {
   "id": "galaxy",
   "title": "Spiral Galaxy",
   "gallery": "sim",
   "galleryTitle": "Particles & Simulations",
   "category": "Particles",
   "description": "Density-wave spiral galaxy — thousands of stars on tilted elliptical orbits with differential rotation, glowing core and dust.",
   "tags": [
    "galaxy",
    "spiral",
    "stars",
    "space"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/sim/galaxy.js",
   "imports": [
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play galaxy",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play galaxy",
    "snapshot": "prism-term snapshot galaxy --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('galaxy'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/galaxy"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n                           :        -\n                               -       #\n                 ::. . .           :: .=.     .\n               - % .    :   #.+.+@=@*#@=@#=.\n              :..:        .:-=+@::%--.+...@%=-. .\n            :-. .  -  *...-@*#:@..:-..= % . %#*.. .\n           .::-.-  .  .::*@%#@@@@+:#...+  . .#**..\n           .@.. :- =:...:@@@@@@@@@:+.. @::.....**=\n           -.%=..:  ...+@::@==.-@@=:: : -    #..\n            +-=@*%:%-.-....@:%@@.=.*. - =.# :++\n            . . .-#@@-@@:.:@#. =:.    %   :.\n                 . :.           .   . . -\n                .   . +    -  - =*. + .\n                   - +  + .   :\n                                                       -"
   }
  },
  {
   "id": "flowfield",
   "title": "Flow Field",
   "gallery": "sim",
   "galleryTitle": "Particles & Simulations",
   "category": "Particles",
   "description": "Particles advected through an evolving curl-noise flow field, leaving fading silk trails colored by heading.",
   "tags": [
    "flow field",
    "particles",
    "noise",
    "trails"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": "prism",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/sim/flowfield.js",
   "imports": [
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play flowfield",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play flowfield",
    "snapshot": "prism-term snapshot flowfield --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('flowfield'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/flowfield"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": ":  ...   .                 .:.. -+*==:+**+\n:++= ++...        .:+*    ....::-+-++===*****           . :\n...=*++=-:  =            ..... ..:-:: =+=+ *  .:        = =.\n==::.+=-::+-=   ....::--===**  ....= :-::  -  .=   ..   + +=\n++=-:*. *+ :.. ..*+++**..      .- .:. =-  .= .=+. .-.   ...=\n ==+=:+- =- ..+:.=+.--  ..-=.       +=-::..-:.-:. .=.  .:::-\n .. --.-: -. .+:.: ..=+   =:.-::==+=-::..==- ==-:...=  . =+\n     = .: :.:.++..  -:. ++-++-+=-          ==== ::.:+  :: ++\n    .: +.  .-:++=..=-  +=-:    .:-+=::....:.    :  ..:..:---\n    .:--...+=.+==-:==--+:. =+++.=-::=-...  .    .....-+  .::\n       . : +. .-+-+++=...+++= ..=+.: -. +:::. ...::-.:++===+\n     :-+:. -=.  =-::.::.. .++.==---:=+ -...:-=**..:-=++=++\n      ..:  .=-  -.-=..... ++-.. ..:.- ::.  ...:=**+-=+=== +\n           :::. =:=.. ::..+.     ..::..   .   .:-+**=**+*=:.\n  .       .....    .  +-: -   - ++ ...     ..::-=+*.::    :-\n  ..             .: ..+**=** ::-= .             ...:-=--   ."
   }
  },
  {
   "id": "life",
   "title": "Game of Life",
   "gallery": "sim",
   "galleryTitle": "Particles & Simulations",
   "category": "Cellular Automata",
   "description": "Conway's Life at braille resolution — age-colored cells, ghost trails, auto-reseeding on stagnation, injected gliders.",
   "tags": [
    "game of life",
    "conway",
    "cellular automata",
    "braille"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/sim/life.js",
   "imports": [
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play life",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play life",
    "snapshot": "prism-term snapshot life --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('life'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/life"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": " % %@  @% % @@                    %#\n %  #  #  #%%%%@                 @%%@\n  @     % @ @  %@@%@               @@\n    % %@       %  %%\n      %       %@  @                                #\n             @%@ %@                        ##     # #\n          @ @     %                        *#     * #\n       @# %%%%%%#%                                 #\n        % % @@@@@     %\n    @#@  @          ##                    #@\n                                         @*% @*@\n                                         @%@            **\n                                                        **\n                                   @    @\n\n  @      @                       @ @"
   }
  },
  {
   "id": "langtons-ant",
   "title": "Langton's Ant",
   "gallery": "sim",
   "galleryTitle": "Particles & Simulations",
   "category": "Cellular Automata",
   "description": "Two Langton's ants at braille resolution, accelerating through ten thousand steps of chaos into the highway.",
   "tags": [
    "langton",
    "ant",
    "turmite",
    "cellular automata",
    "emergence",
    "braille"
   ],
   "kind": "scene",
   "size": {
    "w": 60,
    "h": 22
   },
   "minSize": {
    "w": 20,
    "h": 7
   },
   "animated": true,
   "loop": 30,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": "prism",
   "params": {
    "ants": {
     "default": 2,
     "min": 1,
     "max": 4,
     "label": "Ants"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/sim/langtons-ant.js",
   "imports": [
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play langtons-ant",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play langtons-ant",
    "snapshot": "prism-term snapshot langtons-ant --size 60x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('langtons-ant'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/langtons-ant"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "             #  #     #  #            **      *  *\n            ###     # # #                      **\n            # #### #  ##\n                  #    #                     **\n               ## # ##%               **      **\n              #  ##   #              *   **  ** *\n              #  #  ####            ***    * *  *\n                ##                  * * *  * * *\n             #     #### #               ***  *\n             # ##     ###                 ***\n              ##    #  #               *     *\n               ##    ##              * * * *  * *\n                      ##            *  * * *\n                       ##           * ** **% **** *\n              ##    # ## #           **  *  **  ***\n step 547"
   }
  },
  {
   "id": "wireworld",
   "title": "Wireworld",
   "gallery": "sim",
   "galleryTitle": "Particles & Simulations",
   "category": "Cellular Automata",
   "description": "Wireworld on a generated circuit board: clock loops fire electrons down routed traces that fan out across it.",
   "tags": [
    "wireworld",
    "cellular automata",
    "circuit",
    "electrons",
    "pcb",
    "clock"
   ],
   "kind": "scene",
   "size": {
    "w": 60,
    "h": 22
   },
   "minSize": {
    "w": 20,
    "h": 7
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "theme",
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/sim/wireworld.js",
   "imports": [
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play wireworld",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play wireworld",
    "snapshot": "prism-term snapshot wireworld --size 60x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('wireworld'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/wireworld"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n\n\n\n\n                                         ---%\n                                        --   +\n                                        - -  -\n                                        - -  -      ----\n                                        - -   ------    -\n                                        - -        -    -\n                                        * -         --%+\n                                          -\n                                       %+-\n                                      *\n"
   }
  },
  {
   "id": "brians-brain",
   "title": "Brian's Brain",
   "gallery": "sim",
   "galleryTitle": "Particles & Simulations",
   "category": "Cellular Automata",
   "description": "Brian's Brain: a three-state automaton where nearly any seed boils into colliding spaceships with glowing wakes.",
   "tags": [
    "brians brain",
    "cellular automata",
    "three-state",
    "spaceships",
    "chaos"
   ],
   "kind": "scene",
   "size": {
    "w": 60,
    "h": 22
   },
   "minSize": {
    "w": 20,
    "h": 7
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "theme",
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/sim/brians-brain.js",
   "imports": [
    "../../color.js",
    "../../palettes.js"
   ],
   "usage": {
    "cli": "prism-term play brians-brain",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play brians-brain",
    "snapshot": "prism-term snapshot brians-brain --size 60x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('brians-brain'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/brians-brain"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "..... ....++..++.::@@:+@             @+:@@::.++..++.... ....\n..... ....+:++:++@.:+@.:+@         @+:.@+:.@++:++:+.... ....\n....+@@@.@+:..:..:+@...:+@        @+:....@+:..:..:+@.@@@+...\n...::@++@..@++@:+@              @+:......  @+:@++@..@++@::..\n..+:.::+:@.@.:+@                @+:......    @+:.@.@:+::.:+.\n..@+...@++@:+@                    @+:......    @+:@++@...+@.\n:.@.:.:.@:+@                                     @+:@.:.:.@.\n:+@  +                                                 +  @+\n++  @@@                                               @@@  +\n:+@  +                                                 +  @+\n:.@.:.:.@:+@                                     @+:@.:.:.@.\n..@+...@++@:+@                                 @+:@++@...+@.\n..+:.::+:@.@.:+@                             @+:.@.@:+::.:+.\n...::@++@..@++@:+@                         @+:@++@..@++@::..\n....+@@@.@+:..:..:+@                     @+:..:..:+@.@@@+...\n..... ....+:++:++@.:+@                 @+:.@++:++:+.... ...."
   }
  },
  {
   "id": "bouncing-balls",
   "title": "Bouncing Balls",
   "gallery": "sim",
   "galleryTitle": "Particles & Simulations",
   "category": "Physics",
   "description": "Balls of different masses colliding elastically under gravity at a fixed 240 Hz step, with shadows and impact flashes.",
   "tags": [
    "physics",
    "collision",
    "elastic",
    "balls",
    "gravity",
    "billiards"
   ],
   "kind": "scene",
   "size": {
    "w": 60,
    "h": 22
   },
   "minSize": {
    "w": 20,
    "h": 7
   },
   "animated": true,
   "loop": 18,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "prism",
   "params": {
    "balls": {
     "default": 9,
     "min": 2,
     "max": 24,
     "label": "Balls"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/sim/bouncing-balls.js",
   "imports": [
    "../objects/_raster.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play bouncing-balls",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play bouncing-balls",
    "snapshot": "prism-term snapshot bouncing-balls --size 60x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('bouncing-balls'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/bouncing-balls"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n\n\n\n\n\n\n                                *#+\n                                ==-:\n                       ==-             *+=\n                                      ===:\n                               +=                 #*=\n                              ==-: ##*            -:-\n                          %+-      +=--       *#*+-   *%+=\n                      ....::...............   -=-:-...==-:\n............................................................"
   }
  },
  {
   "id": "double-pendulum",
   "title": "Double Pendulum",
   "gallery": "sim",
   "galleryTitle": "Particles & Simulations",
   "category": "Physics",
   "description": "Three double pendulums started a thousandth of a radian apart — together at first, then chaos splits their trails.",
   "tags": [
    "pendulum",
    "chaos",
    "rk4",
    "butterfly effect",
    "trails",
    "braille"
   ],
   "kind": "scene",
   "size": {
    "w": 48,
    "h": 24
   },
   "minSize": {
    "w": 16,
    "h": 8
   },
   "animated": true,
   "loop": 20,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": "prism",
   "params": {
    "pendulums": {
     "default": 3,
     "min": 1,
     "max": 6,
     "label": "Pendulums"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/sim/double-pendulum.js",
   "imports": [
    "../../color.js",
    "../../palettes.js"
   ],
   "usage": {
    "cli": "prism-term play double-pendulum",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play double-pendulum",
    "snapshot": "prism-term snapshot double-pendulum --size 48x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('double-pendulum'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/double-pendulum"
   },
   "preview": {
    "w": 48,
    "h": 16,
    "text": "\n\n                                 ::\n                                  :\n                                  :\n                                  ::\n                                   :\n           ============            :\n         -=           =%%%+        :\n         -###*        **   ++++=  ::\n          #-- ***   **         +++:\n          ###--  ***       ********++\n             #####*********      ::***\n                 --             --\n                   ---         --\n                      ---------"
   }
  },
  {
   "id": "harmonograph",
   "title": "Harmonograph",
   "gallery": "sim",
   "galleryTitle": "Particles & Simulations",
   "category": "Physics",
   "description": "Damped rotary harmonograph — a pen traces evolving Lissajous figures in braille, re-tuning its pendulums every cycle.",
   "tags": [
    "harmonograph",
    "lissajous",
    "pendulum",
    "curves"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": "prism",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/sim/harmonograph.js",
   "imports": [
    "../../color.js",
    "../../easing.js"
   ],
   "usage": {
    "cli": "prism-term play harmonograph",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play harmonograph",
    "snapshot": "prism-term snapshot harmonograph --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('harmonograph'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/harmonograph"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n                                           *\n                   ++++ ++++              ++*\n                  ++++++++++++         +++++*\n                  ++ +++++++ ++      +++++++*\n                 + +++++++++++++     + +++++*\n                 ++ ++++++++++++     + +++++*\n                 +++++ +++++++++++   + +++++*\n                 ++++++++++++++++++  ++*++ +*\n                 ++++++ +  ++++++++++ +*++ +\n                 ++++++ +   +++++++++++++++*\n                 ++++++++    *+++++++++++++*\n                 +++++++     * ++++++++++ +*\n                 +++++        ** + +++++++*\n                 +++           ***+++ ++++*\n                 +               **    ***"
   }
  },
  {
   "id": "n-body",
   "title": "N-Body Orbits",
   "gallery": "sim",
   "galleryTitle": "Particles & Simulations",
   "category": "Physics",
   "description": "The figure-eight three-body choreography, with a ring of dust that the dancing trio flings about chaotically.",
   "tags": [
    "n-body",
    "gravity",
    "orbits",
    "three-body",
    "figure eight",
    "chaos",
    "braille"
   ],
   "kind": "scene",
   "size": {
    "w": 60,
    "h": 22
   },
   "minSize": {
    "w": 20,
    "h": 7
   },
   "animated": true,
   "loop": 30,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": "prism",
   "params": {
    "dust": {
     "default": 14,
     "min": 0,
     "max": 40,
     "label": "Dust particles"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/sim/n-body.js",
   "imports": [
    "../../color.js",
    "../../palettes.js"
   ],
   "usage": {
    "cli": "prism-term play n-body",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play n-body",
    "snapshot": "prism-term snapshot n-body --size 60x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('n-body'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/n-body"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "     :     #        ==\n          ==        =\n          =         =                                      :\n         =          -                         #\n         =      :::+++                  -----===\n         =   ***: -  +++++         -----    ====\n         = ---  --       ++=    ::--         -  ==      :\n          :=   --           ===::            -   +     :\n      ::::-=  --           ###===             --:: ::::\n    -::    =++-          :###   ====           ***:\n  ---       -++++    *****         ==---    --::*\n =-         -:  +*****                 ------\n#=           :::\n=            : :::                                       ===\n             ::   ::::                                 #==\n figure-eight choreography . 3 equal masses"
   }
  },
  {
   "id": "string-waves",
   "title": "Plucked Strings",
   "gallery": "sim",
   "galleryTitle": "Particles & Simulations",
   "category": "Physics",
   "description": "A six-string harp plucked in an arpeggio: real wave-equation strings that split, reflect and ring down.",
   "tags": [
    "wave equation",
    "strings",
    "harp",
    "standing waves",
    "pluck",
    "braille"
   ],
   "kind": "scene",
   "size": {
    "w": 60,
    "h": 20
   },
   "minSize": {
    "w": 20,
    "h": 7
   },
   "animated": true,
   "loop": 6,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": "prism",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/sim/string-waves.js",
   "imports": [
    "../../color.js",
    "../../palettes.js"
   ],
   "usage": {
    "cli": "prism-term play string-waves",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play string-waves",
    "snapshot": "prism-term snapshot string-waves --size 60x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('string-waves'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/string-waves"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n %% ++++++++++++++++++++++++++++++++++++++++++++++++++++%\n\n\n %% ++++++++++++++++++++++++++++++++++++++++++++++++++++%\n\n %%                                                     %\n    +++++++++++-+-+-+++++++++++++++++++-+-+++++++++++++++\n                     - -             -     -\n %% ************************                        ****%\n               - - - - -    ************************\n\n %% ***********                  ***********************%\n               ******************\n %%        +++++++++++++++++                            %\n    +++++++                 +++++++++++++++++++++++++++++"
   }
  },
  {
   "id": "reaction-diffusion",
   "title": "Reaction-Diffusion",
   "gallery": "sim",
   "galleryTitle": "Particles & Simulations",
   "category": "Life & Flocking",
   "description": "Gray-Scott reaction-diffusion drifting from dividing spots to coral to mazes, shaded as a lit living relief.",
   "tags": [
    "gray-scott",
    "reaction diffusion",
    "turing",
    "morphogenesis",
    "pattern"
   ],
   "kind": "scene",
   "size": {
    "w": 60,
    "h": 22
   },
   "minSize": {
    "w": 20,
    "h": 7
   },
   "animated": true,
   "loop": 42,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "aurora",
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 3,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/sim/reaction-diffusion.js",
   "imports": [
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play reaction-diffusion",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play reaction-diffusion",
    "snapshot": "prism-term snapshot reaction-diffusion --size 60x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('reaction-diffusion'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/reaction-diffusion"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "*+=+=..      +**++=..                                     .#\n*=++:..     .#*+==-..                                     .#\n=--:...     :**==-...                                      -\n::....      .#+-....                                       .\n ....        :-....      .=#*:\n              ....      :#***=.                    .=+=.\n                       .***+=:.                   :#**+*.\n                      .***+=-..                  .***+=-:\n                      =**+==...                  :#*===:..\n                     .#*+=-....                  :**===...\n                      **-:....                   :**==:..\n                      :=.....                    .+=:....\n  ..                   ....                       .:....\n.=##=.          ...                                 ..\n#***+=.       .-###=.                                      .\n**++=:.      .*****=.                                      +"
   }
  },
  {
   "id": "boids",
   "title": "Boids",
   "gallery": "sim",
   "galleryTitle": "Particles & Simulations",
   "category": "Life & Flocking",
   "description": "Reynolds flocking (separation, alignment, cohesion) with a hunting predator — oriented arrowheads with fading wakes.",
   "tags": [
    "boids",
    "flocking",
    "birds",
    "predator"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": "prism",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/sim/boids.js",
   "imports": [
    "../../color.js",
    "../../palettes.js"
   ],
   "usage": {
    "cli": "prism-term play boids",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play boids",
    "snapshot": "prism-term snapshot boids --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('boids'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/boids"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "        ..  .       ***: .*###\n   ........::..    :.:......:-**# ######.         *\n  :.:.: .:**    *****......***:-####* #####     :*\n  **** **:*     ****::.   :-  ..  .:    -# #   .   ..:###\n  *#* **                ..        .                 :****\n                       ..                         ...-******\n                                                  ..::-*****\n                                                    .:...++\n                                                       ..::+\n                          ***.  ..           ..:###..   ..:\n                      :.... ::## :#       ..:########:###*\n           ***:    +++*:.::. .. #  #        ..:#######    *:\n        ***:..:... ******-*          .           #### #  ...\n     *        ...    **  *           .:          .:###\n ****:*:..***:.******: .              #   . ..:###\n*     .. ..       ...:..               #   .:##   #"
   }
  },
  {
   "id": "slime-mold",
   "title": "Slime Mold",
   "gallery": "sim",
   "galleryTitle": "Particles & Simulations",
   "category": "Life & Flocking",
   "description": "Physarum agents sensing and laying scent trails self-organize into a transport network linking food sources.",
   "tags": [
    "physarum",
    "slime mold",
    "agents",
    "network",
    "emergence",
    "trails"
   ],
   "kind": "scene",
   "size": {
    "w": 60,
    "h": 22
   },
   "minSize": {
    "w": 20,
    "h": 7
   },
   "animated": true,
   "loop": 20,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": "toxic",
   "params": {
    "agents": {
     "default": 1,
     "min": 0.2,
     "max": 3,
     "label": "Agent density"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/sim/slime-mold.js",
   "imports": [
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play slime-mold",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play slime-mold",
    "snapshot": "prism-term snapshot slime-mold --size 60x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('slime-mold'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/slime-mold"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n\n\n\n\n                                  =========++======\n                     == ===    ===+***++=+*#%#*++==\n           ======   ========  ===+*%%%*++#%@@%%*++==\n            === =   =======   ===+#%%%#**%@@###*+==\n            = =        ===    ===+#%%%#**%@%%%%*====\n                                ==+*##*++#%@%%*+==\n                                  ==++===+*##+==\n                                        ======\n\n\n"
   }
  },
  {
   "id": "snow",
   "title": "Snow over a Cabin",
   "gallery": "sim",
   "galleryTitle": "Particles & Simulations",
   "category": "Weather",
   "description": "Parallax snowfall in a gusty wind that settles into drifts and a roof cap, around a cabin with a glowing window.",
   "tags": [
    "snow",
    "winter",
    "cabin",
    "drifts",
    "weather",
    "particles",
    "night"
   ],
   "kind": "scene",
   "size": {
    "w": 64,
    "h": 22
   },
   "minSize": {
    "w": 24,
    "h": 8
   },
   "animated": true,
   "loop": 30,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "ice",
   "params": {
    "density": {
     "default": 1,
     "min": 0.2,
     "max": 3,
     "label": "Snowfall"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/sim/snow.js",
   "imports": [
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play snow",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play snow",
    "snapshot": "prism-term snapshot snow --size 64x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('snow'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/snow"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": ".......%.......................#.#..........%.%.:--:........\n...+...........................%............+:=%::..........\n.#....................%.....................-=+-.....+......\n.....................%...#%...#.............===-+...........\n.........#..............................%..:=-..............\n......................................%%.%%::...............\n.........................#......%.%%%%.....%%%..............\n.........................*:*....%%%...........%%...........%\n..............%..........:::........:::::::::...............\n....*:*.............%....*:%......::%%%:::..::.............%\n....::::..............%..::::.....::%%%:::..::..............\n....::::............+...##-##=====::::::::..::++++++++***#**\n+++++++========++#++*##########################%%######%%###\n###%%##########%%###%%%%%###################################\n############################################################\n#######################################################*****"
   }
  },
  {
   "id": "rain",
   "title": "Night Rain",
   "gallery": "sim",
   "galleryTitle": "Particles & Simulations",
   "category": "Weather",
   "description": "Parallax rain over a city skyline — street and rooftop splashes, flickering windows, and lightning that silhouettes the city.",
   "tags": [
    "rain",
    "city",
    "lightning",
    "night"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/sim/rain.js",
   "imports": [
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play rain",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play rain",
    "snapshot": "prism-term snapshot rain --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('rain'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/rain"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "=     ==   :  *     =* =: =:  =  *     =:       *\n=      =      *:     =**  :=::=        == =     *     :\n    =   =      *     =  *  = ::=        = =  :  =*     :\n     =:  : =    *     =  *   ::          = =  :  =  : =:\n     = :  :     *        *:       =        =      =  ::  :\n    : = =        *        *:                ::         :  :\n :   :    *     **           *:              :::       :  :\n :   :    *             .      :                :       .\n           ...          .               ....            .\n           ...        .....             ....     .........\n           ...        .....       .     ....     .........\n           ... ..........#.       .     ....     ...+..*....\n ...       ... ............ . ......    ....     ...........\n ...... ...... ............ . ......... ....     ......+....\n....... ...... ........................ ....................\n....... ...... ........................ ...................."
   }
  },
  {
   "id": "cracktro",
   "title": "Cracktro",
   "gallery": "retro",
   "galleryTitle": "Retro & Demoscene",
   "category": "Intros & Scrollers",
   "description": "Full cracktro: chrome logo with shine sweep, pumping raster bars, greetings scroller and starfield.",
   "tags": [
    "cracktro",
    "intro",
    "logo",
    "scroller",
    "raster bars",
    "demoscene"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 36,
    "h": 12
   },
   "animated": true,
   "loop": 14,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "prism",
   "params": {
    "logo": {
     "default": "PRISM",
     "label": "Logo"
    },
    "text": {
     "default": "CRACKED AND TRAINED BY THE PRISM CREW   *   GREETINGS TO ALL TERMINAL SCENERS   *   CALL OUR BOARD AT 300-2400 BAUD   *   ",
     "label": "Scroller"
    },
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/retro/cracktro.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play cracktro",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play cracktro",
    "snapshot": "prism-term snapshot cracktro --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('cracktro'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/cracktro"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "......................%...........-.........................\n..............:::::..:::::..::::..:::-*.=:...::.............\n..............++..++.++..++..++..++.....#@#.+++.............\n..............%%..%%.%%..%%..%%..%%...-.%%%@@%%.+...........\n..............*****..*****...**...****..**.*.%@.............\n..............--.....--.--...--......--.--...--.............\n.........:....--.....--..--..--......--.--...--.............\n####..........##.....##..##.####.#####..##...##....***..***.\n#...#.###..............................****.****......*..*..\n#.....#..#.......***..*...*.***.......***...*...*.*...*..*..\n####..#...#.....*...*.*...*.*..+........*...*...*.*****..*..\n#.....#...#.....*...*.**..*.*...+.......*...****..*...*..*..\n#.....#...#.....*****.*.*.*.*...+.......*...*.*...*...*..*..\n####..#..#......*...*.*..**.*...+.......*...*..*..*...*.***.\n....#.###.......*...*.*...*.*..+........*...*...*.*.........\n=...............*...*.*...*.***............................."
   }
  },
  {
   "id": "sine-scroller",
   "title": "Sine Scroller",
   "gallery": "retro",
   "galleryTitle": "Retro & Demoscene",
   "category": "Intros & Scrollers",
   "description": "Big bitmap-font message riding a sine wave, copper-tinted per scanline, over a parallax starfield.",
   "tags": [
    "scroller",
    "sine",
    "cracktro",
    "demoscene",
    "bitmap font"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 20
   },
   "minSize": {
    "w": 30,
    "h": 8
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "prism",
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    },
    "text": {
     "default": "PRISM TERMINAL FACETS   *   SINE SCROLLERS NEVER DIE   *   GREETINGS TO EVERY SYSOP STILL RUNNING A NODE   *   ",
     "label": "Message"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/retro/sine-scroller.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play sine-scroller",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play sine-scroller",
    "snapshot": "prism-term snapshot sine-scroller --size 80x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('sine-scroller'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/sine-scroller"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "................................................%...........\n########..##########...........................%............\n..........####....###...%%%%................................\n..........####....####..######..-*%%%.......................\n######....##########.#..######..#####%..%%%%................\n..........####..####....####..########....##%%%%............\n..........####....###...####..##..####....####....%%%%......\n********..****....****..****......****....****....******..%%\n.....................*.=****......****....****....*******.**\n...................#..............****....****....****..****\n.....................................+..++++++....++++...+++\n............................................****..****....**\n..................................................****....**\n..........................................................**\n............................................................\n....................................................=......."
   }
  },
  {
   "id": "starfield",
   "title": "Starfield",
   "gallery": "retro",
   "galleryTitle": "Retro & Demoscene",
   "category": "Intros & Scrollers",
   "description": "Warp-speed 3D starfield — braille motion streaks, periodic warp surges, depth-based color temperature.",
   "tags": [
    "starfield",
    "warp",
    "space",
    "demoscene"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/retro/starfield.js",
   "imports": [],
   "usage": {
    "cli": "prism-term play starfield",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play starfield",
    "snapshot": "prism-term snapshot starfield --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('starfield'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/starfield"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "      ::.-:   ...        :                   #:-  =    +--=\n         ..:..  #+        .    #  +   :=   =....:- :..::\n*  *++=       .   =- =       * = :   .   :-.    ..:.#..-=\n:+==-::::..        :.*..     :: .      ..     *.=++*.\n       :..... *+     .-:     ..       +   :-::--\n       +=+*=-::.-::.=-: .           .: .::..       ..:-=\n            #  ...     :.             ..-+#::=+#\n      =+=-::...::..              .:*  ..:..:-=*    ...:-+\n*+=-:::...         -.*:.         :*  .    ..::::  ..    ..::\n        ..      #+=         .         .=*.  ..::-=*-.:::--=+\n     +-:   .    *=:...  :. =     .:    ..:..+ .:...::==\n       =-:.. .....-:.  *           -+.    -=:-=+     :=++*#%\n       +-:.=+=-: +        :..        %.:    ...:#%:-\n           ..   .. . ..  =: .           .* ..  .:-=+=  ..::\n        =-:   =:  : :.   #..=  .       ...:= :=  :-=*      -\n           -:.   =+=  .. + :   -         :: ..: .. . ...."
   }
  },
  {
   "id": "dycp-scroller",
   "title": "DYCP Scroller",
   "gallery": "retro",
   "galleryTitle": "Retro & Demoscene",
   "category": "Intros & Scrollers",
   "description": "C64-style DYCP scroller: every letter bobs on its own sine, mirrored in a rippling water line.",
   "tags": [
    "dycp",
    "scroller",
    "c64",
    "reflection",
    "demoscene"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 20
   },
   "minSize": {
    "w": 30,
    "h": 8
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "candy",
   "params": {
    "text": {
     "default": "DIFFERENT Y CHARACTER POSITION   ",
     "label": "Message"
    },
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/retro/dycp-scroller.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play dycp-scroller",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play dycp-scroller",
    "snapshot": "prism-term snapshot dycp-scroller --size 80x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('dycp-scroller'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/dycp-scroller"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "............................................................\n.....................%%%%%..%%%%%%..........................\n%%%%%%........%%%%%%.##..##.##.....%%..%%.%%%%%%......@@..@@\n##.....%%%%%%.##.....##..##.##.....%%%.%%...%%........%%..%%\n##.....##.....##.....#####..#####..%%%%%%...%%.........%%%%.\n#####..##.....#####..##.##..##.....%%.%%%...%%..........%%..\n##.....#####..##.....##..##.##.....%%..%%...%%..........%%..\n##.....##.....##.....##..##.######.%%..%%...%%..........%%..\n##.....##.....######...............%%..%%...%%..........%%..\n.......##...................................................\n............................................................\n@%%%%%%%%%@@@@@@%%%%%%%%%%@@@@@@%%%%%%%%%%@@@@@@%%%%%%%%%@@@\n......--....................................................\n-.....--.....------...............--..--...--..........==...\n-.....--.....--.....--..--.------.--..--...--..........==...\n--.....-----..--.....--..--.--.....--..--...--..........==.."
   }
  },
  {
   "id": "boing-ball",
   "title": "Boing Ball",
   "gallery": "retro",
   "galleryTitle": "Retro & Demoscene",
   "category": "Intros & Scrollers",
   "description": "The 1984 Amiga Boing Ball: a spinning checkered sphere bouncing across a grid room with its shadow.",
   "tags": [
    "boing",
    "amiga",
    "ball",
    "checker",
    "1984"
   ],
   "kind": "scene",
   "size": {
    "w": 64,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 8
   },
   "animated": true,
   "loop": 6,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "hires",
   "palette": "prism",
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/retro/boing-ball.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play boing-ball",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play boing-ball",
    "snapshot": "prism-term snapshot boing-ball --size 64x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('boing-ball'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/boing-ball"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "============================================================\n=...=...=...=...=...=...=...=...=...=...=...=...=...=...=...\n=...=...=...=...=...=...=...=...=...=...=...=...=...=...=...\n============================================================\n=...=...=...=...=...=...=..-@@--%-#.=...=...=...=...=...=...\n=...=...=...=...=...=...=-@@@--@@@-%:...=...=...=...=...=...\n========================%--@@@@@---%#::::===================\n=...=...=...=...=...=...-@@---@@-%%%::+.:...=...=...=...=...\n=...=...=...=...=...=...:%%-@@@---%:#*:.:...=...=...=...=...\n========================:#::-%-:##::*+::::==================\n=...=...=...=...=...=...=.::##::*:++:...:...=...=...=...=...\n=...=...=...=...=...=...=...=.+::...:...=...=...=...=...=...\n============================================================\n=...=...=...=...=...=...=...=...=...=...=...=...=...=...=...\n============================================================\n...=.....=.....=.....=.....=............=.....=.....=.....=."
   }
  },
  {
   "id": "vector-balls",
   "title": "Vector Balls",
   "gallery": "retro",
   "galleryTitle": "Retro & Demoscene",
   "category": "Intros & Scrollers",
   "description": "64 Phong-lit spheres morphing cube → sphere → torus → helix while the formation tumbles in 3D.",
   "tags": [
    "vector balls",
    "amiga",
    "3d",
    "morph",
    "demoscene",
    "spheres"
   ],
   "kind": "scene",
   "size": {
    "w": 70,
    "h": 24
   },
   "minSize": {
    "w": 24,
    "h": 8
   },
   "animated": true,
   "loop": 16,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "prism",
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/retro/vector-balls.js",
   "imports": [
    "./_kit.js",
    "../../math3d.js",
    "../../easing.js"
   ],
   "usage": {
    "cli": "prism-term play vector-balls",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play vector-balls",
    "snapshot": "prism-term snapshot vector-balls --size 70x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('vector-balls'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/vector-balls"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "...........................+................................\n.........................+=:.=..--:.........................\n...................**-=:........=#=:...=-...................\n...................-:..::+#=:-:....:..:+-...................\n.................++*=.....:...........--:...................\n................::::.........:....:....=..+=................\n...............**-............++-....+%=:...................\n..................::=#=:.............=:...+=................\n...............:-:...........-:...........-:................\n................++-..--...++=.....++=:..:.+=................\n................+=.......:--:.=-..--:.-..=..................\n.....................+=.-..............=--:.................\n...................*--:.:...==...=++-..:....................\n...................::...+=.==:....-:.*-.....................\n........................=+-..==+=...........................\n............................................................"
   }
  },
  {
   "id": "dot-tunnel",
   "title": "Dot Tunnel",
   "gallery": "retro",
   "galleryTitle": "Retro & Demoscene",
   "category": "Intros & Scrollers",
   "description": "Rings of dots racing at the camera along a snaking path, twisting ring by ring, fading with depth.",
   "tags": [
    "tunnel",
    "dots",
    "demoscene",
    "3d",
    "braille"
   ],
   "kind": "scene",
   "size": {
    "w": 70,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 8
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": "prism",
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/retro/dot-tunnel.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play dot-tunnel",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play dot-tunnel",
    "snapshot": "prism-term snapshot dot-tunnel --size 70x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('dot-tunnel'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/dot-tunnel"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "        ##\n        ##                      ** **  **\n                         ** **  ** **  ** **\n     ##                  ** **            ** **\n     ##                **    * *  * * *      ****\n                    ** **  *  ** * ** * * *    **\n                    **    * * +++++++ +*    *    **\n   ##              **    * *++++++++++++ *  *    ***\n   ##              **   * **++---==++++++ *       **\n                   **   * *+++=====++++++ *  *    **\n   ##              **   *  *+++++++++++ + *  *    **\n   ##              **    *  *+++++++++++**  *     **\n                    **    *  **+ +++ * *   *      **\n                    ****    *   * **   * *      **\n    ##                **      * *  * *        ****\n    ##                  **                  ****"
   }
  },
  {
   "id": "rotozoomer",
   "title": "Rotozoomer",
   "gallery": "retro",
   "galleryTitle": "Retro & Demoscene",
   "category": "Intros & Scrollers",
   "description": "One small emblem tile rotated and zoomed across the whole screen every frame — classic texture mapping.",
   "tags": [
    "rotozoom",
    "texture mapping",
    "demoscene",
    "rotation",
    "zoom"
   ],
   "kind": "scene",
   "size": {
    "w": 70,
    "h": 22
   },
   "minSize": {
    "w": 16,
    "h": 6
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "prism",
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/retro/rotozoomer.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play rotozoomer",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play rotozoomer",
    "snapshot": "prism-term snapshot rotozoomer --size 70x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('rotozoomer'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/rotozoomer"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "--...-@@--...--###@...--..@@@*..-...--...------...--@..-..--\n..--@###------@@##*@..-..---..---..-...---..------@*@---..@#\n..@@@###@..------...--...-..---...--..-.@*---...@####@..---.\n---.....@---...--.--...---...-@@--...-@###@..---...@@@---..-\n-...---.....---...--@--...--@##@-..--..@@**..-...--...------\n----...--@.....--@@##---.--@@###@..--.---..---......---..---\n..--.--@@*@--...@####@..---.--..@---..--.---...---...@*---..\n--...@####@...--...-.@---...--..-...---...-@@--...-@###@..--\n...---..-@@@--...---..-..---...--@.-...--@###--.--.@@@#*....\n@.-...---...-----...---......--@##---.-@@@###@..--.---...--.\n.---..-..---...------@*@--...@####@..---..-..@---..------...\n-...---..-.@@--...@@###@...--...-.@---...--..-...---...-@@--\n@*---..-@@###...---..@@@@--...---...-.---...--@..-..--@###--\n##@-.---.@@#*@.....---...-----...---...-..--@*#@--..@@@###@.\n##@...-----...---.....---...---.--@*@--...@####@..---.....@-\n...@--...--.--...---....@@---..-@####...---..-@@---...---..."
   }
  },
  {
   "id": "copper-bars",
   "title": "Copper Bars",
   "gallery": "retro",
   "galleryTitle": "Retro & Demoscene",
   "category": "Raster & Copper",
   "description": "Glossy raster bars swinging through each other in depth, each a gradient with a white-hot core.",
   "tags": [
    "copper",
    "raster bars",
    "amiga",
    "demoscene",
    "gradient"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 20
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "prism",
   "params": {
    "bars": {
     "default": 7,
     "min": 2,
     "max": 14,
     "label": "Bars"
    },
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/retro/copper-bars.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play copper-bars",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play copper-bars",
    "snapshot": "prism-term snapshot copper-bars --size 80x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('copper-bars'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/copper-bars"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "............................................................\n............................................................\n------------------------------------------------------------\n############################################################\n++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++\n============================================================\n############################################################\n++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++\n............................................................\n++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++\n%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%\n************************************************************\n............................................................\n++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++\n............................................................\n............................................................"
   }
  },
  {
   "id": "kefrens-bars",
   "title": "Kefrens Bars",
   "gallery": "retro",
   "galleryTitle": "Retro & Demoscene",
   "category": "Raster & Copper",
   "description": "One shaded bar per scanline that never gets erased, stacking into a twisting staircase of cylinders.",
   "tags": [
    "kefrens",
    "alcatraz",
    "raster",
    "amiga",
    "demoscene"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 22
   },
   "minSize": {
    "w": 24,
    "h": 8
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "prism",
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/retro/kefrens-bars.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play kefrens-bars",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play kefrens-bars",
    "snapshot": "prism-term snapshot kefrens-bars --size 80x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('kefrens-bars'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/kefrens-bars"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "...........................=##=.............................\n...........................==##=............................\n...........................===##=...........................\n...........................====##=..........................\n...........................====+##+.........................\n...........................====++%%+........................\n...........................====+++%%+.......................\n...........................====++++%%+......................\n...........................====+++++%%+.....................\n...........................====++++++%%+....................\n...........................====+++++++##+...................\n...........................====+++++++=##=..................\n...........................====+++++++==##=.................\n...........................====+++++++==##=.................\n...........................====+++++++===##=................\n...........................====+++++++===##=................"
   }
  },
  {
   "id": "twister",
   "title": "Twister",
   "gallery": "retro",
   "galleryTitle": "Retro & Demoscene",
   "category": "Raster & Copper",
   "description": "A square column twisting along its height, drawn per scanline from its visible faces — Amiga classic.",
   "tags": [
    "twister",
    "amiga",
    "raster",
    "demoscene",
    "column"
   ],
   "kind": "scene",
   "size": {
    "w": 60,
    "h": 24
   },
   "minSize": {
    "w": 16,
    "h": 8
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "prism",
   "params": {
    "twist": {
     "default": 1,
     "min": 0,
     "max": 3,
     "label": "Twist"
    },
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/retro/twister.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play twister",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play twister",
    "snapshot": "prism-term snapshot twister --size 60x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('twister'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/twister"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "...................==++++++++++++=-+++++++++++-.............\n....................--======-=++++++++++++++==..............\n......................::==****************=.................\n....................==+++++++++++++++=:-----:...............\n.........=.........--+++++++++++--===========-..............\n...................::-----===++++++++++++++==...............\n......................==++++++++++++++++:-..................\n...................--+++++++++++++++--======.+..............\n+.................--==============++++++++++=...............\n..................--==========*************==...............\n.................-::------++##############++................\n...................:----++###############++.........-.......\n...................:---+++###############+.-................\n..................::----++###############+..................\n.................::------++###############+.................\n................--===========*************=................."
   }
  },
  {
   "id": "checker-floor",
   "title": "Checker Floor",
   "gallery": "retro",
   "galleryTitle": "Retro & Demoscene",
   "category": "Raster & Copper",
   "description": "A perspective checkerboard plane racing to the horizon and banking, under a raster-gradient sky.",
   "tags": [
    "checkerboard",
    "floor",
    "perspective",
    "amiga",
    "mode 7"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 8
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "sunset",
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/retro/checker-floor.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play checker-floor",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play checker-floor",
    "snapshot": "prism-term snapshot checker-floor --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('checker-floor'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/checker-floor"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "............................................................\n............................................................\n............................................................\n::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::\n::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::\n------------------------------------------------------------\n============================================================\n++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++\n************************************************************\n****#####*****#####****#####**###*****#####****#####*****###\n######*******######*******#######*****#*******#######*******\n#######********######***########*********#########********##\n#**********###########**********###########****######*******\n*******#############************############*************###\n###*****#########***************##############**************\n***************%%%%%%%*********%%%%%%%%%%%%%%%%%************"
   }
  },
  {
   "id": "bbs-login",
   "title": "BBS Login",
   "gallery": "retro",
   "galleryTitle": "Retro & Demoscene",
   "category": "BBS & ANSI Art",
   "description": "A 1993 BBS login: block-letter board logo, caller stats in a double frame and a login that types itself.",
   "tags": [
    "bbs",
    "ansi",
    "login",
    "dial-up",
    "1993",
    "sysop"
   ],
   "kind": "art",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 40,
    "h": 12
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "colors": {
     "default": "vga",
     "options": [
      "vga",
      "theme"
     ],
     "label": "Colors"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/retro/bbs-login.js",
   "imports": [
    "./_kit.js",
    "./_bbs.js"
   ],
   "usage": {
    "cli": "prism-term play bbs-login",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play bbs-login",
    "snapshot": "prism-term snapshot bbs-login --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('bbs-login'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/bbs-login"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "  PRISM BBS . Node 3 of 8 . 2400 bps . ANSI          23:41\n\n  P R I S M   B B S\n\n       +==+ SYSTEM +================================+\n       | Last caller ..... CRASH OVERRIDE           |\n       | Calls today ..... 42                       |\n       | Your node ....... 3 of 8                   |\n       | Software ........ PRISM/BBS 0.42           |\n       +============================================+\n\n       Enter your handle (NEW to apply): LIGHTMAN\n\n\n\n       [ESC] hang up   [?] help   SysOp is AVAILABLE"
   }
  },
  {
   "id": "bbs-menu",
   "title": "BBS Main Menu",
   "gallery": "retro",
   "galleryTitle": "Retro & Demoscene",
   "category": "BBS & ANSI Art",
   "description": "A BBS main menu: shaded header, hotkey commands with a walking lightbar, who-is-online and time left.",
   "tags": [
    "bbs",
    "ansi",
    "menu",
    "lightbar",
    "hotkeys",
    "sysop"
   ],
   "kind": "art",
   "size": {
    "w": 80,
    "h": 22
   },
   "minSize": {
    "w": 40,
    "h": 12
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "colors": {
     "default": "vga",
     "options": [
      "vga",
      "theme"
     ],
     "label": "Colors"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/retro/bbs-menu.js",
   "imports": [
    "./_kit.js",
    "./_bbs.js"
   ],
   "usage": {
    "cli": "prism-term play bbs-menu",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play bbs-menu",
    "snapshot": "prism-term snapshot bbs-menu --size 80x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('bbs-menu'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/bbs-menu"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": ".......::::::########### MAIN MENU ############::::::.......\n           PRISM BBS . terminal art since 1991\n\n +==+ COMMANDS +==========================================+\n | [M] Message Bases           [W] Who's Online           |\n | [F] File Areas              [N] New Files Scan         |\n | [D] Door Games              [U] User Settings          |\n | [C] Chat with SysOp         [L] Last Callers           |\n | [B] Bulletins               [G] Goodbye (Log Off)      |\n +========================================================+\n\n  Command > C\n\n\n\n Time left: 58 min  .  Node 3  .  3 new msgs  .  ^v select"
   }
  },
  {
   "id": "ansi-logo",
   "title": "ANSI Group Logo",
   "gallery": "retro",
   "galleryTitle": "Retro & Demoscene",
   "category": "BBS & ANSI Art",
   "description": "A 1994-style ANSI group logo: bevelled block letters in ░▒▓ shading with a light sweep and sparkles.",
   "tags": [
    "ansi",
    "logo",
    "bbs",
    "acid",
    "ice",
    "scene",
    "shading"
   ],
   "kind": "art",
   "size": {
    "w": 78,
    "h": 16
   },
   "minSize": {
    "w": 36,
    "h": 8
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "text": {
     "default": "PRISM",
     "label": "Logo"
    },
    "colors": {
     "default": "vga",
     "options": [
      "vga",
      "theme"
     ],
     "label": "Colors"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/retro/ansi-logo.js",
   "imports": [
    "./_kit.js",
    "./_bbs.js"
   ],
   "usage": {
    "cli": "prism-term play ansi-logo",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ansi-logo",
    "snapshot": "prism-term snapshot ansi-logo --size 78x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ansi-logo'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ansi-logo"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n\n\n\n             ##_\"#\" ##_*#\" \"##_*_:_____##_ _##\n             \"\"*_#_:\"\"__#_: \"_* \"\"\"__  \"_*_\"\"_\n             \"*:\"\"\" \"_:\"\"\"  \"_:   \"\"\"\"_\"_: \"\"_\n             \"_:*   \"_: \"_:\"\"*__\"\"\"\"_:\"\"_:  \"_\n\n             -[ PRiSM ANSi & ASCii DiViSiON ]-\n        ans by ckr/prism . 10/94 . 80x25 . iCE-free\n\n\n\n\n"
   }
  },
  {
   "id": "ansi-landscape",
   "title": "ANSI Moonlit Lake",
   "gallery": "retro",
   "galleryTitle": "Retro & Demoscene",
   "category": "BBS & ANSI Art",
   "description": "A moonlit lake in true ANSI shading — VGA colors and ░▒▓ dithers — with twinkling stars and a rippling reflection.",
   "tags": [
    "ansi",
    "landscape",
    "moon",
    "lake",
    "bbs",
    "shading",
    "night"
   ],
   "kind": "art",
   "size": {
    "w": 80,
    "h": 22
   },
   "minSize": {
    "w": 30,
    "h": 8
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "colors": {
     "default": "vga",
     "options": [
      "vga",
      "theme"
     ],
     "label": "Colors"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/retro/ansi-landscape.js",
   "imports": [
    "./_kit.js",
    "./_bbs.js"
   ],
   "usage": {
    "cli": "prism-term play ansi-landscape",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ansi-landscape",
    "snapshot": "prism-term snapshot ansi-landscape --size 80x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ansi-landscape'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ansi-landscape"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "                                        \"\"_____\".\n           *    *                .    ...:_ #__:..\n......................................... ###_\" ...*........\n................+...............+......:.\"\"###\":.:..........\n\"_:::+::::::::::+:::::::::+::::::::::::::._.:...::::::::::::\n.._#################################..\"....\"__###+##########\n...\"_      *                  .   \"...........\"_       \"_\n..\"  \"____\" \"..\"\" \"_......\"\"....\".......\"......\" \" \"\"...\"_..\n\"      \"\"     \"    \"\"#___    #........\"   \"  \"       \"\"\"\"\"\"\"\n\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\n::::::::::::::::::::_.\"\"\"::::.:::::::::::::--:::::::::::::__\n:::::_\"\"\"\"\":\"..\"\":_\"......\"\"....\":::::::::-:::~::\":\"\":::_\"..\n:::_\"\"                             ::::::::~:~_\"\"       \"\"\n:\"##################################::_::::.- -#############\n\"####################################### \"-~  ##~###########\n:::::::::::::::::::::::::::::::::::::::##.:~-:\"\".##:::::::::"
   }
  },
  {
   "id": "modem-connect",
   "title": "Modem Connect",
   "gallery": "retro",
   "galleryTitle": "Retro & Demoscene",
   "category": "BBS & ANSI Art",
   "description": "Dialing a BBS: ATZ, ATDT, RINGING, CONNECT 2400 — then the welcome screen paints in at line speed.",
   "tags": [
    "modem",
    "dial-up",
    "bbs",
    "ansi",
    "connect",
    "hayes",
    "2400 baud"
   ],
   "kind": "art",
   "size": {
    "w": 80,
    "h": 22
   },
   "minSize": {
    "w": 40,
    "h": 10
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "colors": {
     "default": "vga",
     "options": [
      "vga",
      "theme"
     ],
     "label": "Colors"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/retro/modem-connect.js",
   "imports": [
    "../../buffer.js",
    "./_kit.js",
    "./_bbs.js"
   ],
   "usage": {
    "cli": "prism-term play modem-connect",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play modem-connect",
    "snapshot": "prism-term snapshot modem-connect --size 80x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('modem-connect'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/modem-connect"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "ATZ\nOK\nATDT 555-0142\nRINGING\n\n\n\n            +==+ Dialing +=====================+\n            | System:  PRISM BBS               |\n            | Number:  555-0142                |\n            | Elapsed: 6s                      |\n            | Status:  Ringing                 |\n            +==================================+\n\n\n PRiSMTERM 3.51 | Alt-Z Help | ANSI-BBS | 2400 N81 | FDX |"
   }
  },
  {
   "id": "vu-meters",
   "title": "VU Meters",
   "gallery": "retro",
   "galleryTitle": "Retro & Demoscene",
   "category": "Chiptune Visuals",
   "description": "Twin analog VU meters on a backlit face — needles with real ballistics, a red zone, peak lamps and LED ladders.",
   "tags": [
    "vu",
    "meter",
    "audio",
    "needle",
    "led",
    "chiptune",
    "hifi"
   ],
   "kind": "scene",
   "size": {
    "w": 64,
    "h": 18
   },
   "minSize": {
    "w": 24,
    "h": 8
   },
   "animated": true,
   "loop": 7.68,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/retro/vu-meters.js",
   "imports": [
    "./_kit.js",
    "./_music.js"
   ],
   "usage": {
    "cli": "prism-term play vu-meters",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play vu-meters",
    "snapshot": "prism-term snapshot vu-meters --size 64x18",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('vu-meters'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/vu-meters"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "                          PK                             PK\n       -10    -5  -3                 -10    -5  -3\n                        0                             0\n-20          .... . ..     +3 -20          .... .  ..    +3\n      ................+.+           ................+.+\n   ....    . . . . ...+.++++     ....    . . . . ...+.++++\n     .             .. .+++         .             .. .+++\n                  ..                             ..\n                  ..                            ..\n              VU ..                         VU ..\n                 ..                            ..\n                ..                            ..\n              ....                          ....\n L           .....             R           .....\nL ||||||||||||||||||||||||||||||||||||||||||||||||||||    0\nR ||||||||||||||||||||||||||||||||||||||||||||||||||||    0"
   }
  },
  {
   "id": "spectrum-analyzer",
   "title": "Spectrum Analyzer",
   "gallery": "retro",
   "galleryTitle": "Retro & Demoscene",
   "category": "Chiptune Visuals",
   "description": "A media-player spectrum analyser: log-spaced bars in eighth-blocks, falling peak caps and a floor reflection.",
   "tags": [
    "spectrum",
    "analyzer",
    "fft",
    "eq",
    "audio",
    "bars",
    "chiptune",
    "winamp"
   ],
   "kind": "scene",
   "size": {
    "w": 64,
    "h": 16
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 7.68,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {
    "style": {
     "default": "classic",
     "options": [
      "classic",
      "palette"
     ],
     "label": "Colors"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/retro/spectrum-analyzer.js",
   "imports": [
    "./_kit.js",
    "./_music.js"
   ],
   "usage": {
    "cli": "prism-term play spectrum-analyzer",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play spectrum-analyzer",
    "snapshot": "prism-term snapshot spectrum-analyzer --size 64x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('spectrum-analyzer'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/spectrum-analyzer"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "        \"\"\n\n     \"\" __                \"\"\n  \"\"    ##                ..                         \"\"\n     .. ##                ##                            \"\"\n     ## ## \"\" \"\"          ## \"\"                   \"\" ## ==\n  __ ## ##       \"\" \"\"    ##       \"\"    \"\" \"\"    == ## ##\n  ## ## ## ..       ..    ##    \"\"    \"\"       \"\" ## ## ##\n  ## ## ## ##       ## \"\" ##    ==    == == ## .. ## ## ##\n  ## ## ## ## ## .. ## __ ## __ ## .. ## ## ## ## ## ## ##\n  ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ##\n  ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ##\n  ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ##\n  ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ##\n  ## ## ## ## ## \"\" ## ## ## \"\" ## \"\" ## ## ## ## ## ## ##\n     60           250            1k           4k       10k"
   }
  },
  {
   "id": "oscilloscope",
   "title": "Oscilloscope",
   "gallery": "retro",
   "galleryTitle": "Retro & Demoscene",
   "category": "Chiptune Visuals",
   "description": "A phosphor oscilloscope on the chiptune: graticule, a bass-triggered trace with persistence, and an XY Lissajous mode.",
   "tags": [
    "oscilloscope",
    "scope",
    "waveform",
    "phosphor",
    "lissajous",
    "audio",
    "chiptune"
   ],
   "kind": "scene",
   "size": {
    "w": 64,
    "h": 18
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 7.68,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": null,
   "params": {
    "mode": {
     "default": "yt",
     "options": [
      "yt",
      "xy"
     ],
     "label": "Mode"
    },
    "phosphor": {
     "default": "green",
     "options": [
      "green",
      "amber",
      "blue",
      "theme"
     ],
     "label": "Phosphor"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/retro/oscilloscope.js",
   "imports": [
    "./_kit.js",
    "./_music.js"
   ],
   "usage": {
    "cli": "prism-term play oscilloscope",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play oscilloscope",
    "snapshot": "prism-term snapshot oscilloscope --size 64x18",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('oscilloscope'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/oscilloscope"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": ":CH1 0.5V/div  :  :  :  :  : -:- :  :  :  :  :  TRIG ^ BASS:\n                             - -\n:  :  :..:  .. :  :  :  :  : -:- :  :  :  :  :  :  :  :  :\n: ..  :..   ..    :     :    ---    :     :     :     :    :\n: .#  .  . .: .:  :  :  :  : -::=: ::= :  =  :  :  :  :  :\n  =##.. ##:: #.              -::== := ##:= ##\n:.#:.#:=#:# =##:  :  :  :  : -:=:##=: #=#:.##:  :  :  :  :.:\n-##=-###=:###:#.:..-::---:: --:=:#:#-#.:###.=#--#-- -#---..#\n#=:===-:=: =-= #:.. ::  -::= :=-#-.###:: .: .# =## -.##  .#.\n=-:::----==--==#.=##- =#:--=##=#- .-.--...::.=##=-# #-.#.=#.\n:  :  :  :  :  =#=## =:## =:#=#. :...  .  : :.:#= ###=.###\n               =##=.#=#==# #.- .             .:  ==: = := =:\n:  :  :  :  :  :  ==### .### ..- :  :  :  :  :.:::.:: == :==\n                         ..  . -               :::  :::  :\n:  :  :  :  :  :  :  :  :  : -:- :  :  :  :  :  :  :  :  :\n:87.3 Hz :  :  :  :  :  :  : -:- :  :  :  :  :  :2.29ms/div:"
   }
  },
  {
   "id": "tracker-pattern",
   "title": "Tracker Pattern",
   "gallery": "retro",
   "galleryTitle": "Retro & Demoscene",
   "category": "Chiptune Visuals",
   "description": "A 4-channel tracker playing a chiptune: hex rows, note/instrument/effect columns and a locked play row with VUs.",
   "tags": [
    "tracker",
    "mod",
    "protracker",
    "pattern",
    "chiptune",
    "music",
    "amiga"
   ],
   "kind": "scene",
   "size": {
    "w": 76,
    "h": 22
   },
   "minSize": {
    "w": 40,
    "h": 10
   },
   "animated": true,
   "loop": 7.68,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/retro/tracker-pattern.js",
   "imports": [
    "./_kit.js",
    "./_music.js"
   ],
   "usage": {
    "cli": "prism-term play tracker-pattern",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play tracker-pattern",
    "snapshot": "prism-term snapshot tracker-pattern --size 76x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('tracker-pattern'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/tracker-pattern"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": " > PRiSMTRACKER . p POS 00 PAT 00  BPM 125 SPD 06  00:03.00\n    1 DRUMS    2 BASS     3 LEAD     4 HATS\n    ###        ####|      #####      ####|\n13|--- -- ---|--- -- ---|--- -- ---|C-5 05 C18\n14|D-2 04 C38|F-2 02 C30|E-5 01 C30|--- -- ---\n15|--- -- ---|--- -- ---|--- -- ---|C-5 05 C10\n16|--- -- ---|F-3 02 C28|--- -- ---|--- -- ---\n17|--- -- ---|--- -- ---|--- -- ---|C-5 05 C18\n18|C-2 03 C40|F-2 02 C30|C-5 01 C30|--- -- ---\n19|--- -- ---|--- -- ---|--- -- ---|C-5 05 C10<\n1A|--- -- ---|F-3 02 C28|--- -- ---|--- -- ---\n1B|--- -- ---|--- -- ---|--- -- ---|C-5 05 C18\n1C|D-2 04 C38|F-2 02 C30|A-4 01 C30|--- -- ---\n1D|--- -- ---|--- -- ---|--- -- ---|C-5 05 C10\n1E|--- -- ---|F-3 02 C28|C-5 01 C30|--- -- ---\n F1 help  F5 play  F8 stop  <> channel  ^v row  Space edit"
   }
  },
  {
   "id": "outrun-road",
   "title": "Outrun Road",
   "gallery": "retro",
   "galleryTitle": "Retro & Demoscene",
   "category": "Outrun",
   "description": "Arcade pseudo-3D racing: a curving road with rumble strips, palm silhouettes, a sunset and a red convertible.",
   "tags": [
    "outrun",
    "racer",
    "pseudo-3d",
    "road",
    "palms",
    "sunset",
    "arcade",
    "1986"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 24,
    "h": 8
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "sunset",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/retro/outrun-road.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play outrun-road",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play outrun-road",
    "snapshot": "prism-term snapshot outrun-road --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('outrun-road'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/outrun-road"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": ".TIME.53...............SCORE 01316600.......................\n..........................%%%%%%%%%%%%......................\n........................%%%%%%%%%%%%%.......................\n::::::::::::::::::::::......%%%%%%%%....%.:::..:.:::::::::::\n::::::::::::::::::::::......%%%%%....::%%.::::::::::::::::::\n-:::::::::::::::::----##.....:::.....:::..::::---------:::::\n:::::::::::::::::::::::::....:::...:::::.:::::::::::::::::::\n=========================.=.::::..======.===================\n========================%---%-------====.===================\n=====================%-----%----------%=.===================\n------------------------------------------------------------\n--------------------------:::::::::::-----------------------\n------------------------:::::::::::::::---------------------\n------------------------##--.......--##---------------------\n----------------------....-----------...--------------------\n---------------------------------------------------282 km/h-"
   }
  },
  {
   "id": "neon-skyline",
   "title": "Neon Skyline",
   "gallery": "retro",
   "galleryTitle": "Retro & Demoscene",
   "category": "Outrun",
   "description": "A neon city drifting past in parallax: lit windows, neon trim, searchlights, waterfront traffic and a rippling harbour.",
   "tags": [
    "city",
    "skyline",
    "neon",
    "night",
    "parallax",
    "reflection",
    "synthwave",
    "cyberpunk"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 22
   },
   "minSize": {
    "w": 24,
    "h": 8
   },
   "animated": true,
   "loop": 40,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "synthwave",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/retro/neon-skyline.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play neon-skyline",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play neon-skyline",
    "snapshot": "prism-term snapshot neon-skyline --size 80x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('neon-skyline'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/neon-skyline"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "............................................................\n.................-.....................................:....\n..................................+.........................\n....*.........................*.............................\n............................................................\n......-.....................................................\n...........................+................................\n........................===============.....................\n*......*....%.+......+%.-...+......+..-...%...%..*..#...*...\n........................@......@....@.@....@................\n.=..............=......................=........=.......=...\n.:..............:......................:........:.......:...\n..:..............:......................:........:.......:..\n........................=......=....=.=....=................\n......-....-.:......:-.:...:......:..:...-...=..-..-...-....\n.......................:::::::::::::::......................"
   }
  },
  {
   "id": "synthwave",
   "title": "Synthwave",
   "gallery": "retro",
   "galleryTitle": "Retro & Demoscene",
   "category": "Outrun",
   "description": "Outrun flyover — striped gradient sun, neon-rimmed mountains and an anti-aliased perspective grid.",
   "tags": [
    "synthwave",
    "outrun",
    "retrowave",
    "grid",
    "sun"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "synthwave",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/retro/synthwave.js",
   "imports": [],
   "usage": {
    "cli": "prism-term play synthwave",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play synthwave",
    "snapshot": "prism-term snapshot synthwave --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('synthwave'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/synthwave"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "*...........................................................\n.........................::%%%%%%::........#................\n......................:##############:......................\n....................::################::.+...*..............\n...................::******************::...................\n..................::********************::..................\n.................:::********************:::.................\n:::++++++++++++:::::-::::::::::::::::::-::::::++:::::::::-::\n+++............++++++++++++----+-===-------+++..+++++-:--.--\n...............:::::::::::--------:::::::::::...............\n............................................................\n............................................................\n............................................................\n............................................................\n...:..........:..........:..........:..........:..........:.\n.....:.....:......:.....:......:.....:......:.....:......:.."
   }
  },
  {
   "id": "banner",
   "title": "Big Text",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Banners & Fonts",
   "description": "Gradient banner text with drop shadow and sparkles, cycling fonts and resolutions (--set text=… to change it).",
   "tags": [
    "banner",
    "big text",
    "fonts",
    "figlet"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "hires",
   "palette": "prism",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/banner.js",
   "imports": [
    "../../color.js",
    "../../text/banner.js",
    "../../text/effects.js"
   ],
   "usage": {
    "cli": "prism-term play banner",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play banner",
    "snapshot": "prism-term snapshot banner --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('banner'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/banner"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": ".                        *\n\n\n######    ##########    ########    ##########  ####      ##\n########  ############*   ############################  ####\n##  ############  ######  ######  ######        ############\n########################  ######    ########    ############\n########  ############  * ######      ########  ######  ####\n##        ###### *####    ######          ############    ##\n##        ######  ############## .####################    ##\n##          ####    ####  ########  ##########    ####\n\n                prism-term . terminal facets\n                        bold . ascii\n\n"
   }
  },
  {
   "id": "text-font-standard",
   "title": "Standard 5x7 Specimen",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Banners & Fonts",
   "description": "Type-specimen waterfall for the standard 5x7 face — upper and lower case with descenders — at every scale that fits.",
   "tags": [
    "font",
    "specimen",
    "bitmap font",
    "waterfall",
    "standard",
    "lcd",
    "lowercase"
   ],
   "kind": "text",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 36,
    "h": 10
   },
   "animated": true,
   "loop": 16,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/fonts.js",
   "imports": [
    "../../color.js",
    "../../text/banner.js",
    "../../text/fonts.js",
    "./_fonts.js",
    "./_text.js"
   ],
   "usage": {
    "cli": "prism-term play text-font-standard",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play text-font-standard",
    "snapshot": "prism-term snapshot text-font-standard --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('text-font-standard'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/text-font-standard"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": " STANDARD  5x7+1  92 glyphs                  mode ascii  1x\n ----------------------------------------------------------\n ##      ##              ####    ####\n ##      ##                ##      ##\n ##      ##    ######      ##      ##      ######\n ##########  ##      ##    ##      ##    ##      ##\n ##      ##  ##########    ##      ##    ##      ##\n ##      ##  ##            ##      ##    ##      ##\n ##      ##    ######    ######  ######    ######\n\n\n\n\n\n\n"
   }
  },
  {
   "id": "text-font-tiny",
   "title": "Tiny 3x5 Specimen",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Banners & Fonts",
   "description": "The compact 3x5 caps-and-digits face set as KPI read-outs, in a waterfall from display size down to its native pixels.",
   "tags": [
    "font",
    "specimen",
    "bitmap font",
    "waterfall",
    "tiny",
    "compact",
    "kpi",
    "numbers"
   ],
   "kind": "text",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 36,
    "h": 10
   },
   "animated": true,
   "loop": 16,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/fonts.js",
   "imports": [
    "../../color.js",
    "../../text/banner.js",
    "../../text/fonts.js",
    "./_fonts.js",
    "./_text.js"
   ],
   "usage": {
    "cli": "prism-term play text-font-tiny",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play text-font-tiny",
    "snapshot": "prism-term snapshot text-font-tiny --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('text-font-tiny'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/text-font-tiny"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": " TINY  3x5  66 glyphs                        mode ascii  1x\n ----------------------------------------------------------\n   ####  ####    ##  ##        ##  ##  ######  ##  ##\n ##      ##  ##  ##  ##        ##  ##      ##      ##\n ##      ####    ##  ##        ######  ######    ##\n ##      ##      ##  ##            ##  ##      ##\n   ####  ##      ######            ##  ######  ##  ##\n\n ######    ##    ######  ######  ##  ##  ######  ######\n ##  ##  ####        ##      ##  ##  ##  ##      ##\n ##  ##    ##    ######    ####  ######  ######  ######\n ##  ##    ##    ##          ##      ##      ##  ##  ##\n ######  ######  ######  ######      ##  ######  ######\n\n\n"
   }
  },
  {
   "id": "text-font-bold",
   "title": "Bold 6x7 Specimen",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Banners & Fonts",
   "description": "The 6x7 display face with two-pixel stems — the heavyweight for headlines — shown as a scaled waterfall with its glyph set.",
   "tags": [
    "font",
    "specimen",
    "bitmap font",
    "waterfall",
    "bold",
    "display",
    "heavy"
   ],
   "kind": "text",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 36,
    "h": 10
   },
   "animated": true,
   "loop": 16,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/fonts.js",
   "imports": [
    "../../color.js",
    "../../text/banner.js",
    "../../text/fonts.js",
    "./_fonts.js",
    "./_text.js"
   ],
   "usage": {
    "cli": "prism-term play text-font-bold",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play text-font-bold",
    "snapshot": "prism-term snapshot text-font-bold --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('text-font-bold'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/text-font-bold"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": " BOLD  6x7  66 glyphs                        mode ascii  1x\n ----------------------------------------------------------\n ##########      ########    ####          ##########\n ####    ####  ####    ####  ####          ####    ####\n ####    ####  ####    ####  ####          ####    ####\n ##########    ####    ####  ####          ####    ####\n ####    ####  ####    ####  ####          ####    ####\n ####    ####  ####    ####  ####          ####    ####\n ##########      ########    ############  ##########\n\n\n\n\n\n\n"
   }
  },
  {
   "id": "text-font-cyber",
   "title": "Cyber 5x7 Specimen",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Banners & Fonts",
   "description": "A new hand-drawn squared techno face — hard corners, square counters, chamfered B and D — in a specimen waterfall.",
   "tags": [
    "font",
    "specimen",
    "bitmap font",
    "waterfall",
    "cyber",
    "techno",
    "squared",
    "new face"
   ],
   "kind": "text",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 36,
    "h": 10
   },
   "animated": true,
   "loop": 16,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/fonts.js",
   "imports": [
    "../../color.js",
    "../../text/banner.js",
    "../../text/fonts.js",
    "./_fonts.js",
    "./_text.js"
   ],
   "usage": {
    "cli": "prism-term play text-font-cyber",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play text-font-cyber",
    "snapshot": "prism-term snapshot text-font-cyber --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('text-font-cyber'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/text-font-cyber"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": " CYBER  5x7  53 glyphs                       mode ascii  1x\n ----------------------------------------------------------\n ##########  ##      ##  ########    ##########  ##########\n ##          ##      ##  ##      ##  ##          ##      ##\n ##          ##      ##  ##      ##  ##          ##      ##\n ##          ##########  ##########  ########    ########\n ##              ##      ##      ##  ##          ##    ##\n ##              ##      ##      ##  ##          ##      ##\n ##########      ##      ########    ##########  ##      ##\n\n\n\n\n\n\n"
   }
  },
  {
   "id": "text-figlet-styles",
   "title": "figlet: Standard, Small, Block",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Banners & Fonts",
   "description": "A shell session running figlet three ways: the outlined standard face, the compact small face and solid block letters with a shadow.",
   "tags": [
    "figlet",
    "banner",
    "shell",
    "standard",
    "small",
    "block"
   ],
   "kind": "text",
   "size": {
    "w": 72,
    "h": 14
   },
   "minSize": {
    "w": 36,
    "h": 9
   },
   "animated": true,
   "loop": 18,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/banners.js",
   "imports": [
    "../../color.js",
    "../../text/banner.js",
    "./_text.js",
    "../ascii/_ascii.js"
   ],
   "usage": {
    "cli": "prism-term play text-figlet-styles",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play text-figlet-styles",
    "snapshot": "prism-term snapshot text-figlet-styles --size 72x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('text-figlet-styles'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/text-figlet-styles"
   },
   "preview": {
    "w": 60,
    "h": 14,
    "text": "$ figlet -f standard Hello\n _       _               ___     ___\n| |     | |             |__ |   |__ |\n| |     | |    _____      | |     | |      _____\n| |_____| |  _|_____|_    | |     | |    _|_____|_\n| _______ | | |_____| |   | |     | |   | |     | |\n| |     | | | ________|   | |     | |   | |     | |\n| |     | | |_|_____     _| |_   _| |_  |_|_____|_|\n|_|     |_|   |_____|   |_____| |_____|   |_____|\n$\n\n\n\n"
   }
  },
  {
   "id": "text-banner-gradient",
   "title": "Gradient Swatch Book",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Banners & Fonts",
   "description": "One word stacked in stripes, each filled with a different palette sweeping horizontally, vertically or diagonally.",
   "tags": [
    "gradient",
    "banner",
    "palette",
    "fill",
    "lolcat"
   ],
   "kind": "text",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 36,
    "h": 10
   },
   "animated": true,
   "loop": 16,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/banners.js",
   "imports": [
    "../../color.js",
    "../../text/banner.js",
    "./_text.js",
    "../ascii/_ascii.js"
   ],
   "usage": {
    "cli": "prism-term play text-banner-gradient",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play text-banner-gradient",
    "snapshot": "prism-term snapshot text-banner-gradient --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('text-banner-gradient'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/text-banner-gradient"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": " prism         ####  ####      ##    ####    ######  ######\n horizontal  ##      ##  ##  ##  ##  ##  ##    ##    ##\n             ##  ##  ####    ######  ##  ##    ##    ####\n             ##  ##  ##  ##  ##  ##  ##  ##    ##    ##\n               ####  ##  ##  ##  ##  ####    ######  ######\n\n sunset        ####  ####      ##    ####    ######  ######\n vertical    ##      ##  ##  ##  ##  ##  ##    ##    ##\n             ##  ##  ####    ######  ##  ##    ##    ####\n             ##  ##  ##  ##  ##  ##  ##  ##    ##    ##\n               ####  ##  ##  ##  ##  ####    ######  ######\n\n\n\n\n"
   }
  },
  {
   "id": "text-banner-outline",
   "title": "Outline & Shadow",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Banners & Fonts",
   "description": "Hollow outline lettering, scanline-filled 80s type, outlined gradients and drop shadows — four looks, one word.",
   "tags": [
    "outline",
    "hollow",
    "shadow",
    "banner",
    "80s"
   ],
   "kind": "text",
   "size": {
    "w": 60,
    "h": 12
   },
   "minSize": {
    "w": 36,
    "h": 8
   },
   "animated": true,
   "loop": 16,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/banners.js",
   "imports": [
    "../../color.js",
    "../../text/banner.js",
    "./_text.js",
    "../ascii/_ascii.js"
   ],
   "usage": {
    "cli": "prism-term play text-banner-outline",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play text-banner-outline",
    "snapshot": "prism-term snapshot text-banner-outline --size 60x12",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('text-banner-outline'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/text-banner-outline"
   },
   "preview": {
    "w": 60,
    "h": 12,
    "text": "##############################      ########################\n####    ##            ##    ##      ##        ##    ####\n####    ######    ######    ##      ####    ####      ##\n####    ##  ##    ##  ##    ##        ##    ####\n####    ##  ##    ##  ##    ##        ##    ####    ##\n####    ##  ##    ##  ##    ##        ##    ####    ####\n####    ##  ##    ##  ##    ############    ####    ####\n      ####  ##    ##  ##            ##        ##    ####\n########    ########  ######################################\n                           hollow\n\n"
   }
  },
  {
   "id": "text-extrude",
   "title": "3D Extruded Banner",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Banners & Fonts",
   "description": "Solid 3D block letters: a stack of shaded offset copies forms the sides, and the light direction swings slowly around.",
   "tags": [
    "3d",
    "extrude",
    "depth",
    "banner",
    "retro"
   ],
   "kind": "text",
   "size": {
    "w": 80,
    "h": 20
   },
   "minSize": {
    "w": 36,
    "h": 10
   },
   "animated": true,
   "loop": 12.566,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/banners.js",
   "imports": [
    "../../color.js",
    "../../text/banner.js",
    "./_text.js",
    "../ascii/_ascii.js"
   ],
   "usage": {
    "cli": "prism-term play text-extrude",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play text-extrude",
    "snapshot": "prism-term snapshot text-extrude --size 80x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('text-extrude'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/text-extrude"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n\n\n\n######    ############  ##########    ############  ####\n########  ############  ############  ############  ####\n########  ############  ############  ############  ####\n    ####  ##########    ############      ####      ########\n    ####  ##########    ############      ####      ########\n    ####  ##########    ##########        ####      ########\n########  ############  ####              ####      ####\n########  ############  ####              ####      ####\n######    ############  ####              ####      ####\n\n\n"
   }
  },
  {
   "id": "textfx",
   "title": "Text Effects",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Reveal & Type",
   "description": "Decrypt, typewriter, wave, rainbow, shimmer, glitch, fade, pulse, marquee and wipe — on big and small text.",
   "tags": [
    "text effects",
    "typewriter",
    "decrypt",
    "glitch"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "prism",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/textfx.js",
   "imports": [
    "../../canvas.js",
    "../../text/banner.js",
    "../../text/effects.js"
   ],
   "usage": {
    "cli": "prism-term play textfx",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play textfx",
    "snapshot": "prism-term snapshot textfx --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('textfx'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/textfx"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "  TEXT EFFECTS                        * . . . . . . . . .\n\n######    ##########    ########    ##########  ####      ##\n########  ############    ############################  ####\n##  ############  ######  ######  ######        ############\n########################  ######    ########    ############\n########  ############    ######      ########  ######  ####\n##        ######  ####    ######          ############    ##\n##        ######  ##############  ####################    ##\n##          ####    ####  ########  ##########    ####\n\n\n\noot cause: connection pool exhausted on db-primary (p99 2.4s\n\n decrypt - Scrambled glyphs resolve into the real text, left"
   }
  },
  {
   "id": "text-typewriter",
   "title": "Typewriter, Human Rhythm",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Reveal & Type",
   "description": "A note typed at human speed — quick bigrams, pauses between words, the odd typo backspaced — with a cursor that blinks at rest.",
   "tags": [
    "typewriter",
    "typing",
    "cursor",
    "blink",
    "realistic"
   ],
   "kind": "text",
   "size": {
    "w": 72,
    "h": 16
   },
   "minSize": {
    "w": 40,
    "h": 10
   },
   "animated": true,
   "loop": 30,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/reveal.js",
   "imports": [
    "../../color.js",
    "../../text/banner.js",
    "./_text.js"
   ],
   "usage": {
    "cli": "prism-term play text-typewriter",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play text-typewriter",
    "snapshot": "prism-term snapshot text-typewriter --size 72x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('text-typewriter'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/text-typewriter"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": " note.txt                                            87 wpm\n------------------------------------------------------------\n  Dear night shift_\n\n\n\n\n\n\n\n\n\n\n\n\n ln 1, col 17   keys 16   backspaces 0"
   }
  },
  {
   "id": "text-type-banner",
   "title": "Big Type & Delete",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Reveal & Type",
   "description": "Big letters typed in one keystroke at a time behind a blinking block cursor, held, deleted back to nothing, then the next word.",
   "tags": [
    "typewriter",
    "banner",
    "cursor",
    "hero",
    "loop"
   ],
   "kind": "text",
   "size": {
    "w": 72,
    "h": 14
   },
   "minSize": {
    "w": 36,
    "h": 8
   },
   "animated": true,
   "loop": 18,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/reveal.js",
   "imports": [
    "../../color.js",
    "../../text/banner.js",
    "./_text.js"
   ],
   "usage": {
    "cli": "prism-term play text-type-banner",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play text-type-banner",
    "snapshot": "prism-term snapshot text-type-banner --size 72x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('text-type-banner'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/text-type-banner"
   },
   "preview": {
    "w": 60,
    "h": 14,
    "text": "\n\n\n\n##\n##\n##\n##\n##\n\n\n\n\n"
   }
  },
  {
   "id": "text-decrypt",
   "title": "Decrypt Reveal",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Reveal & Type",
   "description": "Fixed-pitch big letters churn through random glyphs and lock in left to right, with a key fingerprint and progress bar resolving below.",
   "tags": [
    "decrypt",
    "scramble",
    "hacker",
    "reveal",
    "cipher"
   ],
   "kind": "text",
   "size": {
    "w": 80,
    "h": 16
   },
   "minSize": {
    "w": 36,
    "h": 9
   },
   "animated": true,
   "loop": 15,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/reveal.js",
   "imports": [
    "../../color.js",
    "../../text/banner.js",
    "./_text.js"
   ],
   "usage": {
    "cli": "prism-term play text-decrypt",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play text-decrypt",
    "snapshot": "prism-term snapshot text-decrypt --size 80x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('text-decrypt'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/text-decrypt"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n\n\n\n          ######   #########  ################\n          ## #### ### ##  ##  ##  ######  ##  ##\n          #####  #### ####  ######  ########  ##\n          ## ##   ### ##  ######    ####  ##  ##\n          ######   #####  ######    ##########\n\n                key 7f:3a:91:c4:0e:b2:58:d6\n           [##############################] 100%\n\n\n\n"
   }
  },
  {
   "id": "text-matrix-reveal",
   "title": "Digital Rain Reveal",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Reveal & Type",
   "description": "Green code rain pours down; wherever a falling head crosses the hidden message, that letter locks in bright and stays.",
   "tags": [
    "matrix",
    "digital rain",
    "reveal",
    "katakana",
    "message"
   ],
   "kind": "text",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 30,
    "h": 10
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/reveal.js",
   "imports": [
    "../../color.js",
    "../../text/banner.js",
    "./_text.js"
   ],
   "usage": {
    "cli": "prism-term play text-matrix-reveal",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play text-matrix-reveal",
    "snapshot": "prism-term snapshot text-matrix-reveal --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('text-matrix-reveal'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/text-matrix-reveal"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "     A A       &51   X    +  >F@  0A          @ 7B 3 =     @\n     >        B  6   C    >  1<%A 4@          1 B/ * 3     #\n  &  1        /  8   %    *  /#41             X +7 A $\n  8           8    $ 2    2  <=19       #     @ 1X 0 4\n  %           #    B 0    9  70$Z       E     1 &- 1 D\nB &         $ -    > $ 8  4  $0C7       8     7  4 - %  $\nE 0 +       2 5    Zw ke  C  operat r...=     C  9 2 9Z -\n& = C F     7 &    5   4     >=<<   X   F   C 2    A -E ZZ\n3 7 Z 1  1  A84    A& he    ld+has  ou. -   * 1    2 +% +9\n@ 2 F 9  X $Z+/    27  B    02DD&   6D  E # $ 0    3  F 5/<\n$   4 X  F &$%     ol ow    0g een  rompt.F * X       F AZ6\n$   E +  % B/Z     %%  A    >8 F%<  ZX% 7 2 7         Z 3%7\n5   - *  % +$<     2*  -    /C   4  8/+ & Z #         @ X/&\n0   % 8  6 D79     5D &#    $    +  +$* Z D 8         > $43\n4C  < >  5 4 3      Z @Z    Z    2  0F% % 4 B         E $ +\n#D    9    Z 0      8 5#    4    B  /%+ - + #         4 3 %"
   }
  },
  {
   "id": "text-falling-letters",
   "title": "Falling Letters",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Reveal & Type",
   "description": "Big letters rain down one after another, bounce to rest under gravity, hold, then drop away through the floor for the next word.",
   "tags": [
    "falling",
    "gravity",
    "bounce",
    "rain",
    "physics"
   ],
   "kind": "text",
   "size": {
    "w": 72,
    "h": 18
   },
   "minSize": {
    "w": 36,
    "h": 10
   },
   "animated": true,
   "loop": 20,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/reveal.js",
   "imports": [
    "../../color.js",
    "../../text/banner.js",
    "./_text.js"
   ],
   "usage": {
    "cli": "prism-term play text-falling-letters",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play text-falling-letters",
    "snapshot": "prism-term snapshot text-falling-letters --size 72x18",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('text-falling-letters'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/text-falling-letters"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "                                 e\n                                                 i\n\n\n              d                        n\n     ####  ####      ##    ##  ##  ######  ######  ##  ##\n   ##      ##  ##  ##  ##  ##  ##    ##      ##    ##  ##\ns  ##  ##  ####    ######  ##  ##    ##      ##      ##\n   ##  ##  ##  ##  ##  ##  ##  ##    ##      ##      ##\n     ####  ##  ##  ##  ##    ##    ######v   ##      ##\n         t\n                                        x\n                               g      v\n                      y                      j\n                                   o\n                 z"
   }
  },
  {
   "id": "text-split-flap",
   "title": "Split-Flap Board",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Reveal & Type",
   "description": "A departures board whose tiles riffle through the alphabet one flap at a time until every row settles on its new flight.",
   "tags": [
    "split flap",
    "solari",
    "departures",
    "board",
    "airport"
   ],
   "kind": "text",
   "size": {
    "w": 66,
    "h": 14
   },
   "minSize": {
    "w": 34,
    "h": 9
   },
   "animated": true,
   "loop": 14,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/reveal.js",
   "imports": [
    "../../color.js",
    "../../text/banner.js",
    "./_text.js"
   ],
   "usage": {
    "cli": "prism-term play text-split-flap",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play text-split-flap",
    "snapshot": "prism-term snapshot text-split-flap --size 66x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('text-split-flap'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/text-split-flap"
   },
   "preview": {
    "w": 60,
    "h": 14,
    "text": "              DEPARTURES                09:41\n              TIME  DEST        GT  STATUS\n              09:41 LISBON      A12 BOARDGNG\n\n              09:55 REYKJAVIK   A03 OG8TIMC8\n\n              10:10 OSAKA -     C07 DALAY 2\n\n              1Z:X5 NAIRO6I     504 ON:THME\n\n              1V:U0 V LPARAICO- D1K 85N 14LED\n\n\n"
   }
  },
  {
   "id": "text-scramble",
   "title": "Scramble Styles",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Reveal & Type",
   "description": "Four ways to scramble a line — glyph noise, binary, hex and shaded blocks — each morphing phrase to phrase, char by char.",
   "tags": [
    "scramble",
    "decode",
    "morph",
    "binary",
    "hex"
   ],
   "kind": "text",
   "size": {
    "w": 60,
    "h": 12
   },
   "minSize": {
    "w": 34,
    "h": 8
   },
   "animated": true,
   "loop": 16,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/reveal.js",
   "imports": [
    "../../color.js",
    "../../text/banner.js",
    "./_text.js"
   ],
   "usage": {
    "cli": "prism-term play text-scramble",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play text-scramble",
    "snapshot": "prism-term snapshot text-scramble --size 60x12",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('text-scramble'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/text-scramble"
   },
   "preview": {
    "w": 60,
    "h": 12,
    "text": "\n\n glyphs   root cause: pool exhausted\n\n binary   00101 handshake ok\n\n hex      80af354c83a3edl1m  0\n\n blocks   loc.ing frequency\n\n\n"
   }
  },
  {
   "id": "text-glitch",
   "title": "RGB Split Glitch",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Glitch & Distort",
   "description": "Big type torn into horizontal slices, red and cyan channels pulled apart, blocks displaced — in bursts, like a failing signal.",
   "tags": [
    "glitch",
    "rgb split",
    "tearing",
    "datamosh",
    "corruption"
   ],
   "kind": "text",
   "size": {
    "w": 72,
    "h": 16
   },
   "minSize": {
    "w": 36,
    "h": 8
   },
   "animated": true,
   "loop": 6,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/glitch.js",
   "imports": [
    "../../color.js",
    "../../text/banner.js",
    "../../text/effects.js",
    "./_text.js"
   ],
   "usage": {
    "cli": "prism-term play text-glitch",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play text-glitch",
    "snapshot": "prism-term snapshot text-glitch --size 72x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('text-glitch'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/text-glitch"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n\n\n\n           ##### ##     #### ######  ##### ##  ##\n          ##     ##      ##    ##   ##     ##  ##\n          ##     ##      ##    ##   ##     ##  ##\n          ## ### ##      ##    ##   ##     ######\n          ##  ## ##      ##    ##   ##     ##  ##\n          ##  ## ##      ##    ##   ##     ##  ##\n           ##### ###### ####   ##    ##### ##  ##\n       #        #          #  ##        #      #\n\n                    ERR 0x5F FRAME DROP\n\n"
   }
  },
  {
   "id": "text-vhs",
   "title": "VHS Tracking",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Glitch & Distort",
   "description": "A title card off a worn tape: wobbling rows, chroma bleed, scanlines and a rolling tracking band, under a PLAY on-screen display.",
   "tags": [
    "vhs",
    "tape",
    "tracking",
    "scanlines",
    "retro",
    "osd"
   ],
   "kind": "text",
   "size": {
    "w": 80,
    "h": 20
   },
   "minSize": {
    "w": 36,
    "h": 10
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/glitch.js",
   "imports": [
    "../../color.js",
    "../../text/banner.js",
    "../../text/effects.js",
    "./_text.js"
   ],
   "usage": {
    "cli": "prism-term play text-vhs",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play text-vhs",
    "snapshot": "prism-term snapshot text-vhs --size 80x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('text-vhs'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/text-vhs"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n  PLAY >\n\n\n     #      ###  ##### #####      #### #   #  ###  #   #\n    #     #   #   #   #         #     #   # #   # #   #\n    #     #   #   #   #         #     #   # #   # #   #\n    #     #####   #   ####       ###  ##### #   # # # #\n    #     #   #   #   #             # #   # #   # # # #\n    #     #   #   #   #             # #   # #   # # # #\n   ##### #   #   #   #####     ####  #   #  ###   # #\n\n   ### ##    #   ###  #           #             #    ##   #\n   ####      #                      #   ##      #\n  OCT.13 2026                                   SP 0:12:37\n"
   }
  },
  {
   "id": "text-melt",
   "title": "Melting Type",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Glitch & Distort",
   "description": "Letters soften and sag, drips run from their undersides and drop into a spreading puddle — then the word sets again.",
   "tags": [
    "melt",
    "drip",
    "liquid",
    "horror",
    "distort"
   ],
   "kind": "text",
   "size": {
    "w": 72,
    "h": 20
   },
   "minSize": {
    "w": 36,
    "h": 10
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/glitch.js",
   "imports": [
    "../../color.js",
    "../../text/banner.js",
    "../../text/effects.js",
    "./_text.js"
   ],
   "usage": {
    "cli": "prism-term play text-melt",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play text-melt",
    "snapshot": "prism-term snapshot text-melt --size 72x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('text-melt'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/text-melt"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n  ####      ####  ############  ####          ############\n  ######  ######  ####          ####              ####\n  ##############  ####          ####              ####\n  ##############  ####  ####    ####              ####\n  ####    # ####  ##########    ####              ####\n  ####    # ####  ####          ####              ####\n  ####    # ####  ####          ####              ####\n  ####      ####  ############  ############      ####\n   #                       #          ##\n   #                       #          #\n                           #\n                           #\n                           #\n                           #\n            ###################################"
   }
  },
  {
   "id": "text-neon-sign",
   "title": "Neon Sign",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Color & Light",
   "description": "Thin glass tubes bent along the letters stutter on one by one with a soft halo; one tired letter keeps buzzing out, like every real sign.",
   "tags": [
    "neon",
    "sign",
    "flicker",
    "glow",
    "night"
   ],
   "kind": "text",
   "size": {
    "w": 64,
    "h": 18
   },
   "minSize": {
    "w": 34,
    "h": 10
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/light.js",
   "imports": [
    "../../color.js",
    "../../text/banner.js",
    "./_text.js"
   ],
   "usage": {
    "cli": "prism-term play text-neon-sign",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play text-neon-sign",
    "snapshot": "prism-term snapshot text-neon-sign --size 64x18",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('text-neon-sign'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/text-neon-sign"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n\n\n\n                      -----------------\n                     --#--##--###-#--#-\n                     -#-#-#-#-#---##-#-\n                     -#-#-##--##--#-##-\n                     -#-#-#---#---#--#-\n                     --#--#- -###-#--#-\n                      ------ ----------\n\n\n\n\n"
   }
  },
  {
   "id": "text-fire",
   "title": "Text on Fire",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Color & Light",
   "description": "The letters are the fuel: every ink pixel feeds a rising flame field, so fire licks off the tops and edges of the word.",
   "tags": [
    "fire",
    "flames",
    "burning",
    "heat",
    "doom fire"
   ],
   "kind": "text",
   "size": {
    "w": 72,
    "h": 20
   },
   "minSize": {
    "w": 36,
    "h": 10
   },
   "animated": true,
   "loop": 6,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/light.js",
   "imports": [
    "../../color.js",
    "../../text/banner.js",
    "./_text.js"
   ],
   "usage": {
    "cli": "prism-term play text-fire",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play text-fire",
    "snapshot": "prism-term snapshot text-fire --size 72x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('text-fire'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/text-fire"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n\n\n    :-   :-   ::: :   := .        :-            :\n    ::   :  -=** --=+ .:=    ---==+*:==    = +  :- -==\n    ++*++**#*-=+* **=++*==+#-+=:-++==* =- + =-==  --= +\n     ***##%**#***   ***** #  *##%*%#*#%+ ***+**##**#+*=\n     ##%%%%#**###%#*#%#%%%#  *######%%#    #*%##*%%####\n     %%%%%%%%%%%%..%%%%%%%%..%%%%%%%%%%..%.%%%%%%%%%%%%\n     %%%%........  ..%%%%.. .%%##....%%%%..##%%........\n     ####......#  -=.####.  .##%%....%%##..####.......=-=\n     ##########. :==.####.  .##########.. .##########.=+\n     ####......    *.####.  .####..####.. .####......*#\n     ####.         ..####.. .####. ..####..####........\n     ****.        .****##**..####.  .****..##**##****##\n"
   }
  },
  {
   "id": "text-rainbow-sweep",
   "title": "Rainbow Sweep",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Color & Light",
   "description": "A bright leading edge sweeps over gray type and leaves a flowing spectrum behind it — then sweeps back and drains it out.",
   "tags": [
    "rainbow",
    "sweep",
    "spectrum",
    "wipe",
    "prism"
   ],
   "kind": "text",
   "size": {
    "w": 72,
    "h": 14
   },
   "minSize": {
    "w": 36,
    "h": 8
   },
   "animated": true,
   "loop": 6,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/light.js",
   "imports": [
    "../../color.js",
    "../../text/banner.js",
    "./_text.js"
   ],
   "usage": {
    "cli": "prism-term play text-rainbow-sweep",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play text-rainbow-sweep",
    "snapshot": "prism-term snapshot text-rainbow-sweep --size 72x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('text-rainbow-sweep'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/text-rainbow-sweep"
   },
   "preview": {
    "w": 60,
    "h": 14,
    "text": "\n\n\n   ##### #####  ######  ##### ###### #####  ##  ## ##   ##\n  ##     ##  ## ##     ##       ##   ##  ## ##  ## ### ###\n  ##     ##  ## ##     ##       ##   ##  ## ##  ## #######\n   ####  #####  #####  ##       ##   #####  ##  ## ## # ##\n      ## ##     ##     ##       ##   ## ##  ##  ## ##   ##\n      ## ##     ##     ##       ##   ##  ## ##  ## ##   ##\n  #####  ##     ######  #####   ##   ##  ##  ####  ##   ##\n\n  ========================================================\n\n"
   }
  },
  {
   "id": "text-lolcat",
   "title": "lolcat Output",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Color & Light",
   "description": "A directory listing piped through lolcat: the classic diagonal rainbow of phase-shifted sines, flowing as with lolcat -a.",
   "tags": [
    "lolcat",
    "rainbow",
    "terminal",
    "pipe",
    "ls"
   ],
   "kind": "text",
   "size": {
    "w": 64,
    "h": 14
   },
   "minSize": {
    "w": 36,
    "h": 8
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/light.js",
   "imports": [
    "../../color.js",
    "../../text/banner.js",
    "./_text.js"
   ],
   "usage": {
    "cli": "prism-term play text-lolcat",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play text-lolcat",
    "snapshot": "prism-term snapshot text-lolcat --size 64x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('text-lolcat'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/text-lolcat"
   },
   "preview": {
    "w": 60,
    "h": 14,
    "text": "$ ls -la ~/projects | lolcat -a\ntotal 48\ndrwxr-xr-x  8 ops ops 4096 Oct 13 09:41 .\ndrwxr-xr-x 24 ops ops 4096 Oct 12 18:02 ..\n-rw-r--r--  1 ops ops  220 Oct  2 11:15 .editorconfig\ndrwxr-xr-x  8 ops ops 4096 Oct 13 09:40 .git\n-rw-r--r--  1 ops ops 1532 Oct 11 16:27 README.md\ndrwxr-xr-x  4 ops ops 4096 Oct  9 10:03 docs\n-rwxr-xr-x  1 ops ops 8721 Oct 13 08:55 deploy.sh\ndrwxr-xr-x  6 ops ops 4096 Oct 12 22:48 src\ndrwxr-xr-x  3 ops ops 4096 Oct 10 14:30 test\n$\n\n"
   }
  },
  {
   "id": "text-chrome",
   "title": "80s Chrome",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Color & Light",
   "description": "Polished-chrome lettering — sky above a hard horizon line, warm ground below — with a glint that slides across and a star-burst sparkle.",
   "tags": [
    "chrome",
    "metal",
    "80s",
    "glint",
    "retro"
   ],
   "kind": "text",
   "size": {
    "w": 72,
    "h": 16
   },
   "minSize": {
    "w": 36,
    "h": 9
   },
   "animated": true,
   "loop": 5,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/light.js",
   "imports": [
    "../../color.js",
    "../../text/banner.js",
    "./_text.js"
   ],
   "usage": {
    "cli": "prism-term play text-chrome",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play text-chrome",
    "snapshot": "prism-term snapshot text-chrome --size 72x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('text-chrome'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/text-chrome"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n\n\n     #####   ##  ##   #####     ####    ##   ##   ######\n    ##       ##  ##   ##  ##   ##  ##   ### ###   ##\n    ##       ##  ##   ##  ##   ##  ##   #######   ##\n    ##       ######   #####    ##  ##   ## # ##   #####\n    ##       ##  ##   ## ##    ##  ##   ##   ##   ##\n    ##       ##  ##   ##  ##   ##  ##   ##   ##   ##\n     #####   ##  ##   ##  ##    ####    ##   ##   ######\n\n                         est. 1986\n\n\n\n"
   }
  },
  {
   "id": "text-wave",
   "title": "Wave & Ripple",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Motion",
   "description": "Letters ride a traveling sine wave while their reflection shimmers below in rippling water, rows sliding as the surface moves.",
   "tags": [
    "wave",
    "ripple",
    "reflection",
    "water",
    "bounce"
   ],
   "kind": "text",
   "size": {
    "w": 72,
    "h": 20
   },
   "minSize": {
    "w": 36,
    "h": 10
   },
   "animated": true,
   "loop": 6.283,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/motion.js",
   "imports": [
    "../../color.js",
    "../../text/banner.js",
    "./_text.js"
   ],
   "usage": {
    "cli": "prism-term play text-wave",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play text-wave",
    "snapshot": "prism-term snapshot text-wave --size 72x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('text-wave'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/text-wave"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "                                       ##      ######\n                               ####    ##      ##\n                       ####    ##  ##  ##      ####\n               ######  ##  ##  ####    ##      ##\n       ####      ##    ####    ##      ######  ######\n       ##  ##    ##    ##      ##\n       ####      ##    ##\n       ##  ##  ######\n       ##  ##\n############################################################\n       ##  ##  ######\n      ####      ##    ##\n      ##  ##    ##    ##      ##\n       ####      ##    ####    ##      ######  ######\n               ######  ##  ##  ####    ##      ##\n                        ####    ##  ##  ##      ####"
   }
  },
  {
   "id": "text-sine-scroller",
   "title": "Sine Scroller",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Motion",
   "description": "The demoscene classic: an endless greetings message scrolls past in big rainbow letters, every pixel column riding a sine curve.",
   "tags": [
    "scroller",
    "sine",
    "demoscene",
    "cracktro",
    "greetings"
   ],
   "kind": "text",
   "size": {
    "w": 80,
    "h": 16
   },
   "minSize": {
    "w": 36,
    "h": 8
   },
   "animated": true,
   "loop": 30,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/motion.js",
   "imports": [
    "../../color.js",
    "../../text/banner.js",
    "./_text.js"
   ],
   "usage": {
    "cli": "prism-term play text-sine-scroller",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play text-sine-scroller",
    "snapshot": "prism-term snapshot text-sine-scroller --size 80x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('text-sine-scroller'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/text-sine-scroller"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n                                       ##########\n                               ####  ####            ##\n                          #    ####  ####          #########\n                       ######  ####  ####  ######  ####\n                 ####  ############  ####    ####    ##\n######   ##  ######    ####  ######  ####    ####      #####\n   ######      ####    ####    ####    ##########          #\n   ####        ####    ####    ####                ####    #\n   ####        ####    ####                            #####\n   ####        ####    ###\n   ####        ######\n   ####      ####\n      #\n\n############################################################"
   }
  },
  {
   "id": "text-marquee",
   "title": "LED Ticker Board",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Motion",
   "description": "An amber dot-matrix sign: every LED is a cell, unlit ones faintly visible, and the message steps left one column at a time.",
   "tags": [
    "marquee",
    "ticker",
    "led",
    "dot matrix",
    "sign"
   ],
   "kind": "text",
   "size": {
    "w": 72,
    "h": 11
   },
   "minSize": {
    "w": 30,
    "h": 9
   },
   "animated": true,
   "loop": 24,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/motion.js",
   "imports": [
    "../../color.js",
    "../../text/banner.js",
    "./_text.js"
   ],
   "usage": {
    "cli": "prism-term play text-marquee",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play text-marquee",
    "snapshot": "prism-term snapshot text-marquee --size 72x11",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('text-marquee'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/text-marquee"
   },
   "preview": {
    "w": 60,
    "h": 11,
    "text": "\n+----------------------------------------------------------+\n|OO...OOO..OOO.O...O.........O...O..OOO..OOOO..OOOO...OOO..|\n|..O.O...O..O..O...O.........O...O.O...O.O...O.O...O.O...O.|\n|..O.O...O..O..OO..O.........O...O.O...O.O...O.O...O.O...O.|\n|OO..OOOOO..O..O.O.O.........OOOOO.OOOOO.OOOO..OOOO..O...O.|\n|O...O...O..O..O..OO.........O...O.O...O.O.O...O...O.O...O.|\n|.O..O...O..O..O...O.........O...O.O...O.O..O..O...O.O...O.|\n|..O.O...O.OOO.O...O.........O...O.O...O.O...O.OOOO...OOO..|\n+----------------------------------------------------------+\n PLATFORM 2   09:41"
   }
  },
  {
   "id": "text-clock",
   "title": "Big Digital Clock",
   "gallery": "text",
   "galleryTitle": "Text Effects",
   "category": "Motion",
   "description": "A big seven-segment clock — HH:MM with a blinking colon, small seconds, faint unlit segments like a real LCD, and the date.",
   "tags": [
    "clock",
    "7 segment",
    "digital",
    "time",
    "lcd"
   ],
   "kind": "text",
   "size": {
    "w": 72,
    "h": 16
   },
   "minSize": {
    "w": 36,
    "h": 8
   },
   "animated": true,
   "loop": 60,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/text/motion.js",
   "imports": [
    "../../color.js",
    "../../text/banner.js",
    "./_text.js"
   ],
   "usage": {
    "cli": "prism-term play text-clock",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play text-clock",
    "snapshot": "prism-term snapshot text-clock --size 72x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('text-clock'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/text-clock"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n     .....    *****          .....    .....     AM\n    .     .  *     *        *     *  .     *\n    .     .  *     *        *     *  .     *\n    .     .  *     *        *     *  .     *\n    .     .  *     *   *    *     *  .     *\n     .....    *****          *****    .....      **    **\n    .     .  .     *        .     *  .     *    *  *  .  *\n    .     .  .     *        .     *  .     *    *  *  .  *\n    .     .  .     *   *    .     *  .     *     ..    **\n    .     .  .     *        .     *  .     *    *  *  .  *\n    .     .  .     *        .     *  .     *    *  *  .  *\n     .....    *****          .....    .....      **    **\n\n                      TUE 13 OCT 2026\n"
   }
  },
  {
   "id": "loaders-line",
   "title": "Line",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Classic Spinners",
   "description": "The original |/-\\ twirl. Pure ASCII, so it works on a serial console, in LANG=C, and on every terminal ever made.",
   "tags": [
    "spinner",
    "loader",
    "ascii",
    "classic",
    "twirl"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/classic.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-line",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-line",
    "snapshot": "prism-term snapshot loaders-line --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-line'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-line"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Linking vmlinux...                  3.0s\n\n - \\ | /\n\n line - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-pipe",
   "title": "Pipe",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Classic Spinners",
   "description": "A box-drawing tee rotating through all eight orientations — sturdy, compact and readable at a glance.",
   "tags": [
    "spinner",
    "loader",
    "box drawing",
    "rotate"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/classic.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-pipe",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-pipe",
    "snapshot": "prism-term snapshot loaders-pipe --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-pipe'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-pipe"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Building dependency tree...         3.0s\n\n - \\ | /\n\n pipe (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-arc",
   "title": "Arc",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Classic Spinners",
   "description": "A quarter arc sweeping round a circle in six steps — the smooth, modern single-cell loader.",
   "tags": [
    "spinner",
    "loader",
    "circle",
    "arc",
    "smooth"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/classic.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-arc",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-arc",
    "snapshot": "prism-term snapshot loaders-arc --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-arc'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-arc"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Negotiating TLS...                  3.0s\n\n - \\ | /\n\n arc (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-circle",
   "title": "Half Circle",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Classic Spinners",
   "description": "A half-filled circle turning in four beats — calm enough for long waits.",
   "tags": [
    "spinner",
    "loader",
    "circle",
    "half"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/classic.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-circle",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-circle",
    "snapshot": "prism-term snapshot loaders-circle --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-circle'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-circle"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Retrying in a moment...             3.0s\n\n - \\ | /\n\n circle (ASCII fallback) - 4 frames - 130 ."
   }
  },
  {
   "id": "loaders-quarter",
   "title": "Quarter Circle",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Classic Spinners",
   "description": "A filled quadrant walking round a circle — a clock face reduced to one glyph.",
   "tags": [
    "spinner",
    "loader",
    "circle",
    "quadrant",
    "clock"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/classic.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-quarter",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-quarter",
    "snapshot": "prism-term snapshot loaders-quarter --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-quarter'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-quarter"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Waiting for a free runner...        3.0s\n\n - \\ | /\n\n quarter (ASCII fallback) - 4 frames - 130."
   }
  },
  {
   "id": "loaders-square-corners",
   "title": "Square Corners",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Classic Spinners",
   "description": "A square with one filled corner orbiting clockwise — geometric and easy on the eye.",
   "tags": [
    "spinner",
    "loader",
    "square",
    "geometric"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/classic.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-square-corners",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-square-corners",
    "snapshot": "prism-term snapshot loaders-square-corners --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-square-corners'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-square-corners"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Attaching to node-03...             3.0s\n\n - \\ | /\n\n squareCorners (ASCII fallback) - 4 frames."
   }
  },
  {
   "id": "loaders-triangle",
   "title": "Triangle",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Classic Spinners",
   "description": "Right triangles spinning through four corners at speed — a sharp, energetic loader.",
   "tags": [
    "spinner",
    "loader",
    "triangle",
    "fast"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/classic.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-triangle",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-triangle",
    "snapshot": "prism-term snapshot loaders-triangle --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-triangle'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-triangle"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Building search index...            3.0s\n\n - \\ | /\n\n triangle (ASCII fallback) - 4 frames - 13."
   }
  },
  {
   "id": "loaders-arrows",
   "title": "Arrows",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Classic Spinners",
   "description": "An arrow pointing round all eight compass directions — reads as \"rotating\" even in a tiny font.",
   "tags": [
    "spinner",
    "loader",
    "arrows",
    "compass",
    "rotate"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/classic.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-arrows",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-arrows",
    "snapshot": "prism-term snapshot loaders-arrows --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-arrows'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-arrows"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Rotating logs...                    3.0s\n\n - \\ | /\n\n arrows (ASCII fallback) - 4 frames - 130 ."
   }
  },
  {
   "id": "loaders-arrows-chase",
   "title": "Arrow Chase",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Classic Spinners",
   "description": "A lit arrowhead marching along a row of hollow ones — direction and progress in five cells.",
   "tags": [
    "spinner",
    "loader",
    "arrows",
    "chase",
    "marquee"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/classic.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-arrows-chase",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-arrows-chase",
    "snapshot": "prism-term snapshot loaders-arrows-chase --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-arrows-chase'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-arrows-chase"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Streaming build logs...             3.0s\n\n - \\ | /\n\n arrows2 (ASCII fallback) - 4 frames - 130."
   }
  },
  {
   "id": "loaders-star",
   "title": "Star",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Classic Spinners",
   "description": "A star cycling through its point counts so it seems to twinkle — friendly, for user-facing CLIs.",
   "tags": [
    "spinner",
    "loader",
    "star",
    "twinkle"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/classic.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-star",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-star",
    "snapshot": "prism-term snapshot loaders-star --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-star'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-star"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Polishing the changelog...          3.0s\n\n - \\ | /\n\n star (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-hamburger",
   "title": "Trigram",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Classic Spinners",
   "description": "Trigram glyphs shifting their broken line up and down — a compact, unusual loader.",
   "tags": [
    "spinner",
    "loader",
    "trigram",
    "lines"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/classic.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-hamburger",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-hamburger",
    "snapshot": "prism-term snapshot loaders-hamburger --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-hamburger'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-hamburger"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Validating schema...                3.0s\n\n - \\ | /\n\n hamburger (ASCII fallback) - 4 frames - 1."
   }
  },
  {
   "id": "loaders-toggle",
   "title": "Toggle",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Classic Spinners",
   "description": "A two-state toggle flipping back and forth — a heartbeat for something that is alive but idle.",
   "tags": [
    "spinner",
    "loader",
    "toggle",
    "two-state",
    "idle"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/classic.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-toggle",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-toggle",
    "snapshot": "prism-term snapshot loaders-toggle --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-toggle'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-toggle"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Watching for changes...             3.0s\n\n - \\ | /\n\n toggle (ASCII fallback) - 4 frames - 130 ."
   }
  },
  {
   "id": "loaders-dqpb",
   "title": "dqpb",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Classic Spinners",
   "description": "The letters d q p b rotating into each other — an ASCII-only spinner with a sense of humour.",
   "tags": [
    "spinner",
    "loader",
    "ascii",
    "letters",
    "playful"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/classic.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-dqpb",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-dqpb",
    "snapshot": "prism-term snapshot loaders-dqpb --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-dqpb'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-dqpb"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " p Rotating letters...                 3.0s\n\n d q p b\n\n dqpb - 4 frames - 100 ms"
   }
  },
  {
   "id": "loaders-flip",
   "title": "Flip",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Classic Spinners",
   "description": "A short line flipping up from the baseline and back down — a coin toss in a single cell.",
   "tags": [
    "spinner",
    "loader",
    "flip",
    "coin"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/classic.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-flip",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-flip",
    "snapshot": "prism-term snapshot loaders-flip --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-flip'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-flip"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Electing primary...                 3.0s\n\n - \\ | /\n\n flip (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-bit-counter",
   "title": "Bit Counter",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Classic Spinners",
   "description": "An eight-bit register counting up in real binary, one increment per tick — low bits flicker, high bits creep. Pure ASCII.",
   "tags": [
    "spinner",
    "loader",
    "ascii",
    "binary",
    "counter",
    "original"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/originals.js",
   "imports": [
    "./_kit.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-bit-counter",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-bit-counter",
    "snapshot": "prism-term snapshot loaders-bit-counter --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-bit-counter'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-bit-counter"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " 11001001 Walking the bitmap...        3.0s\n\n 11000111 11001000 11001001 11001010     ..\n\n bit-counter - 64 frames - 120 ms"
   }
  },
  {
   "id": "loaders-dots",
   "title": "Dots",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Braille Spinners",
   "description": "The braille dot rotation npm, ora and half of all modern CLIs use — the default for a reason.",
   "tags": [
    "spinner",
    "loader",
    "braille",
    "npm",
    "ora",
    "default"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "braille",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/braille.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-dots",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-dots",
    "snapshot": "prism-term snapshot loaders-dots --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-dots'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-dots"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Fetching 214 packages...            3.0s\n\n - \\ | /\n\n dots (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-dots-fill",
   "title": "Dots Filled",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Braille Spinners",
   "description": "A full braille cell with one dot missing, the gap orbiting round — bold enough for dark themes.",
   "tags": [
    "spinner",
    "loader",
    "braille",
    "bold"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "braille",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/braille.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-dots-fill",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-dots-fill",
    "snapshot": "prism-term snapshot loaders-dots-fill --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-dots-fill'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-dots-fill"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Downloading rustc 1.82.0...         3.0s\n\n - \\ | /\n\n dots2 (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-dot-orbit",
   "title": "Single Dot",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Braille Spinners",
   "description": "One braille dot tracing the edge of its cell — the quietest loader in the catalog.",
   "tags": [
    "spinner",
    "loader",
    "braille",
    "minimal",
    "quiet"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "braille",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/braille.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-dot-orbit",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-dot-orbit",
    "snapshot": "prism-term snapshot loaders-dot-orbit --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-dot-orbit'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-dot-orbit"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Idle  polling every 5s...           3.0s\n\n - \\ | /\n\n dots3 (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-dot-bounce",
   "title": "Dot Bounce",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Braille Spinners",
   "description": "A single dot bouncing up and down the left column of a braille cell.",
   "tags": [
    "spinner",
    "loader",
    "braille",
    "bounce"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "braille",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/braille.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-dot-bounce",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-dot-bounce",
    "snapshot": "prism-term snapshot loaders-dot-bounce --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-dot-bounce'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-dot-bounce"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Waiting for ack...                  3.0s\n\n - \\ | /\n\n bounce (ASCII fallback) - 4 frames - 130 ."
   }
  },
  {
   "id": "loaders-dot-stack",
   "title": "Dot Stack",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Braille Spinners",
   "description": "Braille rows filling the cell from the bottom and draining again — a tiny level meter.",
   "tags": [
    "spinner",
    "loader",
    "braille",
    "fill",
    "level"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "braille",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/braille.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-dot-stack",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-dot-stack",
    "snapshot": "prism-term snapshot loaders-dot-stack --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-dot-stack'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-dot-stack"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Filling cache...                    3.0s\n\n - \\ | /\n\n loadingBlocks (ASCII fallback) - 4 frames."
   }
  },
  {
   "id": "loaders-snake",
   "title": "Snake",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Braille Spinners",
   "description": "A four-dot snake chasing its tail round a two-cell perimeter — busy without being loud.",
   "tags": [
    "spinner",
    "loader",
    "braille",
    "snake",
    "perimeter"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "braille",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/braille.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-snake",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-snake",
    "snapshot": "prism-term snapshot loaders-snake --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-snake'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-snake"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Walking the tree...                 3.0s\n\n - \\ | /\n\n snake (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-dot-fill",
   "title": "Cell Fill",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Braille Spinners",
   "description": "One braille cell filling dot by dot from the bottom, then draining — progress without a number.",
   "tags": [
    "spinner",
    "loader",
    "braille",
    "fill"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "braille",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/braille.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-dot-fill",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-dot-fill",
    "snapshot": "prism-term snapshot loaders-dot-fill --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-dot-fill'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-dot-fill"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Warming up the JIT...               3.0s\n\n - \\ | /\n\n fill (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-dot-wave",
   "title": "Dot Wave",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Braille Spinners",
   "description": "Five braille dots riding a traveling sine wave — a calm, readable loader for long tasks.",
   "tags": [
    "spinner",
    "loader",
    "braille",
    "wave",
    "original"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "braille",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/originals.js",
   "imports": [
    "./_kit.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-dot-wave",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-dot-wave",
    "snapshot": "prism-term snapshot loaders-dot-wave --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-dot-wave'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-dot-wave"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Applying migrations...              3.0s\n\n - \\ | /\n\n dot-wave (ASCII fallback) - 4 frames - 13."
   }
  },
  {
   "id": "loaders-snake-game",
   "title": "Snake Game",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Braille Spinners",
   "description": "A braille snake slithering a serpentine path, eating the dot ahead of it and growing longer.",
   "tags": [
    "spinner",
    "loader",
    "braille",
    "snake",
    "game",
    "original"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "braille",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/originals.js",
   "imports": [
    "./_kit.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-snake-game",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-snake-game",
    "snapshot": "prism-term snapshot loaders-snake-game --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-snake-game'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-snake-game"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Eating backlog...                   3.0s\n\n - \\ | /\n\n snake-game (ASCII fallback) - 4 frames - ."
   }
  },
  {
   "id": "loaders-spiral",
   "title": "Spiral",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Braille Spinners",
   "description": "A two-armed braille spiral turning on the spot — hypnotic in a single pair of cells.",
   "tags": [
    "spinner",
    "loader",
    "braille",
    "spiral",
    "original"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "braille",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/originals.js",
   "imports": [
    "./_kit.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-spiral",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-spiral",
    "snapshot": "prism-term snapshot loaders-spiral --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-spiral'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-spiral"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Hashing...                          3.0s\n\n - \\ | /\n\n spiral (ASCII fallback) - 4 frames - 130 ."
   }
  },
  {
   "id": "loaders-pulse",
   "title": "Pulse",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Bars & Pulses",
   "description": "A dot swelling to a disc and back — a gentle pulse for \"still alive, nothing to report\".",
   "tags": [
    "spinner",
    "loader",
    "pulse",
    "dot",
    "breathe"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/bars.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-pulse",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-pulse",
    "snapshot": "prism-term snapshot loaders-pulse --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-pulse'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-pulse"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Keepalive...                        3.0s\n\n - \\ | /\n\n pulse (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-pulse-ring",
   "title": "Pulse Ring",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Bars & Pulses",
   "description": "A ring growing from a point to a bullseye and collapsing again — a sonar ping in one glyph.",
   "tags": [
    "spinner",
    "loader",
    "pulse",
    "ring",
    "sonar"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/bars.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-pulse-ring",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-pulse-ring",
    "snapshot": "prism-term snapshot loaders-pulse-ring --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-pulse-ring'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-pulse-ring"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Broadcasting presence...            3.0s\n\n - \\ | /\n\n pulseRing (ASCII fallback) - 4 frames - 1."
   }
  },
  {
   "id": "loaders-grow",
   "title": "Grow",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Bars & Pulses",
   "description": "A lower block rising to full height and falling back — the simplest level meter there is.",
   "tags": [
    "spinner",
    "loader",
    "blocks",
    "grow",
    "vertical"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/bars.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-grow",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-grow",
    "snapshot": "prism-term snapshot loaders-grow --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-grow'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-grow"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Growing heap...                     3.0s\n\n - \\ | /\n\n grow (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-grow-horizontal",
   "title": "Grow Sideways",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Bars & Pulses",
   "description": "A left-anchored block widening to full and shrinking back in eighths — smooth at any font size.",
   "tags": [
    "spinner",
    "loader",
    "blocks",
    "grow",
    "horizontal"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/bars.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-grow-horizontal",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-grow-horizontal",
    "snapshot": "prism-term snapshot loaders-grow-horizontal --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-grow-horizontal'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-grow-horizontal"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Resizing filesystem...              3.0s\n\n - \\ | /\n\n growH (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-bars",
   "title": "Shrinking Bar",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Bars & Pulses",
   "description": "A full block shrinking to a sliver and filling back up — the reverse of Grow Sideways.",
   "tags": [
    "spinner",
    "loader",
    "blocks",
    "shrink"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/bars.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-bars",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-bars",
    "snapshot": "prism-term snapshot loaders-bars --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-bars'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-bars"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Vacuuming tables...                 3.0s\n\n - \\ | /\n\n bars (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-shade",
   "title": "Shade",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Bars & Pulses",
   "description": "Dark, medium and light shade cycling — a flickering block like an old CRT warming up.",
   "tags": [
    "spinner",
    "loader",
    "shade",
    "crt",
    "flicker"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/bars.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-shade",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-shade",
    "snapshot": "prism-term snapshot loaders-shade --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-shade'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-shade"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Calibrating...                      3.0s\n\n - \\ | /\n\n noise (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-layer",
   "title": "Layers",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Bars & Pulses",
   "description": "One, two, then three horizontal strokes stacking up — layers being built.",
   "tags": [
    "spinner",
    "loader",
    "layers",
    "stack"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/bars.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-layer",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-layer",
    "snapshot": "prism-term snapshot loaders-layer --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-layer'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-layer"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Squashing layers...                 3.0s\n\n - \\ | /\n\n layer (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-quadrant-walk",
   "title": "Quadrant Walk",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Bars & Pulses",
   "description": "A quadrant block stepping round the four corners of its cell.",
   "tags": [
    "spinner",
    "loader",
    "quadrant",
    "block",
    "walk"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/bars.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-quadrant-walk",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-quadrant-walk",
    "snapshot": "prism-term snapshot loaders-quadrant-walk --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-quadrant-walk'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-quadrant-walk"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Cycling keys...                     3.0s\n\n - \\ | /\n\n boxBounce (ASCII fallback) - 4 frames - 1."
   }
  },
  {
   "id": "loaders-half-walk",
   "title": "Half-Block Walk",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Bars & Pulses",
   "description": "Half blocks turning left, top, right, bottom — a chunky square spinner that reads from across the room.",
   "tags": [
    "spinner",
    "loader",
    "half block",
    "chunky"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/bars.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-half-walk",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-half-walk",
    "snapshot": "prism-term snapshot loaders-half-walk --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-half-walk'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-half-walk"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Draining connections...             3.0s\n\n - \\ | /\n\n boxBounce2 (ASCII fallback) - 4 frames - ."
   }
  },
  {
   "id": "loaders-bouncing-bar",
   "title": "Bouncing Bar",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Bars & Pulses",
   "description": "Three equals signs bouncing inside brackets — the indeterminate bar of countless installers, pure ASCII.",
   "tags": [
    "spinner",
    "loader",
    "ascii",
    "indeterminate",
    "brackets"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/bars.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-bouncing-bar",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-bouncing-bar",
    "snapshot": "prism-term snapshot loaders-bouncing-bar --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-bouncing-bar'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-bouncing-bar"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " [ ===  ] Configuring packages...      3.0s\n\n [===   ] [ ===  ] [  === ] [   ===]     ..\n\n bouncingBar - 6 frames - 80 ms"
   }
  },
  {
   "id": "loaders-aesthetic",
   "title": "Aesthetic",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Bars & Pulses",
   "description": "Parallelogram segments filling a seven-cell track one by one — a loader that looks like a progress bar.",
   "tags": [
    "spinner",
    "loader",
    "segments",
    "fill",
    "track"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/bars.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-aesthetic",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-aesthetic",
    "snapshot": "prism-term snapshot loaders-aesthetic --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-aesthetic'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-aesthetic"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Encoding preview...                 3.0s\n\n - \\ | /\n\n aesthetic (ASCII fallback) - 4 frames - 1."
   }
  },
  {
   "id": "loaders-simple-dots",
   "title": "Ellipsis",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Bars & Pulses",
   "description": "Dots appearing one, two, three, then clearing — the typing ellipsis, pure ASCII.",
   "tags": [
    "spinner",
    "loader",
    "ascii",
    "ellipsis",
    "typing"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/bars.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-simple-dots",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-simple-dots",
    "snapshot": "prism-term snapshot loaders-simple-dots --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-simple-dots'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-simple-dots"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " ... Thinking                          3.0s\n\n .   ..  ...\n\n simpleDots - 4 frames - 300 ms"
   }
  },
  {
   "id": "loaders-point",
   "title": "Point",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Bars & Pulses",
   "description": "A bullet traveling along a row of small dots — a three-cell carousel.",
   "tags": [
    "spinner",
    "loader",
    "dots",
    "carousel"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/bars.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-point",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-point",
    "snapshot": "prism-term snapshot loaders-point --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-point'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-point"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Scrolling cursor...                 3.0s\n\n - \\ | /\n\n point (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-ripple",
   "title": "Ripple",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Bars & Pulses",
   "description": "Concentric braille rings expanding from a point and fading out — a sonar ping with depth.",
   "tags": [
    "spinner",
    "loader",
    "braille",
    "ripple",
    "sonar",
    "original"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "braille",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/originals.js",
   "imports": [
    "./_kit.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-ripple",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-ripple",
    "snapshot": "prism-term snapshot loaders-ripple --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-ripple'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-ripple"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Waiting for gossip...               3.0s\n\n - \\ | /\n\n ripple (ASCII fallback) - 4 frames - 130 ."
   }
  },
  {
   "id": "loaders-morse",
   "title": "Morse Lamp",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Bars & Pulses",
   "description": "A signal lamp flashing PRISM in real Morse timing while the code scrolls past on a tape.",
   "tags": [
    "spinner",
    "loader",
    "morse",
    "signal",
    "lamp",
    "original"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/originals.js",
   "imports": [
    "./_kit.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-morse",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-morse",
    "snapshot": "prism-term snapshot loaders-morse --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-morse'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-morse"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Signalling peers...                 3.0s\n\n - \\ | /\n\n morse (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-moon",
   "title": "Moon Phases",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Orbits & Shapes",
   "description": "The moon waxing and waning through eight phases — perfect for long-running, overnight jobs.",
   "tags": [
    "spinner",
    "loader",
    "emoji",
    "moon",
    "phases"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/orbits.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-moon",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-moon",
    "snapshot": "prism-term snapshot loaders-moon --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-moon'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-moon"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Snapshotting volumes...             3.0s\n\n - \\ | /\n\n moon (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-clock",
   "title": "Clock",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Orbits & Shapes",
   "description": "A clock face ticking through twelve hours — time is passing, literally.",
   "tags": [
    "spinner",
    "loader",
    "emoji",
    "clock",
    "time"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/orbits.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-clock",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-clock",
    "snapshot": "prism-term snapshot loaders-clock --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-clock'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-clock"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Sleeping until 02:00...             3.0s\n\n - \\ | /\n\n clock (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-earth",
   "title": "Globe",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Orbits & Shapes",
   "description": "The globe turning to show each continent in turn — for network and replication waits.",
   "tags": [
    "spinner",
    "loader",
    "emoji",
    "globe",
    "network"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/orbits.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-earth",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-earth",
    "snapshot": "prism-term snapshot loaders-earth --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-earth'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-earth"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Propagating DNS...                  3.0s\n\n - \\ | /\n\n earth (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-hourglass",
   "title": "Hourglass",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Orbits & Shapes",
   "description": "An hourglass flipping between running and empty — the universal \"this will take a while\".",
   "tags": [
    "spinner",
    "loader",
    "emoji",
    "hourglass",
    "wait"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/orbits.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-hourglass",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-hourglass",
    "snapshot": "prism-term snapshot loaders-hourglass --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-hourglass'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-hourglass"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Queued behind 3 jobs...             3.0s\n\n - \\ | /\n\n hourglass (ASCII fallback) - 4 frames - 1."
   }
  },
  {
   "id": "loaders-heart",
   "title": "Heartbeat",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Orbits & Shapes",
   "description": "An outlined heart beating solid and back — a liveness signal for watchers and daemons.",
   "tags": [
    "spinner",
    "loader",
    "heart",
    "liveness",
    "daemon"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/orbits.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-heart",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-heart",
    "snapshot": "prism-term snapshot loaders-heart --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-heart'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-heart"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / All checks passing...               3.0s\n\n - \\ | /\n\n heart (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-bouncing-ball",
   "title": "Bouncing Ball",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Orbits & Shapes",
   "description": "A ball bouncing wall to wall inside parentheses — the playful indeterminate bar.",
   "tags": [
    "spinner",
    "loader",
    "ball",
    "bounce",
    "indeterminate"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/orbits.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-bouncing-ball",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-bouncing-ball",
    "snapshot": "prism-term snapshot loaders-bouncing-ball --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-bouncing-ball'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-bouncing-ball"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Shuffling data...                   3.0s\n\n - \\ | /\n\n bouncingBall (ASCII fallback) - 4 frames ."
   }
  },
  {
   "id": "loaders-gravity-bounce",
   "title": "Gravity Bounce",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Orbits & Shapes",
   "description": "A braille ball hopping across eight cells under real parabolic gravity, trailing two dots of motion blur.",
   "tags": [
    "spinner",
    "loader",
    "physics",
    "ball",
    "gravity",
    "original"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "braille",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/originals.js",
   "imports": [
    "./_kit.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-gravity-bounce",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-gravity-bounce",
    "snapshot": "prism-term snapshot loaders-gravity-bounce --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-gravity-bounce'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-gravity-bounce"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Bouncing requests...                3.0s\n\n - \\ | /\n\n gravity-bounce (ASCII fallback) - 4 frame."
   }
  },
  {
   "id": "loaders-electrons",
   "title": "Electron Orbits",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Orbits & Shapes",
   "description": "Two electrons circling a warm nucleus on tilted orbits, drawn in four braille cells.",
   "tags": [
    "spinner",
    "loader",
    "braille",
    "atom",
    "orbit",
    "original"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "braille",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/originals.js",
   "imports": [
    "./_kit.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-electrons",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-electrons",
    "snapshot": "prism-term snapshot loaders-electrons --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-electrons'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-electrons"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Charging capacitors...              3.0s\n\n - \\ | /\n\n electrons (ASCII fallback) - 4 frames - 1."
   }
  },
  {
   "id": "loaders-clock-hand",
   "title": "Clock Hand",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Orbits & Shapes",
   "description": "A braille clock hand sweeping a two-cell dial round a fixed hub — Unicode-only, no emoji needed.",
   "tags": [
    "spinner",
    "loader",
    "braille",
    "clock",
    "time",
    "original"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "braille",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/originals.js",
   "imports": [
    "./_kit.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-clock-hand",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-clock-hand",
    "snapshot": "prism-term snapshot loaders-clock-hand --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-clock-hand'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-clock-hand"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Sleeping 30s...                     3.0s\n\n - \\ | /\n\n clock-hand (ASCII fallback) - 4 frames - ."
   }
  },
  {
   "id": "loaders-pong",
   "title": "Pong",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Orbits & Shapes",
   "description": "Two braille paddles rallying a ball across an eight-cell court, forever.",
   "tags": [
    "spinner",
    "loader",
    "braille",
    "pong",
    "game",
    "original"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "braille",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/originals.js",
   "imports": [
    "./_kit.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-pong",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-pong",
    "snapshot": "prism-term snapshot loaders-pong --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-pong'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-pong"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Exchanging keys...                  3.0s\n\n - \\ | /\n\n pong (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-chomp",
   "title": "Chomp",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Orbits & Shapes",
   "description": "A round braille mouth chomping its way through a row of pellets — a cheerful queue-drain loader.",
   "tags": [
    "spinner",
    "loader",
    "braille",
    "chomp",
    "game",
    "original"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "braille",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/originals.js",
   "imports": [
    "./_kit.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-chomp",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-chomp",
    "snapshot": "prism-term snapshot loaders-chomp --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-chomp'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-chomp"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Processing 1,204 items...           3.0s\n\n - \\ | /\n\n chomp (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-orbit",
   "title": "Orbit",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Truecolor Spinners",
   "description": "A planet with a short tail circling a lit sun in braille, shaded ice-blue — elegant for network waits.",
   "tags": [
    "spinner",
    "loader",
    "braille",
    "orbit",
    "planet"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "braille",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/truecolor.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-orbit",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-orbit",
    "snapshot": "prism-term snapshot loaders-orbit --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-orbit'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-orbit"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Handshaking with 10.20.4.11...      3.0s\n\n - \\ | /\n\n orbit (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-thinking",
   "title": "Thinking",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Truecolor Spinners",
   "description": "Three blobs bobbing on a phase-shifted wave in aurora colors — the AI typing indicator.",
   "tags": [
    "spinner",
    "loader",
    "braille",
    "ai",
    "typing"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "braille",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/truecolor.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-thinking",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-thinking",
    "snapshot": "prism-term snapshot loaders-thinking --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-thinking'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-thinking"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Reading the logs...                 3.0s\n\n - \\ | /\n\n thinking (ASCII fallback) - 4 frames - 13."
   }
  },
  {
   "id": "loaders-wave",
   "title": "Wave",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Truecolor Spinners",
   "description": "A sine wave of eighth blocks rolling across eight cells in ocean blues.",
   "tags": [
    "spinner",
    "loader",
    "blocks",
    "wave",
    "sine"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/truecolor.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-wave",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-wave",
    "snapshot": "prism-term snapshot loaders-wave --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-wave'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-wave"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Tuning in...                        3.0s\n\n - \\ | /\n\n wave (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-equalizer",
   "title": "Equalizer",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Truecolor Spinners",
   "description": "A five-band audio equalizer bouncing to an unheard beat, in synthwave colors.",
   "tags": [
    "spinner",
    "loader",
    "blocks",
    "equalizer",
    "audio",
    "music"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/truecolor.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-equalizer",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-equalizer",
    "snapshot": "prism-term snapshot loaders-equalizer --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-equalizer'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-equalizer"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Normalizing loudness...             3.0s\n\n - \\ | /\n\n equalizer (ASCII fallback) - 4 frames - 1."
   }
  },
  {
   "id": "loaders-dna",
   "title": "DNA",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Truecolor Spinners",
   "description": "Two braille helix strands twisting round each other with base-pair rungs, candy-colored.",
   "tags": [
    "spinner",
    "loader",
    "braille",
    "dna",
    "helix"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "braille",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/truecolor.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-dna",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-dna",
    "snapshot": "prism-term snapshot loaders-dna --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-dna'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-dna"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Aligning reads...                   3.0s\n\n - \\ | /\n\n dna (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-radar",
   "title": "Scanner Beam",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Truecolor Spinners",
   "description": "A block beam sweeping back and forth with a fading afterglow — scanning for something.",
   "tags": [
    "spinner",
    "loader",
    "blocks",
    "scan",
    "beam"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/truecolor.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-radar",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-radar",
    "snapshot": "prism-term snapshot loaders-radar --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-radar'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-radar"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Sweeping caches...                  3.0s\n\n - \\ | /\n\n radar (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-prism",
   "title": "Prism Facets",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Truecolor Spinners",
   "description": "Faceted diamonds catching a rainbow as they turn — Prism's own loader.",
   "tags": [
    "spinner",
    "loader",
    "diamond",
    "rainbow",
    "prism"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/truecolor.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-prism",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-prism",
    "snapshot": "prism-term snapshot loaders-prism --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-prism'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-prism"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Refracting...                       3.0s\n\n - \\ | /\n\n prism (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-binary",
   "title": "Binary",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Truecolor Spinners",
   "description": "A six-digit register of bits flickering in matrix green — for encodes, hashes and checksums.",
   "tags": [
    "spinner",
    "loader",
    "binary",
    "bits",
    "ascii digits"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "ascii",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/truecolor.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-binary",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-binary",
    "snapshot": "prism-term snapshot loaders-binary --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-binary'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-binary"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " 011001 Verifying checksum...          3.0s\n\n 101101 011001 100111 001010 000110 01010..\n\n binary - 12 frames - 80 ms"
   }
  },
  {
   "id": "loaders-katakana",
   "title": "Digital Rain",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Truecolor Spinners",
   "description": "Half-width katakana and digits flickering in green — a six-cell slice of digital rain.",
   "tags": [
    "spinner",
    "loader",
    "katakana",
    "matrix",
    "rain"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/truecolor.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-katakana",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-katakana",
    "snapshot": "prism-term snapshot loaders-katakana --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-katakana'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-katakana"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Following the white rabbit...       3.0s\n\n - \\ | /\n\n matrix (ASCII fallback) - 4 frames - 130 ."
   }
  },
  {
   "id": "loaders-prism-sweep",
   "title": "Prism Refraction",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Truecolor Spinners",
   "description": "A pulse of white light hitting a prism and fanning into a turning rainbow — Prism's signature loader.",
   "tags": [
    "spinner",
    "loader",
    "prism",
    "rainbow",
    "light",
    "original"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "braille",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/originals.js",
   "imports": [
    "./_kit.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-prism-sweep",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-prism-sweep",
    "snapshot": "prism-term snapshot loaders-prism-sweep --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-prism-sweep'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-prism-sweep"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Splitting the spectrum...           3.0s\n\n - \\ | /\n\n prism-sweep (ASCII fallback) - 4 frames -."
   }
  },
  {
   "id": "loaders-ecg",
   "title": "ECG",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Truecolor Spinners",
   "description": "An ECG trace scrolling across eight braille cells — P wave, QRS spike, T wave. A heartbeat for health checks.",
   "tags": [
    "spinner",
    "loader",
    "braille",
    "ecg",
    "heartbeat",
    "health",
    "original"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "braille",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/originals.js",
   "imports": [
    "./_kit.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-ecg",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-ecg",
    "snapshot": "prism-term snapshot loaders-ecg --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-ecg'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-ecg"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Monitoring vitals...                3.0s\n\n - \\ | /\n\n ecg (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "loaders-breathing-dots",
   "title": "Breathing Dots",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Truecolor Spinners",
   "description": "Three dots swelling in sequence while their color drifts between accent and violet — a chat-style wait.",
   "tags": [
    "spinner",
    "loader",
    "dots",
    "typing",
    "chat",
    "original"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/originals.js",
   "imports": [
    "./_kit.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-breathing-dots",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-breathing-dots",
    "snapshot": "prism-term snapshot loaders-breathing-dots --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-breathing-dots'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-breathing-dots"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Composing reply...                  3.0s\n\n - \\ | /\n\n breathing-dots (ASCII fallback) - 4 frame."
   }
  },
  {
   "id": "loaders-ember",
   "title": "Ember",
   "gallery": "loaders",
   "galleryTitle": "Spinners & Loaders",
   "category": "Truecolor Spinners",
   "description": "Three cells of braille flame flickering with embers breaking loose — for burn-in, soak and stress tests.",
   "tags": [
    "spinner",
    "loader",
    "braille",
    "fire",
    "flame",
    "original"
   ],
   "kind": "spinner",
   "size": {
    "w": 44,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "braille",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {
    "speed": {
     "default": 1,
     "min": 0.25,
     "max": 4,
     "label": "Speed"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/loaders/originals.js",
   "imports": [
    "./_kit.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play loaders-ember",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play loaders-ember",
    "snapshot": "prism-term snapshot loaders-ember --size 44x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('loaders-ember'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/loaders-ember"
   },
   "preview": {
    "w": 44,
    "h": 5,
    "text": " / Stress testing 8 cores...           3.0s\n\n - \\ | /\n\n ember (ASCII fallback) - 4 frames - 130 ms"
   }
  },
  {
   "id": "progress-smooth",
   "title": "Smooth Eighths",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Progress Bars",
   "description": "Eighth-block bars that move in 1/8-cell steps, so even a 20-column bar creeps forward smoothly. Three jobs at different speeds.",
   "tags": [
    "eighth blocks",
    "smooth",
    "multi"
   ],
   "kind": "progress",
   "size": {
    "w": 60,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 1
   },
   "animated": true,
   "loop": 9,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/bars.js",
   "imports": [
    "../../widgets/index.js",
    "../../canvas.js",
    "../../color.js",
    "../../buffer.js",
    "../../width.js",
    "./_sim.js",
    "../charts/_data.js"
   ],
   "usage": {
    "cli": "prism-term play progress-smooth",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-smooth",
    "snapshot": "prism-term snapshot progress-smooth --size 60x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-smooth'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-smooth"
   },
   "preview": {
    "w": 60,
    "h": 5,
    "text": " assets     [######################------------------]  56%\n\n chunks     [##################----------------------]  45%\n\n sourcemaps [#################-----------------------]  41%"
   }
  },
  {
   "id": "progress-braille",
   "title": "Braille Bars",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Progress Bars",
   "description": "Hairline progress drawn in braille dots at twice the horizontal resolution — a thin rule, a dotted track and a glowing head.",
   "tags": [
    "braille",
    "thin",
    "hairline"
   ],
   "kind": "progress",
   "size": {
    "w": 60,
    "h": 5
   },
   "minSize": {
    "w": 20,
    "h": 1
   },
   "animated": true,
   "loop": 8,
   "glyphs": "braille",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/bars.js",
   "imports": [
    "../../widgets/index.js",
    "../../canvas.js",
    "../../color.js",
    "../../buffer.js",
    "../../width.js",
    "./_sim.js",
    "../charts/_data.js"
   ],
   "usage": {
    "cli": "prism-term play progress-braille",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-braille",
    "snapshot": "prism-term snapshot progress-braille --size 60x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-braille'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-braille"
   },
   "preview": {
    "w": 60,
    "h": 5,
    "text": " [############################----------------------]   56%\n\n [##########################------------------------]   52%\n\n [#######################---------------------------]   46%"
   }
  },
  {
   "id": "progress-gradient",
   "title": "Gradient Shimmer",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Progress Bars",
   "description": "Solid gradient fills — prism, aurora, fire and ocean — with a highlight sweeping along the finished part.",
   "tags": [
    "gradient",
    "truecolor",
    "shimmer"
   ],
   "kind": "progress",
   "size": {
    "w": 60,
    "h": 7
   },
   "minSize": {
    "w": 20,
    "h": 1
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/bars.js",
   "imports": [
    "../../widgets/index.js",
    "../../canvas.js",
    "../../color.js",
    "../../buffer.js",
    "../../width.js",
    "./_sim.js",
    "../charts/_data.js"
   ],
   "usage": {
    "cli": "prism-term play progress-gradient",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-gradient",
    "snapshot": "prism-term snapshot progress-gradient --size 60x7",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-gradient'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-gradient"
   },
   "preview": {
    "w": 60,
    "h": 7,
    "text": " prism   [##########################-----------------]  59%\n\n aurora  [#########################------------------]  57%\n\n fire    [#######################--------------------]  52%\n\n ocean   [#####################----------------------]  48%"
   }
  },
  {
   "id": "progress-segmented",
   "title": "Segmented LEDs",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Progress Bars",
   "description": "Discrete segment bars — parallelograms, squares and LED ticks — that light one by one and shift color by zone.",
   "tags": [
    "segments",
    "led",
    "zones",
    "battery"
   ],
   "kind": "progress",
   "size": {
    "w": 50,
    "h": 5
   },
   "minSize": {
    "w": 22,
    "h": 1
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/bars.js",
   "imports": [
    "../../widgets/index.js",
    "../../canvas.js",
    "../../color.js",
    "../../buffer.js",
    "../../width.js",
    "./_sim.js",
    "../charts/_data.js"
   ],
   "usage": {
    "cli": "prism-term play progress-segmented",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-segmented",
    "snapshot": "prism-term snapshot progress-segmented --size 50x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-segmented'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-segmented"
   },
   "preview": {
    "w": 50,
    "h": 5,
    "text": " charge   #############...........  53%\n\n sync     ============------------  51%\n\n rebuild  |||||||||||:::::::::::::  47%"
   }
  },
  {
   "id": "progress-indeterminate",
   "title": "Indeterminate",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Progress Bars",
   "description": "For work with no known end: a gliding block, a barber-pole stripe and a stretching Material-style band.",
   "tags": [
    "indeterminate",
    "barber pole",
    "busy"
   ],
   "kind": "progress",
   "size": {
    "w": 60,
    "h": 5
   },
   "minSize": {
    "w": 20,
    "h": 1
   },
   "animated": true,
   "loop": 6,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/bars.js",
   "imports": [
    "../../widgets/index.js",
    "../../canvas.js",
    "../../color.js",
    "../../buffer.js",
    "../../width.js",
    "./_sim.js",
    "../charts/_data.js"
   ],
   "usage": {
    "cli": "prism-term play progress-indeterminate",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-indeterminate",
    "snapshot": "prism-term snapshot progress-indeterminate --size 60x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-indeterminate'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-indeterminate"
   },
   "preview": {
    "w": 60,
    "h": 5,
    "text": " waiting for server            #######\n\n negotiating        ///////////////////////////////////////\n\n resolving host     ===================="
   }
  },
  {
   "id": "progress-ascii-classic",
   "title": "ASCII Classics",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Progress Bars",
   "description": "The 7-bit bars everyone recognizes — hashes, an arrow head, pipes and stars — for serial consoles and LANG=C.",
   "tags": [
    "ascii",
    "classic",
    "hash",
    "retro"
   ],
   "kind": "progress",
   "size": {
    "w": 56,
    "h": 7
   },
   "minSize": {
    "w": 24,
    "h": 1
   },
   "animated": true,
   "loop": 8,
   "glyphs": "ascii",
   "color": "mono",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/bars.js",
   "imports": [
    "../../widgets/index.js",
    "../../canvas.js",
    "../../color.js",
    "../../buffer.js",
    "../../width.js",
    "./_sim.js",
    "../charts/_data.js"
   ],
   "usage": {
    "cli": "prism-term play progress-ascii-classic",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-ascii-classic",
    "snapshot": "prism-term snapshot progress-ascii-classic --size 56x7",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-ascii-classic'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-ascii-classic"
   },
   "preview": {
    "w": 56,
    "h": 7,
    "text": " [############################-------------------]  59%\n\n [==========================>                    ]  56%\n\n [||||||||||||||||||||||||                       ]  51%\n\n (**********************.........................)  47%"
   }
  },
  {
   "id": "progress-thin-line",
   "title": "Thin Line",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Progress Bars",
   "description": "The heavy-line bar modern Python and Rust tools use — a bright run, a half-cap head and a dim remainder, with elapsed time.",
   "tags": [
    "line",
    "rich",
    "pip",
    "minimal"
   ],
   "kind": "progress",
   "size": {
    "w": 64,
    "h": 5
   },
   "minSize": {
    "w": 24,
    "h": 1
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/bars.js",
   "imports": [
    "../../widgets/index.js",
    "../../canvas.js",
    "../../color.js",
    "../../buffer.js",
    "../../width.js",
    "./_sim.js",
    "../charts/_data.js"
   ],
   "usage": {
    "cli": "prism-term play progress-thin-line",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-thin-line",
    "snapshot": "prism-term snapshot progress-thin-line --size 64x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-thin-line'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-thin-line"
   },
   "preview": {
    "w": 60,
    "h": 5,
    "text": " Downloading ==================---------------  54% 0:00:03\n\n Building    ================-----------------  48% 0:00:03\n\n Publishing  ==============-------------------  43% 0:00:03"
   }
  },
  {
   "id": "progress-vertical",
   "title": "Vertical Fill",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Progress Bars",
   "description": "One column per worker filling bottom-up in eighth blocks — parallel shards, partitions or cores at a glance.",
   "tags": [
    "vertical",
    "columns",
    "workers",
    "parallel"
   ],
   "kind": "progress",
   "size": {
    "w": 48,
    "h": 10
   },
   "minSize": {
    "w": 16,
    "h": 4
   },
   "animated": true,
   "loop": 9,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/bars.js",
   "imports": [
    "../../widgets/index.js",
    "../../canvas.js",
    "../../color.js",
    "../../buffer.js",
    "../../width.js",
    "./_sim.js",
    "../charts/_data.js"
   ],
   "usage": {
    "cli": "prism-term play progress-vertical",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-vertical",
    "snapshot": "prism-term snapshot progress-vertical --size 48x10",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-vertical'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-vertical"
   },
   "preview": {
    "w": 48,
    "h": 10,
    "text": " .... .... .... .... .... .... .... ....\n .... .... .... .... .... .... .... ....\n ____ .... .... .... .... .... .... ....\n #### .... #### ____ #### #### ____ ____\n #### ____ #### #### #### #### #### ####\n #### #### #### #### #### #### #### ####\n #### #### #### #### #### #### #### ####\n #### #### #### #### #### #### #### ####\n  66   44   60   51   57   61   53   55\n w1   w2   w3   w4   w5   w6   w7   w8"
   }
  },
  {
   "id": "progress-shaded",
   "title": "Shaded Edge",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Progress Bars",
   "description": "Full blocks with a ▓▒░ shaded leading edge over a light-shade track — the bar that feels like a DOS installer.",
   "tags": [
    "shade",
    "dos",
    "retro",
    "installer"
   ],
   "kind": "progress",
   "size": {
    "w": 56,
    "h": 3
   },
   "minSize": {
    "w": 20,
    "h": 1
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/bars.js",
   "imports": [
    "../../widgets/index.js",
    "../../canvas.js",
    "../../color.js",
    "../../buffer.js",
    "../../width.js",
    "./_sim.js",
    "../charts/_data.js"
   ],
   "usage": {
    "cli": "prism-term play progress-shaded",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-shaded",
    "snapshot": "prism-term snapshot progress-shaded --size 56x3",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-shaded'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-shaded"
   },
   "preview": {
    "w": 56,
    "h": 3,
    "text": " #########################-......................   52%\n\n #############################-..................   60%"
   }
  },
  {
   "id": "progress-multi-bar",
   "title": "Parallel Downloads",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Steps & Pipelines",
   "description": "Six downloads sharing one link, each with its own bar and rate, plus a total line whose bytes, rate and ETA add up.",
   "tags": [
    "multi",
    "parallel",
    "downloads",
    "total"
   ],
   "kind": "progress",
   "size": {
    "w": 76,
    "h": 8
   },
   "minSize": {
    "w": 36,
    "h": 3
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/pipelines.js",
   "imports": [
    "../../buffer.js",
    "../../color.js",
    "../../width.js",
    "./_sim.js",
    "./_log.js"
   ],
   "usage": {
    "cli": "prism-term play progress-multi-bar",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-multi-bar",
    "snapshot": "prism-term snapshot progress-multi-bar --size 76x8",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-multi-bar'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-multi-bar"
   },
   "preview": {
    "w": 60,
    "h": 8,
    "text": " base-image.tar.zst  ==============------------------  44%\n models/encoder.bin  ==============------------------  45%\n assets.pak          ================================  99%\n locale-pack.tar     ================================  OK\n docs.zip            ================================  OK\n checksums.txt       ================================  OK\n\n total 58.0 MiB / 102.2 MiB  56%  12.6 MiB/s  eta 00:04"
   }
  },
  {
   "id": "progress-docker-pull",
   "title": "docker pull",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Steps & Pipelines",
   "description": "Layers going Waiting → Downloading → Extracting → Pull complete, three at a time; like docker, bars only show past 110 columns.",
   "tags": [
    "docker",
    "container",
    "layers",
    "pull"
   ],
   "kind": "progress",
   "size": {
    "w": 112,
    "h": 11
   },
   "minSize": {
    "w": 44,
    "h": 4
   },
   "animated": true,
   "loop": 14,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/pipelines.js",
   "imports": [
    "../../buffer.js",
    "../../color.js",
    "../../width.js",
    "./_sim.js",
    "./_log.js"
   ],
   "usage": {
    "cli": "prism-term play progress-docker-pull",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-docker-pull",
    "snapshot": "prism-term snapshot progress-docker-pull --size 112x11",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-docker-pull'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-docker-pull"
   },
   "preview": {
    "w": 60,
    "h": 11,
    "text": "1.4.2: Pulling from prism/demo\n00fb86738b42: Downloading  17.13MB/29.13MB\nc835484f3e32: Already exists\n248c1e89af9e: Downloading  17.76MB/41.02MB\nd025601c567f: Download complete\nbb5522e53a50: Downloading  1.397MB/3.27MB\nae8dae18aefd: Waiting\n9ff8d085b8cf: Waiting\n\n\n"
   }
  },
  {
   "id": "progress-cargo-build",
   "title": "cargo build",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Steps & Pipelines",
   "description": "Green \"Compiling\" lines scrolling above cargo's pinned Building bar; the count and the crates in flight match the log.",
   "tags": [
    "cargo",
    "rust",
    "build",
    "compile"
   ],
   "kind": "progress",
   "size": {
    "w": 80,
    "h": 10
   },
   "minSize": {
    "w": 40,
    "h": 3
   },
   "animated": true,
   "loop": 12,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/pipelines.js",
   "imports": [
    "../../buffer.js",
    "../../color.js",
    "../../width.js",
    "./_sim.js",
    "./_log.js"
   ],
   "usage": {
    "cli": "prism-term play progress-cargo-build",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-cargo-build",
    "snapshot": "prism-term snapshot progress-cargo-build --size 80x10",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-cargo-build'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-cargo-build"
   },
   "preview": {
    "w": 60,
    "h": 10,
    "text": "   Compiling quote v1.0.37\n   Compiling syn v2.0.85\n   Compiling serde v1.0.214\n   Compiling serde_derive v1.0.214\n   Compiling memchr v2.7.4\n   Compiling itoa v1.0.11\n   Compiling ryu v1.0.18\n   Compiling bytes v1.8.0\n   Compiling pin-project-lite v0.2.15\n    Building [====>               ] 12/43: ryu, bytes, pi..."
   }
  },
  {
   "id": "progress-ci-pipeline",
   "title": "CI Pipeline",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Steps & Pipelines",
   "description": "lint → build → test → deploy as connected stages with timings; on alternate runs a test fails, is retried, then passes.",
   "tags": [
    "ci",
    "pipeline",
    "stages",
    "deploy",
    "failure",
    "retry"
   ],
   "kind": "progress",
   "size": {
    "w": 78,
    "h": 7
   },
   "minSize": {
    "w": 40,
    "h": 3
   },
   "animated": true,
   "loop": 14,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/pipelines.js",
   "imports": [
    "../../buffer.js",
    "../../color.js",
    "../../width.js",
    "./_sim.js",
    "./_log.js"
   ],
   "usage": {
    "cli": "prism-term play progress-ci-pipeline",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-ci-pipeline",
    "snapshot": "prism-term snapshot progress-ci-pipeline --size 78x7",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-ci-pipeline'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-ci-pipeline"
   },
   "preview": {
    "w": 60,
    "h": 7,
    "text": "+----------+    +----------+    +----------+    +----------+\n| OK lint  |--->| - build  |--->| o test   |--->| o deploy |\n| 1.2s     |    | 1.4s     |    | pending  |    | pending  |\n+----------+    +----------+    +----------+    +----------+\n\n- build  running\n"
   }
  },
  {
   "id": "progress-step-wizard",
   "title": "Step Wizard",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Steps & Pipelines",
   "description": "A setup wizard's step indicator — finished steps filled, the current one ringed, the rest hollow — with a caption.",
   "tags": [
    "steps",
    "wizard",
    "onboarding",
    "stepper"
   ],
   "kind": "progress",
   "size": {
    "w": 70,
    "h": 5
   },
   "minSize": {
    "w": 30,
    "h": 2
   },
   "animated": true,
   "loop": 9,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/pipelines.js",
   "imports": [
    "../../buffer.js",
    "../../color.js",
    "../../width.js",
    "./_sim.js",
    "./_log.js"
   ],
   "usage": {
    "cli": "prism-term play progress-step-wizard",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-step-wizard",
    "snapshot": "prism-term snapshot progress-step-wizard --size 70x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-step-wizard'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-step-wizard"
   },
   "preview": {
    "w": 60,
    "h": 5,
    "text": "\n  *=============*=============@=============o=============o\nAccount      Network       Storage       Review       Deploy\n\n Step 3 of 5  -  Attach a 200 GB volume"
   }
  },
  {
   "id": "progress-checklist",
   "title": "Runbook Checklist",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Steps & Pipelines",
   "description": "A maintenance runbook ticking through its steps with timings; on alternate runs the smoke test fails once and is retried.",
   "tags": [
    "checklist",
    "tasks",
    "runbook",
    "retry"
   ],
   "kind": "progress",
   "size": {
    "w": 64,
    "h": 7
   },
   "minSize": {
    "w": 32,
    "h": 3
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/pipelines.js",
   "imports": [
    "../../buffer.js",
    "../../color.js",
    "../../width.js",
    "./_sim.js",
    "./_log.js"
   ],
   "usage": {
    "cli": "prism-term play progress-checklist",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-checklist",
    "snapshot": "prism-term snapshot progress-checklist --size 64x7",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-checklist'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-checklist"
   },
   "preview": {
    "w": 60,
    "h": 7,
    "text": " OK Check free disk space                              0.6s\n OK Drain prism-api from the load balancer             1.2s\n -  Migrate database                      4,600/18,400 rows\n o  Start prism-api 0.9.2\n o  Run smoke tests\n o  Return to the load balancer\n 2/6 steps  -  2.8s"
   }
  },
  {
   "id": "progress-apt",
   "title": "apt install",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Steps & Pipelines",
   "description": "apt fetching then unpacking packages while dpkg's progress line holds the bottom row: Progress: [ 64%] [####....].",
   "tags": [
    "apt",
    "dpkg",
    "debian",
    "ubuntu",
    "packages"
   ],
   "kind": "progress",
   "size": {
    "w": 80,
    "h": 12
   },
   "minSize": {
    "w": 40,
    "h": 3
   },
   "animated": true,
   "loop": 13,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/pipelines.js",
   "imports": [
    "../../buffer.js",
    "../../color.js",
    "../../width.js",
    "./_sim.js",
    "./_log.js"
   ],
   "usage": {
    "cli": "prism-term play progress-apt",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-apt",
    "snapshot": "prism-term snapshot progress-apt --size 80x12",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-apt'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-apt"
   },
   "preview": {
    "w": 60,
    "h": 12,
    "text": "Reading package lists... Done\nBuilding dependency tree... Done\nReading state information... Done\nThe following NEW packages will be installed:\n  libprism0 prism-cli prism-data\n0 upgraded, 3 newly installed, 0 to remove and 0 not upgrad.\nNeed to get 4,484 kB of archives.\nAfter this operation, 17.9 MB of additional disk space will.\nGet:1 http://pkg.example/apt stable/main amd64 libprism0 am.\nGet:2 http://pkg.example/apt stable/main amd64 prism-cli am.\nGet:3 http://pkg.example/apt stable/main amd64 prism-data a.\n"
   }
  },
  {
   "id": "progress-pip-install",
   "title": "pip install",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Steps & Pipelines",
   "description": "pip collecting wheels with its rich bar — pink run, green size, red rate, cyan ETA — then installing and reporting.",
   "tags": [
    "pip",
    "python",
    "wheel",
    "rich"
   ],
   "kind": "progress",
   "size": {
    "w": 80,
    "h": 9
   },
   "minSize": {
    "w": 44,
    "h": 3
   },
   "animated": true,
   "loop": 11,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/pipelines.js",
   "imports": [
    "../../buffer.js",
    "../../color.js",
    "../../width.js",
    "./_sim.js",
    "./_log.js"
   ],
   "usage": {
    "cli": "prism-term play progress-pip-install",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-pip-install",
    "snapshot": "prism-term snapshot progress-pip-install --size 80x9",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-pip-install'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-pip-install"
   },
   "preview": {
    "w": 60,
    "h": 9,
    "text": "Collecting prism-sdk==2.4.0\n  Downloading prism_sdk-2.4.0-py3-none-any.whl (5.2 MB)\n     ========================== 5.2/5.2 MB 2.8 MB/s eta 0...\nCollecting httpx>=0.27 (from prism-sdk==2.4.0)\n  Using cached httpx-0.27.2-py3-none-any.whl (76 kB)\nCollecting numpy>=2.0 (from prism-sdk==2.4.0)\n\n\n"
   }
  },
  {
   "id": "progress-git-clone",
   "title": "git clone",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Steps & Pipelines",
   "description": "git's counters in order — enumerating, counting, compressing, receiving with size and rate, resolving deltas, updating files.",
   "tags": [
    "git",
    "clone",
    "objects",
    "deltas"
   ],
   "kind": "progress",
   "size": {
    "w": 80,
    "h": 8
   },
   "minSize": {
    "w": 44,
    "h": 3
   },
   "animated": true,
   "loop": 10,
   "glyphs": "ascii",
   "color": "mono",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/pipelines.js",
   "imports": [
    "../../buffer.js",
    "../../color.js",
    "../../width.js",
    "./_sim.js",
    "./_log.js"
   ],
   "usage": {
    "cli": "prism-term play progress-git-clone",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-git-clone",
    "snapshot": "prism-term snapshot progress-git-clone --size 80x8",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-git-clone'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-git-clone"
   },
   "preview": {
    "w": 60,
    "h": 8,
    "text": "Cloning into 'prism-demo'...\nremote: Enumerating objects: 4821, done.\nremote: Counting objects: 100% (812/812), done.\nremote: Compressing objects: 100% (390/390), done.\nReceiving objects:  31% (1523/4821), 20.06 MiB | 15.55 MiB/s\n\n\n"
   }
  },
  {
   "id": "progress-npm-install",
   "title": "npm install",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Steps & Pipelines",
   "description": "npm's gauge — a bracketed fill, a spinner and the current reify step — ending in the added, funding and audit summary.",
   "tags": [
    "npm",
    "node",
    "gauge",
    "javascript"
   ],
   "kind": "progress",
   "size": {
    "w": 80,
    "h": 7
   },
   "minSize": {
    "w": 40,
    "h": 2
   },
   "animated": true,
   "loop": 9,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/pipelines.js",
   "imports": [
    "../../buffer.js",
    "../../color.js",
    "../../width.js",
    "./_sim.js",
    "./_log.js"
   ],
   "usage": {
    "cli": "prism-term play progress-npm-install",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-npm-install",
    "snapshot": "prism-term snapshot progress-npm-install --size 80x7",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-npm-install'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-npm-install"
   },
   "preview": {
    "w": 60,
    "h": 7,
    "text": "~/prism-web $ npm install\n(#####.....) - reify:undici: timing reifyNode:node_module...\n\n\n\n\n"
   }
  },
  {
   "id": "progress-odometer",
   "title": "Rolling Odometer",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Counters & Odometers",
   "description": "A mechanical counter: each drum rolls to its next digit only while the drum to its right passes 9, shaded like a cylinder.",
   "tags": [
    "odometer",
    "counter",
    "rolling",
    "mechanical"
   ],
   "kind": "progress",
   "size": {
    "w": 48,
    "h": 7
   },
   "minSize": {
    "w": 24,
    "h": 3
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/counters.js",
   "imports": [
    "../../buffer.js",
    "../../canvas.js",
    "../../color.js",
    "../../widgets/index.js",
    "../../width.js",
    "./_sim.js",
    "../charts/_data.js",
    "./_fonts.js"
   ],
   "usage": {
    "cli": "prism-term play progress-odometer",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-odometer",
    "snapshot": "prism-term snapshot progress-odometer --size 48x7",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-odometer'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-odometer"
   },
   "preview": {
    "w": 48,
    "h": 7,
    "text": " requests served                           11/s\n               0   1 7 3   9 0 2\n               1,  2 8 4,  0 1 3\n               2   3 9 5   1 2 4\n\n\n +65 this run  -  avg 21.8/s"
   }
  },
  {
   "id": "progress-seven-segment",
   "title": "Seven-Segment Counter",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Counters & Odometers",
   "description": "A big LED counter with unlit ghost segments, ticking up in bursts and blinking when the batch completes. Classic _|_ in ASCII.",
   "tags": [
    "seven segment",
    "led",
    "counter",
    "retro"
   ],
   "kind": "progress",
   "size": {
    "w": 44,
    "h": 8
   },
   "minSize": {
    "w": 20,
    "h": 3
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/counters.js",
   "imports": [
    "../../buffer.js",
    "../../canvas.js",
    "../../color.js",
    "../../widgets/index.js",
    "../../width.js",
    "./_sim.js",
    "../charts/_data.js",
    "./_fonts.js"
   ],
   "usage": {
    "cli": "prism-term play progress-seven-segment",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-seven-segment",
    "snapshot": "prism-term snapshot progress-seven-segment --size 44x8",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-seven-segment'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-seven-segment"
   },
   "preview": {
    "w": 44,
    "h": 8,
    "text": " BATCH 7                                RUN\n                   _   _   _\n                | |_| |_|   |\n                | |_|  _|   |\n\n\n\n 1,897 of 4,096 - 376/s - eta 00:06"
   }
  },
  {
   "id": "progress-big-percent",
   "title": "Big Percentage",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Counters & Odometers",
   "description": "A banner-sized percentage with an extruded shadow over a slim bar — the number you can read from across the room.",
   "tags": [
    "percent",
    "banner",
    "big",
    "figlet"
   ],
   "kind": "progress",
   "size": {
    "w": 48,
    "h": 9
   },
   "minSize": {
    "w": 20,
    "h": 2
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/counters.js",
   "imports": [
    "../../buffer.js",
    "../../canvas.js",
    "../../color.js",
    "../../widgets/index.js",
    "../../width.js",
    "./_sim.js",
    "../charts/_data.js",
    "./_fonts.js"
   ],
   "usage": {
    "cli": "prism-term play progress-big-percent",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-big-percent",
    "snapshot": "prism-term snapshot progress-big-percent --size 48x9",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-big-percent'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-big-percent"
   },
   "preview": {
    "w": 48,
    "h": 9,
    "text": "\n            ##  ##  ######  ##  ##\n            ##: ##:  :::##:  :: ##:\n            ######:   ####:   ## ::\n             :::##:    :##: ## ::\n                ##: ######: ##: ##\n                 ::  ::::::  ::  ::\n  [##################------------------------]\n Reindexing - 10,831/24,960 docs - eta 00:06"
   }
  },
  {
   "id": "progress-counter-tiles",
   "title": "Live Counter Tiles",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Counters & Odometers",
   "description": "Four running totals ticking at their own rates; digits that just changed flash, with a rate sparkline under each.",
   "tags": [
    "counters",
    "kpi",
    "tiles",
    "sparkline",
    "live"
   ],
   "kind": "progress",
   "size": {
    "w": 64,
    "h": 10
   },
   "minSize": {
    "w": 36,
    "h": 2
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/counters.js",
   "imports": [
    "../../buffer.js",
    "../../canvas.js",
    "../../color.js",
    "../../widgets/index.js",
    "../../width.js",
    "./_sim.js",
    "../charts/_data.js",
    "./_fonts.js"
   ],
   "usage": {
    "cli": "prism-term play progress-counter-tiles",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-counter-tiles",
    "snapshot": "prism-term snapshot progress-counter-tiles --size 64x10",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-counter-tiles'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-counter-tiles"
   },
   "preview": {
    "w": 60,
    "h": 10,
    "text": "+---------------------------+ +----------------------------+\n| requests          +1.2k/s | | rows written        +591/s |\n| 48,221,122                | | 913,759                    |\n| ..........:=##+++++#*+-.: | |            -===*#=:--.:-*+ |\n+---------------------------+ +----------------------------+\n+---------------------------+ +----------------------------+\n| jobs done          +8.2/s | | errors 5xx          +0.4/s |\n| 18,462                    | | 1,206                      |\n| ::::::::::#+=++. :=#**+#* | |            .:-==-*#*==++:. |\n+---------------------------+ +----------------------------+"
   }
  },
  {
   "id": "progress-ring",
   "title": "Progress Rings",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Counters & Odometers",
   "description": "Braille rings filling clockwise around a centered percentage — upload, transcode and verify, finishing one after another.",
   "tags": [
    "ring",
    "donut",
    "circular",
    "braille"
   ],
   "kind": "progress",
   "size": {
    "w": 60,
    "h": 10
   },
   "minSize": {
    "w": 14,
    "h": 6
   },
   "animated": true,
   "loop": 10,
   "glyphs": "braille",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/counters.js",
   "imports": [
    "../../buffer.js",
    "../../canvas.js",
    "../../color.js",
    "../../widgets/index.js",
    "../../width.js",
    "./_sim.js",
    "../charts/_data.js",
    "./_fonts.js"
   ],
   "usage": {
    "cli": "prism-term play progress-ring",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-ring",
    "snapshot": "prism-term snapshot progress-ring --size 60x10",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-ring'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-ring"
   },
   "preview": {
    "w": 60,
    "h": 10,
    "text": "      ....####            ....####            ....####\n    ...      ###        ...      ###        ...      ###\n  ...          ###    ...          ###    ...          ###\n  ..            ##    ..            ##    ..            ##\n  ..    49%     ##    ..    42%     ##    ..    35%     ##\n  ...          ###    ...          ###    ...          ###\n    ...      ###        ...      ###        ...      ...\n      ....####            ........            ........\n\n upload 2.1/4.2 GB  transcode 775/1,820    verify 112/312"
   }
  },
  {
   "id": "progress-concentric-rings",
   "title": "Concentric Rings",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Counters & Odometers",
   "description": "Three nested rings for the three phases of a rolling deploy, each with a legend row giving its count, so color is never the only cue.",
   "tags": [
    "rings",
    "activity",
    "nested",
    "rollout",
    "braille"
   ],
   "kind": "progress",
   "size": {
    "w": 52,
    "h": 11
   },
   "minSize": {
    "w": 24,
    "h": 3
   },
   "animated": true,
   "loop": 11,
   "glyphs": "braille",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/counters.js",
   "imports": [
    "../../buffer.js",
    "../../canvas.js",
    "../../color.js",
    "../../widgets/index.js",
    "../../width.js",
    "./_sim.js",
    "../charts/_data.js",
    "./_fonts.js"
   ],
   "usage": {
    "cli": "prism-term play progress-concentric-rings",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-concentric-rings",
    "snapshot": "prism-term snapshot progress-concentric-rings --size 52x11",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-concentric-rings'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-concentric-rings"
   },
   "preview": {
    "w": 52,
    "h": 11,
    "text": "       .....#####\n    .....      #####\n   ...  ....====  ###\n  ..  ....    ====  ##   # nodes drained      14/24\n ... ...  ..++  === ###\n ..  ..  ..  ++  ==  ##  = images pulled        3/9\n ... ...  ....  === ###\n  ..  ....    ====  ##   + health checks      20/60\n   ...  ......==  ###\n    ...##      #####     rolling out 0.9.2\n       ##########"
   }
  },
  {
   "id": "progress-countdown",
   "title": "Maintenance Countdown",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Timers & Countdowns",
   "description": "Big T-minus digits to a maintenance window, going amber then red while sessions drain, then counting up once it opens.",
   "tags": [
    "countdown",
    "maintenance",
    "window",
    "t-minus"
   ],
   "kind": "progress",
   "size": {
    "w": 60,
    "h": 10
   },
   "minSize": {
    "w": 30,
    "h": 2
   },
   "animated": true,
   "loop": 18,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/timers.js",
   "imports": [
    "../../buffer.js",
    "../../canvas.js",
    "../../color.js",
    "../../width.js",
    "./_sim.js",
    "../charts/_data.js",
    "./_fonts.js",
    "./_log.js"
   ],
   "usage": {
    "cli": "prism-term play progress-countdown",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-countdown",
    "snapshot": "prism-term snapshot progress-countdown --size 60x10",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-countdown'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-countdown"
   },
   "preview": {
    "w": 60,
    "h": 10,
    "text": " Maintenance window opens in\n             ######  ######      ######  ######\n             ##  ##  ##  ##  ##  ##  ##  ##  ##\n             ##  ##  ##  ##      ##  ##  ######\n             ##  ##  ##  ##  ##  ##  ##      ##\n             ######  ######      ######  ######\n\n  ===================================---------------------\n now 01:59:51 UTC  -  window 02:00-02:30 UTC\n draining sessions - 754 of 1,204 open"
   }
  },
  {
   "id": "progress-stopwatch",
   "title": "Lap Stopwatch",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Timers & Countdowns",
   "description": "A centisecond stopwatch timing benchmark runs; each lap is logged with its delta to the best, and the best lap is marked.",
   "tags": [
    "stopwatch",
    "laps",
    "benchmark",
    "timer"
   ],
   "kind": "progress",
   "size": {
    "w": 48,
    "h": 11
   },
   "minSize": {
    "w": 28,
    "h": 2
   },
   "animated": true,
   "loop": 20,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/timers.js",
   "imports": [
    "../../buffer.js",
    "../../canvas.js",
    "../../color.js",
    "../../width.js",
    "./_sim.js",
    "../charts/_data.js",
    "./_fonts.js",
    "./_log.js"
   ],
   "usage": {
    "cli": "prism-term play progress-stopwatch",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-stopwatch",
    "snapshot": "prism-term snapshot progress-stopwatch --size 48x11",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-stopwatch'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-stopwatch"
   },
   "preview": {
    "w": 48,
    "h": 11,
    "text": " bench: cold start - lap 1/6          * running\n          ### ###   ### ###   ### ###\n          # # # # # # #   #   # # # #\n          # # # #   # #  ##   # # # #\n          # # # # # # #   #   # # # #\n          ### ###   ### ### # ### ###\n\n\n\n\n"
   }
  },
  {
   "id": "progress-health-wait",
   "title": "Health-Check Wait",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Timers & Countdowns",
   "description": "Waiting for a service to report healthy within a 12 s budget: probes back off 1-2-4 s, and every other run it times out and rolls back.",
   "tags": [
    "timeout",
    "health check",
    "backoff",
    "deploy",
    "failure"
   ],
   "kind": "progress",
   "size": {
    "w": 72,
    "h": 9
   },
   "minSize": {
    "w": 36,
    "h": 2
   },
   "animated": true,
   "loop": 15,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/timers.js",
   "imports": [
    "../../buffer.js",
    "../../canvas.js",
    "../../color.js",
    "../../width.js",
    "./_sim.js",
    "../charts/_data.js",
    "./_fonts.js",
    "./_log.js"
   ],
   "usage": {
    "cli": "prism-term play progress-health-wait",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-health-wait",
    "snapshot": "prism-term snapshot progress-health-wait --size 72x9",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-health-wait'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-health-wait"
   },
   "preview": {
    "w": 60,
    "h": 9,
    "text": "Waiting for prism-api (10.20.0.14:8080/healthz) to become...\n  probe 1  XX connect: connection refused  4ms\n  probe 2  XX HTTP 503 Service Unavailable (starting)  6ms\n\n\n\n\n\n- next probe in 0.5s      #################..... 9.0s left"
   }
  },
  {
   "id": "progress-flip-clock",
   "title": "Split-Flap Clock",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Timers & Countdowns",
   "description": "A split-flap clock rolling over midnight on New Year's Eve — every flap falls at once and the date card turns over with them.",
   "tags": [
    "flip clock",
    "split flap",
    "clock",
    "midnight"
   ],
   "kind": "progress",
   "size": {
    "w": 52,
    "h": 7
   },
   "minSize": {
    "w": 20,
    "h": 1
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/timers.js",
   "imports": [
    "../../buffer.js",
    "../../canvas.js",
    "../../color.js",
    "../../width.js",
    "./_sim.js",
    "../charts/_data.js",
    "./_fonts.js",
    "./_log.js"
   ],
   "usage": {
    "cli": "prism-term play progress-flip-clock",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-flip-clock",
    "snapshot": "prism-term snapshot progress-flip-clock --size 52x7",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-flip-clock'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-flip-clock"
   },
   "preview": {
    "w": 52,
    "h": 7,
    "text": "                  THU 31 DEC 2026\n            ### ###   ### ###   ### ###\n              #   # # #   # # # #     #\n            ###  ##   ### ###   ###   #\n            #     # #   #   # #   #  #\n            ### ###   ### ###   ###  #\n"
   }
  },
  {
   "id": "progress-sand-timer",
   "title": "netplan try",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Timers & Countdowns",
   "description": "netplan try's revert countdown beside a braille hourglass that really drains; some runs are confirmed in time, others revert.",
   "tags": [
    "hourglass",
    "netplan",
    "revert",
    "confirm",
    "countdown"
   ],
   "kind": "progress",
   "size": {
    "w": 80,
    "h": 10
   },
   "minSize": {
    "w": 40,
    "h": 2
   },
   "animated": true,
   "loop": 14,
   "glyphs": "braille",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/timers.js",
   "imports": [
    "../../buffer.js",
    "../../canvas.js",
    "../../color.js",
    "../../width.js",
    "./_sim.js",
    "../charts/_data.js",
    "./_fonts.js",
    "./_log.js"
   ],
   "usage": {
    "cli": "prism-term play progress-sand-timer",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-sand-timer",
    "snapshot": "prism-term snapshot progress-sand-timer --size 80x10",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-sand-timer'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-sand-timer"
   },
   "preview": {
    "w": 60,
    "h": 10,
    "text": "\n  +-----+\n   \\: :/     $ sudo netplan try --timeout 10\n    \\:/      Do you want to keep these settings?\n     .\n    / \\      Press ENTER before the timeout to accept the...\n   / : \\\n  +-----+    Changes will revert in   7 seconds\n\n"
   }
  },
  {
   "id": "progress-systemd-timers",
   "title": "systemctl list-timers",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Timers & Countdowns",
   "description": "systemctl list-timers with LEFT counting down live; a timer that fires reschedules and re-sorts. Narrow terminals keep NEXT, LEFT, UNIT.",
   "tags": [
    "systemd",
    "timers",
    "cron",
    "schedule",
    "linux"
   ],
   "kind": "progress",
   "size": {
    "w": 146,
    "h": 11
   },
   "minSize": {
    "w": 40,
    "h": 3
   },
   "animated": true,
   "loop": 20,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/timers.js",
   "imports": [
    "../../buffer.js",
    "../../canvas.js",
    "../../color.js",
    "../../width.js",
    "./_sim.js",
    "../charts/_data.js",
    "./_fonts.js",
    "./_log.js"
   ],
   "usage": {
    "cli": "prism-term play progress-systemd-timers",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-systemd-timers",
    "snapshot": "prism-term snapshot progress-systemd-timers --size 146x11",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-systemd-timers'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-systemd-timers"
   },
   "preview": {
    "w": 60,
    "h": 11,
    "text": "LEFT          UNIT\n3s left       prism-healthcheck.timer\n11s left      prism-metrics-flush.timer\n3h 12min left apt-daily.timer\n10h left      logrotate.timer\n11h left      man-db.timer\n23h left      systemd-tmpfiles-clean.timer\n5 days left   fstrim.timer\n\n7 timers listed.\nPass --all to see loaded but inactive timers, too."
   }
  },
  {
   "id": "progress-tqdm",
   "title": "tqdm",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Transfers & ETA",
   "description": "Nested tqdm bars from a training run: epochs outside, batches inside, with elapsed<remaining, it/s and a live loss postfix.",
   "tags": [
    "tqdm",
    "python",
    "training",
    "ml",
    "nested"
   ],
   "kind": "progress",
   "size": {
    "w": 80,
    "h": 6
   },
   "minSize": {
    "w": 44,
    "h": 1
   },
   "animated": true,
   "loop": 13,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/transfers.js",
   "imports": [
    "../../buffer.js",
    "../../canvas.js",
    "../../color.js",
    "../../widgets/index.js",
    "../../width.js",
    "./_sim.js",
    "./_log.js"
   ],
   "usage": {
    "cli": "prism-term play progress-tqdm",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-tqdm",
    "snapshot": "prism-term snapshot progress-tqdm --size 80x6",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-tqdm'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-tqdm"
   },
   "preview": {
    "w": 60,
    "h": 6,
    "text": "epoch 1: train_loss=0.512 val_loss=0.448 val_acc=0.861\nepoch 2: train_loss=0.371 val_loss=0.352 val_acc=0.894\n\n\ntraining:  40%|#####6        | 2/5 [00:49<01:13, 24.60s/it]\nepoch 3:  53%|5| 133/250 [00:02<00:05, 22.56it/s, loss=0...."
   }
  },
  {
   "id": "progress-wget",
   "title": "wget",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Transfers & ETA",
   "description": "wget with its scrolling filename, =====> bar, size, rate and eta; every other run the connection resets and it resumes with +++ for the bytes it had.",
   "tags": [
    "wget",
    "download",
    "http",
    "resume",
    "failure"
   ],
   "kind": "progress",
   "size": {
    "w": 80,
    "h": 13
   },
   "minSize": {
    "w": 44,
    "h": 2
   },
   "animated": true,
   "loop": 11,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/transfers.js",
   "imports": [
    "../../buffer.js",
    "../../canvas.js",
    "../../color.js",
    "../../widgets/index.js",
    "../../width.js",
    "./_sim.js",
    "./_log.js"
   ],
   "usage": {
    "cli": "prism-term play progress-wget",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-wget",
    "snapshot": "prism-term snapshot progress-wget --size 80x13",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-wget'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-wget"
   },
   "preview": {
    "w": 60,
    "h": 13,
    "text": "--2026-10-13 14:02:11--  https://dl.example.com/prism-0.9.2.\ntgz\nResolving dl.example.com (dl.example.com)... 192.0.2.44\nConnecting to dl.example.com (dl.example.com)|192.0.2.44|:44\n3... connected.\nHTTP request sent, awaiting response... 200 OK\nLength: 68157440 (65M) [application/gzip]\nSaving to: 'prism-0.9.2.tgz'\n\nprism-0.9.2.tgz  35%[=>      ]   23.26M  8.13MB/s    eta 5s\n\n\n"
   }
  },
  {
   "id": "progress-curl-hash",
   "title": "curl -#",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Transfers & ETA",
   "description": "curl's hash-bar mode across the full width with a right-hand percentage; on alternate runs a reset is resumed with -C -.",
   "tags": [
    "curl",
    "download",
    "hash",
    "resume",
    "failure"
   ],
   "kind": "progress",
   "size": {
    "w": 80,
    "h": 7
   },
   "minSize": {
    "w": 40,
    "h": 1
   },
   "animated": true,
   "loop": 13,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/transfers.js",
   "imports": [
    "../../buffer.js",
    "../../canvas.js",
    "../../color.js",
    "../../widgets/index.js",
    "../../width.js",
    "./_sim.js",
    "./_log.js"
   ],
   "usage": {
    "cli": "prism-term play progress-curl-hash",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-curl-hash",
    "snapshot": "prism-term snapshot progress-curl-hash --size 80x7",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-curl-hash'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-curl-hash"
   },
   "preview": {
    "w": 60,
    "h": 7,
    "text": "$ curl -# -O https://dl.example.com/prism/assets-0.9.2.tar\n#################################################### 100.0%\n\n\n\n\n"
   }
  },
  {
   "id": "progress-curl-meter",
   "title": "curl progress meter",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Transfers & ETA",
   "description": "curl's default meter — % Total, % Received, Average Speed, Time Total/Spent/Left, Current Speed — updating once a second as curl does.",
   "tags": [
    "curl",
    "download",
    "meter",
    "table"
   ],
   "kind": "progress",
   "size": {
    "w": 80,
    "h": 7
   },
   "minSize": {
    "w": 80,
    "h": 3
   },
   "animated": true,
   "loop": 12,
   "glyphs": "ascii",
   "color": "mono",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/transfers.js",
   "imports": [
    "../../buffer.js",
    "../../canvas.js",
    "../../color.js",
    "../../widgets/index.js",
    "../../width.js",
    "./_sim.js",
    "./_log.js"
   ],
   "usage": {
    "cli": "prism-term play progress-curl-meter",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-curl-meter",
    "snapshot": "prism-term snapshot progress-curl-meter --size 80x7",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-curl-meter'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-curl-meter"
   },
   "preview": {
    "w": 60,
    "h": 7,
    "text": "$ curl -o prism-db-01.dump https://dl.example.com/backups/p.\n  % Total    % Received % Xferd  Average Speed   Time    Ti.\n                                 Dload  Upload   Total   Sp.\n 33 64.0M   33 21.2M    0     0  10.6M      0  0:00:06  0:0.\n\n\n"
   }
  },
  {
   "id": "progress-pv",
   "title": "pv",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Transfers & ETA",
   "description": "pv -pterab on a restore stream: bytes, timer, current and average rate, a full-width bar and ETA, with a stall when the far end pushes back.",
   "tags": [
    "pv",
    "pipe",
    "throughput",
    "restore"
   ],
   "kind": "progress",
   "size": {
    "w": 96,
    "h": 4
   },
   "minSize": {
    "w": 66,
    "h": 1
   },
   "animated": true,
   "loop": 17,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/transfers.js",
   "imports": [
    "../../buffer.js",
    "../../canvas.js",
    "../../color.js",
    "../../widgets/index.js",
    "../../width.js",
    "./_sim.js",
    "./_log.js"
   ],
   "usage": {
    "cli": "prism-term play progress-pv",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-pv",
    "snapshot": "prism-term snapshot progress-pv --size 96x4",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-pv'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-pv"
   },
   "preview": {
    "w": 60,
    "h": 4,
    "text": "$ pv -pterab backup.tar.zst | ssh restore@10.20.0.31 'zstd .\n 508MiB 0:00:03 [ 240MiB/s] [ 181MiB/s] [    ]  22% ETA 0:0.\n\n"
   }
  },
  {
   "id": "progress-rsync",
   "title": "rsync --progress",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Transfers & ETA",
   "description": "rsync -av --progress pushing a tree: per-file bytes, percent, rate and time with xfr#/to-chk counters, then the sent/received summary.",
   "tags": [
    "rsync",
    "backup",
    "sync",
    "files"
   ],
   "kind": "progress",
   "size": {
    "w": 80,
    "h": 12
   },
   "minSize": {
    "w": 64,
    "h": 2
   },
   "animated": true,
   "loop": 14,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/transfers.js",
   "imports": [
    "../../buffer.js",
    "../../canvas.js",
    "../../color.js",
    "../../widgets/index.js",
    "../../width.js",
    "./_sim.js",
    "./_log.js"
   ],
   "usage": {
    "cli": "prism-term play progress-rsync",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-rsync",
    "snapshot": "prism-term snapshot progress-rsync --size 80x12",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-rsync'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-rsync"
   },
   "preview": {
    "w": 60,
    "h": 12,
    "text": "$ rsync -av --progress ./prism/ backup@10.20.0.31:/srv/back.\nsending incremental file list\n./\nassets/\nassets/atlas-0.png\n      6,815,744 100%    6.20MB/s    0:00:01 (xfr#1, to-chk=.\nassets/atlas-1.png\n      6,291,456 100%    6.20MB/s    0:00:00 (xfr#2, to-chk=.\nassets/fonts/\n\n\n"
   }
  },
  {
   "id": "progress-speed-graph",
   "title": "Download Speed Graph",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Transfers & ETA",
   "description": "A download drawing its own throughput history as a braille area chart, with a dotted average line, peak, now and ETA.",
   "tags": [
    "speed",
    "graph",
    "throughput",
    "braille",
    "download"
   ],
   "kind": "progress",
   "size": {
    "w": 72,
    "h": 10
   },
   "minSize": {
    "w": 32,
    "h": 3
   },
   "animated": true,
   "loop": 12,
   "glyphs": "braille",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/transfers.js",
   "imports": [
    "../../buffer.js",
    "../../canvas.js",
    "../../color.js",
    "../../widgets/index.js",
    "../../width.js",
    "./_sim.js",
    "./_log.js"
   ],
   "usage": {
    "cli": "prism-term play progress-speed-graph",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-speed-graph",
    "snapshot": "prism-term snapshot progress-speed-graph --size 72x10",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-speed-graph'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-speed-graph"
   },
   "preview": {
    "w": 60,
    "h": 10,
    "text": " prism-dataset-2026q3.tar - 180/470 MB                  38%\n                                                   110 MB/s\n     .    +\n    +#.+ +#++\n   .####+####                                      -- avg\n   ##########+\n  .###########++\n +##############                                   0\n ======================------------------------------------\n now 35.8 MB/s - avg 59.9 MB/s - peak 107.6 MB/s - eta 0..."
   }
  },
  {
   "id": "progress-eta-converge",
   "title": "ETA Convergence",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Transfers & ETA",
   "description": "Why ETAs jump: the instant-rate estimate (dots) against an EWMA-smoothed one (solid) and the true time left (dashed) across stalls.",
   "tags": [
    "eta",
    "ewma",
    "smoothing",
    "chart",
    "estimate"
   ],
   "kind": "chart",
   "size": {
    "w": 72,
    "h": 12
   },
   "minSize": {
    "w": 36,
    "h": 4
   },
   "animated": true,
   "loop": 11,
   "glyphs": "braille",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/transfers.js",
   "imports": [
    "../../buffer.js",
    "../../canvas.js",
    "../../color.js",
    "../../widgets/index.js",
    "../../width.js",
    "./_sim.js",
    "./_log.js"
   ],
   "usage": {
    "cli": "prism-term play progress-eta-converge",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-eta-converge",
    "snapshot": "prism-term snapshot progress-eta-converge --size 72x12",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-eta-converge'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-eta-converge"
   },
   "preview": {
    "w": 60,
    "h": 12,
    "text": " ... instant  ** EWMA tau 1.3s  -- actual\n 18s  *****\n                     .\n        .\n           *\n            *\n      - - - -** -   . .\n         ..   .***- - -\n           ... ...******\n\n 0s\n left: instant 00:06  ewma 00:05  actual 00:06"
   }
  },
  {
   "id": "progress-queue-drain",
   "title": "Queue Drain",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Transfers & ETA",
   "description": "A backlog draining while producers keep adding: consumers autoscale 4 → 8, the net rate sets the ETA, and the depth is charted.",
   "tags": [
    "queue",
    "backlog",
    "autoscale",
    "workers",
    "eta"
   ],
   "kind": "progress",
   "size": {
    "w": 64,
    "h": 10
   },
   "minSize": {
    "w": 36,
    "h": 2
   },
   "animated": true,
   "loop": 13,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/transfers.js",
   "imports": [
    "../../buffer.js",
    "../../canvas.js",
    "../../color.js",
    "../../widgets/index.js",
    "../../width.js",
    "./_sim.js",
    "./_log.js"
   ],
   "usage": {
    "cli": "prism-term play progress-queue-drain",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-queue-drain",
    "snapshot": "prism-term snapshot progress-queue-drain --size 64x10",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-queue-drain'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-queue-drain"
   },
   "preview": {
    "w": 60,
    "h": 10,
    "text": " queue thumbnails.render - 15,031 waiting\n ###############################################...........\n consumers ########  8 running\n ||||||||||||||||||||||||||||||||||||\n ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||\n ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||\n ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||\n in +405/s  out -2091/s  net -1686/s  eta 00:09\n processed 4,718 - produced 1,347\n ^ scaled consumers 4 -> 8 (backlog > 10,000)"
   }
  },
  {
   "id": "progress-throughput",
   "title": "Throughput Gauge",
   "gallery": "progress",
   "galleryTitle": "Progress & Counters",
   "category": "Transfers & ETA",
   "description": "A needle gauge of link use during a backup stream, beside the running total, average and peak and a rolling rate sparkline.",
   "tags": [
    "throughput",
    "gauge",
    "bandwidth",
    "network",
    "backup"
   ],
   "kind": "progress",
   "size": {
    "w": 64,
    "h": 10
   },
   "minSize": {
    "w": 30,
    "h": 2
   },
   "animated": true,
   "loop": 12,
   "glyphs": "braille",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/progress/transfers.js",
   "imports": [
    "../../buffer.js",
    "../../canvas.js",
    "../../color.js",
    "../../widgets/index.js",
    "../../width.js",
    "./_sim.js",
    "./_log.js"
   ],
   "usage": {
    "cli": "prism-term play progress-throughput",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play progress-throughput",
    "snapshot": "prism-term snapshot progress-throughput --size 64x10",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('progress-throughput'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/progress-throughput"
   },
   "preview": {
    "w": 60,
    "h": 10,
    "text": "                           backup stream -> 10.20.0.31\n 740 MB/s                  sent   20.99 GB\n [############.........]   now    740 MB/s  59% of 10GbE\n egress                    avg    864 MB/s  peak 1.08 GB/s\n\n\n\n                           last 6s\n\n                           .................-==**+++=++**+="
   }
  },
  {
   "id": "charts-spark-bars",
   "title": "Sparkline Rows",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "Sparklines",
   "description": "Four live metrics as eighth-block sparklines with labels, window min/max, current values and threshold coloring — the classic `spark` look.",
   "tags": [
    "sparkline",
    "trend",
    "metrics",
    "eighth blocks",
    "cpu"
   ],
   "kind": "chart",
   "size": {
    "w": 66,
    "h": 5
   },
   "minSize": {
    "w": 22,
    "h": 2
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/sparklines.js",
   "imports": [
    "../../color.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-spark-bars",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-spark-bars",
    "snapshot": "prism-term snapshot charts-spark-bars --size 66x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-spark-bars'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-spark-bars"
   },
   "preview": {
    "w": 60,
    "h": 5,
    "text": "cpu  ._-----===--==----____-+**###*+= v43 ^63            53%\nmem  -__..___-=+++**+====-_----=++*#* v52 ^68            66%\nnet  #==_....-=--=##*====*#*+=*#*=--= v14 ^35        24 MB/s\nerr  #####***+===-___..._------=+=--- v0.1 ^1.2         0.5%\n"
   }
  },
  {
   "id": "charts-spark-braille",
   "title": "Braille Sparklines",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "Sparklines",
   "description": "High-resolution braille sparklines (2×4 dots per cell) with min/max markers, a glowing last-value point and a live readout.",
   "tags": [
    "sparkline",
    "braille",
    "high resolution",
    "min max",
    "latency"
   ],
   "kind": "chart",
   "size": {
    "w": 56,
    "h": 9
   },
   "minSize": {
    "w": 24,
    "h": 3
   },
   "animated": true,
   "loop": 12,
   "glyphs": "braille",
   "color": "truecolor",
   "canvas": "line",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/sparklines.js",
   "imports": [
    "../../color.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-spark-braille",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-spark-braille",
    "snapshot": "prism-term snapshot charts-spark-braille --size 56x9",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-spark-braille'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-spark-braille"
   },
   "preview": {
    "w": 56,
    "h": 9,
    "text": "p95 latency                            v133  ^213  167ms\n--                 ======   =+++#+++   +   +++**      @@\n- -------*--=======      ===        +++ +++     ********\nrequests                            v1101  ^1774  1257/s\n---#-=            =+++++++   +++++*****               @@\n      ============        +++          **********#*#####\nqueue depth                                 v11  ^41  20\n:  ----------------===========#======++               @@\n---                                    +++++++*+++++++++"
   }
  },
  {
   "id": "charts-spark-dots",
   "title": "Dot Sparkline & Threshold",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "Sparklines",
   "description": "A dot-plot sparkline against a dashed SLO threshold: points that breach glow red and are counted live.",
   "tags": [
    "sparkline",
    "dots",
    "threshold",
    "slo",
    "breach"
   ],
   "kind": "chart",
   "size": {
    "w": 60,
    "h": 6
   },
   "minSize": {
    "w": 24,
    "h": 3
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/sparklines.js",
   "imports": [
    "../../color.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-spark-dots",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-spark-dots",
    "snapshot": "prism-term snapshot charts-spark-dots --size 60x6",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-spark-dots'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-spark-dots"
   },
   "preview": {
    "w": 60,
    "h": 6,
    "text": "checkout p95                           -- SLO  0 over 290 ms\n\n** * *** * *** * *** *  ** * *** * *** * *** * *** * *** * *\n = = = = = = = = = = = * = = = = = = = = = = = = = = = = = =\n                       =\n"
   }
  },
  {
   "id": "charts-spark-wall",
   "title": "Sparkline Wall",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "Sparklines",
   "description": "A NOC wall of a dozen services: name, a mini sparkline and the current rate, each tinted by health.",
   "tags": [
    "sparkline",
    "wallboard",
    "noc",
    "services",
    "grid"
   ],
   "kind": "chart",
   "size": {
    "w": 80,
    "h": 8
   },
   "minSize": {
    "w": 26,
    "h": 3
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/sparklines.js",
   "imports": [
    "../../color.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-spark-wall",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-spark-wall",
    "snapshot": "prism-term snapshot charts-spark-wall --size 80x8",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-spark-wall'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-spark-wall"
   },
   "preview": {
    "w": 60,
    "h": 8,
    "text": "o api-gw   *@%:-@#:-  253 o rx-orders-==-:=*%@ 1099\no auth     @%*==+-:+  344 o loyalty  @%#@@*=-: 1039\no cart     %@@%%##+:  503 o notify   #*==::=#@  416\no checkout =:-%#=+@@  674 o inventory--+%@#%#:  478\no search   *@@#**+-:  608\no catalog  ::==:-*@@  781\no payments %@%**#+-:  829\no orders   @%*+=-::-  960"
   }
  },
  {
   "id": "charts-line-multi",
   "title": "Latency Percentiles",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "Line & Area",
   "description": "p50, p95 and p99 as a braille multi-series line chart with y-axis ticks, gridlines, time labels and a legend.",
   "tags": [
    "line chart",
    "latency",
    "percentiles",
    "braille",
    "legend"
   ],
   "kind": "chart",
   "size": {
    "w": 72,
    "h": 16
   },
   "minSize": {
    "w": 24,
    "h": 6
   },
   "animated": true,
   "loop": 12,
   "glyphs": "braille",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/lines.js",
   "imports": [
    "../../color.js",
    "../../widgets/index.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-line-multi",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-line-multi",
    "snapshot": "prism-term snapshot charts-line-multi --size 72x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-line-multi'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-line-multi"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "Latency (ms) - last 60 s\n    o p50  o p95  o p99\n627+-----.:.--------.:-----------..--------.----...:--------\n   |  .:.: : .:..:.:: : :.........:.   :.:.......  :...\n   |::: .   :: . .    .:.    ..    :..:.    . ..    :.:    :\n456+-------------------------------..-.----------------:-..-\n   |                                                    :\n   |                  ...\n285+:.....-...........--............--...-.......----------.\n   |      .               .         ..   .      ............\n171+--------------------------------------------------------\n   |................ .....    .............  ...............\n   |                .    ..... .. .       ...\n  0+--------------------------------------------------------\n   +--------------------------------------------------------\n    -60s                      -30s                       now"
   }
  },
  {
   "id": "charts-area-dither",
   "title": "Dithered Area",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "Line & Area",
   "description": "An area chart whose fill fades toward the baseline with ordered dithering — a bright crest line over a halftone body.",
   "tags": [
    "area chart",
    "dither",
    "halftone",
    "braille",
    "throughput"
   ],
   "kind": "chart",
   "size": {
    "w": 64,
    "h": 12
   },
   "minSize": {
    "w": 20,
    "h": 4
   },
   "animated": true,
   "loop": 12,
   "glyphs": "braille",
   "color": "truecolor",
   "canvas": "line",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/lines.js",
   "imports": [
    "../../color.js",
    "../../widgets/index.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-area-dither",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-area-dither",
    "snapshot": "prism-term snapshot charts-area-dither --size 64x12",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-area-dither'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-area-dither"
   },
   "preview": {
    "w": 60,
    "h": 12,
    "text": "egress                                              488 Mb/s\n\n ######\n#******##         ##       ######   ####\n * * * * ### ##  # *#######* * * ### * *####\n*** *** ****#**##***************************####      #\n * * * * * * * * * * * * * + + + * * + * * + * *###### #####\n+ + + + * * * + * + + + + + + + + + + + + + + ++***** +++ +*\n           +   +   +   +           +       +   + + + + + + +\n+ + + + + + + + + + + + + + + + + = = = = = + + + + + + = =\n\n                                              =   =   =   ="
   }
  },
  {
   "id": "charts-stream-scroll",
   "title": "Smooth Streaming Line",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "Line & Area",
   "description": "A live line that scrolls a sub-sample at a time with dotted gridlines, axis labels and a pinned current-value tag.",
   "tags": [
    "streaming",
    "line chart",
    "realtime",
    "scroll",
    "grid"
   ],
   "kind": "chart",
   "size": {
    "w": 70,
    "h": 14
   },
   "minSize": {
    "w": 24,
    "h": 5
   },
   "animated": true,
   "loop": 10,
   "glyphs": "braille",
   "color": "truecolor",
   "canvas": "line",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/lines.js",
   "imports": [
    "../../color.js",
    "../../widgets/index.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-stream-scroll",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-stream-scroll",
    "snapshot": "prism-term snapshot charts-stream-scroll --size 70x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-stream-scroll'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-stream-scroll"
   },
   "preview": {
    "w": 60,
    "h": 14,
    "text": "100%\n\n\n        .   .   .   .   .   .   .   .   .   .   .   .   .\n\n                                      *** *\n          +++++                  *****   * ***    **####\n 50% +++++  .  +++++++++.   .  **   .   .   . ****  . 49.2%\n                        ++    *\n                          +***\n        .   .   .   .   .   .   .   .   .   .   .   .   .\n\n\n  0%"
   }
  },
  {
   "id": "charts-btop-cpu",
   "title": "btop-style CPU",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "Line & Area",
   "description": "A btop-inspired CPU panel: filled braille history colored by height, per-core bars with percentages, and load averages.",
   "tags": [
    "btop",
    "cpu",
    "cores",
    "system monitor",
    "braille"
   ],
   "kind": "chart",
   "size": {
    "w": 80,
    "h": 16
   },
   "minSize": {
    "w": 34,
    "h": 7
   },
   "animated": true,
   "loop": 12,
   "glyphs": "braille",
   "color": "truecolor",
   "canvas": "line",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/lines.js",
   "imports": [
    "../../color.js",
    "../../widgets/index.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-btop-cpu",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-btop-cpu",
    "snapshot": "prism-term snapshot charts-btop-cpu --size 80x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-btop-cpu'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-btop-cpu"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "+- cpu ----------------------------------------------------+\n|                                 |c0 ######.........  42% |\n|                                 |c1 #######........  53% |\n|                                 |c2 ###########....  76% |\n|                                 |c3 ###............  21% |\n|                                 |c4 #######........  52% |\n|                                 |c5 ########.......  59% |\n|    #      ###                   |c6 #######........  49% |\n|  ####################   ##      |c7 #########......  64% |\n|############################## ##|                        |\n|#################################|                        |\n|#################################|                        |\n|#################################|                        |\n|#################################|                        |\n|total  32%                       |                        |\n+----------------------------------------------------------+"
   }
  },
  {
   "id": "charts-stacked-area",
   "title": "Stacked Area",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "Line & Area",
   "description": "Traffic by region as stacked half-block areas — each layer keeps its color, the stack keeps its total.",
   "tags": [
    "stacked area",
    "regions",
    "traffic",
    "half blocks",
    "composition"
   ],
   "kind": "chart",
   "size": {
    "w": 64,
    "h": 12
   },
   "minSize": {
    "w": 20,
    "h": 4
   },
   "animated": true,
   "loop": 14,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/lines.js",
   "imports": [
    "../../color.js",
    "../../widgets/index.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-stacked-area",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-stacked-area",
    "snapshot": "prism-term snapshot charts-stacked-area --size 64x12",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-stacked-area'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-stacked-area"
   },
   "preview": {
    "w": 60,
    "h": 12,
    "text": "# us-east  # eu-west  # ap-south                    83 k rps\n\n        *********                  *          ******\n********=========*******  *********=**********======***   **\n========================**=============================***==\n==++=++++++++++++==========+======++===========++===========\n++++++++++++++++++++++++=+++++++++++++++++++++++++++++====++\n++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++\n++==============++++==+++++==+=======+++++++++++=+====++++++\n============================================================\n============================================================\n============================================================"
   }
  },
  {
   "id": "charts-scatter",
   "title": "Scatter & Trend",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "Line & Area",
   "description": "Latency against payload size for three services as drifting braille clusters, with a least-squares trend line.",
   "tags": [
    "scatter",
    "regression",
    "clusters",
    "braille",
    "correlation"
   ],
   "kind": "chart",
   "size": {
    "w": 60,
    "h": 16
   },
   "minSize": {
    "w": 24,
    "h": 6
   },
   "animated": true,
   "loop": 16,
   "glyphs": "braille",
   "color": "truecolor",
   "canvas": "line",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/lines.js",
   "imports": [
    "../../color.js",
    "../../widgets/index.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-scatter",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-scatter",
    "snapshot": "prism-term snapshot charts-scatter --size 60x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-scatter'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-scatter"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "o search  o checkout  o catalog                     r = 0.50\n^|                                              #\n |                              #   #  #   # # #    #   #\n |                              # #      # #   #      * * *\n |                        # #     # ## ## ##### * * *\n |                 #      # ### ####  ##* *#* * ###       #\n |    #                    ### **#* *#*#  #\n |               * *## ###  *#*#*## # *  *# # #\n | *  *  #   *# #  * **#*#*####     #   #\n |*      #   *#** ***#* * #  #* # *     #\n |##* * * *#* ***   #  # #  ##  #    #    #\n |*** *#* **### # #**#   #  *\n |* ****  #  *      *  #   #\n |# * *     *      #\n |            *\n +--------------------------------------------- payload KB -"
   }
  },
  {
   "id": "charts-bubble",
   "title": "Bubble Chart",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "Line & Area",
   "description": "Services plotted by request rate and error rate, bubble area showing cost — bubbles breathe as traffic shifts.",
   "tags": [
    "bubble chart",
    "cost",
    "services",
    "blocks",
    "x y"
   ],
   "kind": "chart",
   "size": {
    "w": 64,
    "h": 16
   },
   "minSize": {
    "w": 24,
    "h": 6
   },
   "animated": true,
   "loop": 14,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "hires",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/lines.js",
   "imports": [
    "../../color.js",
    "../../widgets/index.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-bubble",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-bubble",
    "snapshot": "prism-term snapshot charts-bubble --size 64x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-bubble'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-bubble"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "^ errors\n\n\n                                              #\n                               ******      notify\n                              checkout       ####\n                              *::::::*\n                      +++      ******\n                     +:::+\n                     auth++              #####\n             ******  +++++              #-----#\n             search*                   #catalog\n             **:::**                     #####\n               ***\n\nrequests ->"
   }
  },
  {
   "id": "charts-bars-vertical",
   "title": "Eased Column Chart",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "Bars & Histograms",
   "description": "Columns that glide to new values with eighth-block precision, value labels riding on top and a labeled baseline.",
   "tags": [
    "bar chart",
    "columns",
    "eighth blocks",
    "animated",
    "hourly"
   ],
   "kind": "chart",
   "size": {
    "w": 56,
    "h": 12
   },
   "minSize": {
    "w": 20,
    "h": 5
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/bars.js",
   "imports": [
    "../../color.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-bars-vertical",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-bars-vertical",
    "snapshot": "prism-term snapshot charts-bars-vertical --size 56x12",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-bars-vertical'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-bars-vertical"
   },
   "preview": {
    "w": 56,
    "h": 12,
    "text": "\n\n                80     81\n         74    .....  _____\n  64    =====  #####  #####\n =====  #####  #####  #####   47\n #####  #####  #####  #####  .....   39            33\n #####  #####  #####  #####  #####  _____   25\n #####  #####  #####  #####  #####  #####  .....  #####\n #####  #####  #####  #####  #####  #####  #####  #####\n #####  #####  #####  #####  #####  #####  #####  #####\n--00-----03-----06-----09-----12-----15-----18-----21---"
   }
  },
  {
   "id": "charts-bars-leaderboard",
   "title": "Live Leaderboard",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "Bars & Histograms",
   "description": "Horizontal bars ranked by throughput that re-sort as values change, with rank numbers and movement arrows.",
   "tags": [
    "leaderboard",
    "ranking",
    "horizontal bars",
    "sorting",
    "top n"
   ],
   "kind": "chart",
   "size": {
    "w": 56,
    "h": 10
   },
   "minSize": {
    "w": 24,
    "h": 3
   },
   "animated": true,
   "loop": 16,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/bars.js",
   "imports": [
    "../../color.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-bars-leaderboard",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-bars-leaderboard",
    "snapshot": "prism-term snapshot charts-bars-leaderboard --size 56x10",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-bars-leaderboard'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-bars-leaderboard"
   },
   "preview": {
    "w": 56,
    "h": 10,
    "text": " 1^ payments  ###################################    583\n 2v search    ##################################     562\n 3  checkout  #################################-     553\n 4^ catalog   #######################-               388\n 5^ auth      ###################-                   325\n 6v notify    ###################-                   325\n 7  cart      #################                      280\n 8  orders    ###############-                       256\n\n"
   }
  },
  {
   "id": "charts-bars-stacked",
   "title": "Stacked Status Codes",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "Bars & Histograms",
   "description": "Per-region request mix as stacked horizontal segments — 2xx, 3xx, 4xx, 5xx — with a legend and live totals.",
   "tags": [
    "stacked bars",
    "http status",
    "segments",
    "regions",
    "composition"
   ],
   "kind": "chart",
   "size": {
    "w": 60,
    "h": 9
   },
   "minSize": {
    "w": 24,
    "h": 3
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/bars.js",
   "imports": [
    "../../color.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-bars-stacked",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-bars-stacked",
    "snapshot": "prism-term snapshot charts-bars-stacked --size 60x9",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-bars-stacked'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-bars-stacked"
   },
   "preview": {
    "w": 60,
    "h": 9,
    "text": "# 2xx  # 3xx  # 4xx  # 5xx\nus-east-1  #########################################    1.2%\nus-west-2  #########################################    1.9%\neu-west-1  #########################################    1.3%\nap-south-1 #########################################    1.8%\nsa-east-1  #########################################    1.9%\nca-central #########################################    1.9%\n\n"
   }
  },
  {
   "id": "charts-histogram-live",
   "title": "Live Latency Histogram",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "Bars & Histograms",
   "description": "A latency distribution that reshapes every second, with p50, p95 and p99 marker lines and a long tail you can watch grow.",
   "tags": [
    "histogram",
    "distribution",
    "percentiles",
    "latency",
    "tail"
   ],
   "kind": "chart",
   "size": {
    "w": 64,
    "h": 14
   },
   "minSize": {
    "w": 24,
    "h": 6
   },
   "animated": true,
   "loop": 14,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/bars.js",
   "imports": [
    "../../color.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-histogram-live",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-histogram-live",
    "snapshot": "prism-term snapshot charts-histogram-live --size 64x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-histogram-live'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-histogram-live"
   },
   "preview": {
    "w": 60,
    "h": 14,
    "text": "               p50                        p95    p99\n            _#=:                          :      :\n           .###=                          :      :\n           #####.                         :      :\n          _######                         :      :\n          #######_                        :      :\n         =########                        :      :\n         #########_                       :      :\n        =##########                       :      :\n       .############                      :      :\n       #############=                     :      :\n     .################.      __===#####==__.     :\n .._=##################===####################==__..... .  .\n0 ms-----------------------------------------------------2 s"
   }
  },
  {
   "id": "charts-candles",
   "title": "Candlesticks",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "Bars & Histograms",
   "description": "OHLC candlesticks at half-cell precision with wicks, green and red bodies, a volume strip and a right-hand price scale.",
   "tags": [
    "candlestick",
    "ohlc",
    "finance",
    "trading",
    "volume"
   ],
   "kind": "chart",
   "size": {
    "w": 72,
    "h": 16
   },
   "minSize": {
    "w": 28,
    "h": 7
   },
   "animated": true,
   "loop": 20,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/bars.js",
   "imports": [
    "../../color.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-candles",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-candles",
    "snapshot": "prism-term snapshot charts-candles --size 72x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-candles'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-candles"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "                               +  =  =     +         152.71\n                              ## **  =     + ## **\n =                            ##  = **     + ## **\n**  =              +     =    ##    **  = ##  + **   146.30\n**  =             ## ## **    ##    ** ** ##     =\n**  =             ## ## ** ## ##     = ** ##\n = **             ##  + ** ##  +     = ** ##\n = **           + ##  +  =  +  +        =  +         138.95\n   **       ** ##  +     =\n   **    ##  =     +\n    = ##  +        +\n    =  +\n    =\n    =                                                125.19\n__ __             __    __    ##    __ __ __\n## ## __ __ __ __ ## __ ## __ ## __ ## ## ## __ __"
   }
  },
  {
   "id": "charts-boxplot",
   "title": "Box Plots",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "Bars & Histograms",
   "description": "Response-time box plots per service: whiskers, interquartile box, median tick and outlier dots on a shared scale.",
   "tags": [
    "box plot",
    "quartiles",
    "statistics",
    "outliers",
    "distribution"
   ],
   "kind": "chart",
   "size": {
    "w": 60,
    "h": 11
   },
   "minSize": {
    "w": 28,
    "h": 3
   },
   "animated": true,
   "loop": 14,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/bars.js",
   "imports": [
    "../../color.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-boxplot",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-boxplot",
    "snapshot": "prism-term snapshot charts-boxplot --size 60x11",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-boxplot'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-boxplot"
   },
   "preview": {
    "w": 60,
    "h": 11,
    "text": "search       |-----##|###-------|  o   o\n\ncheckout      |-----###|###--------| o    o\n\ncatalog       |--------###|####-----------| o       o\n\nauth                |-------###|###----------|  o       o\n\ncart               |---------###|####-----------|      o\n\n          +0--------------+200-------------+400----------600"
   }
  },
  {
   "id": "charts-gauge-arc",
   "title": "Arc Gauges",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "Gauges & Meters",
   "description": "CPU, memory and disk as braille arc gauges with warning and critical zones, sweeping needles and centered readouts.",
   "tags": [
    "gauge",
    "arc",
    "needle",
    "speedometer",
    "zones"
   ],
   "kind": "chart",
   "size": {
    "w": 72,
    "h": 12
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 12,
   "glyphs": "braille",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/gauges.js",
   "imports": [
    "../../color.js",
    "../../widgets/index.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-gauge-arc",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-gauge-arc",
    "snapshot": "prism-term snapshot charts-gauge-arc --size 72x12",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-gauge-arc'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-gauge-arc"
   },
   "preview": {
    "w": 60,
    "h": 12,
    "text": "\n\n\n     .:+++++:.           .:+++++:.           .:+++++:.\n  .+##+++::++##+.     .+##++::+++##+.     .+##++:::++##+.\n :##+.  .:   .+##:   :##+.    :  .+##:   :##+.       .+##:\n:##.           .##: :##.           .##: :##.         ..:##:\n+#+     46%     +#+ +#+     56%     +#+ +#+     86%  .  +#+\n        CPU               Memory               Disk\n\n\n"
   }
  },
  {
   "id": "charts-gauge-ring",
   "title": "Ring Gauges",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "Gauges & Meters",
   "description": "Four full-circle ring gauges — availability, cache hits, error budget, saturation — filling and draining in real time.",
   "tags": [
    "gauge",
    "ring",
    "donut",
    "percent",
    "slo"
   ],
   "kind": "chart",
   "size": {
    "w": 72,
    "h": 11
   },
   "minSize": {
    "w": 18,
    "h": 6
   },
   "animated": true,
   "loop": 14,
   "glyphs": "braille",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/gauges.js",
   "imports": [
    "../../color.js",
    "../../widgets/index.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-gauge-ring",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-gauge-ring",
    "snapshot": "prism-term snapshot charts-gauge-ring --size 72x11",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-gauge-ring'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-gauge-ring"
   },
   "preview": {
    "w": 60,
    "h": 11,
    "text": "     .:::::::.           .:::::::.           .:::::::.\n  .:###++:++###:.     .:###++:++###:.     .:###+++++###:.\n .##+.       .+##.   .##+.       .+##.   .##+.   :   .+##.\n.##:           :##. .##:           :##. .##:     .     :##.\n:#+     98%     +#: :#+     85%     +#: :#+     49%     +#:\n:#+             +#: :#+          ...+#: :#+             +#:\n.##:        .. :##. .##:           :##. .##:           :##.\n .##.         :##.   .##.         .##.   .##.         .##.\n  .Availability..     ..Cache hits ..     .Error budget..\n\n"
   }
  },
  {
   "id": "charts-vu-meter",
   "title": "Stereo VU Meter",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "Gauges & Meters",
   "description": "Left and right LED ladders with fast attack, slow release, peak-hold markers and a dB scale — straight off a mixing desk.",
   "tags": [
    "vu meter",
    "led",
    "audio",
    "peak hold",
    "db"
   ],
   "kind": "chart",
   "size": {
    "w": 60,
    "h": 6
   },
   "minSize": {
    "w": 20,
    "h": 2
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/gauges.js",
   "imports": [
    "../../color.js",
    "../../widgets/index.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-vu-meter",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-vu-meter",
    "snapshot": "prism-term snapshot charts-vu-meter --size 60x6",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-vu-meter'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-vu-meter"
   },
   "preview": {
    "w": 60,
    "h": 6,
    "text": "\nL ######################################....|.........   0.2\n\nR #####################...................|...........  -4.8\n\n       -20        -10          -5     -3       0    3"
   }
  },
  {
   "id": "charts-bullet",
   "title": "Bullet Charts",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "Gauges & Meters",
   "description": "Stephen Few-style bullet charts: qualitative bands, the actual value as a bar and a target tick, one KPI per row.",
   "tags": [
    "bullet chart",
    "kpi",
    "target",
    "bands",
    "goals"
   ],
   "kind": "chart",
   "size": {
    "w": 60,
    "h": 8
   },
   "minSize": {
    "w": 28,
    "h": 2
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/gauges.js",
   "imports": [
    "../../color.js",
    "../../widgets/index.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-bullet",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-bullet",
    "snapshot": "prism-term snapshot charts-bullet --size 60x8",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-bullet'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-bullet"
   },
   "preview": {
    "w": 60,
    "h": 8,
    "text": "Revenue  =================================|===         278k$\n\nSignups  ============================    |               799\n\nNPS      =========================|                       59\n\nUptime   =================================|==         99.93%\n"
   }
  },
  {
   "id": "charts-pie",
   "title": "Sweeping Donut",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "Gauges & Meters",
   "description": "A cost-by-service donut that sweeps in segment by segment, then slowly turns, with a legend of shares beside it.",
   "tags": [
    "pie",
    "donut",
    "share",
    "cost",
    "legend"
   ],
   "kind": "chart",
   "size": {
    "w": 56,
    "h": 14
   },
   "minSize": {
    "w": 24,
    "h": 6
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "hires",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/gauges.js",
   "imports": [
    "../../color.js",
    "../../widgets/index.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-pie",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-pie",
    "snapshot": "prism-term snapshot charts-pie --size 56x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-pie'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-pie"
   },
   "preview": {
    "w": 56,
    "h": 14,
    "text": "               ******\n           **++++++++++##\n        **++++++======++**##         # compute       38%\n       *+++++===    =+++++**#\n      %#*++==          ++++**#       # storage       22%\n     %#****              +++**#\n     %****+              ++++*#      # network       16%\n     %****+    $48.2k    ++++*#\n     %#****    /month    +++**#      # database      14%\n      %#****+          ++++**#\n       %#******+    ++++++**#        # other         10%\n        %%#**********+++**##\n           %%#*********##\n               %%%%%%"
   }
  },
  {
   "id": "charts-radar",
   "title": "Radar Profile",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "Gauges & Meters",
   "description": "A six-axis spider chart comparing a service to its baseline: the live profile morphs as the service degrades and heals.",
   "tags": [
    "radar",
    "spider chart",
    "profile",
    "comparison",
    "braille"
   ],
   "kind": "chart",
   "size": {
    "w": 56,
    "h": 16
   },
   "minSize": {
    "w": 26,
    "h": 8
   },
   "animated": true,
   "loop": 16,
   "glyphs": "braille",
   "color": "truecolor",
   "canvas": "line",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/gauges.js",
   "imports": [
    "../../color.js",
    "../../widgets/index.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-radar",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-radar",
    "snapshot": "prism-term snapshot charts-radar --size 56x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-radar'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-radar"
   },
   "preview": {
    "w": 56,
    "h": 16,
    "text": "\n                         latency\n                           .@@\n                       ....***...\n                    ...  **...** ....\n          availability+**.. . ..** @@errors\n                  .++.@@+++++.   .**. .\n                  .  +* ... .+...  .* .\n                  .  .+* . ...++   .* .\n                  .  .@@*.. . ..++ . *.\n                  . .... **... ++***@@.\n              cost..   ....*@@***. saturation\n                    ....   .+.    ...\n                        ... . ....\n                           ...\n                         traffic"
   }
  },
  {
   "id": "charts-heatmap-latency",
   "title": "Latency Heatmap",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "Heatmaps & Matrices",
   "description": "Request latency as a scrolling heatmap — log-spaced buckets over time in half-blocks, hot where requests pile up.",
   "tags": [
    "heatmap",
    "latency",
    "histogram over time",
    "half blocks",
    "magma"
   ],
   "kind": "chart",
   "size": {
    "w": 72,
    "h": 14
   },
   "minSize": {
    "w": 24,
    "h": 5
   },
   "animated": true,
   "loop": 16,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": "magma",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/heatmaps.js",
   "imports": [
    "../../color.js",
    "../../palettes.js",
    "../../canvas.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-heatmap-latency",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-heatmap-latency",
    "snapshot": "prism-term snapshot charts-heatmap-latency --size 72x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-heatmap-latency'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-heatmap-latency"
   },
   "preview": {
    "w": 60,
    "h": 14,
    "text": "    5s \"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\n       \"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\n       \"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\n    1s \"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\n       \"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\n       \"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\n 200ms \"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\n       \"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\n       \"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\n  50ms \"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\n       \"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\n       \"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\n  10ms \"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\n       -60s                checkout p95                  now"
   }
  },
  {
   "id": "charts-heatmap-week",
   "title": "Hour × Weekday Heatmap",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "Heatmaps & Matrices",
   "description": "Requests by hour of day and weekday: a weekly traffic heatmap with the current hour filling in live and a cursor reading cells.",
   "tags": [
    "heatmap",
    "weekday",
    "hourly",
    "traffic",
    "punch card"
   ],
   "kind": "chart",
   "size": {
    "w": 80,
    "h": 10
   },
   "minSize": {
    "w": 30,
    "h": 8
   },
   "animated": true,
   "loop": 14,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": "magma",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/heatmaps.js",
   "imports": [
    "../../color.js",
    "../../palettes.js",
    "../../canvas.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-heatmap-week",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-heatmap-week",
    "snapshot": "prism-term snapshot charts-heatmap-week --size 80x10",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-heatmap-week'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-heatmap-week"
   },
   "preview": {
    "w": 60,
    "h": 10,
    "text": "    00          06          12          18\nMon # # # # # # # # # # # # # # # # # # # # # # # #\nTue # # # # # # # # # # # # # # # # # # # # # # # #\nWed # # # # # # # # # # # # # # # # # # # # # # # #\nThu # # # # # # # # # # # # # # # # # # # # # # # #\nFri # # # # # # # # # # # # # # # # # # # # # # # #\nSat # # # # # # # # # # # # # # # # # # # # # # # #\nSun # # # # # # # # # # # # # # # #<. . . . . . . .\n    Sun 04:00  3.2k req/h                     peak Mon 14:00\n"
   }
  },
  {
   "id": "charts-calendar",
   "title": "Contribution Calendar",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "Heatmaps & Matrices",
   "description": "A GitHub-style contributions calendar — weeks by weekday, five intensity levels, month labels and a \"today\" cell filling in.",
   "tags": [
    "calendar",
    "github",
    "contributions",
    "activity",
    "heatmap"
   ],
   "kind": "chart",
   "size": {
    "w": 80,
    "h": 10
   },
   "minSize": {
    "w": 24,
    "h": 8
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/heatmaps.js",
   "imports": [
    "../../color.js",
    "../../palettes.js",
    "../../canvas.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-calendar",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-calendar",
    "snapshot": "prism-term snapshot charts-calendar --size 80x10",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-calendar'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-calendar"
   },
   "preview": {
    "w": 60,
    "h": 10,
    "text": "    Mar Mar Apr May Jun Jul Aug Sep Sep Oct Nov Dec Jan Feb\nMon ########################################################\n    ########################################################\nWed ########################################################\n    ########################################################\nFri ########################################################\n    #######################################################\n    #######################################################\n    1,371 contributions in the last year   Less ##### More\n"
   }
  },
  {
   "id": "charts-corr-matrix",
   "title": "Correlation Matrix",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "Heatmaps & Matrices",
   "description": "Pairwise correlations between six service metrics on a diverging scale, with coefficients printed in every cell.",
   "tags": [
    "correlation",
    "matrix",
    "diverging",
    "statistics",
    "metrics"
   ],
   "kind": "chart",
   "size": {
    "w": 52,
    "h": 9
   },
   "minSize": {
    "w": 30,
    "h": 7
   },
   "animated": true,
   "loop": 16,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/heatmaps.js",
   "imports": [
    "../../color.js",
    "../../palettes.js",
    "../../canvas.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-corr-matrix",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-corr-matrix",
    "snapshot": "prism-term snapshot charts-corr-matrix --size 52x9",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-corr-matrix'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-corr-matrix"
   },
   "preview": {
    "w": 52,
    "h": 9,
    "text": "       cpu    mem    rps    p95    err    gc\ncpu   1.00    .33    .68    .39    .24    .21\nmem    .26   1.00    .13    .35    .09    .71\nrps    .67    .19   1.00    .30   -.02    .16\np95    .48    .36    .27   1.00    .62    .50\nerr    .13    .06    .07    .64   1.00    .14\ngc     .40    .70    .20    .41    .28   1.00\n\n"
   }
  },
  {
   "id": "charts-kpi-row",
   "title": "KPI Tile Row",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "KPI Tiles",
   "description": "Four boxed KPI tiles — throughput, latency, errors, saturation — each with a value, a colored delta and a trend sparkline.",
   "tags": [
    "kpi",
    "tiles",
    "dashboard",
    "delta",
    "sparkline"
   ],
   "kind": "chart",
   "size": {
    "w": 80,
    "h": 5
   },
   "minSize": {
    "w": 22,
    "h": 4
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/kpis.js",
   "imports": [
    "../../color.js",
    "../../widgets/index.js",
    "../../text/index.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-kpi-row",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-kpi-row",
    "snapshot": "prism-term snapshot charts-kpi-row --size 80x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-kpi-row'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-kpi-row"
   },
   "preview": {
    "w": 60,
    "h": 5,
    "text": "+ throughput -----+ + p95 latency ----+ + error rate ------+\n| 2345 rps        | | 195 ms  v 13.9% | | 0.48 %   ^ 1.55% |\n| ..:.... .::.... | |    .........    | |  . ..:. :... :.  |\n|     . .::       | | ...        .:.. | | :...  ..   ..  . |\n+-----------------+ +-----------------+ +------------------+"
   }
  },
  {
   "id": "charts-big-number",
   "title": "Big Number Tile",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "KPI Tiles",
   "description": "The wallboard hero tile: one metric in a bold bitmap font, its trend arrow and delta, and a sparkline underneath.",
   "tags": [
    "big number",
    "hero metric",
    "bitmap font",
    "kpi",
    "wallboard"
   ],
   "kind": "chart",
   "size": {
    "w": 56,
    "h": 10
   },
   "minSize": {
    "w": 24,
    "h": 5
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/kpis.js",
   "imports": [
    "../../color.js",
    "../../widgets/index.js",
    "../../text/index.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-big-number",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-big-number",
    "snapshot": "prism-term snapshot charts-big-number --size 56x10",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-big-number'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-big-number"
   },
   "preview": {
    "w": 56,
    "h": 10,
    "text": "ORDERS / MIN\n  ##    ######        ######    ##    ######\n####        ##        ##  ##  ####        ##\n  ##    ######        ##  ##    ##      ####\n  ##    ##        ##  ##  ##    ##        ##\n######  ######  ##    ######  ######  ######\nv 8.9% vs 15 s ago\n***+--==:.:=+*++**+=====+++*****#####@#++*@@####@#*++*+-\n\n"
   }
  },
  {
   "id": "charts-noc-wallboard",
   "title": "NOC Wallboard",
   "gallery": "charts",
   "galleryTitle": "Charts & Metrics",
   "category": "KPI Tiles",
   "description": "A compact operations wallboard: headline KPIs, a latency chart and the top error sources, laid out to fill any screen.",
   "tags": [
    "wallboard",
    "noc",
    "dashboard",
    "kpi",
    "errors"
   ],
   "kind": "chart",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 44,
    "h": 12
   },
   "animated": true,
   "loop": 14,
   "glyphs": "braille",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/charts/kpis.js",
   "imports": [
    "../../color.js",
    "../../widgets/index.js",
    "../../text/index.js",
    "./_data.js"
   ],
   "usage": {
    "cli": "prism-term play charts-noc-wallboard",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play charts-noc-wallboard",
    "snapshot": "prism-term snapshot charts-noc-wallboard --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('charts-noc-wallboard'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/charts-noc-wallboard"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "+ requests -------+ + p95 latency ----+ + errors ----------+\n| 5787 rps        | | 189 ms  v 15.6% | | 0.29 %     v 38% |\n| .......    .... | | ....      ....  | | .:..... . .      |\n| . .  . :....    | |    ......:.  .. | | .    ........:.. |\n+-----------------+ +-----------------+ +------------------+\n+- p95 latency - ms ---------------------------------------+\n| 263+---------.--...--------.....:-------...--------------|\n|    |    ..  : :.. ...   :..:     :. . :.. .:...      ..  |\n| 188+....--...--------...----------....--------..::.:..-..|\n|    |                                                     |\n|    |                                                     |\n|75.2+-----------------------------------------------------|\n|    |                                                     |\n|   0+-----------------------------------------------------|\n|    +-----------------------------------------------------|\n+----------------------------------------------------------+"
   }
  },
  {
   "id": "ai-thinking-shimmer",
   "title": "Thinking Shimmer",
   "gallery": "ai",
   "galleryTitle": "AI Working",
   "category": "Thinking States",
   "description": "An agent working indicator: a breathing glyph and a status verb with a shimmer sweeping across it, elapsed time and tokens, finished steps ticking off above.",
   "tags": [
    "thinking",
    "shimmer",
    "working",
    "spinner",
    "agent"
   ],
   "kind": "spinner",
   "size": {
    "w": 64,
    "h": 6
   },
   "minSize": {
    "w": 36,
    "h": 3
   },
   "animated": true,
   "loop": 9.5,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ai/thinking-shimmer.js",
   "imports": [
    "./_ai.js"
   ],
   "usage": {
    "cli": "prism-term play ai-thinking-shimmer",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ai-thinking-shimmer",
    "snapshot": "prism-term snapshot ai-thinking-shimmer --size 64x6",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ai-thinking-shimmer'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ai-thinking-shimmer"
   },
   "preview": {
    "w": 60,
    "h": 6,
    "text": "+ Read trace 4bf92f35 (31 spans)\n. Correlating spans with pool metrics...\n  (7s . v 1.3k tokens . esc to interrupt)\n\n\n"
   }
  },
  {
   "id": "ai-typing-indicators",
   "title": "Typing Indicators",
   "gallery": "ai",
   "galleryTitle": "AI Working",
   "category": "Thinking States",
   "description": "Six labeled \"model is working\" indicators side by side: a chat bubble of bouncing dots, a braille wave, a pulse, an orbit, a scanner and an equalizer.",
   "tags": [
    "typing indicator",
    "dots",
    "wave",
    "loading",
    "chat"
   ],
   "kind": "spinner",
   "size": {
    "w": 66,
    "h": 4
   },
   "minSize": {
    "w": 33,
    "h": 4
   },
   "animated": true,
   "loop": 2.4,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ai/typing-indicators.js",
   "imports": [
    "./_ai.js"
   ],
   "usage": {
    "cli": "prism-term play ai-typing-indicators",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ai-typing-indicators",
    "snapshot": "prism-term snapshot ai-typing-indicators --size 66x4",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ai-typing-indicators'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ai-typing-indicators"
   },
   "preview": {
    "w": 60,
    "h": 4,
    "text": "  +-------+                            O\n  | . . . |    :=*#*=:  .   .   .    . * .    -----=#=-\n  +-+-----+                            .\n  bounce     wave       pulse      orbit      scan"
   }
  },
  {
   "id": "ai-reasoning-summary",
   "title": "Reasoning Summary",
   "gallery": "ai",
   "galleryTitle": "AI Working",
   "category": "Thinking States",
   "description": "A collapsible reasoning block: thought summaries stream in dim italics while the model works, fold to \"Thought for 14s\", then the final answer streams below.",
   "tags": [
    "reasoning",
    "thinking",
    "collapsible",
    "summary",
    "chain of thought"
   ],
   "kind": "text",
   "size": {
    "w": 64,
    "h": 11
   },
   "minSize": {
    "w": 40,
    "h": 7
   },
   "animated": true,
   "loop": 11,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ai/reasoning-summary.js",
   "imports": [
    "./_ai.js"
   ],
   "usage": {
    "cli": "prism-term play ai-reasoning-summary",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ai-reasoning-summary",
    "snapshot": "prism-term snapshot ai-reasoning-summary --size 64x11",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ai-reasoning-summary'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ai-reasoning-summary"
   },
   "preview": {
    "w": 60,
    "h": 11,
    "text": "v . Thinking...\n| p99 jumped at 12:02, same minute as the v2.14.1 deploy.\n| The diff raises worker concurrency 8 -> 32 but the pool\n| stays at 20.\n| Pool wait time tracks p99 almost exactly, so requests\n\n\n\n\n\n"
   }
  },
  {
   "id": "ai-status-orb",
   "title": "Status Orb",
   "gallery": "ai",
   "galleryTitle": "AI Working",
   "category": "Thinking States",
   "description": "An assistant status orb on a hi-res canvas that breathes when idle, ripples while listening, swirls while thinking and pulses rings while speaking.",
   "tags": [
    "orb",
    "voice",
    "status",
    "assistant",
    "canvas"
   ],
   "kind": "scene",
   "size": {
    "w": 36,
    "h": 14
   },
   "minSize": {
    "w": 20,
    "h": 8
   },
   "animated": true,
   "loop": 9,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "hires",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ai/status-orb.js",
   "imports": [
    "./_ai.js"
   ],
   "usage": {
    "cli": "prism-term play ai-status-orb",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ai-status-orb",
    "snapshot": "prism-term snapshot ai-status-orb --size 36x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ai-status-orb'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ai-status-orb"
   },
   "preview": {
    "w": 36,
    "h": 14,
    "text": "                .....\n           .::---:::---::.\n         .:-:..       ..:-:.\n       .:-:.    +++      .:-:.\n      .:-.    ****+++++=   .-:.\n      :-:   *******++++==   :-:\n     .::.  ++******++++==   .::.\n     .::.   ++++++++++====  .::.\n      :-:   ++++++++=====   :-:\n      .:-.   ==========    .-:.\n       .:-:.      ===    .:-:.\n         .:-:..       ..:-:.\n           .::---:::---::.\n            Listening..."
   }
  },
  {
   "id": "ai-token-stream",
   "title": "Token Stream",
   "gallery": "ai",
   "galleryTitle": "AI Working",
   "category": "Token Streams",
   "description": "A response streaming token by token with each new token flashing, a live tok/s sparkline and TTFT, then a tokenizer view that tints every token boundary.",
   "tags": [
    "tokens",
    "streaming",
    "llm",
    "tokenizer",
    "tok/s"
   ],
   "kind": "text",
   "size": {
    "w": 64,
    "h": 9
   },
   "minSize": {
    "w": 36,
    "h": 6
   },
   "animated": true,
   "loop": 9,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ai/token-stream.js",
   "imports": [
    "./_ai.js"
   ],
   "usage": {
    "cli": "prism-term play ai-token-stream",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ai-token-stream",
    "snapshot": "prism-term snapshot ai-token-stream --size 64x9",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ai-token-stream'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ai-token-stream"
   },
   "preview": {
    "w": 60,
    "h": 9,
    "text": "Checkout latency is up because the DB pool is exhausted.\nSince v2.14.1 the service runs 32 workers but still opens\nonly 20_\n\n\n\n\n------------------------------------------------------------\nTTFT 380ms . 16 tok/s . 34/65 tokens            .*-=--#===.="
   }
  },
  {
   "id": "ai-markdown-stream",
   "title": "Markdown Stream",
   "gallery": "ai",
   "galleryTitle": "AI Working",
   "category": "Token Streams",
   "description": "Markdown rendered live as it streams: headings, bold and inline code spans, bullets, and a fenced code block that is syntax-colored line by line.",
   "tags": [
    "markdown",
    "streaming",
    "render",
    "code block",
    "llm"
   ],
   "kind": "text",
   "size": {
    "w": 64,
    "h": 17
   },
   "minSize": {
    "w": 44,
    "h": 10
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ai/markdown-stream.js",
   "imports": [
    "./_ai.js"
   ],
   "usage": {
    "cli": "prism-term play ai-markdown-stream",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ai-markdown-stream",
    "snapshot": "prism-term snapshot ai-markdown-stream --size 64x17",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ai-markdown-stream'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ai-markdown-stream"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "Root cause\nThe connection pool is too small for the new worker count.\n\n * workers: 8 -> 32 in v2.14.1\n * pool: 20, unchanged\n\nFix\n|                                                        js\n| const pool = createPool(_\n\n\n\n\n\n\n"
   }
  },
  {
   "id": "ai-throughput-meter",
   "title": "Throughput Meter",
   "gallery": "ai",
   "galleryTitle": "AI Working",
   "category": "Token Streams",
   "description": "An inference panel with concurrent streams, per-stream progress and tok/s, an aggregate throughput sparkline, TTFT, cache hit rate and a running cost.",
   "tags": [
    "throughput",
    "tok/s",
    "inference",
    "dashboard",
    "latency"
   ],
   "kind": "widget",
   "size": {
    "w": 70,
    "h": 8
   },
   "minSize": {
    "w": 50,
    "h": 8
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ai/throughput-meter.js",
   "imports": [
    "./_ai.js"
   ],
   "usage": {
    "cli": "prism-term play ai-throughput-meter",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ai-throughput-meter",
    "snapshot": "prism-term snapshot ai-throughput-meter --size 70x8",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ai-throughput-meter'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ai-throughput-meter"
   },
   "preview": {
    "w": 60,
    "h": 8,
    "text": "+ reasoning-large . 3 streaming ---------------------------+\n|req 7f3a ###............... 167/900  54 t/s  ttft 280ms   |\n|req 2c91 ##................ 102/700  61 t/s  ttft 340ms   |\n|req 9be0 #############..... 366/480  48 t/s  ttft 310ms   |\n|req d41e ..................   0/620  queued               |\n|throughput ::::::::::::::::::====########## 163 tok/s     |\n|cache hits 62% . input 18.2k . output 1.4k . p95 ttft 3...|\n+------------------------------------------------ $0.0205 -+"
   }
  },
  {
   "id": "ai-structured-output",
   "title": "Structured Output",
   "gallery": "ai",
   "galleryTitle": "AI Working",
   "category": "Token Streams",
   "description": "Structured output streaming in: JSON arrives token by token, pretty-printed with syntax color, while a schema checklist ticks off each completed field.",
   "tags": [
    "json",
    "structured output",
    "schema",
    "streaming",
    "tool use"
   ],
   "kind": "text",
   "size": {
    "w": 72,
    "h": 14
   },
   "minSize": {
    "w": 44,
    "h": 10
   },
   "animated": true,
   "loop": 9,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ai/structured-output.js",
   "imports": [
    "./_ai.js"
   ],
   "usage": {
    "cli": "prism-term play ai-structured-output",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ai-structured-output",
    "snapshot": "prism-term snapshot ai-structured-output --size 72x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ai-structured-output'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ai-structured-output"
   },
   "preview": {
    "w": 60,
    "h": 14,
    "text": "{\n  \"incident\": \"INC-3127\",\n  \"severity\": \"sev2\",\n  \"service\": \"checkout-api\",\n  \"cause\": \"db pool starvation\",\n  \"confidence\": 0.87,\n  \"actions\": [\n    { \"type\": \"config\", \"set\": \"DB_POOL_SIZE=_\n\n\n\n\n\nstreaming . 5/7 fields . 81/119 tokens"
   }
  },
  {
   "id": "ai-tool-call-card",
   "title": "Tool Calls",
   "gallery": "ai",
   "galleryTitle": "AI Working",
   "category": "Tool Calls",
   "description": "An agent's tool calls stacking up: each shows a spinner and its arguments while running, then collapses to a one-line result, with long output folded away.",
   "tags": [
    "tool calls",
    "agent",
    "function calling",
    "coding agent",
    "spinner"
   ],
   "kind": "tui",
   "size": {
    "w": 64,
    "h": 12
   },
   "minSize": {
    "w": 40,
    "h": 8
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ai/tool-calls.js",
   "imports": [
    "./_ai.js"
   ],
   "usage": {
    "cli": "prism-term play ai-tool-call-card",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ai-tool-call-card",
    "snapshot": "prism-term snapshot ai-tool-call-card --size 64x12",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ai-tool-call-card'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ai-tool-call-card"
   },
   "preview": {
    "w": 60,
    "h": 12,
    "text": "* Read(src/db/pool.js)\n  \\_ Read 24 lines\n\n* Bash(kubectl top pods -n checkout)\n  \\_ 3 pods, cpu 41-58%\n  \\_ ... +12 lines (ctrl+o to expand)\n\n| Grep(pattern: \"POOL_SIZE\")\n  \\_ Working...\n\n\n"
   }
  },
  {
   "id": "ai-parallel-tools",
   "title": "Parallel Tool Calls",
   "gallery": "ai",
   "galleryTitle": "AI Working",
   "category": "Tool Calls",
   "description": "Four tool calls fanned out in parallel as a live waterfall: bars grow while each runs, finish at different times with their results, then fan back in.",
   "tags": [
    "parallel",
    "tool calls",
    "waterfall",
    "fan out",
    "agent"
   ],
   "kind": "tui",
   "size": {
    "w": 70,
    "h": 6
   },
   "minSize": {
    "w": 48,
    "h": 6
   },
   "animated": true,
   "loop": 7,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ai/parallel-tools.js",
   "imports": [
    "./_ai.js"
   ],
   "usage": {
    "cli": "prism-term play ai-parallel-tools",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ai-parallel-tools",
    "snapshot": "prism-term snapshot ai-parallel-tools --size 70x6",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ai-parallel-tools'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ai-parallel-tools"
   },
   "preview": {
    "w": 60,
    "h": 6,
    "text": "- Running 4 tools in parallel . 2.3s\n\n| logs.search    ========................. + 214 matches\n| metrics.query  =================........ + 840ms\n| traces.find    =================\\....... 2.3s\n` deploys.list   =====.................... + v2.14.1 @ 12:02"
   }
  },
  {
   "id": "ai-approval-prompt",
   "title": "Approval Prompt",
   "gallery": "ai",
   "galleryTitle": "AI Working",
   "category": "Tool Calls",
   "description": "A human-in-the-loop approval prompt: the agent proposes a production command with its reason, the user weighs the options and approves, then it runs.",
   "tags": [
    "approval",
    "human in the loop",
    "permission",
    "confirm",
    "agent"
   ],
   "kind": "tui",
   "size": {
    "w": 70,
    "h": 10
   },
   "minSize": {
    "w": 50,
    "h": 10
   },
   "animated": true,
   "loop": 7,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ai/approval-prompt.js",
   "imports": [
    "./_ai.js"
   ],
   "usage": {
    "cli": "prism-term play ai-approval-prompt",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ai-approval-prompt",
    "snapshot": "prism-term snapshot ai-approval-prompt --size 70x10",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ai-approval-prompt'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ai-approval-prompt"
   },
   "preview": {
    "w": 60,
    "h": 10,
    "text": "+ Bash command --------------------------------------------+\n| kubectl -n checkout set env deploy/checkout-api DB_PO... |\n| Raise the checkout DB pool to match 32 workers (fix #... |\n|                                                          |\n| Do you want to proceed?                                  |\n|   1. Yes                                                 |\n|   2. Yes, and don't ask again for kubectl set env in ... |\n| > 3. No, and tell the agent what to do differently       |\n| ^v choose . enter confirm . esc deny                     |\n+----------------------------------------------------------+"
   }
  },
  {
   "id": "ai-diff-apply",
   "title": "Diff Apply",
   "gallery": "ai",
   "galleryTitle": "AI Working",
   "category": "Tool Calls",
   "description": "An agent applying an edit: the unified diff streams in line by line with old and new line numbers and +/- gutters, then the edit is applied and summarized.",
   "tags": [
    "diff",
    "edit",
    "patch",
    "code change",
    "agent"
   ],
   "kind": "tui",
   "size": {
    "w": 72,
    "h": 10
   },
   "minSize": {
    "w": 48,
    "h": 9
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ai/diff-apply.js",
   "imports": [
    "./_ai.js"
   ],
   "usage": {
    "cli": "prism-term play ai-diff-apply",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ai-diff-apply",
    "snapshot": "prism-term snapshot ai-diff-apply --size 72x10",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ai-diff-apply'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ai-diff-apply"
   },
   "preview": {
    "w": 60,
    "h": 10,
    "text": "| Update(src/db/pool.js)\n  \\_ Proposing edit...\n       3   3   import { createPool } from './db.js';\n       4   4\n       5     - const POOL_SIZE = 20;\n           5 + const POOL_SIZE = Number(process.env.DB_PO...\n       6   6   const TIMEOUT_MS = 5000;\n           7 + const POOL_WAIT_ALERT_MS = 200; // alert: ...\n       7   8\n       8   9   export function pool() {"
   }
  },
  {
   "id": "ai-task-plan",
   "title": "Task Plan",
   "gallery": "ai",
   "galleryTitle": "AI Working",
   "category": "Agents & Pipelines",
   "description": "An agent's plan as a live checklist: each task moves from pending to in progress with a spinner to done and dimmed, with timings and a waiting-on-human step.",
   "tags": [
    "plan",
    "todo",
    "checklist",
    "agent",
    "progress"
   ],
   "kind": "tui",
   "size": {
    "w": 60,
    "h": 8
   },
   "minSize": {
    "w": 40,
    "h": 7
   },
   "animated": true,
   "loop": 9,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ai/task-plan.js",
   "imports": [
    "./_ai.js"
   ],
   "usage": {
    "cli": "prism-term play ai-task-plan",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ai-task-plan",
    "snapshot": "prism-term snapshot ai-task-plan --size 60x8",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ai-task-plan'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ai-task-plan"
   },
   "preview": {
    "w": 60,
    "h": 8,
    "text": "* Plan 2/5 done\n  \\_ + Reproduce the p99 spike from traces              1.1s\n     + Correlate p99 with deploys and pool metrics      1.5s\n     | Draft a fix: DB_POOL_SIZE 20 -> 40               0.1s\n     [ ] Get approval from on-call\n     [ ] Apply and verify p99 < 300ms\n\n. ctrl+t to hide plan"
   }
  },
  {
   "id": "ai-chat-transcript",
   "title": "Chat Transcript",
   "gallery": "ai",
   "galleryTitle": "AI Working",
   "category": "Agents & Pipelines",
   "description": "A chat transcript: user questions sit right-aligned in tinted bubbles, the assistant streams replies on the left with a tool-use chip, and the view scrolls.",
   "tags": [
    "chat",
    "transcript",
    "assistant",
    "conversation",
    "streaming"
   ],
   "kind": "tui",
   "size": {
    "w": 64,
    "h": 16
   },
   "minSize": {
    "w": 40,
    "h": 10
   },
   "animated": true,
   "loop": 9.5,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ai/chat-transcript.js",
   "imports": [
    "./_ai.js"
   ],
   "usage": {
    "cli": "prism-term play ai-chat-transcript",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ai-chat-transcript",
    "snapshot": "prism-term snapshot ai-chat-transcript --size 64x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ai-chat-transcript'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ai-chat-transcript"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "                                                         you\n                         Why is checkout slow since lunch?\n\n * searched traces, metrics, deploys\n\nmainbrain\np99 went from 180ms to 2.4s at 12:02,\nright when v2.14.1 shipped. That_\n\n\n\n\n\n\n\n"
   }
  },
  {
   "id": "ai-multi-agent-log",
   "title": "Multi-Agent Log",
   "gallery": "ai",
   "galleryTitle": "AI Working",
   "category": "Agents & Pipelines",
   "description": "A multi-agent message log: color-tagged agents post findings and hand work to one another, handoffs marked with arrows and the newest line flashing.",
   "tags": [
    "multi-agent",
    "log",
    "handoff",
    "agents",
    "orchestration"
   ],
   "kind": "tui",
   "size": {
    "w": 72,
    "h": 10
   },
   "minSize": {
    "w": 48,
    "h": 6
   },
   "animated": true,
   "loop": 9,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ai/multi-agent-log.js",
   "imports": [
    "./_ai.js"
   ],
   "usage": {
    "cli": "prism-term play ai-multi-agent-log",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ai-multi-agent-log",
    "snapshot": "prism-term snapshot ai-multi-agent-log --size 72x10",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ai-multi-agent-log'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ai-multi-agent-log"
   },
   "preview": {
    "w": 60,
    "h": 10,
    "text": "12:04:31 collector ingesting checkout spans, 1.2k/s\n12:04:33 collector> analyst  p99 2.4s on POST /checkout s...\n12:04:34 analyst   pool_wait_ms tracks p99 (r=0.94)\n12:04:36 analyst   deploy v2.14.1 at 12:02 raised workers...\n\n\n\n\n\n"
   }
  },
  {
   "id": "ai-context-window",
   "title": "Context Window",
   "gallery": "ai",
   "galleryTitle": "AI Working",
   "category": "Model Internals",
   "description": "A 200k context window filling turn by turn as a stacked bar of system, tools, files, history and output, until auto-compact squeezes history into a summary.",
   "tags": [
    "context window",
    "tokens",
    "compaction",
    "memory",
    "budget"
   ],
   "kind": "chart",
   "size": {
    "w": 64,
    "h": 9
   },
   "minSize": {
    "w": 40,
    "h": 7
   },
   "animated": true,
   "loop": 9,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ai/context-window.js",
   "imports": [
    "./_ai.js"
   ],
   "usage": {
    "cli": "prism-term play ai-context-window",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ai-context-window",
    "snapshot": "prism-term snapshot ai-context-window --size 64x9",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ai-context-window'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ai-context-window"
   },
   "preview": {
    "w": 60,
    "h": 9,
    "text": "context                                   111.3k / 200k  56%\n#====+++++++::::::::::::::::::::%%..........................\n#====+++++++::::::::::::::::::::%%..........................\n                                                ^\n# system 3.2k   = tools 11.8k   + files 23.7k\n: history 67.0k   % output 5.7k\n\n\nturn 12 . history grows with every tool result"
   }
  },
  {
   "id": "ai-sampling-probs",
   "title": "Next-Token Sampling",
   "gallery": "ai",
   "galleryTitle": "AI Working",
   "category": "Model Internals",
   "description": "Next-token sampling: top-k candidates as probability bars that sharpen or flatten as temperature slides, then the sampled token flashes and joins the text.",
   "tags": [
    "sampling",
    "temperature",
    "probabilities",
    "top-k",
    "logits"
   ],
   "kind": "chart",
   "size": {
    "w": 60,
    "h": 10
   },
   "minSize": {
    "w": 40,
    "h": 9
   },
   "animated": true,
   "loop": 7.800000000000001,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ai/sampling-probs.js",
   "imports": [
    "./_ai.js"
   ],
   "usage": {
    "cli": "prism-term play ai-sampling-probs",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ai-sampling-probs",
    "snapshot": "prism-term snapshot ai-sampling-probs --size 60x10",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ai-sampling-probs'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ai-sampling-probs"
   },
   "preview": {
    "w": 60,
    "h": 10,
    "text": "The p99 spike is caused by DB pool_\n\ntemperature ------O------------- 0.66\n\n_exhaustion  ###################...............  58.1%\n_starvation  ##########........................  31.7%\n_limits      #.................................   4.4%\n_size        #.................................   3.3%\n_wait        ..................................   2.1%\n_churn       ..................................   0.5%"
   }
  },
  {
   "id": "ai-attention-heatmap",
   "title": "Attention Heatmap",
   "gallery": "ai",
   "galleryTitle": "AI Working",
   "category": "Model Internals",
   "description": "An attention heatmap over one sentence with a causal mask: the query row walks down showing its strongest link, flipping between a previous-token and a coreference head.",
   "tags": [
    "attention",
    "heatmap",
    "transformer",
    "heads",
    "interpretability"
   ],
   "kind": "chart",
   "size": {
    "w": 66,
    "h": 13
   },
   "minSize": {
    "w": 50,
    "h": 12
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ai/attention-heatmap.js",
   "imports": [
    "./_ai.js"
   ],
   "usage": {
    "cli": "prism-term play ai-attention-heatmap",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ai-attention-heatmap",
    "snapshot": "prism-term snapshot ai-attention-heatmap --size 66x13",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ai-attention-heatmap'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ai-attention-heatmap"
   },
   "preview": {
    "w": 60,
    "h": 13,
    "text": "       The  pool is   too  sma. for  32   wor. s\nThe    #### .... .... .... .... .... .... .... ....\npool   #### :::: .... .... .... .... .... .... ....\nis     .... #### :::: .... .... .... .... .... ....\ntoo    .... .... #### :::: .... .... .... .... ....\nsmall  .... .... .... #### :::: .... .... .... ....\nfor    .... .... .... .... #### :::: .... .... .... <\n32     .... .... .... .... .... #### :::: .... ....\nworker .... .... .... .... .... .... #### :::: ....\ns      .... .... .... .... .... .... .... #### ::::\n\nlayer 14 . causal mask . darker = more attention\nhead 2 (previous-token) . \"for\" attends to \"small\" 69%"
   }
  },
  {
   "id": "status-toast-stack",
   "title": "Toast Stack",
   "gallery": "status",
   "galleryTitle": "Notifications & Status",
   "category": "Toasts & Alerts",
   "description": "Notifications slide in from the right, stack, push older ones down and dismiss themselves with a draining timer line.",
   "tags": [
    "toast",
    "notification",
    "snackbar",
    "stack",
    "slide in"
   ],
   "kind": "widget",
   "size": {
    "w": 50,
    "h": 15
   },
   "minSize": {
    "w": 30,
    "h": 5
   },
   "animated": true,
   "loop": 13.2,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/status/alerts.js",
   "imports": [
    "../../color.js",
    "../charts/_data.js"
   ],
   "usage": {
    "cli": "prism-term play status-toast-stack",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play status-toast-stack",
    "snapshot": "prism-term snapshot status-toast-stack --size 50x15",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('status-toast-stack'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/status-toast-stack"
   },
   "preview": {
    "w": 50,
    "h": 15,
    "text": "      | i Backup complete                       x\n      |   pg-orders snapshot 41.2 GB in 3m 12s\n      |-------------------------------------------\n\n      | + Deploy finished                       x\n      |   checkout v2.14.2 is live on 12/12 hos.\n      |-------------------------------------------\n\n\n\n\n\n\n\n"
   }
  },
  {
   "id": "status-alert-banners",
   "title": "Alert Banners",
   "gallery": "status",
   "galleryTitle": "Notifications & Status",
   "category": "Toasts & Alerts",
   "description": "Info, success, warning and error banners with accent rails, icons and dismiss marks; the newest one pulses for attention.",
   "tags": [
    "alert",
    "banner",
    "callout",
    "severity",
    "message"
   ],
   "kind": "widget",
   "size": {
    "w": 72,
    "h": 8
   },
   "minSize": {
    "w": 30,
    "h": 4
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/status/alerts.js",
   "imports": [
    "../../color.js",
    "../charts/_data.js"
   ],
   "usage": {
    "cli": "prism-term play status-alert-banners",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play status-alert-banners",
    "snapshot": "prism-term snapshot status-alert-banners --size 72x8",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('status-alert-banners'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/status-alert-banners"
   },
   "preview": {
    "w": 60,
    "h": 8,
    "text": "| i Heads up  pg-orders maintenance tonight 02:00-02:30.  x\n\n| + Resolved  Checkout latency is back to normal.         x\n\n| ! Degraded  Search is slower than usual (p95 1.8 s).    x\n\n| x Outage  Payments failing in eu-west-1 - team engage.  x\n"
   }
  },
  {
   "id": "status-incident-banner",
   "title": "Incident Banner",
   "gallery": "status",
   "galleryTitle": "Notifications & Status",
   "category": "Toasts & Alerts",
   "description": "A pulsing SEV1 banner with a live elapsed timer, affected-service chips, the incident commander and the bridge link.",
   "tags": [
    "incident",
    "sev1",
    "outage",
    "banner",
    "on-call"
   ],
   "kind": "widget",
   "size": {
    "w": 72,
    "h": 5
   },
   "minSize": {
    "w": 34,
    "h": 2
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/status/alerts.js",
   "imports": [
    "../../color.js",
    "../charts/_data.js"
   ],
   "usage": {
    "cli": "prism-term play status-incident-banner",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play status-incident-banner",
    "snapshot": "prism-term snapshot status-incident-banner --size 72x5",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('status-incident-banner'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/status-incident-banner"
   },
   "preview": {
    "w": 60,
    "h": 5,
    "text": "  SEV1  o INCIDENT  Checkout failing for some elapsed 12:37\n affected  checkout   payments   orIC: @riley - comms: @sam\n bridge: https://status.example/inc/2471 - updates every 1.\n * 14:02 identified: connection pool exhaustion after v2.1.\n * 14:09 mitigating: pool resized 20 -> 40, recovering"
   }
  },
  {
   "id": "status-oncall-pager",
   "title": "On-call Page",
   "gallery": "status",
   "galleryTitle": "Notifications & Status",
   "category": "Toasts & Alerts",
   "description": "An incoming page: the alert, its context, ringing indicator and an acknowledge countdown that escalates when it runs out.",
   "tags": [
    "pager",
    "on-call",
    "alert",
    "acknowledge",
    "escalation"
   ],
   "kind": "widget",
   "size": {
    "w": 56,
    "h": 9
   },
   "minSize": {
    "w": 34,
    "h": 6
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/status/alerts.js",
   "imports": [
    "../../color.js",
    "../charts/_data.js"
   ],
   "usage": {
    "cli": "prism-term play status-oncall-pager",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play status-oncall-pager",
    "snapshot": "prism-term snapshot status-oncall-pager --size 56x9",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('status-oncall-pager'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/status-oncall-pager"
   },
   "preview": {
    "w": 56,
    "h": 9,
    "text": "    PAGE - high urgency                       14:02:11\n\n  checkout p95 > 1s for 5 minutes\n  service checkout - env prod - region us-east-1\n  runbook: wiki.example/runbooks/checkout-latency\n\n  ack in  7s ############################............\n   [a] Acknowledge    [e] Escalate\n"
   }
  },
  {
   "id": "status-deploy-success",
   "title": "Deploy Succeeded",
   "gallery": "status",
   "galleryTitle": "Notifications & Status",
   "category": "Toasts & Alerts",
   "description": "The good-news moment: rollout dots fill host by host, the check lands, confetti falls and the summary — version, hosts, duration — settles in.",
   "tags": [
    "deploy",
    "success",
    "celebration",
    "confetti",
    "release"
   ],
   "kind": "widget",
   "size": {
    "w": 60,
    "h": 12
   },
   "minSize": {
    "w": 30,
    "h": 6
   },
   "animated": true,
   "loop": 9,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/status/alerts.js",
   "imports": [
    "../../color.js",
    "../charts/_data.js"
   ],
   "usage": {
    "cli": "prism-term play status-deploy-success",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play status-deploy-success",
    "snapshot": "prism-term snapshot status-deploy-success --size 60x12",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('status-deploy-success'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/status-deploy-success"
   },
   "preview": {
    "w": 60,
    "h": 12,
    "text": "           +    *    `    .o+ o +           o      o\n          `       .       .  o    .        ..\n  +   ``           .  `   `    .       +.o             `+*\n   *      +   o ` *    +    o  o.   .    +      o .      +\n    . o         `        o  o    *o       ..  .`   o+ `\n o    *  `           +  Deploy succeeded       `\n\n\n\n\n\n"
   }
  },
  {
   "id": "status-vim-line",
   "title": "Vim Statusline",
   "gallery": "status",
   "galleryTitle": "Notifications & Status",
   "category": "Status Lines",
   "description": "A lightline-style vim statusline over a live buffer: modes change, text is typed in, :w writes and the ruler follows the cursor.",
   "tags": [
    "vim",
    "statusline",
    "lightline",
    "editor",
    "modes"
   ],
   "kind": "widget",
   "size": {
    "w": 80,
    "h": 9
   },
   "minSize": {
    "w": 40,
    "h": 3
   },
   "animated": true,
   "loop": 13,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/status/lines.js",
   "imports": [
    "../../color.js",
    "../charts/_data.js",
    "./_logs.js"
   ],
   "usage": {
    "cli": "prism-term play status-vim-line",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play status-vim-line",
    "snapshot": "prism-term snapshot status-vim-line --size 80x9",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('status-vim-line'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/status-vim-line"
   },
   "preview": {
    "w": 60,
    "h": 9,
    "text": "  39   const started = performance.now();\n  40   while (pool.inUse >= pool.size) {\n  41W    if (performance.now() - started > ms) throw new Po.\n  42     m|\n  43     await sleep(5);\n  44   }\n  45   pool.inUse++;\n INSERT  git: main  src/pool.js [+]  x 0 ! 1          42:6\n-- INSERT --"
   }
  },
  {
   "id": "status-tmux-bar",
   "title": "tmux Session",
   "gallery": "status",
   "galleryTitle": "Notifications & Status",
   "category": "Status Lines",
   "description": "A tmux session: split panes with a shell and a live log tail, the active-pane border, and the classic green status bar with a ticking clock.",
   "tags": [
    "tmux",
    "status bar",
    "panes",
    "multiplexer",
    "session"
   ],
   "kind": "widget",
   "size": {
    "w": 80,
    "h": 12
   },
   "minSize": {
    "w": 40,
    "h": 4
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/status/lines.js",
   "imports": [
    "../../color.js",
    "../charts/_data.js",
    "./_logs.js"
   ],
   "usage": {
    "cli": "prism-term play status-tmux-bar",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play status-tmux-bar",
    "snapshot": "prism-term snapshot status-tmux-bar --size 80x12",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('status-tmux-bar'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/status-tmux-bar"
   },
   "preview": {
    "w": 60,
    "h": 12,
    "text": "~/src/checkout $ make test    |14:02:03 postgres checkpoint.\nok   checkout/pool     0.412s |14:02:03 payments TLS handsh.\nok   checkout/cart     0.208s |14:02:04 kernel   TCP: reque.\n                              |14:02:05 nginx    GET /api/v.\n                              |14:02:05 cron     (root) CMD.\n                              |14:02:06 auth     session is.\n                              |14:02:06 checkout order ord-.\n                              |14:02:07 envoy    upstream c.\n                              |14:02:08 systemd  Started Da.\n                              |14:02:08 sshd     Accepted p.\n                              |14:02:09 checkout pool at 85.\n[ops] 0:zsh- 1:vim* 2:logs      ^B  \"web-01\" 14:02 13-Oct-26"
   }
  },
  {
   "id": "status-powerline",
   "title": "Powerline Prompt",
   "gallery": "status",
   "galleryTitle": "Notifications & Status",
   "category": "Status Lines",
   "description": "A two-line powerline shell prompt — user, path, git and kube segments, exit code, duration and clock — as commands run.",
   "tags": [
    "powerline",
    "prompt",
    "shell",
    "git",
    "kubernetes"
   ],
   "kind": "widget",
   "size": {
    "w": 80,
    "h": 12
   },
   "minSize": {
    "w": 40,
    "h": 3
   },
   "animated": true,
   "loop": 15,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/status/lines.js",
   "imports": [
    "../../color.js",
    "../charts/_data.js",
    "./_logs.js"
   ],
   "usage": {
    "cli": "prism-term play status-powerline",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play status-powerline",
    "snapshot": "prism-term snapshot status-powerline --size 80x12",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('status-powerline'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/status-powerline"
   },
   "preview": {
    "w": 60,
    "h": 12,
    "text": " deploy@web-01 > ~/src/checkout > git: main >\n> git pull --rebase\nSuccessfully rebased and updated refs/heads/main.\n deploy@web-01 > ~/src/checkout > git: main >\n>\n\n\n\n\n\n\n"
   }
  },
  {
   "id": "status-badges",
   "title": "Badges & Pills",
   "gallery": "status",
   "galleryTitle": "Notifications & Status",
   "category": "Badges & Pills",
   "description": "Log-level badges with ticking counts, state chips, outline tags, environment pills and notification counters, all theme-tinted.",
   "tags": [
    "badge",
    "pill",
    "chip",
    "tag",
    "counter"
   ],
   "kind": "widget",
   "size": {
    "w": 72,
    "h": 9
   },
   "minSize": {
    "w": 36,
    "h": 4
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/status/badges.js",
   "imports": [
    "../../color.js",
    "../charts/_data.js",
    "./_logs.js"
   ],
   "usage": {
    "cli": "prism-term play status-badges",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play status-badges",
    "snapshot": "prism-term snapshot status-badges --size 72x9",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('status-badges'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/status-badges"
   },
   "preview": {
    "w": 60,
    "h": 9,
    "text": " DEBUG  41,237   INFO  120,552   WARN  888   ERROR  122\n\n | running   . pending   + passed   x failed   - skipped\n\n[v2.14.2] [prod] [us-east-1] [canary 25%] [owner: payments]\n\nenv  DEV   STAGING   CANARY   PROD\n\nInbox  3   PRs  12   Alerts  3   Mentions 0"
   }
  },
  {
   "id": "status-shields",
   "title": "README Shields",
   "gallery": "status",
   "galleryTitle": "Notifications & Status",
   "category": "Badges & Pills",
   "description": "Two-tone README shields — build, tests, coverage, version, licence, downloads — with the build badge running, passing and failing.",
   "tags": [
    "shields",
    "badges",
    "readme",
    "build status",
    "coverage"
   ],
   "kind": "widget",
   "size": {
    "w": 72,
    "h": 6
   },
   "minSize": {
    "w": 30,
    "h": 3
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/status/badges.js",
   "imports": [
    "../../color.js",
    "../charts/_data.js",
    "./_logs.js"
   ],
   "usage": {
    "cli": "prism-term play status-shields",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play status-shields",
    "snapshot": "prism-term snapshot status-shields --size 72x6",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('status-shields'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/status-shields"
   },
   "preview": {
    "w": 60,
    "h": 6,
    "text": " build  passing   tests  1,204 passed   coverage  86.6%\n\n release  v2.14.2   license  MIT   downloads  12.3k/week\n\n uptime  99.98%   vulns  0 known   docs  passing\n"
   }
  },
  {
   "id": "status-ci-pipeline",
   "title": "CI Pipeline",
   "gallery": "status",
   "galleryTitle": "Notifications & Status",
   "category": "Badges & Pills",
   "description": "A CI pipeline graph: stages of jobs going pending → running → passed, one flaky test failing and retrying, and the overall badge.",
   "tags": [
    "ci",
    "pipeline",
    "build",
    "jobs",
    "deploy"
   ],
   "kind": "widget",
   "size": {
    "w": 80,
    "h": 10
   },
   "minSize": {
    "w": 44,
    "h": 6
   },
   "animated": true,
   "loop": 18,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/status/badges.js",
   "imports": [
    "../../color.js",
    "../charts/_data.js",
    "./_logs.js"
   ],
   "usage": {
    "cli": "prism-term play status-ci-pipeline",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play status-ci-pipeline",
    "snapshot": "prism-term snapshot status-ci-pipeline --size 80x10",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('status-ci-pipeline'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/status-ci-pipeline"
   },
   "preview": {
    "w": 60,
    "h": 10,
    "text": " | running  Pipeline #48213 - main - 4c1e9a2 \"awai.   1m 00s\n\nbuild       test        security    staging     producti.\n\n+ compile---| unit   ---o sast   ---o deploy ---o canary.\n+ docker.   - integr.   o deps-a.   o smoke     o promote\n            | lint\n\n\n2/11 jobs passed - 1 retried - artifacts 3 - coverage 87.4%"
   }
  },
  {
   "id": "status-health-checks",
   "title": "Health Checks",
   "gallery": "status",
   "galleryTitle": "Notifications & Status",
   "category": "Health & Uptime",
   "description": "Service health checks re-running in a sweep: live status dots and words, latency, last-checked age and a latency trail per row.",
   "tags": [
    "health check",
    "status dot",
    "monitoring",
    "latency",
    "probe"
   ],
   "kind": "widget",
   "size": {
    "w": 76,
    "h": 10
   },
   "minSize": {
    "w": 36,
    "h": 4
   },
   "animated": true,
   "loop": 16,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/status/health.js",
   "imports": [
    "../../color.js",
    "../../easing.js",
    "../charts/_data.js"
   ],
   "usage": {
    "cli": "prism-term play status-health-checks",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play status-health-checks",
    "snapshot": "prism-term snapshot status-health-checks --size 76x10",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('status-health-checks'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/status-health-checks"
   },
   "preview": {
    "w": 60,
    "h": 10,
    "text": "  SERVICE     CHECK                  STATUS      LATENCY  AG\no api-gateway GET /healthz           OK            19 ms  3s\no auth        GET /v1/ping           OK            25 ms  2s\no checkout    GET /ready             OK            49 ms  2s\no payments    TCP 10.20.4.12:8443    OK            31 ms  1s\no search      GET /_cluster/health   OK            58 ms  1s\no postgres    SELECT 1               OK           3.1 ms  0s\n. redis       PING                   checking     1.2 ms\no kafka       metadata               OK           8.2 ms  3s\n"
   }
  },
  {
   "id": "status-uptime-slo",
   "title": "Uptime & SLO",
   "gallery": "status",
   "galleryTitle": "Notifications & Status",
   "category": "Health & Uptime",
   "description": "A status-page uptime history: 90 daily bars per service, uptime against its SLO, the error budget left and a cursor reading each day.",
   "tags": [
    "uptime",
    "slo",
    "error budget",
    "status page",
    "history"
   ],
   "kind": "widget",
   "size": {
    "w": 80,
    "h": 13
   },
   "minSize": {
    "w": 40,
    "h": 4
   },
   "animated": true,
   "loop": 18,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/status/health.js",
   "imports": [
    "../../color.js",
    "../../easing.js",
    "../charts/_data.js"
   ],
   "usage": {
    "cli": "prism-term play status-uptime-slo",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play status-uptime-slo",
    "snapshot": "prism-term snapshot status-uptime-slo --size 80x13",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('status-uptime-slo'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/status-uptime-slo"
   },
   "preview": {
    "w": 60,
    "h": 13,
    "text": "API       ################################## 99.99% 75% left\n          SLO 99.95%  ---------------------------------- err\nCheckout  #####=######=#=################### 99.94% 43% left\n          SLO 99.90%  ---------------------------------- err\nPayments  ###############=##=############### 99.79%  0% left\n          SLO 99.95%  ---------------------------------- err\nSearch    ####################=#########_### 99.91% 83% left\n          SLO 99.5%   ---------------------------------- err\nWebhooks  #####=####=#####=#####=########### 99.91% 10% left\n          SLO 99.90%  ---------------------------------- err\n          34 days ago                  today\n          > Sep 15: Checkout 22 min down\n"
   }
  },
  {
   "id": "status-service-matrix",
   "title": "Service Matrix",
   "gallery": "status",
   "galleryTitle": "Notifications & Status",
   "category": "Health & Uptime",
   "description": "Services by region as a status grid: an incident starts in one cell, spreads to its neighbors, then heals, with words, not just color.",
   "tags": [
    "status matrix",
    "regions",
    "services",
    "incident",
    "grid"
   ],
   "kind": "widget",
   "size": {
    "w": 80,
    "h": 11
   },
   "minSize": {
    "w": 44,
    "h": 5
   },
   "animated": true,
   "loop": 16,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/status/health.js",
   "imports": [
    "../../color.js",
    "../../easing.js",
    "../charts/_data.js"
   ],
   "usage": {
    "cli": "prism-term play status-service-matrix",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play status-service-matrix",
    "snapshot": "prism-term snapshot status-service-matrix --size 80x11",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('status-service-matrix'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/status-service-matrix"
   },
   "preview": {
    "w": 60,
    "h": 11,
    "text": "          use1    usw2    euw1    eun1    aps1    ape1\napi       o ok    o ok    o ok    o ok    o ok    o ok\nauth      o ok    o ok    o ok    o ok    o ok    o ok\ncheckout  o ok    o ok    o ok    o ok    o ok    o ok\npayments  o ok    o ok    o degr  o ok    o ok    o ok\nsearch    o ok    o ok    o ok    o ok    o ok    o ok\ncdn       o ok    o ok    o ok    o ok    o ok    o ok\nemail     o ok    o ok    o ok    o ok    # maint o ok\nkafka     o ok    o ok    o ok    o ok    o ok    o ok\no 46 ok  o 1 degraded  o 0 down  # 1 maint        recovering\n"
   }
  },
  {
   "id": "status-heartbeat",
   "title": "Heartbeat Monitor",
   "gallery": "status",
   "galleryTitle": "Notifications & Status",
   "category": "Health & Uptime",
   "description": "A cron and worker heartbeat monitor drawn as an ECG trace: each check-in is a beat, missed beats flatline red and raise the alarm.",
   "tags": [
    "heartbeat",
    "ecg",
    "monitor",
    "dead man switch",
    "braille"
   ],
   "kind": "widget",
   "size": {
    "w": 72,
    "h": 9
   },
   "minSize": {
    "w": 30,
    "h": 4
   },
   "animated": true,
   "loop": 24,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/status/health.js",
   "imports": [
    "../../color.js",
    "../../easing.js",
    "../charts/_data.js"
   ],
   "usage": {
    "cli": "prism-term play status-heartbeat",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play status-heartbeat",
    "snapshot": "prism-term snapshot status-heartbeat --size 72x9",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('status-heartbeat'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/status-heartbeat"
   },
   "preview": {
    "w": 60,
    "h": 9,
    "text": "billing-worker  ALIVE        every 0.8s - last beat 0.3s ago\n\n\n\n                                                           @\n--================+++++++++++++++++*****************########\n\n\n12/15 check-ins in the last 12 s - 3 missed - alert after 2"
   }
  },
  {
   "id": "status-log-syslog",
   "title": "journalctl -f",
   "gallery": "status",
   "galleryTitle": "Notifications & Status",
   "category": "Log Lines",
   "description": "A journal follow in syslog format with journald priority colors — red errors, yellow warnings, dim debug — scrolling as lines arrive.",
   "tags": [
    "journalctl",
    "syslog",
    "logs",
    "tail -f",
    "priority"
   ],
   "kind": "widget",
   "size": {
    "w": 80,
    "h": 14
   },
   "minSize": {
    "w": 40,
    "h": 4
   },
   "animated": true,
   "loop": 20,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/status/logs.js",
   "imports": [
    "../../color.js",
    "../charts/_data.js",
    "./_logs.js"
   ],
   "usage": {
    "cli": "prism-term play status-log-syslog",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play status-log-syslog",
    "snapshot": "prism-term snapshot status-log-syslog --size 80x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('status-log-syslog'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/status-log-syslog"
   },
   "preview": {
    "w": 60,
    "h": 14,
    "text": "-- Journal begins at Mon 2026-10-12 00:00:01 UTC. --\nOct 13 14:02:03 checkout-01 envoy[1259]: upstream checkout .\nOct 13 14:02:03 web-01 nginx[1296]: GET /api/v2/catalog?pag.\nOct 13 14:02:04 auth-01 cron[1333]: (root) CMD (/usr/local/.\nOct 13 14:02:04 web-02 payments[1370]: retrying authorize (.\nOct 13 14:02:05 checkout-02 nginx[1407]: upstream response .\nOct 13 14:02:05 web-02 orders[1444]: kafka produce topic=or.\nOct 13 14:02:05 pg-orders-01 postgres[1481]: checkpoint com.\nOct 13 14:02:06 pg-orders-01 postgres[1518]: checkpoint com.\nOct 13 14:02:06 payments-01 nginx[1555]: GET /api/v2/catalo.\nOct 13 14:02:07 pg-orders-01 sshd[1592]: Accepted publickey.\nOct 13 14:02:07 web-01 sshd[1629]: Accepted publickey for d.\nOct 13 14:02:08 web-02 cron[1666]: (root) CMD (/usr/local/b.\nOct 13 14:02:08 auth-01 sshd[1703]: Accepted publickey for ."
   }
  },
  {
   "id": "status-log-json",
   "title": "JSON Log Lines",
   "gallery": "status",
   "galleryTitle": "Notifications & Status",
   "category": "Log Lines",
   "description": "Structured JSON logs piped through jq -c: keys, strings, numbers and level values each colored, with errors flagged in the gutter.",
   "tags": [
    "json",
    "structured logs",
    "jq",
    "logs",
    "syntax color"
   ],
   "kind": "widget",
   "size": {
    "w": 80,
    "h": 14
   },
   "minSize": {
    "w": 40,
    "h": 4
   },
   "animated": true,
   "loop": 20,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/status/logs.js",
   "imports": [
    "../../color.js",
    "../charts/_data.js",
    "./_logs.js"
   ],
   "usage": {
    "cli": "prism-term play status-log-json",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play status-log-json",
    "snapshot": "prism-term snapshot status-log-json --size 80x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('status-log-json'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/status-log-json"
   },
   "preview": {
    "w": 60,
    "h": 14,
    "text": "$ kubectl logs -f deploy/checkout | jq -c\n  {\"ts\":\"14:02:03.124Z\",\"level\":\"info\",\"svc\":\"postgres\",\"ms.\n  {\"ts\":\"14:02:03.749Z\",\"level\":\"info\",\"svc\":\"checkout\",\"ms.\n  {\"ts\":\"14:02:04.374Z\",\"level\":\"info\",\"svc\":\"sshd\",\"msg\":\".\n  {\"ts\":\"14:02:05.999Z\",\"level\":\"info\",\"svc\":\"auth\",\"msg\":\".\n  {\"ts\":\"14:02:05.624Z\",\"level\":\"info\",\"svc\":\"sshd\",\"msg\":\".\n  {\"ts\":\"14:02:06.249Z\",\"level\":\"info\",\"svc\":\"systemd\",\"msg\"\n  {\"ts\":\"14:02:06.874Z\",\"level\":\"info\",\"svc\":\"sshd\",\"msg\":\".\n  {\"ts\":\"14:02:07.499Z\",\"level\":\"info\",\"svc\":\"postgres\",\"ms.\n  {\"ts\":\"14:02:08.124Z\",\"level\":\"info\",\"svc\":\"checkout\",\"ms.\n  {\"ts\":\"14:02:08.749Z\",\"level\":\"info\",\"svc\":\"auth\",\"msg\":\".\n  {\"ts\":\"14:02:09.374Z\",\"level\":\"info\",\"svc\":\"sshd\",\"msg\":\".\n  {\"ts\":\"14:02:10.999Z\",\"level\":\"warn\",\"svc\":\"payments\",\"ms.\n  {\"ts\":\"14:02:10.624Z\",\"level\":\"info\",\"svc\":\"envoy\",\"msg\":."
   }
  },
  {
   "id": "status-log-logfmt",
   "title": "logfmt Viewer",
   "gallery": "status",
   "galleryTitle": "Notifications & Status",
   "category": "Log Lines",
   "description": "logfmt key=value lines under a level filter bar with live counts; the filter steps from all levels to warnings and errors only.",
   "tags": [
    "logfmt",
    "logs",
    "filter",
    "level badges",
    "key value"
   ],
   "kind": "widget",
   "size": {
    "w": 80,
    "h": 14
   },
   "minSize": {
    "w": 40,
    "h": 4
   },
   "animated": true,
   "loop": 18,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/status/logs.js",
   "imports": [
    "../../color.js",
    "../charts/_data.js",
    "./_logs.js"
   ],
   "usage": {
    "cli": "prism-term play status-log-logfmt",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play status-log-logfmt",
    "snapshot": "prism-term snapshot status-log-logfmt --size 80x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('status-log-logfmt'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/status-log-logfmt"
   },
   "preview": {
    "w": 60,
    "h": 14,
    "text": " DEBUG 150   INFO 3,098   WARN 510   ERROR 210   CRIT 40\nts=14:02:03.333 level=info svc=systemd msg=\"Started Daily a.\nts=14:02:03.666 level=info svc=postgres msg=\"checkpoint com.\nts=14:02:04.000 level=info svc=auth msg=\"session issued use.\nts=14:02:04.333 level=info svc=sshd msg=\"Accepted publickey.\nts=14:02:04.666 level=info svc=nginx msg=\"GET /api/v2/catal.\nts=14:02:05.000 level=info svc=sshd msg=\"Accepted publickey.\nts=14:02:05.333 level=info svc=sshd msg=\"Accepted publickey.\nts=14:02:05.666 level=info svc=auth msg=\"session issued use.\nts=14:02:06.000 level=info svc=cron msg=\"(root) CMD (/usr/l.\nts=14:02:06.333 level=info svc=cron msg=\"(root) CMD (/usr/l.\nts=14:02:06.666 level=info svc=envoy msg=\"upstream checkout.\nts=14:02:07.000 level=debug svc=orders msg=\"kafka produce t.\nts=14:02:07.333 level=warn svc=nginx msg=\"upstream response."
   }
  },
  {
   "id": "ops-htop",
   "title": "htop",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "System Monitors",
   "description": "htop as you know it: per-core CPU meters, Mem/Swp bars, tasks, load average and uptime over a live, CPU-sorted process list.",
   "tags": [
    "htop",
    "top",
    "cpu",
    "memory",
    "processes",
    "monitor"
   ],
   "kind": "tui",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 40,
    "h": 10
   },
   "animated": true,
   "loop": 20,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/htop.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-htop",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-htop",
    "snapshot": "prism-term snapshot ops-htop --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-htop'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-htop"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": " 1[|||||||||           36.4%]  5[|||||||||||         47.7%]\n 2[||||||||            36.1%]  6[||||||||||||||      57.4%]\n 3[||||||||||||        43.9%]  7[|||||||||||         46.3%]\n 4[||||||||||||||      52.9%]  8[|||||||||           38.6%]\nMem[||||||||||||||5.6G/14.4G]  Tasks: 214, 1032 thr; 2 runni\nSwp[|              107M/1.9G]  Load average: 1.81 1.10 1.04\n                               Uptime: 41 days, 03:12:10\n\n    PID USER      PRI  NI  VIRT   RES S CPU%v MEM%    TIME+\n   3110 app        20   0 10.6G  935M R  31.1  6.2  2:11.31\n   2051 postgres   20   0  4.1G  515M R  14.4  3.4  1:11.99\n   3172 app        20   0  5.7G  2.0G R  11.7 13.8  1:55.05\n   8803 app        20   0  391M  286M R   8.2  1.9  1:46.17\n   1288 www-data   20   0 58.2M 31.5M R   4.1  0.4  0:43.33\n   1289 www-data   20   0 58.2M 30.5M R   2.3  0.4  0:37.41\nF1Help  F2Setup F3SearchF4FilterF5Tree  F6SortByF7Nice -F8Ni"
   }
  },
  {
   "id": "ops-load-watch",
   "title": "Load Average Watch",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "System Monitors",
   "description": "watch -n2 uptime with a braille history of the 1/5/15-minute load against the core count, so a creeping box is obvious.",
   "tags": [
    "uptime",
    "load average",
    "watch",
    "chart",
    "cpu"
   ],
   "kind": "chart",
   "size": {
    "w": 72,
    "h": 14
   },
   "minSize": {
    "w": 40,
    "h": 6
   },
   "animated": true,
   "loop": 30,
   "glyphs": "ascii",
   "color": "16",
   "canvas": "line",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/load-watch.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-load-watch",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-load-watch",
    "snapshot": "prism-term snapshot ops-load-watch --size 72x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-load-watch'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-load-watch"
   },
   "preview": {
    "w": 60,
    "h": 14,
    "text": "Every 2.0s: uptime          node03: Tue Oct 13 14:02:14 2026\n 14:02:14 up 41 days,  3:12,  2 users,  load average: 3.26,\n\n  10\n\n  8c ......................................................\n\n                            ##### ###\n     ##                #####+++++#+++###\n     ++####        ####+++++     +   +++############## ####\n       ++++########++++                 ++++++++++++++#++++\n           ++++++++         +++++ +++                 +\n   0 ++                +++++     +   +++\n     -216s                                               now"
   }
  },
  {
   "id": "ops-nmon",
   "title": "nmon CPU Utilization",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "System Monitors",
   "description": "nmon's CPU Utilization panel: the boxed nmon frame and per-CPU bars of U (user), s (system) and W (wait) on a 0–100 scale.",
   "tags": [
    "nmon",
    "cpu",
    "utilization",
    "aix",
    "monitor"
   ],
   "kind": "tui",
   "size": {
    "w": 80,
    "h": 15
   },
   "minSize": {
    "w": 56,
    "h": 7
   },
   "animated": true,
   "loop": 16,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/nmon.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-nmon",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-nmon",
    "snapshot": "prism-term snapshot ops-nmon --size 80x15",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-nmon'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-nmon"
   },
   "preview": {
    "w": 60,
    "h": 15,
    "text": "+nmon-16p------[H for help]---Hostname=node03-----Refresh= +\n| CPU Utilisation -----------------------------------------|\n|--------------------------+------------------------------+|\n|CPU User%  Sys% Wait%  Idle||0     |25     |50    |75  100|\n|  1  30.9   5.7   0.5  62.9|UUUUUUUUUss>                  |\n|  2  41.9   7.6   2.0  48.4|UUUUUUUUUUUUUssW>             |\n|  3  44.2   8.6   0.2  47.0|UUUUUUUUUUUUUsss>             |\n|  4  47.0   6.7   1.4  45.0|UUUUUUUUUUUUUUss>             |\n|  5  17.9   6.5   0.1  75.5|UUUUUss>                      |\n|  6  42.3   7.2   2.0  48.5|UUUUUUUUUUUUUssW>             |\n|  7  19.0   4.4   1.5  75.0|UUUUUUs>                      |\n|  8  47.8   8.3   1.2  42.6|UUUUUUUUUUUUUUss>             |\n|--------------------------+------------------------------+|\n|Avg  36.4   6.9   1.1  55.6|UUUUUUUUUUUss                 |\n+----------------------------------------------------------+"
   }
  },
  {
   "id": "ops-sensors",
   "title": "sensors",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "System Monitors",
   "description": "lm-sensors output: coretemp package and cores with high/crit limits, an NVMe composite and fan speeds, readings warming with load.",
   "tags": [
    "sensors",
    "lm-sensors",
    "temperature",
    "fan",
    "hardware"
   ],
   "kind": "tui",
   "size": {
    "w": 64,
    "h": 18
   },
   "minSize": {
    "w": 50,
    "h": 8
   },
   "animated": true,
   "loop": 20,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/sensors.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-sensors",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-sensors",
    "snapshot": "prism-term snapshot ops-sensors --size 64x18",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-sensors'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-sensors"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "coretemp-isa-0000\nAdapter: ISA adapter\nPackage id 0:    +52.6 C  (high = +80.0 C, crit = +100.0 C)\nCore 0:          +51.4 C  (high = +80.0 C, crit = +100.0 C)\nCore 1:          +50.9 C  (high = +80.0 C, crit = +100.0 C)\nCore 2:          +52.8 C  (high = +80.0 C, crit = +100.0 C)\nCore 3:          +54.3 C  (high = +80.0 C, crit = +100.0 C)\n\nnvme-pci-0100\nAdapter: PCI adapter\nComposite:       +41.0 C  (low  = -273.1 C, high = +84.8 C)\n                          (crit = +84.8 C)\n\nit8628-isa-0a40\nAdapter: ISA adapter\nfan1:           1378 RPM  (min =    0 RPM)"
   }
  },
  {
   "id": "ops-top",
   "title": "top",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "System Monitors",
   "description": "procps top: the uptime/Tasks/%Cpu(s)/MiB Mem/MiB Swap summary and a CPU-sorted process table that refreshes every few seconds.",
   "tags": [
    "top",
    "procps",
    "cpu",
    "processes",
    "load"
   ],
   "kind": "tui",
   "size": {
    "w": 80,
    "h": 20
   },
   "minSize": {
    "w": 50,
    "h": 8
   },
   "animated": true,
   "loop": 15,
   "glyphs": "ascii",
   "color": "mono",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/top.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-top",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-top",
    "snapshot": "prism-term snapshot ops-top --size 80x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-top'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-top"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "top - 14:02:14 up 41 days,  3:12,  2 users,  load average: 1\nTasks: 214 total,   2 running, 212 sleeping,   0 stopped,\n%Cpu(s):  9.9 us,  2.5 sy,  0.0 ni, 86.8 id,  0.6 wa,  0.0 h\nMiB Mem :  15872.4 total,   2025.8 free,   5690.2 used,   81\nMiB Swap:   2048.0 total,   1936.0 free,    112.0 used.   98\n\n    PID USER     PR  NI    VIRT    RES    SHR S  %CPU  %MEM\n   3110 app      20   0   10.6g 935.2m  41.2m R  24.5   6.2\n   2051 postgres 20   0    4.4g 540.1m 512.0m R  15.9   3.4\n   8803 app      20   0  410.2m 300.4m  18.1m R   7.9   1.9\n   3172 app      20   0    6.1g   2.1g  28.7m R   7.4  13.8\n   1288 www-data 20   0   61.2m  33.1m   6.0m R   3.0   0.4\n   1289 www-data 20   0   61.2m  32.4m   6.0m R   2.7   0.4\n   7781 ops      20   0    9.1m   5.2m   3.4m R   1.5   0.0\n   3390 redis    20   0  120.4m 190.3m   4.1m R   1.3   1.2\n   4107 root     20   0    2.1g 110.0m  48.2m S   0.8   0.7"
   }
  },
  {
   "id": "ops-vmstat",
   "title": "vmstat 1",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "System Monitors",
   "description": "vmstat 1 streaming one line a second under its two-line header: run queue, memory, swap, block I/O, interrupts, context switches, CPU split.",
   "tags": [
    "vmstat",
    "memory",
    "io",
    "cpu",
    "procps"
   ],
   "kind": "tui",
   "size": {
    "w": 80,
    "h": 14
   },
   "minSize": {
    "w": 50,
    "h": 5
   },
   "animated": true,
   "loop": 20,
   "glyphs": "ascii",
   "color": "mono",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/vmstat.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-vmstat",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-vmstat",
    "snapshot": "prism-term snapshot ops-vmstat --size 80x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-vmstat'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-vmstat"
   },
   "preview": {
    "w": 60,
    "h": 14,
    "text": "procs -----------memory---------- ---swap-- -----io---- -sys\n r  b   swpd   free   buff  cache   si   so    bi    bo   in\n 2  0 114688 2040433 430112 8343959    0    0   136   177 34\n 1  0 114688 2107928 430112 8347674    0    0   347   166 26\n 1  0 114688 2147995 430112 8350085    0    0   298   407 21\n 1  0 114688 2103262 430112 8347760    0    0   410   379 26\n 2  0 114688 2034645 430112 8344078    0    0   542   141 33\n 3  0 114688 2020561 430112 8343353    0    0   293   262 35\n 4  0 114688 1945781 430112 8339392    0    0   411   231 43\n 3  0 114688 1982178 430112 8341786    0    0   604   367 38\n 1  0 114688 2089435 430112 8347564    0    0   366   252 26\n 1  0 114688 2141388 430112 8350130    0    0   680    82 21\n 2  0 114688 2052365 430112 8345240    0    0    80   642 31\n 3  0 114688 1994759 430112 8341655    0    0   352   768 39"
   }
  },
  {
   "id": "ops-df",
   "title": "df -h",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Disk & Storage",
   "description": "df -h with Size/Used/Avail/Use% columns and a usage bar per mount; /var/log creeps toward full and turns red past 90%.",
   "tags": [
    "df",
    "disk",
    "filesystem",
    "storage",
    "usage"
   ],
   "kind": "tui",
   "size": {
    "w": 80,
    "h": 12
   },
   "minSize": {
    "w": 48,
    "h": 5
   },
   "animated": true,
   "loop": 30,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/df.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-df",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-df",
    "snapshot": "prism-term snapshot ops-df --size 80x12",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-df'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-df"
   },
   "preview": {
    "w": 60,
    "h": 12,
    "text": "Filesystem  Size  Used Avail Use% Mounted on   Usage\n/dev/mappe   98G   61G   37G  62% /            [######....]\n/dev/nvme0  1.0G  215M  809M  21% /boot/efi    [##........]\n/dev/mappe   40G   22G   18G  56% /var         [######....]\n/dev/mappe   20G   16G  3.8G  81% /var/log     [########..]\n/dev/mappe  200G   42G  158G  21% /home        [##........]\n/dev/sdb1   7.1T  6.7T  466G  94% /srv/backup  [#########.]\ntmpfs       7.8G    3M  7.8G   0% /dev/shm     [..........]\ntmpfs       1.6G    3M  1.6G   0% /run         [..........]\nnfs01.exam  2.0T  907G  1.1T  45% /mnt/share   [#####.....]\n\n"
   }
  },
  {
   "id": "ops-iostat",
   "title": "iostat -dx",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Disk & Storage",
   "description": "iostat -dx 2 refreshing: avg-cpu, then per-device reads/writes, throughput, await, queue size and %util with a utilization bar.",
   "tags": [
    "iostat",
    "sysstat",
    "disk",
    "io",
    "latency"
   ],
   "kind": "tui",
   "size": {
    "w": 80,
    "h": 13
   },
   "minSize": {
    "w": 60,
    "h": 6
   },
   "animated": true,
   "loop": 20,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/iostat.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-iostat",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-iostat",
    "snapshot": "prism-term snapshot ops-iostat --size 80x13",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-iostat'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-iostat"
   },
   "preview": {
    "w": 60,
    "h": 13,
    "text": "avg-cpu:  %user   %nice %system %iowait  %steal   %idle\n           13.66    0.00    3.41    0.68    0.00   82.25\n\nDevice        r/s     w/s     rkB/s     wkB/s r_await w_awai\nnvme0n1    272.53  485.23  17441.84  15042.25    0.33    0.6\nnvme1n1    168.08  299.26  10757.04   9277.13    0.24    0.4\nsda         68.55  122.05   4387.22   3783.64    2.60    4.4\nsdb         40.57   72.24   2596.56   2239.33    7.14   11.9\ndm-0       248.38  442.24  15896.52  13709.54    0.39    0.7\ndm-1        81.31  144.77   5203.96   4488.02    0.44    0.7\n\n\n"
   }
  },
  {
   "id": "ops-ncdu",
   "title": "ncdu",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Disk & Storage",
   "description": "ncdu browsing /var: sizes and [##########] bars sorted largest first, a selection that walks the list, and the total disk usage footer.",
   "tags": [
    "ncdu",
    "du",
    "disk usage",
    "storage",
    "cleanup"
   ],
   "kind": "tui",
   "size": {
    "w": 72,
    "h": 16
   },
   "minSize": {
    "w": 44,
    "h": 6
   },
   "animated": true,
   "loop": 20,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/ncdu.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-ncdu",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-ncdu",
    "snapshot": "prism-term snapshot ops-ncdu --size 72x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-ncdu'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-ncdu"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "ncdu 1.19 ~ Use the arrow keys to navigate, press ? for help\n--- /var ---------------------------------------------------\n  18.4 GiB [##########] /lib\n   9.2 GiB [#####     ] /log\n   2.3 GiB [#         ] /cache\n 905.2 MiB [          ] /www\n 524.3 MiB [          ] /backups\n  42.2 MiB [          ] /spool\n  19.3 MiB [          ] /tmp\n   1.2 MiB [          ] /mail\n 429.9 KiB [          ] /opt\n  21.0 KiB [          ] /local\n\n\n\n Total disk usage:  31.4 GiB  Apparent size:  30.9 GiB  Item"
   }
  },
  {
   "id": "ops-zpool-status",
   "title": "zpool status",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Disk & Storage",
   "description": "zpool status for a mirrored pool mid-scrub: scanned/issued throughput and % done ticking, the vdev tree, and clean error counters.",
   "tags": [
    "zfs",
    "zpool",
    "scrub",
    "raid",
    "mirror",
    "storage"
   ],
   "kind": "tui",
   "size": {
    "w": 76,
    "h": 20
   },
   "minSize": {
    "w": 60,
    "h": 8
   },
   "animated": true,
   "loop": 30,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/zpool.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-zpool-status",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-zpool-status",
    "snapshot": "prism-term snapshot ops-zpool-status --size 76x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-zpool-status'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-zpool-status"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "  pool: tank\n state: ONLINE\n  scan: scrub in progress since Tue Oct 13 13:41:02 2026\n        1.12T / 3.62T scanned at 1.02G/s, 1.00T / 3.62T issu\n        0B repaired, 27.71% done, 00:58:04 to go\nconfig:\n\n        NAME                            STATE     READ WRITE\n        tank                            ONLINE       0     0\n          mirror-0                      ONLINE       0     0\n            ata-HDD-8TB-ZA1B2C3D        ONLINE       0     0\n            ata-HDD-8TB-ZA1B4E5F        ONLINE       0     0\n          mirror-1                      ONLINE       0     0\n            ata-HDD-8TB-ZA1C7G8H        ONLINE       0     0\n            ata-HDD-8TB-ZA1C9J0K        ONLINE       0     0\n        logs"
   }
  },
  {
   "id": "ops-mtr",
   "title": "mtr",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Network",
   "description": "mtr's curses view: header and key help, then each hop's Loss%, Snt, Last, Avg, Best, Wrst and StDev updating as probes return.",
   "tags": [
    "mtr",
    "traceroute",
    "latency",
    "packet loss",
    "network"
   ],
   "kind": "tui",
   "size": {
    "w": 80,
    "h": 14
   },
   "minSize": {
    "w": 64,
    "h": 6
   },
   "animated": true,
   "loop": 30,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/mtr.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-mtr",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-mtr",
    "snapshot": "prism-term snapshot ops-mtr --size 80x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-mtr'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-mtr"
   },
   "preview": {
    "w": 60,
    "h": 14,
    "text": "                My traceroute  [v0.95]\nnode03 (10.20.2.31) -> api.example (2026-10-13T14:02:14+0000\nKeys:  Help   Display mode   Restart statistics   Order of f\n                 Packets               Pings\n Host         Loss%   Snt   Last   Avg  Best  Wrst StDev\n  1. _gateway  0.0%    21    0.4   0.4   0.4   0.9   0.1\n  2. 10.20.0.  0.0%    21    0.8   0.9   0.8   1.8   0.3\n  3. 100.64.1  0.0%    21    2.9   3.3   2.7   6.5   0.9\n  4. 198.51.1  0.0%    21    7.2   7.1   6.0  10.9   1.2\n  5. 198.51.1  4.8%    21    8.4   7.6   6.3  28.2   5.5\n  6. 203.0.11  0.0%    21   15.0  13.0  10.9  33.4   5.6\n  7. 203.0.11  0.0%    21   20.7  19.8  16.6  62.9  11.6\n  8. api.exam  0.0%    21   27.5  23.9  19.9  66.3  11.6\n"
   }
  },
  {
   "id": "ops-nload",
   "title": "nload",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Network",
   "description": "nload watching eth0: scrolling Incoming and Outgoing bar graphs in # and |, with Curr, Avg, Min, Max and Ttl figures for each direction.",
   "tags": [
    "nload",
    "bandwidth",
    "traffic",
    "throughput",
    "network",
    "graph"
   ],
   "kind": "chart",
   "size": {
    "w": 80,
    "h": 22
   },
   "minSize": {
    "w": 50,
    "h": 10
   },
   "animated": true,
   "loop": 30,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/nload.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-nload",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-nload",
    "snapshot": "prism-term snapshot ops-nload --size 80x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-nload'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-nload"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "Device eth0 [10.20.2.31] (1/3):\n============================================================\nIncoming (100% = 400.00 MBit/s)\n     .\n  .###                  .             Curr:   110.26 MBit/s\n |####                  #.. |          Avg:   169.14 MBit/s\n|##### ..          ..   ######         Min:    61.70 MBit/s\n#########||.   .|.|###|.######.  .|#.  Max:   363.85 MBit/s\n#############|#######################  Ttl:   1849.76 GByte\nOutgoing (100% = 50.00 MBit/s)\n  .          ##\n#.#         .##.                    | Curr:    39.89 MBit/s\n######      ####.                   #  Avg:    22.96 MBit/s\n######      ######           ..     #  Min:     8.16 MBit/s\n######|. ...######  .|..|######|. ..#  Max:    54.01 MBit/s\n###################|#################  Ttl:    212.56 GByte"
   }
  },
  {
   "id": "ops-ping",
   "title": "ping",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Network",
   "description": "ping streaming replies (with the odd late or lost packet), a latency sparkline of recent RTTs and the running rtt min/avg/max/mdev line.",
   "tags": [
    "ping",
    "icmp",
    "latency",
    "rtt",
    "network",
    "sparkline"
   ],
   "kind": "tui",
   "size": {
    "w": 78,
    "h": 14
   },
   "minSize": {
    "w": 46,
    "h": 5
   },
   "animated": true,
   "loop": 30,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/ping.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-ping",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-ping",
    "snapshot": "prism-term snapshot ops-ping --size 78x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-ping'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-ping"
   },
   "preview": {
    "w": 60,
    "h": 14,
    "text": "PING api.example (192.0.2.80) 56(84) bytes of data.\n\n\n\n\n\n\n64 bytes from api.example (192.0.2.80): icmp_seq=1 ttl=56 ti\nno answer yet for icmp_seq=2\n64 bytes from api.example (192.0.2.80): icmp_seq=3 ttl=56 ti\n64 bytes from api.example (192.0.2.80): icmp_seq=4 ttl=56 ti\nrtt .!..\n4 packets transmitted, 3 received, 25% packet loss, time 400\nrtt min/avg/max/mdev = 24.720/25.199/25.619/0.370 ms"
   }
  },
  {
   "id": "ops-ss-listen",
   "title": "ss -tulpn",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Network",
   "description": "Typing `sudo ss -tulpn` and getting the listening sockets: Netid, State, queues, local/peer addresses and the owning processes.",
   "tags": [
    "ss",
    "sockets",
    "ports",
    "listening",
    "netstat",
    "network"
   ],
   "kind": "tui",
   "size": {
    "w": 80,
    "h": 13
   },
   "minSize": {
    "w": 60,
    "h": 5
   },
   "animated": true,
   "loop": 12,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/net-commands.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-ss-listen",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-ss-listen",
    "snapshot": "prism-term snapshot ops-ss-listen --size 80x13",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-ss-listen'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-ss-listen"
   },
   "preview": {
    "w": 60,
    "h": 13,
    "text": "ops@node03:~$ sudo ss -tulpn\nNetid State   Recv-Q Send-Q     Local Address:Port Peer Addr\nudp   UNCONN       0      0       127.0.0.53%lo:53 0.0.0.0:*\nudp   UNCONN       0      0          0.0.0.0:51820 0.0.0.0:*\ntcp   LISTEN       0   4096       127.0.0.53%lo:53 0.0.0.0:*\ntcp   LISTEN       0    128             0.0.0.0:22 0.0.0.0:*\ntcp   LISTEN       0    511             0.0.0.0:80 0.0.0.0:*\ntcp   LISTEN       0    511            0.0.0.0:443 0.0.0.0:*\ntcp   LISTEN       0    200         127.0.0.1:5432 0.0.0.0:*\ntcp   LISTEN       0    511         127.0.0.1:6379 0.0.0.0:*\ntcp   LISTEN       0   4096                 *:9100 *:*\nops@node03:~$\n"
   }
  },
  {
   "id": "ops-ip-brief",
   "title": "ip -br -c addr",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Network",
   "description": "Typing `ip -br -c addr` then `ip -br -c link`: interfaces in color, UP green, DOWN red, IPv4 and IPv6 addresses and MACs.",
   "tags": [
    "ip",
    "iproute2",
    "interfaces",
    "addresses",
    "network"
   ],
   "kind": "tui",
   "size": {
    "w": 76,
    "h": 13
   },
   "minSize": {
    "w": 56,
    "h": 5
   },
   "animated": true,
   "loop": 14,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/net-commands.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-ip-brief",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-ip-brief",
    "snapshot": "prism-term snapshot ops-ip-brief --size 76x13",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-ip-brief'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-ip-brief"
   },
   "preview": {
    "w": 60,
    "h": 13,
    "text": "ops@node03:~$ ip -br -c addr\nlo               UNKNOWN        127.0.0.1/8 ::1/128\neth0             UP             10.20.2.31/24 fe80::5054:ff:\neth1             DOWN\ndocker0          DOWN           172.17.0.1/16\nwg0              UNKNOWN        10.99.0.3/24\nops@node03:~$\n\n\n\n\n\n"
   }
  },
  {
   "id": "ops-traceroute",
   "title": "traceroute",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Network",
   "description": "traceroute revealing the path hop by hop: three probe times per hop as they return, with * * * for a router that won't answer.",
   "tags": [
    "traceroute",
    "hops",
    "routing",
    "latency",
    "network"
   ],
   "kind": "tui",
   "size": {
    "w": 78,
    "h": 13
   },
   "minSize": {
    "w": 56,
    "h": 5
   },
   "animated": true,
   "loop": 16,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/net-commands.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-traceroute",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-traceroute",
    "snapshot": "prism-term snapshot ops-traceroute --size 78x13",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-traceroute'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-traceroute"
   },
   "preview": {
    "w": 60,
    "h": 13,
    "text": "ops@node03:~$ traceroute api.example\ntraceroute to api.example (192.0.2.80), 30 hops max, 60 byte\n 1  _gateway (10.20.2.1)  0.386 ms 0.435 ms 0.426 ms\n 2  10.20.0.1 (10.20.0.1)  0.949 ms\n\n\n\n\n\n\n\n\n"
   }
  },
  {
   "id": "ops-systemctl-status",
   "title": "systemctl status",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Processes & Services",
   "description": "systemctl status nginx.service: the green ● active (running) block, Main PID, tasks, memory, CPU, CGroup tree and a journal tail with a live reload.",
   "tags": [
    "systemd",
    "systemctl",
    "service",
    "status",
    "nginx",
    "unit"
   ],
   "kind": "tui",
   "size": {
    "w": 80,
    "h": 20
   },
   "minSize": {
    "w": 56,
    "h": 6
   },
   "animated": true,
   "loop": 24,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/services.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-systemctl-status",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-systemctl-status",
    "snapshot": "prism-term snapshot ops-systemctl-status --size 80x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-systemctl-status'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-systemctl-status"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "* nginx.service - A high performance web server and a revers\n     Loaded: loaded (/usr/lib/systemd/system/nginx.service;\n     Active: active (running) since Tue 2026-10-13 09:14:02\n       Docs: man:nginx(8)\n    Process: 1281 ExecStartPre=/usr/sbin/nginx -t -q -g \"dae\n   Main PID: 1287 (nginx)\n      Tasks: 5 (limit: 18922)\n     Memory: 21.6M (peak: 24.1M)\n        CPU: 2min 11.495s\n     CGroup: /system.slice/nginx.service\n             |-1287 \"nginx: master process /usr/sbin/nginx -\n             |-1288 \"nginx: worker process\"\n             |-1289 \"nginx: worker process\"\n             |-1290 \"nginx: worker process\"\n             `-1291 \"nginx: worker process\"\n"
   }
  },
  {
   "id": "ops-systemctl-failed",
   "title": "Failed Units",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Processes & Services",
   "description": "systemctl --failed shows two red ● failed units; a restart fixes the backup job and the follow-up listing drops to one.",
   "tags": [
    "systemd",
    "failed",
    "units",
    "troubleshooting",
    "restart"
   ],
   "kind": "tui",
   "size": {
    "w": 80,
    "h": 16
   },
   "minSize": {
    "w": 60,
    "h": 6
   },
   "animated": true,
   "loop": 22,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/services.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-systemctl-failed",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-systemctl-failed",
    "snapshot": "prism-term snapshot ops-systemctl-failed --size 80x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-systemctl-failed'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-systemctl-failed"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "ops@node03:~$ systemctl --failed\n  UNIT                   LOAD   ACTIVE SUB    DESCRIPTION\n* certbot-renew.service  loaded failed failed Renew TLS cert\n* backup-db.service      loaded failed failed Nightly databa\n\n2 loaded units listed.\nops@node03:~$\n\n\n\n\n\n\n\n\n"
   }
  },
  {
   "id": "ops-pstree",
   "title": "pstree -p",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Processes & Services",
   "description": "pstree -p under watch: systemd and its children as a tree with PIDs, while a cron job forks a backup script that runs and exits.",
   "tags": [
    "pstree",
    "processes",
    "tree",
    "pids",
    "cron"
   ],
   "kind": "diagram",
   "size": {
    "w": 80,
    "h": 16
   },
   "minSize": {
    "w": 50,
    "h": 6
   },
   "animated": true,
   "loop": 20,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/services.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-pstree",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-pstree",
    "snapshot": "prism-term snapshot ops-pstree --size 80x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-pstree'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-pstree"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "Every 2.0s: pstree -p       node03: Tue Oct 13 14:02:13 2026\n\nsystemd(1)-+-agetty(902)\n           |-containerd(4120)---8*[{containerd}]\n           |-cron(712)\n           |-dockerd(4107)-+-docker-proxy(4301)\n           |               `-14*[{dockerd}]\n           |-nginx(1287)---4*[nginx(1288)]\n           |-node(3110)---10*[{node}]\n           |-postgres(2040)-+-postgres(2044)\n           |                |-postgres(2045)\n           |                `-postgres(2051)\n           |-redis-server(3390)---4*[{redis-server}]\n           |-sshd(941)---sshd(7702)---sshd(7744)---bash(7745\n           |-systemd-journal(612)\n           `-systemd-logind(733)"
   }
  },
  {
   "id": "ops-list-timers",
   "title": "systemctl list-timers",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Processes & Services",
   "description": "systemd timers with NEXT, LEFT counting down live, LAST and PASSED; when a timer fires its LAST/PASSED reset and NEXT rolls forward.",
   "tags": [
    "systemd",
    "timers",
    "cron",
    "schedule",
    "countdown"
   ],
   "kind": "tui",
   "size": {
    "w": 80,
    "h": 10
   },
   "minSize": {
    "w": 64,
    "h": 4
   },
   "animated": true,
   "loop": 60,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/services.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-list-timers",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-list-timers",
    "snapshot": "prism-term snapshot ops-list-timers --size 80x10",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-list-timers'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-list-timers"
   },
   "preview": {
    "w": 60,
    "h": 10,
    "text": "NEXT     LEFT        LAST     PASSED         UNIT\n14:03:37 1min 23s    14:02:07 7s ago         backup-db.timer\n14:07:00 4min 46s    14:02:00 14s ago        logrotate.timer\n14:30:00 27min 46s   02:30:00 11h 32min ago  certbot-renew.t\n19:08:20 5h 6min     13:08:20 53min 54s ago  apt-daily.timer\n08:36:40 18h 34min   08:36:40 5h 25min ago   systemd-tmpfile\n07:33:20 3 days      07:33:20 3 days ago     fstrim.timer\n\n\n6 timers listed."
   }
  },
  {
   "id": "ops-docker-pull",
   "title": "docker pull",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Containers & Clusters",
   "description": "docker pull with parallel layers going Waiting, Downloading (byte counters, ETA; bars past 110 cols), Verifying, Extracting, Pull complete, then the digest.",
   "tags": [
    "docker",
    "pull",
    "layers",
    "image",
    "progress",
    "registry"
   ],
   "kind": "progress",
   "size": {
    "w": 80,
    "h": 13
   },
   "minSize": {
    "w": 60,
    "h": 6
   },
   "animated": true,
   "loop": 22,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/containers.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-docker-pull",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-docker-pull",
    "snapshot": "prism-term snapshot ops-docker-pull --size 80x13",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-docker-pull'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-docker-pull"
   },
   "preview": {
    "w": 60,
    "h": 13,
    "text": "ops@node03:~$ docker pull registry.example/shop/api:2.14.1\n2.14.1: Pulling from shop/api\na2318d6c47ec: Downloading  8.492MB/29.1MB 2s\n3f1a2b0c9d88: Downloading  1.772MB/43.7MB 4s\n7c0e5d4b2a19: Waiting\ne81f9a0b3c47: Waiting\n0bd4c8e7f611: Waiting\n52a9e0d1c3f8: Waiting\nc6e2b7a40d95: Waiting\n\n\n\n"
   }
  },
  {
   "id": "ops-docker-stats",
   "title": "docker stats",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Containers & Clusters",
   "description": "docker stats refreshing every half second per container: name, CPU %, memory usage against its limit, MEM %, network I/O and PIDs.",
   "tags": [
    "docker",
    "stats",
    "containers",
    "cpu",
    "memory",
    "monitor"
   ],
   "kind": "tui",
   "size": {
    "w": 80,
    "h": 10
   },
   "minSize": {
    "w": 64,
    "h": 4
   },
   "animated": true,
   "loop": 20,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/containers.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-docker-stats",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-docker-stats",
    "snapshot": "prism-term snapshot ops-docker-stats --size 80x10",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-docker-stats'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-docker-stats"
   },
   "preview": {
    "w": 60,
    "h": 10,
    "text": "NAME            CPU %    MEM USAGE / LIMIT    MEM %   NET I/\nshop-api-1      26.33%   387.8MiB / 2.00GiB   18.94%  366.6M\nshop-api-2      23.94%   378.8MiB / 2.00GiB   18.50%  726.0M\nshop-worker-1   53.23%   791.2MiB / 1023.3MiB 77.32%  1.09GB\nshop-db-1       23.07%   1.21GiB / 4.00GiB    30.16%  1.44GB\nshop-redis-1    3.82%    58.9MiB / 488.3MiB   12.07%  1.80GB\nshop-nginx-1    3.39%    13.2MiB / 244.1MiB   5.41%   2.16GB\notel-collector  4.65%    84.7MiB / 488.3MiB   17.35%  2.52GB\nnode-exporter   0.79%    17.2MiB / 122.1MiB   14.10%  2.88GB\n"
   }
  },
  {
   "id": "ops-kubectl-pods",
   "title": "kubectl get pods -w",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Containers & Clusters",
   "description": "kubectl get pods -w during a rolling update: new pods go Pending → ContainerCreating → Running while old ones go Terminating, appended live.",
   "tags": [
    "kubernetes",
    "kubectl",
    "pods",
    "rollout",
    "watch",
    "k8s"
   ],
   "kind": "tui",
   "size": {
    "w": 80,
    "h": 16
   },
   "minSize": {
    "w": 60,
    "h": 6
   },
   "animated": true,
   "loop": 30,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/containers.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-kubectl-pods",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-kubectl-pods",
    "snapshot": "prism-term snapshot ops-kubectl-pods --size 80x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-kubectl-pods'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-kubectl-pods"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "ops@node03:~/deploy$ kubectl get pods -n shop -w\nNAME                        READY   STATUS              REST\nworker-5f4b9c7d6f-q8l2c     1/1     Running             0\nredis-0                     1/1     Running             0\napi-6c8f7d9b5c-4tq2m        1/1     Running             0\napi-6c8f7d9b5c-7mzxk        1/1     Running             0\napi-6c8f7d9b5c-r9b8n        1/1     Running             0\n\n\n\n\n\n\n\n\n"
   }
  },
  {
   "id": "ops-kubectl-rollout",
   "title": "kubectl rollout status",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Containers & Clusters",
   "description": "kubectl rollout status counting up \"N out of 5 new replicas have been updated…\" to \"successfully rolled out\", with a replica strip below.",
   "tags": [
    "kubernetes",
    "kubectl",
    "rollout",
    "deployment",
    "replicas"
   ],
   "kind": "progress",
   "size": {
    "w": 80,
    "h": 12
   },
   "minSize": {
    "w": 60,
    "h": 5
   },
   "animated": true,
   "loop": 20,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/containers.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-kubectl-rollout",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-kubectl-rollout",
    "snapshot": "prism-term snapshot ops-kubectl-rollout --size 80x12",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-kubectl-rollout'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-kubectl-rollout"
   },
   "preview": {
    "w": 60,
    "h": 12,
    "text": "ops@node03:~/deploy$ kubectl rollout status deployment/api -\nWaiting for deployment \"api\" rollout to finish: 1 out of 5 n\new replicas have been updated...\n\n\n\n\n\n\n\n\nreplicas ## ## ## ## ##  1/5 updated . revision 42"
   }
  },
  {
   "id": "ops-k9s",
   "title": "k9s-style Pod Console",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Containers & Clusters",
   "description": "A k9s-style cluster console: context/cluster header with CPU/MEM, key hints, a titled pod table colored by status, a moving selection and a crumb bar.",
   "tags": [
    "k9s",
    "kubernetes",
    "pods",
    "console",
    "tui",
    "k8s"
   ],
   "kind": "tui",
   "size": {
    "w": 80,
    "h": 20
   },
   "minSize": {
    "w": 60,
    "h": 10
   },
   "animated": true,
   "loop": 24,
   "glyphs": "ascii",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/containers.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-k9s",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-k9s",
    "snapshot": "prism-term snapshot ops-k9s --size 80x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-k9s'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-k9s"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": " Context: prod-east\n Cluster: prod-east\n User:    ops\n K9s Rev: v0.32.5\n K8s Rev: v1.31.2\n CPU:     36%\n MEM:     62%\n+--------------------- Pods(shop)[9] ----------------------+\n| NAME^                     READY STATUS            RESTART|\n| api-7d9c6b5f8d-2xk4p      1/1   Running           0      |\n| api-7d9c6b5f8d-9qwlz      1/1   Running           0      |\n| api-7d9c6b5f8d-hv6s1      1/1   Running           0      |\n| worker-5f4b9c7d6f-q8l2c   1/1   Running           0      |\n| worker-5f4b9c7d6f-x7d9p   0/1   CrashLoopBackOff  7      |\n+----------------------------------------------------------+\n <pod>  shop"
   }
  },
  {
   "id": "ops-journalctl-follow",
   "title": "journalctl -f",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Logs & Journals",
   "description": "journalctl -f following the system journal with priority colors: crit/err red, warning yellow, notice bold, info plain and debug dim.",
   "tags": [
    "journalctl",
    "systemd",
    "journal",
    "logs",
    "follow",
    "syslog"
   ],
   "kind": "tui",
   "size": {
    "w": 80,
    "h": 16
   },
   "minSize": {
    "w": 50,
    "h": 5
   },
   "animated": true,
   "loop": 40,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/logs.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-journalctl-follow",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-journalctl-follow",
    "snapshot": "prism-term snapshot ops-journalctl-follow --size 80x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-journalctl-follow'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-journalctl-follow"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "Oct 13 14:02:08 node03 shop-worker[3118]: heartbeat ok (lag\n3ms)\nOct 13 14:02:09 node03 sshd[7811]: Invalid user admin from 2\n03.0.113.77 port 40112\nOct 13 14:02:10 node03 shop-api[2210]: GET /api/v1/cart 200\n18ms\nOct 13 14:02:11 node03 shop-api[2210]: GET /api/v1/cart 200\n18ms\nOct 13 14:02:11 node03 shop-api[2210]: pool: waited 412ms fo\nr a connection (limit 250ms)\nOct 13 14:02:12 node03 CRON[9001]: (root) CMD (/usr/local/bi\nn/backup.sh --quiet)\nOct 13 14:02:13 node03 shop-api[2210]: POST /api/v1/checkout\n 201 142ms\nOct 13 14:02:13 node03 shop-api[2210]: GET /api/v1/cart 200\n18ms"
   }
  },
  {
   "id": "ops-nginx-access",
   "title": "nginx access log",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Logs & Journals",
   "description": "An lnav-style view following the nginx access log: lines chopped at the edge, status codes by class, a 5xx burst tinting lines red, and a status bar.",
   "tags": [
    "nginx",
    "access-log",
    "http",
    "status-codes",
    "lnav",
    "logs"
   ],
   "kind": "tui",
   "size": {
    "w": 80,
    "h": 16
   },
   "minSize": {
    "w": 56,
    "h": 6
   },
   "animated": true,
   "loop": 30,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/logs.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-nginx-access",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-nginx-access",
    "snapshot": "prism-term snapshot ops-nginx-access --size 80x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-nginx-access'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-nginx-access"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": " 2026-10-13 14:02:13  LOG > access.log > access_log\n10.20.4.17 - - [13/Oct/2026:14:02:10 +0000] \"GET /healthz HT\n198.51.100.83 - - [13/Oct/2026:14:02:10 +0000] \"POST /api/v1\n198.51.100.136 - - [13/Oct/2026:14:02:11 +0000] \"GET /api/v1\n10.20.4.17 - - [13/Oct/2026:14:02:11 +0000] \"GET /healthz HT\n198.51.100.13 - - [13/Oct/2026:14:02:11 +0000] \"GET /api/v1/\n198.51.100.45 - - [13/Oct/2026:14:02:11 +0000] \"POST /api/v1\n198.51.100.111 - - [13/Oct/2026:14:02:12 +0000] \"GET /api/v1\n10.20.4.17 - - [13/Oct/2026:14:02:12 +0000] \"GET /healthz HT\n10.20.4.17 - - [13/Oct/2026:14:02:12 +0000] \"GET /healthz HT\n198.51.100.54 - - [13/Oct/2026:14:02:12 +0000] \"GET /api/v1/\n198.51.100.36 - - [13/Oct/2026:14:02:13 +0000] \"POST /api/v1\n198.51.100.25 - - [13/Oct/2026:14:02:13 +0000] \"GET /api/v1/\n198.51.100.133 - - [13/Oct/2026:14:02:13 +0000] \"GET /api/v1\n198.51.100.75 - - [13/Oct/2026:14:02:13 +0000] \"POST /api/v1\n L48,222  100%  0 errors  5 warnings            ?:View Help"
   }
  },
  {
   "id": "ops-dmesg",
   "title": "dmesg -wH",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Logs & Journals",
   "description": "dmesg -wH following the kernel ring buffer with relative timestamps: a USB disk attaches, a segfault, then an oom-kill sequence in red.",
   "tags": [
    "dmesg",
    "kernel",
    "oom-killer",
    "segfault",
    "ring-buffer",
    "logs"
   ],
   "kind": "tui",
   "size": {
    "w": 80,
    "h": 18
   },
   "minSize": {
    "w": 56,
    "h": 6
   },
   "animated": true,
   "loop": 26,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/logs.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-dmesg",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-dmesg",
    "snapshot": "prism-term snapshot ops-dmesg --size 80x18",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-dmesg'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-dmesg"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "ops@node03:~$ sudo dmesg -wH\n[Oct13 14:01] EXT4-fs (dm-0): mounted filesystem 3f1c9a2e-77\nb0-4d1e-9c55-0a8e2f61b7d4 r/w with ordered data mode. Quota\nmode: none.\n[  +0.500000] virtio_net virtio0 eth0: link up\n[  +0.400000] IPv6: ADDRCONF(NETDEV_CHANGE): eth0: link beco\nmes ready\n[  +0.800000] perf: interrupt took too long (2503 > 2500), l\nowering kernel.perf_event_max_sample_rate to 79750\n[  +0.800000] br-6f1d0c: port 2(veth3a91c2e) entered forward\ning state\n[Oct13 14:02] usb 1-2: new high-speed USB device number 7 us\ning xhci_hcd\n\n\n"
   }
  },
  {
   "id": "ops-log-histogram",
   "title": "Log Histogram",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Logs & Journals",
   "description": "An lnav-style histogram of log volume in 10-second buckets: bars split into errors, warnings and normal, with the current bucket filling live.",
   "tags": [
    "lnav",
    "histogram",
    "logs",
    "errors",
    "volume",
    "timeline"
   ],
   "kind": "chart",
   "size": {
    "w": 80,
    "h": 16
   },
   "minSize": {
    "w": 50,
    "h": 6
   },
   "animated": true,
   "loop": 60,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/logs.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-log-histogram",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-log-histogram",
    "snapshot": "prism-term snapshot ops-log-histogram --size 80x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-log-histogram'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-log-histogram"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": " 2026-10-13 14:02:14  HIST > syslog > 10s buckets\n14:00:00 ################################\n14:00:10 ###############################\n14:00:20 #################################\n14:00:30 #################################\n14:00:40 ##################################\n14:00:50 ######################################\n14:01:00 ###################################\n14:01:10 ########################################\n14:01:20 ############################################\n14:01:30 ##################################################\n14:01:40 ###########################################\n14:01:50 #########################################\n14:02:00 ################################\n14:02:10 #############\n Errors #  Warnings #  Normal #"
   }
  },
  {
   "id": "ops-motd",
   "title": "SSH Login MOTD",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Login & MOTD",
   "description": "ssh into a server and get the landscape-style MOTD: system load, disk, memory, processes, the IPv4 address, pending updates and a restart notice.",
   "tags": [
    "motd",
    "ssh",
    "login",
    "sysinfo",
    "banner",
    "updates"
   ],
   "kind": "tui",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 60,
    "h": 8
   },
   "animated": true,
   "loop": 18,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/motd.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-motd",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-motd",
    "snapshot": "prism-term snapshot ops-motd --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-motd'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-motd"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": " * Support:        https://help.sable.example\n\n System information as of Tue Oct 13 14:02:13 UTC 2026\n\n  System load:  0.44               Processes:             21\n  Usage of /:   61.3% of 96.73GB   Users logged in:       1\n  Memory usage: 38%                IPv4 address for eth0: 10\n  Swap usage:   0%\n\n12 updates can be applied immediately.\n4 of these updates are standard security updates.\nTo see these additional updates run: apt list --upgradable\n\n*** System restart required ***\nLast login: Tue Oct 13 09:41:57 2026 from 10.20.2.15\nops@node03:~$"
   }
  },
  {
   "id": "ops-sysinfo-card",
   "title": "System Info Card",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Login & MOTD",
   "description": "A fastfetch-style system card: an original ASCII prism splitting a beam into a spectrum, beside OS, kernel, uptime, CPU, memory, disk and IP.",
   "tags": [
    "fastfetch",
    "neofetch",
    "sysinfo",
    "ascii-art",
    "logo",
    "login"
   ],
   "kind": "tui",
   "size": {
    "w": 80,
    "h": 18
   },
   "minSize": {
    "w": 44,
    "h": 10
   },
   "animated": true,
   "loop": 12,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/motd.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-sysinfo-card",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-sysinfo-card",
    "snapshot": "prism-term snapshot ops-sysinfo-card --size 80x18",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-sysinfo-card'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-sysinfo-card"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "ops@node03:~$ fastfetch\nops@node03\n----------\nOS: Sable Linux 26.04 LTS x86_64\nHost: KVM/QEMU Standard PC (Q35 + ICH9, 2009)\nKernel: Linux 6.8.0-45-generic\nUptime: 4 hours, 48 mins\nPackages: 1287 (dpkg)\nShell: bash 5.2.21\nTerminal: /dev/pts/0\nCPU: 8 x Virtual CPU @ 2.60 GHz\nMemory: 6.09 GiB / 15.61 GiB (39%)\nSwap: 0 B / 4.00 GiB (0%)\nDisk (/): 59.31 GiB / 96.73 GiB (61%) - ext4\nLocal IP (eth0): 10.20.2.31/24\nLocale: C.UTF-8"
   }
  },
  {
   "id": "ops-getty-login",
   "title": "Console Login",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Login & MOTD",
   "description": "A tty1 console: the /etc/issue banner with hostname and addresses, a mistyped password and \"Login incorrect\", then a successful login.",
   "tags": [
    "getty",
    "console",
    "login",
    "issue",
    "tty",
    "banner"
   ],
   "kind": "tui",
   "size": {
    "w": 80,
    "h": 18
   },
   "minSize": {
    "w": 64,
    "h": 8
   },
   "animated": true,
   "loop": 22,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/motd.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-getty-login",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-getty-login",
    "snapshot": "prism-term snapshot ops-getty-login --size 80x18",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-getty-login'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-getty-login"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\nSable Linux 26.04 LTS node03 tty1\n\n  +--------------------------------------------------------+\n  |  node03.prod.example                                   |\n  |  Authorized use only. All activity may be monitored.   |\n  +--------------------------------------------------------+\n  eth0: 10.20.2.31  fe80::5054:ff:fe3a:21c4\n\nnode03 login: ops\nPassword:\n\n\n\n\n"
   }
  },
  {
   "id": "ops-fail2ban",
   "title": "fail2ban Jail",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Login & MOTD",
   "description": "fail2ban-client status for the sshd jail (failed and banned counts, the banned IP list), an unban, and the status again as attempts keep coming.",
   "tags": [
    "fail2ban",
    "ssh",
    "brute-force",
    "ban",
    "security",
    "jail"
   ],
   "kind": "tui",
   "size": {
    "w": 80,
    "h": 18
   },
   "minSize": {
    "w": 64,
    "h": 8
   },
   "animated": true,
   "loop": 24,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/motd.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-fail2ban",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-fail2ban",
    "snapshot": "prism-term snapshot ops-fail2ban --size 80x18",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-fail2ban'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-fail2ban"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "ops@node03:~$ sudo fail2ban-client status sshd\nStatus for the jail: sshd\n|- Filter\n|  |- Currently failed: 3\n|  |- Total failed:     1287\n|  `- Journal matches:  _SYSTEMD_UNIT=sshd.service + _COMM=s\n`- Actions\n   |- Currently banned: 4\n   |- Total banned:     212\n   `- Banned IP list:   203.0.113.77 198.51.100.23 192.0.2.1\nops@node03:~$\n\n\n\n\n"
   }
  },
  {
   "id": "ops-prompt-bash",
   "title": "bash Prompt",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Shell Prompts",
   "description": "The classic user@host:dir$ bash prompt in green and blue: cd into a repo, a colored ls -la, cat a file, then a deploy script dry run.",
   "tags": [
    "bash",
    "ps1",
    "prompt",
    "shell",
    "ls",
    "session"
   ],
   "kind": "tui",
   "size": {
    "w": 80,
    "h": 16
   },
   "minSize": {
    "w": 50,
    "h": 5
   },
   "animated": true,
   "loop": 20,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/prompts.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-prompt-bash",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-prompt-bash",
    "snapshot": "prism-term snapshot ops-prompt-bash --size 80x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-prompt-bash'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-prompt-bash"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "ops@node03:~$ cd ~/src/shop-api\nops@node03:~/src/shop-api$ ls -la\ntotal 64\ndrwxr-xr-x  7 ops ops 4096 Oct 13 13:58 .\ndrwxr-xr-x  5 ops ops 4096 Oct  9 11:20 ..\ndrwxr-xr-x  8 ops ops 4096 Oct 13 13:58 .git\n-rw-r--r--  1 ops ops  412 Oct  2 16:44 .gitignore\n-rw-r--r--  1 ops ops 1931 Oct 13 13:41 Makefile\n-rw-r--r--  1 ops ops    7 Oct 13 13:41 VERSION\ndrwxr-xr-x  4 ops ops 4096 Oct 12 18:03 cmd\n-rwxr-xr-x  1 ops ops 1288 Oct  6 09:12 deploy.sh\ndrwxr-xr-x  2 ops ops 4096 Oct 13 13:41 internal\nlrwxrwxrwx  1 ops ops   17 Oct  6 09:12 logs -> /var/log/sho\nops@node03:~/src/shop-api$\n\n"
   }
  },
  {
   "id": "ops-prompt-git",
   "title": "git Prompt",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Shell Prompts",
   "description": "A __git_ps1 prompt tracking the repo: dirty *, staged +, untracked %, and upstream u+1 / u+2-1 / u= as files are staged, committed, rebased and pushed.",
   "tags": [
    "git",
    "prompt",
    "ps1",
    "branch",
    "ahead-behind",
    "bash"
   ],
   "kind": "tui",
   "size": {
    "w": 80,
    "h": 16
   },
   "minSize": {
    "w": 60,
    "h": 5
   },
   "animated": true,
   "loop": 26,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/prompts.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-prompt-git",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-prompt-git",
    "snapshot": "prism-term snapshot ops-prompt-git --size 80x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-prompt-git'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-prompt-git"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "ops@node03:~/src/shop-api (main *% u+1)$ git status -sb\n## main...origin/main [ahead 1]\n M internal/cart/retry.go\n M internal/cart/retry_test.go\n?? notes.txt\nops@node03:~/src/shop-api (main *% u+1)$\n\n\n\n\n\n\n\n\n\n"
   }
  },
  {
   "id": "ops-prompt-powerline",
   "title": "Powerline Prompt",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Shell Prompts",
   "description": "A powerline prompt of colored segments (user@host, path, git branch going dirty and clean) plus a red exit-code segment after a failing make lint.",
   "tags": [
    "powerline",
    "prompt",
    "segments",
    "git",
    "exit-code",
    "nerd-font"
   ],
   "kind": "tui",
   "size": {
    "w": 80,
    "h": 14
   },
   "minSize": {
    "w": 60,
    "h": 5
   },
   "animated": true,
   "loop": 22,
   "glyphs": "ascii",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {
    "nerd": {
     "default": 0,
     "min": 0,
     "max": 1,
     "label": "Powerline font glyphs"
    }
   },
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/prompts.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-prompt-powerline",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-prompt-powerline",
    "snapshot": "prism-term snapshot ops-prompt-powerline --size 80x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-prompt-powerline'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-prompt-powerline"
   },
   "preview": {
    "w": 60,
    "h": 14,
    "text": " ops@node03 > ~/src/shop-api > main + > make test\nok    shop/api/internal/cart       0.412s\nok    shop/api/internal/checkout   1.208s\n ops@node03 > ~/src/shop-api > main + >\n\n\n\n\n\n\n\n\n\n"
   }
  },
  {
   "id": "ops-prompt-exitcode",
   "title": "Exit-Code Prompt",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Shell Prompts",
   "description": "A prompt that shows the last exit status: a typo gives ✗ 127, Ctrl-C gives ✗ 130, curl -f gives ✗ 22, and a clean run gives a green ✓.",
   "tags": [
    "prompt",
    "exit-code",
    "ps1",
    "bash",
    "troubleshooting",
    "curl"
   ],
   "kind": "tui",
   "size": {
    "w": 80,
    "h": 14
   },
   "minSize": {
    "w": 60,
    "h": 5
   },
   "animated": true,
   "loop": 24,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/prompts.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-prompt-exitcode",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-prompt-exitcode",
    "snapshot": "prism-term snapshot ops-prompt-exitcode --size 80x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-prompt-exitcode'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-prompt-exitcode"
   },
   "preview": {
    "w": 60,
    "h": 14,
    "text": "[0] [ops@node03 ~]$ kubeclt get pods -n shop\nbash: kubeclt: command not found\n[127] [ops@node03 ~]$\n\n\n\n\n\n\n\n\n\n\n"
   }
  },
  {
   "id": "ops-tmux-status",
   "title": "tmux Session",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Shell Prompts",
   "description": "A tmux window split in two: a log tail above, a shell below, a green active-pane border, and the status line with windows, flags and a clock.",
   "tags": [
    "tmux",
    "status-line",
    "panes",
    "multiplexer",
    "session",
    "windows"
   ],
   "kind": "tui",
   "size": {
    "w": 80,
    "h": 20
   },
   "minSize": {
    "w": 50,
    "h": 8
   },
   "animated": true,
   "loop": 30,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/prompts.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-tmux-status",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-tmux-status",
    "snapshot": "prism-term snapshot ops-tmux-status --size 80x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-tmux-status'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-tmux-status"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "14:02:10 WARN  pool: waited 412ms for a connection\n14:02:11 INFO  POST /api/v1/checkout 201 142ms\n14:02:11 INFO  POST /api/v1/checkout 201 142ms\n14:02:12 INFO  GET /api/v1/cart 200 18ms\n14:02:12 INFO  GET /api/v1/cart 200 18ms\n14:02:13 ERROR payments.internal.example: context deadline e\n14:02:14 INFO  POST /api/v1/checkout 201 142ms\n------------------------------------------------------------\nops@node03:~$ kubectl -n shop get deploy api\nNAME   READY   UP-TO-DATE   AVAILABLE   AGE\napi    5/5     5            5           41d\nops@node03:~$\n\n\n\n[prod] 0:logs- 1:shell* 2:deploy    \"node03\" 14:02 13-Oct-26"
   }
  },
  {
   "id": "ops-prompt-starship",
   "title": "Starship-Style Prompt",
   "gallery": "ops",
   "galleryTitle": "Linux & Ops",
   "category": "Shell Prompts",
   "description": "A two-line starship-style prompt: kube context (red for prod), repo, git branch and status, toolchain versions, command duration, then ❯.",
   "tags": [
    "starship",
    "prompt",
    "kubernetes",
    "git",
    "two-line",
    "zsh"
   ],
   "kind": "tui",
   "size": {
    "w": 80,
    "h": 16
   },
   "minSize": {
    "w": 60,
    "h": 6
   },
   "animated": true,
   "loop": 26,
   "glyphs": "ascii",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ops/prompts.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play ops-prompt-starship",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ops-prompt-starship",
    "snapshot": "prism-term snapshot ops-prompt-starship --size 80x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ops-prompt-starship'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ops-prompt-starship"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "kube prod-east (shop) in shop-api on git:main [!?^1] via go\n> go test ./...\nok    shop/api/internal/cart       0.412s\n\nkube prod-east (shop) in shop-api on git:main [!?^1] via go\n>\n\n\n\n\n\n\n\n\n\n"
   }
  },
  {
   "id": "diagrams-architecture",
   "title": "Service Architecture",
   "gallery": "diagrams",
   "galleryTitle": "Architecture Diagrams",
   "category": "Boxes & Flows",
   "description": "Clients, load balancer, three app servers and the data tier, with request packets flowing along routed edges; app-2 fails health checks and traffic reroutes.",
   "tags": [
    "architecture",
    "load balancer",
    "traffic",
    "topology",
    "failover"
   ],
   "kind": "diagram",
   "size": {
    "w": 76,
    "h": 18
   },
   "minSize": {
    "w": 58,
    "h": 14
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/diagrams/architecture.js",
   "imports": [
    "../callouts/_kit.js",
    "./_lines.js"
   ],
   "usage": {
    "cli": "prism-term play diagrams-architecture",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play diagrams-architecture",
    "snapshot": "prism-term snapshot diagrams-architecture --size 76x18",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('diagrams-architecture'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/diagrams-architecture"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n                                +-------+     +----------+\n                             +-o| app-1 |o-+->| postgres |\n                             |  +-------+  |  +----------+\n                             |   412 rps   |    v repl\n                             |             |\n+---------+     +---------+  |  +-------+  |  +----------+\n| clients |ooo-o| edge-lb |-oo->| app-2 |--o->| replica  |\n+---------+     +---------+  |  +-------+  |  +----------+\n                             |   405 rps   |\n                             o             |\n                             |  +-------+  |  +----------+\n                             +-o| app-3 |--+o>|  redis   |\n                                +-------+     +----------+\n                                 418 rps\nrps 1.2k . p99 180ms . 3/3 healthy"
   }
  },
  {
   "id": "diagrams-flowchart",
   "title": "Flowchart",
   "gallery": "diagrams",
   "galleryTitle": "Architecture Diagrams",
   "category": "Boxes & Flows",
   "description": "A delivery flowchart with decisions, labeled yes/no branches and a fix loop; a run token walks it, fails tests once, loops back, then ships via a canary.",
   "tags": [
    "flowchart",
    "decision",
    "process",
    "ci",
    "graph"
   ],
   "kind": "diagram",
   "size": {
    "w": 64,
    "h": 17
   },
   "minSize": {
    "w": 62,
    "h": 17
   },
   "animated": true,
   "loop": 9,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/diagrams/flowchart.js",
   "imports": [
    "../callouts/_kit.js",
    "./_lines.js"
   ],
   "usage": {
    "cli": "prism-term play diagrams-flowchart",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play diagrams-flowchart",
    "snapshot": "prism-term snapshot diagrams-flowchart --size 64x17",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('diagrams-flowchart'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/diagrams-flowchart"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "                     +--------------- fix ---------------+\n                     v                                   @\n+---------+      +-------+      +----------+  no    +-------\n| commit  |----->| build |----->| x tests? |------->| notify\n+---------+      +-------+      +----------+        +-------\n                                      |\n                                      |yes\n                                      v\n+-------+ yes +-----------+     +-----------+\n| ship  |<----| healthy?  |<----| canary 5% |\n+-------+     +-----------+     +-----------+\n                    |no\n                    v\n              +-----------+\n              | roll back |\nx unit tests failed . author notified"
   }
  },
  {
   "id": "diagrams-pipeline",
   "title": "CI/CD Pipeline",
   "gallery": "diagrams",
   "galleryTitle": "Architecture Diagrams",
   "category": "Boxes & Flows",
   "description": "A pipeline graph: build fans out to parallel test jobs with spinners and timings, fans in to staging, waits at a human approval gate, then deploys to prod.",
   "tags": [
    "pipeline",
    "ci",
    "cd",
    "dag",
    "approval"
   ],
   "kind": "diagram",
   "size": {
    "w": 76,
    "h": 13
   },
   "minSize": {
    "w": 70,
    "h": 13
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/diagrams/pipeline.js",
   "imports": [
    "../callouts/_kit.js",
    "./_lines.js"
   ],
   "usage": {
    "cli": "prism-term play diagrams-pipeline",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play diagrams-pipeline",
    "snapshot": "prism-term snapshot diagrams-pipeline --size 76x13",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('diagrams-pipeline'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/diagrams-pipeline"
   },
   "preview": {
    "w": 60,
    "h": 13,
    "text": "              +---------+\n            +>| + unit  |-+\n            | +---------+ |\n            |    1m11s    |\n+---------+ | +---------+ | +-----------+   +-----------+\n| + build |-+>| | integ |-+>| o staging |-->| o approve |-->\n+---------+ | +---------+ | +-----------+   +-----------+\n   1m11s    |    1m17s    |\n            | +---------+ |\n            +>| + lint  |-+\n              +---------+\n                 0m38s\n| pipeline #2291 . 2m45s"
   }
  },
  {
   "id": "diagrams-state-machine",
   "title": "State Machine",
   "gallery": "diagrams",
   "galleryTitle": "Architecture Diagrams",
   "category": "Boxes & Flows",
   "description": "A job lifecycle state machine with labeled transitions: the active state lights up and a token rides each edge through a failure, a backoff retry and success.",
   "tags": [
    "state machine",
    "fsm",
    "lifecycle",
    "transitions",
    "retry"
   ],
   "kind": "diagram",
   "size": {
    "w": 70,
    "h": 14
   },
   "minSize": {
    "w": 68,
    "h": 14
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/diagrams/state-machine.js",
   "imports": [
    "../callouts/_kit.js",
    "./_lines.js"
   ],
   "usage": {
    "cli": "prism-term play diagrams-state-machine",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play diagrams-state-machine",
    "snapshot": "prism-term snapshot diagrams-state-machine --size 70x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('diagrams-state-machine'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/diagrams-state-machine"
   },
   "preview": {
    "w": 60,
    "h": 14,
    "text": "\n    +----------+ schedule +---------+   exit 0   +----------\n *->| PENDING  |--------->| RUNNING |----------->| SUCCEEDED\n    +----------+          +---------+            +----------\n          ^                    |\n          |                    |\n          | backoff 4s         | exit 1\n          |                    |\n          |                    v\n    +----------+ retry <=3+---------+   exhausted   +------+\n    | RETRYING |<------o--| FAILED  |-------------->| DEAD |\n    +----------+          +---------+               +------+\n\n12:04:37 job/reindex-8812 attempt 1/3  FAILED -> RETRYING..."
   }
  },
  {
   "id": "diagrams-file-tree",
   "title": "File Tree",
   "gallery": "diagrams",
   "galleryTitle": "Architecture Diagrams",
   "category": "Trees & Hierarchies",
   "description": "A tree(1)-style explorer with guide lines: folders expand and collapse as the cursor walks, with git status markers, file sizes and a live summary.",
   "tags": [
    "file tree",
    "tree",
    "explorer",
    "directory",
    "git status"
   ],
   "kind": "diagram",
   "size": {
    "w": 56,
    "h": 18
   },
   "minSize": {
    "w": 36,
    "h": 10
   },
   "animated": true,
   "loop": 7,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/diagrams/file-tree.js",
   "imports": [
    "../callouts/_kit.js"
   ],
   "usage": {
    "cli": "prism-term play diagrams-file-tree",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play diagrams-file-tree",
    "snapshot": "prism-term snapshot diagrams-file-tree --size 56x18",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('diagrams-file-tree'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/diagrams-file-tree"
   },
   "preview": {
    "w": 56,
    "h": 16,
    "text": "v prism-term/\n|-- v src/\n|   |-- v facets/\n|   |   |-- > ai/\n|   |   |-- v callouts/\n|   |   |   |-- _kit.js                             6.8K\n|   |   |   `-- table-grid.js                    M  3.9K\n|   |   `-- > input/\n|   |-- buffer.js                                   7.7K\n|   `-- canvas.js                                   9.4K\n|-- v test/\n|   |-- facets.test.js                              4.4K\n|   `-- tui.test.js                              ?  2.1K\n|-- package.json                                 M  1.2K\n`-- README.md                                       8.0K\n6 directories, 8 files . ^v move  l open  h close"
   }
  },
  {
   "id": "diagrams-org-chart",
   "title": "Agent Hierarchy",
   "gallery": "diagrams",
   "galleryTitle": "Architecture Diagrams",
   "category": "Trees & Hierarchies",
   "description": "An org-chart style tree with elbow connectors: mainbrain sends instructions down to its agents and their findings travel back up, each hop lighting the path.",
   "tags": [
    "org chart",
    "hierarchy",
    "tree",
    "agents",
    "elbow connectors"
   ],
   "kind": "diagram",
   "size": {
    "w": 76,
    "h": 16
   },
   "minSize": {
    "w": 70,
    "h": 16
   },
   "animated": true,
   "loop": 7,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/diagrams/org-chart.js",
   "imports": [
    "../callouts/_kit.js",
    "./_lines.js"
   ],
   "usage": {
    "cli": "prism-term play diagrams-org-chart",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play diagrams-org-chart",
    "snapshot": "prism-term snapshot diagrams-org-chart --size 76x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('diagrams-org-chart'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/diagrams-org-chart"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "                        +-------------+\n                        |  mainbrain  |\n                        +------+------+\n                               |\n          +--------------------+------------------------+\n          |                    |                        |\n    +-----+-----+         +----+----+               +---+---\n    | collector |         | analyst |               | fixer\n    +-----+-----+         +----+----+               +---+---\n          |                    |                        |\n     +----+----+         +-----+---o-+            +-----+---\n     |         |         |           |            |\n+----+---+ +---+--+ +----+---+ +-----+-----+ +----+----+ +--\n| ingest | | logs | | detect | | correlate | | planner | | a\n+--------+ +------+ +--------+ +-----------+ +---------+ +--\ncorrelate -> mainbrain  pool wait ~ p99 (r=0.94)"
   }
  },
  {
   "id": "diagrams-dependency-tree",
   "title": "Dependency Tree",
   "gallery": "diagrams",
   "galleryTitle": "Architecture Diagrams",
   "category": "Trees & Hierarchies",
   "description": "An npm ls --all tree printing line by line with deduped markers, then npm audit lights the path from the root to a vulnerable transitive dependency.",
   "tags": [
    "dependencies",
    "npm ls",
    "tree",
    "supply chain",
    "vulnerability"
   ],
   "kind": "diagram",
   "size": {
    "w": 60,
    "h": 20
   },
   "minSize": {
    "w": 44,
    "h": 10
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/diagrams/dependency-tree.js",
   "imports": [
    "../callouts/_kit.js"
   ],
   "usage": {
    "cli": "prism-term play diagrams-dependency-tree",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play diagrams-dependency-tree",
    "snapshot": "prism-term snapshot diagrams-dependency-tree --size 60x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('diagrams-dependency-tree'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/diagrams-dependency-tree"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "@checkout/api@2.14.1 /srv/checkout\n+-+ express@4.21.2\n| +-- body-parser@1.20.3\n| +-+ send@0.19.0\n| | `-- ms@2.1.3\n| `-- qs@6.13.0\n+-+ pg@8.13.1\n| +-- pg-pool@3.7.0\n| `-- pg-protocol@1.7.0\n+-+ @checkout/telemetry@1.4.0\n| +-- left-padder@1.0.2 ! high\n| `-- ms@2.1.3 deduped\n+-- ioredis@5.4.1\n`-- zod@3.23.8\n\n$"
   }
  },
  {
   "id": "diagrams-network-topology",
   "title": "Learned Network Map",
   "gallery": "diagrams",
   "galleryTitle": "Architecture Diagrams",
   "category": "Networks & Topologies",
   "description": "A network map that builds itself passively from telemetry: hosts, subnets and the gateway appear as they show up in incoming data, and links draw in.",
   "tags": [
    "network",
    "topology",
    "discovery",
    "subnet",
    "passive"
   ],
   "kind": "diagram",
   "size": {
    "w": 76,
    "h": 18
   },
   "minSize": {
    "w": 66,
    "h": 18
   },
   "animated": true,
   "loop": 9,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/diagrams/network-topology.js",
   "imports": [
    "../callouts/_kit.js",
    "./_lines.js"
   ],
   "usage": {
    "cli": "prism-term play diagrams-network-topology",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play diagrams-network-topology",
    "snapshot": "prism-term snapshot diagrams-network-topology --size 76x18",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('diagrams-network-topology'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/diagrams-network-topology"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n\n\n\n                          +-------+\n                          |core-gw| 10.20.0.1\n                          +---+---+\n               +--------------+--------------+\n           +---+---+                     +---+---+\n           |sw-web | 10.20.1.0/24        |sw-data|\n           +---+---+                     +---+---+\n     +---------+                   +---------+\n +---+---+ +---+---+           +---+---+ +---+---+\n |web-01 | |web-02 |           | db-01 | |cach...|\n +-------+ +-------+           +-------+ +-------+\n* learned cache-01 (10.20.2.51) from redis client span peer"
   }
  },
  {
   "id": "diagrams-cluster-map",
   "title": "Cluster Map",
   "gallery": "diagrams",
   "galleryTitle": "Architecture Diagrams",
   "category": "Networks & Topologies",
   "description": "Three cluster nodes with pods colored by service plus CPU and memory; node-b goes NotReady, its pods are evicted and reschedule onto healthy nodes.",
   "tags": [
    "kubernetes",
    "cluster",
    "pods",
    "scheduling",
    "nodes"
   ],
   "kind": "diagram",
   "size": {
    "w": 72,
    "h": 12
   },
   "minSize": {
    "w": 54,
    "h": 10
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/diagrams/cluster-map.js",
   "imports": [
    "../callouts/_kit.js"
   ],
   "usage": {
    "cli": "prism-term play diagrams-cluster-map",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play diagrams-cluster-map",
    "snapshot": "prism-term snapshot diagrams-cluster-map --size 72x12",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('diagrams-cluster-map'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/diagrams-cluster-map"
   },
   "preview": {
    "w": 60,
    "h": 12,
    "text": "+ node-a ---------+ + x node-b -------+ + node-c ---------+\n| # # # # # #     | | x x x x x x     | | # # # # #       |\n|                 | |                 | |                 |\n| cpu ###.... 45% | | cpu .......  0% | | cpu ##..... 38% |\n| mem ####... 60% | | mem .......  0% | | mem ###.... 53% |\n+-----------------+ +-----------------+ +-----------------+\n# checkout  # payments  # search  # inventory  o pending\n\nx node-b NotReady: kubelet stopped posting status\n\n\n"
   }
  },
  {
   "id": "diagrams-service-mesh",
   "title": "Service Mesh",
   "gallery": "diagrams",
   "galleryTitle": "Architecture Diagrams",
   "category": "Networks & Topologies",
   "description": "A service mesh on a braille canvas: services ring the frame, diagonal call edges carry traffic comets, and one edge runs hot red during a latency spike.",
   "tags": [
    "service mesh",
    "graph",
    "traffic",
    "braille",
    "latency"
   ],
   "kind": "diagram",
   "size": {
    "w": 64,
    "h": 18
   },
   "minSize": {
    "w": 44,
    "h": 12
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "256",
   "canvas": "line",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/diagrams/service-mesh.js",
   "imports": [
    "../callouts/_kit.js"
   ],
   "usage": {
    "cli": "prism-term play diagrams-service-mesh",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play diagrams-service-mesh",
    "snapshot": "prism-term snapshot diagrams-service-mesh --size 64x18",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('diagrams-service-mesh'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/diagrams-service-mesh"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n                              * payments\n                      p99 2.4s ====-::+\n            * checkout --=::=*        ---- ::\n              ==-  ----::---------------- --++-\n              ::       ----              --- * notify\n             -             ----\n          ::-                  ----\n     * gateway                     ----::+*\n           ----::+*                    ----\n             ::* ----                      * inventory\n              -      ----                      ::*\n             * users     ---::+*        ==*----\n                 -           ----     -::-\n                               * search\nx checkout -> payments p99 2.4s . retries 3.1%"
   }
  },
  {
   "id": "diagrams-sequence",
   "title": "Sequence Diagram",
   "gallery": "diagrams",
   "galleryTitle": "Architecture Diagrams",
   "category": "Sequence & Timelines",
   "description": "A sequence diagram of one checkout request: call arrows grow between lifelines, activation bars mark who is busy, a note flags pool wait, returns come back dashed.",
   "tags": [
    "sequence diagram",
    "uml",
    "lifeline",
    "request",
    "trace"
   ],
   "kind": "diagram",
   "size": {
    "w": 72,
    "h": 19
   },
   "minSize": {
    "w": 60,
    "h": 13
   },
   "animated": true,
   "loop": 9,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/diagrams/sequence.js",
   "imports": [
    "../callouts/_kit.js",
    "./_lines.js"
   ],
   "usage": {
    "cli": "prism-term play diagrams-sequence",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play diagrams-sequence",
    "snapshot": "prism-term snapshot diagrams-sequence --size 72x19",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('diagrams-sequence'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/diagrams-sequence"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "  +--------+     +---------+   +----------+   +----------+\n  | client |     | gateway |   | checkout |   | postgres |\n  +--------+     +---------+   +----------+   +----------+\n       :              :              :              :\n       :- POST /ch...>#              :              :\n       :              #- createOr...>#              :\n       :              #              #- BEGIN; I...>#\n       :               pool 20/20 busy, waited 840ms\n       :              :              :              :\n       :              :              :              :\n       :              :              :              :\n       :              :              :              :\n       :              :              :              :\n       :              :              :              :\n       :              :              :              :\ntrace 4bf92f35 . 4/7 spans"
   }
  },
  {
   "id": "diagrams-gantt",
   "title": "Gantt Chart",
   "gallery": "diagrams",
   "galleryTitle": "Architecture Diagrams",
   "category": "Sequence & Timelines",
   "description": "A rollout Gantt chart for a maintenance window: the now-line sweeps, bars fill as tasks finish, and an overrunning migration slides every dependent task later.",
   "tags": [
    "gantt",
    "schedule",
    "timeline",
    "plan",
    "rollout"
   ],
   "kind": "diagram",
   "size": {
    "w": 72,
    "h": 12
   },
   "minSize": {
    "w": 54,
    "h": 11
   },
   "animated": true,
   "loop": 9,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/diagrams/gantt.js",
   "imports": [
    "../callouts/_kit.js"
   ],
   "usage": {
    "cli": "prism-term play diagrams-gantt",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play diagrams-gantt",
    "snapshot": "prism-term snapshot diagrams-gantt --size 72x12",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('diagrams-gantt'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/diagrams-gantt"
   },
   "preview": {
    "w": 60,
    "h": 12,
    "text": "                01:00  02:00  03:00  04:00  05:00  06:00\n                +------+------+--|---+------+------+------+\nsnapshot db     ####             |\nmigrate schema      ######## +15m|\ncanary 5%                   #####==\ncanary 25%                       | ======\nfull rollout                     |       ========\nverify & soak                    |               =======\nall-clear                        |                      <>\n\n                               03:24\n! migrate schema +15m . all-clear now 06:45"
   }
  },
  {
   "id": "diagrams-timeline",
   "title": "Incident Timeline",
   "gallery": "diagrams",
   "galleryTitle": "Architecture Diagrams",
   "category": "Sequence & Timelines",
   "description": "An incident timeline: a playhead sweeps from deploy to resolution, events pop in on alternating label lanes, and MTTA and MTTR spans draw in at the end.",
   "tags": [
    "timeline",
    "incident",
    "postmortem",
    "mttr",
    "events"
   ],
   "kind": "diagram",
   "size": {
    "w": 76,
    "h": 14
   },
   "minSize": {
    "w": 64,
    "h": 14
   },
   "animated": true,
   "loop": 9,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/diagrams/timeline.js",
   "imports": [
    "../callouts/_kit.js"
   ],
   "usage": {
    "cli": "prism-term play diagrams-timeline",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play diagrams-timeline",
    "snapshot": "prism-term snapshot diagrams-timeline --size 76x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('diagrams-timeline'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/diagrams-timeline"
   },
   "preview": {
    "w": 60,
    "h": 14,
    "text": "12:02 deploy v2.14.1\n    |\n    | 12:11 on-call paged\n    |          |\n    |          |\n=+==o=======o==o=o==+====#-------------+------------------+-\n12:00       |    |12:15              12:30             12:45\n  12:09 p99 alert|fires\n                 |\n        12:13 acknowledged\n\n\n\nINC-3127 . 12:19 . acknowledged"
   }
  },
  {
   "id": "diagrams-git-graph",
   "title": "Git Log Graph",
   "gallery": "diagrams",
   "galleryTitle": "Architecture Diagrams",
   "category": "Git Graphs",
   "description": "git log --graph, live: a feature branch forks off main, main moves on, then the pull request merges, with colored lanes and decorated refs.",
   "tags": [
    "git",
    "log",
    "graph",
    "branches",
    "merge"
   ],
   "kind": "diagram",
   "size": {
    "w": 72,
    "h": 12
   },
   "minSize": {
    "w": 48,
    "h": 9
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/diagrams/git-graph.js",
   "imports": [
    "../callouts/_kit.js"
   ],
   "usage": {
    "cli": "prism-term play diagrams-git-graph",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play diagrams-git-graph",
    "snapshot": "prism-term snapshot diagrams-git-graph --size 72x12",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('diagrams-git-graph'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/diagrams-git-graph"
   },
   "preview": {
    "w": 60,
    "h": 12,
    "text": "$ git log --graph --oneline --decorate\n* 7c2e9b1 (HEAD -> feat/pool-size) Size DB pool to workers\n* 3b8d0aa Add pool wait alert\n* 9d02c55 (main, tag: v2.14.0) Release v2.14.0\n* 5f1a8e3 Fix cart rounding\n* 0b77e21 Add OTLP exporter\n* 6c4f2d9 Split checkout worker\n\n\n\n\n"
   }
  },
  {
   "id": "diagrams-git-branches",
   "title": "Branch Diagram",
   "gallery": "diagrams",
   "galleryTitle": "Architecture Diagrams",
   "category": "Git Graphs",
   "description": "A left-to-right branch diagram: a feature branch and a hotfix fork from main, gain commits and merge back as a playhead reveals history, with release tags.",
   "tags": [
    "git",
    "branches",
    "gitflow",
    "merge",
    "release"
   ],
   "kind": "diagram",
   "size": {
    "w": 76,
    "h": 11
   },
   "minSize": {
    "w": 56,
    "h": 11
   },
   "animated": true,
   "loop": 9,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/diagrams/git-branches.js",
   "imports": [
    "../callouts/_kit.js",
    "./_lines.js"
   ],
   "usage": {
    "cli": "prism-term play diagrams-git-branches",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play diagrams-git-branches",
    "snapshot": "prism-term snapshot diagrams-git-branches --size 76x11",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('diagrams-git-branches'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/diagrams-git-branches"
   },
   "preview": {
    "w": 60,
    "h": 11,
    "text": "                v2.14.0         :\n                   |            :\nmain         --o---o--+-----o--+-\n                      |        |:\n                      |        |:\nhotfix/cart           |        +-\n                      |         :\n                      |         :\nfeat/pool...          +---o------\n                                :\ne41d7f2 main  Bump checkout deps"
   }
  },
  {
   "id": "diagrams-git-rebase",
   "title": "Rebase, Visualized",
   "gallery": "diagrams",
   "galleryTitle": "Architecture Diagrams",
   "category": "Git Graphs",
   "description": "A git rebase, visualized: feature commits lift off their old base and replay onto main with new hashes, leaving ghosts, then main fast-forwards to a line.",
   "tags": [
    "git",
    "rebase",
    "fast-forward",
    "history",
    "branches"
   ],
   "kind": "diagram",
   "size": {
    "w": 72,
    "h": 12
   },
   "minSize": {
    "w": 52,
    "h": 12
   },
   "animated": true,
   "loop": 9,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/diagrams/git-rebase.js",
   "imports": [
    "../callouts/_kit.js",
    "./_lines.js"
   ],
   "usage": {
    "cli": "prism-term play diagrams-git-rebase",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play diagrams-git-rebase",
    "snapshot": "prism-term snapshot diagrams-git-rebase --size 72x12",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('diagrams-git-rebase'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/diagrams-git-rebase"
   },
   "preview": {
    "w": 60,
    "h": 12,
    "text": "\n     A      B       C\n     o------o-------o  main\n  6c4f2d 9d02c5  e41d7f\n            |       |        D'     E'\n            |       +--------o------o  feat/pool-size\n            |             9a1c4e f02b7d\n            |        D      E\n            +--------o------o\n                  3b8d0a 7c2e9b\nSuccessfully rebased and updated refs/heads/feat/pool-size.\n$ git rebase main"
   }
  },
  {
   "id": "callouts-border-styles",
   "title": "Border Styles",
   "gallery": "callouts",
   "galleryTitle": "Callouts & Layout",
   "category": "Panels & Borders",
   "description": "All six border styles — round, single, double, heavy, dashed and ASCII — with a focus ring stepping through them.",
   "tags": [
    "border",
    "box",
    "panel",
    "box-drawing",
    "frame"
   ],
   "kind": "widget",
   "size": {
    "w": 72,
    "h": 18
   },
   "minSize": {
    "w": 34,
    "h": 9
   },
   "animated": true,
   "loop": 7.2,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/callouts/border-styles.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play callouts-border-styles",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play callouts-border-styles",
    "snapshot": "prism-term snapshot callouts-border-styles --size 72x18",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('callouts-border-styles'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/callouts-border-styles"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "+----- round -----+ +---- single -----+ +---- double -----+\n|                 | |                 | |                 |\n|                 | |                 | |                 |\n|   the default   | | classic square  | |  for emphasis   |\n|                 | |                 | |                 |\n|                 | |                 | |                 |\n+-----------------+ +-----------------+ +-----------------+\n\n+----- heavy -----+ +---- dashed -----+ +----- ascii -----+\n|                 | |                 | |                 |\n|                 | |                 | |                 |\n|    for focus    | | secondary panes | | 7-bit, anywhere |\n|                 | |                 | |                 |\n|                 | |                 | |                 |\n+-----------------+ +-----------------+ +-----------------+\n"
   }
  },
  {
   "id": "callouts-titled-panels",
   "title": "Titled Panels",
   "gallery": "callouts",
   "galleryTitle": "Callouts & Layout",
   "category": "Panels & Borders",
   "description": "Panels with left, centered and right titles and live footers — a clock, a pager and a status — the bread and butter of TUI layout.",
   "tags": [
    "panel",
    "title",
    "footer",
    "layout",
    "box"
   ],
   "kind": "widget",
   "size": {
    "w": 72,
    "h": 14
   },
   "minSize": {
    "w": 36,
    "h": 8
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/callouts/titled-panels.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play callouts-titled-panels",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play callouts-titled-panels",
    "snapshot": "prism-term snapshot callouts-titled-panels --size 72x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('callouts-titled-panels'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/callouts-titled-panels"
   },
   "preview": {
    "w": 60,
    "h": 14,
    "text": "+ Overview -------+ +-- Deployments --+ +-------- Health -+\n|                 | |                 | |                 |\n| req...  1,848/s | | che...  v2.14.0 | | edge       * ok |\n|                 | |                 | |                 |\n| p95      228 ms | | paym...  v4.2.7 | | app        * ok |\n|                 | |                 | |                 |\n| errors   0.28 % | | search   v2.3.0 | | data       * ok |\n|                 | |                 | |                 |\n|                 | |                 | |                 |\n|                 | |                 | |                 |\n|                 | |                 | |                 |\n|                 | |                 | |                 |\n|                 | |                 | |                 |\n+------ 14:03:03 -+ +----------- 2/5 -+ + | syncing ------+"
   }
  },
  {
   "id": "callouts-focus-panels",
   "title": "Focus Ring Layout",
   "gallery": "callouts",
   "galleryTitle": "Callouts & Layout",
   "category": "Panels & Borders",
   "description": "A four-pane dashboard where keyboard focus cycles with Tab — heavy accent border on the active pane, dimmed borders on the rest.",
   "tags": [
    "focus",
    "layout",
    "panes",
    "tab",
    "dashboard"
   ],
   "kind": "tui",
   "size": {
    "w": 76,
    "h": 20
   },
   "minSize": {
    "w": 40,
    "h": 10
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/callouts/focus-panels.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play callouts-focus-panels",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play callouts-focus-panels",
    "snapshot": "prism-term snapshot callouts-focus-panels --size 76x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('callouts-focus-panels'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/callouts-focus-panels"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "+ Services -------------+ + > Logs ------------------------+\n| * edge-lb     running | | GET /account 200 34ms          |\n| * api-gateway running | | POST /login 200 88ms           |\n| * checkout    running | | GET /cart 200 41ms             |\n| * payments    degraded| | POST /checkout 201 212ms       |\n| * catalog     running | | GET /search?q=tent 200 22ms    |\n+-----------------------+ +--------------------------------+\n\n+ Alerts ---------------+ + Metrics -----------------------+\n| WARN  payments p95... | | cpu   ###########........  54% |\n| INFO  deploy searc... | | mem   #####..............  25% |\n| OK    cert renewed... | | disk  ######.............  30% |\n|                       | | net   ###############....  77% |\n|                       | |                                |\n+-----------------------+ +--------------------------------+\n                         Tab next pane . Shift-Tab previous"
   }
  },
  {
   "id": "callouts-shadow-card",
   "title": "Shadow Card",
   "gallery": "callouts",
   "galleryTitle": "Callouts & Layout",
   "category": "Panels & Borders",
   "description": "A card with a shaded drop shadow that lifts on hover and settles, with a glint sweeping its title — depth with nothing but shade blocks.",
   "tags": [
    "card",
    "shadow",
    "elevation",
    "depth",
    "hover"
   ],
   "kind": "widget",
   "size": {
    "w": 52,
    "h": 14
   },
   "minSize": {
    "w": 34,
    "h": 9
   },
   "animated": true,
   "loop": 6,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/callouts/shadow-card.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play callouts-shadow-card",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play callouts-shadow-card",
    "snapshot": "prism-term snapshot callouts-shadow-card --size 52x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('callouts-shadow-card'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/callouts-shadow-card"
   },
   "preview": {
    "w": 52,
    "h": 14,
    "text": "\n   +------------------------------------------+\n   |                                          |\n   |  Deploy complete +                       |::::\n   |                                          |::::\n   |  checkout v2.14.1 is live on 12/12 hosts |::::\n   |  MTTR 0s . 0 errors during rollout       |::::\n   |                                          |::::\n   |                    [ View logs ] [ Done ]|::::\n   +------------------------------------------+::::\n       ::::::::::::::::::::::::::::::::::::::::::::\n       ::::::::::::::::::::::::::::::::::::::::::::\n\n"
   }
  },
  {
   "id": "callouts-code-block",
   "title": "Code Block",
   "gallery": "callouts",
   "galleryTitle": "Callouts & Layout",
   "category": "Panels & Borders",
   "description": "A titled code panel with line numbers, syntax coloring, a breakpoint, diff markers and a debugger-style current line stepping through.",
   "tags": [
    "code",
    "syntax",
    "line numbers",
    "debugger",
    "snippet"
   ],
   "kind": "widget",
   "size": {
    "w": 66,
    "h": 14
   },
   "minSize": {
    "w": 36,
    "h": 7
   },
   "animated": true,
   "loop": 9,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/callouts/code-block.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play callouts-code-block",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play callouts-code-block",
    "snapshot": "prism-term snapshot callouts-code-block --size 66x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('callouts-code-block'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/callouts-code-block"
   },
   "preview": {
    "w": 60,
    "h": 14,
    "text": "+ inventory/reserve.js ------------------------------------+\n|   1   import { connect } from './db.js';                 |\n|   2                                                      |\n|   3   export async function reserve(order) {             |\n|   4 +   const pool = await connect({ size: 40 });        |\n|>  5     const rows = await pool.query('SELECT * FROM s...|\n|*  6     if (rows.length === 0) throw new Error(\"out of...|\n|   7     // hold the items for ten minutes                |\n|   8 ~   await pool.query('UPDATE stock SET held = true');|\n|   9     return { ok: true, held: rows.length };          |\n|  10   }                                                  |\n|                                                          |\n|                                                          |\n+------------------------------- Ln 5, Col 3 - JavaScript -+"
   }
  },
  {
   "id": "callouts-admonitions",
   "title": "Admonitions",
   "gallery": "callouts",
   "galleryTitle": "Callouts & Layout",
   "category": "Admonitions",
   "description": "NOTE, TIP, IMPORTANT, WARNING and CAUTION blocks in the GitHub alert style — colored gutter bar, icon, title and wrapped body.",
   "tags": [
    "admonition",
    "alert",
    "note",
    "warning",
    "callout",
    "github"
   ],
   "kind": "widget",
   "size": {
    "w": 72,
    "h": 22
   },
   "minSize": {
    "w": 34,
    "h": 6
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/callouts/admonitions.js",
   "imports": [
    "./_admon.js"
   ],
   "usage": {
    "cli": "prism-term play callouts-admonitions",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play callouts-admonitions",
    "snapshot": "prism-term snapshot callouts-admonitions --size 72x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('callouts-admonitions'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/callouts-admonitions"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "| * Tip\n| Pipe any page of the terminal site through less -R to\n| scroll it with color.\n\n\n\n\n\n\n\n\n\n\n\n\n"
   }
  },
  {
   "id": "callouts-alert-banners",
   "title": "Alert Banners",
   "gallery": "callouts",
   "galleryTitle": "Callouts & Layout",
   "category": "Admonitions",
   "description": "Full-width info, success, warning and error banners that slide in from the edge, stack, pulse when critical and get dismissed.",
   "tags": [
    "banner",
    "alert",
    "notification",
    "status",
    "slide"
   ],
   "kind": "widget",
   "size": {
    "w": 76,
    "h": 12
   },
   "minSize": {
    "w": 36,
    "h": 4
   },
   "animated": true,
   "loop": 9,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/callouts/alert-banners.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play callouts-alert-banners",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play callouts-alert-banners",
    "snapshot": "prism-term snapshot callouts-alert-banners --size 76x12",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('callouts-alert-banners'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/callouts-alert-banners"
   },
   "preview": {
    "w": 60,
    "h": 12,
    "text": "| i  Maintenance                                         x\n|    pg-orders-replica-01 patch window starts at 02:0...\n\n| +  Resolved                                            x\n|    checkout latency back to baseline (p95 212 ms)\n\n| !  Degraded                                            x\n|    payments p95 above SLO for 3 minutes\n\n\n\n"
   }
  },
  {
   "id": "callouts-block-quote",
   "title": "Block Quote",
   "gallery": "callouts",
   "galleryTitle": "Callouts & Layout",
   "category": "Admonitions",
   "description": "A pull quote with an accent gutter that types itself out, attributes its author, then fades to the next — for READMEs and MOTDs.",
   "tags": [
    "quote",
    "blockquote",
    "typewriter",
    "motd",
    "pull quote"
   ],
   "kind": "text",
   "size": {
    "w": 64,
    "h": 9
   },
   "minSize": {
    "w": 30,
    "h": 5
   },
   "animated": true,
   "loop": 21,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/callouts/block-quote.js",
   "imports": [
    "./_kit.js",
    "./_admon.js"
   ],
   "usage": {
    "cli": "prism-term play callouts-block-quote",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play callouts-block-quote",
    "snapshot": "prism-term snapshot callouts-block-quote --size 64x9",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('callouts-block-quote'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/callouts-block-quote"
   },
   "preview": {
    "w": 60,
    "h": 9,
    "text": "\n\n\n |  \"Simplicity is prerequisite for reliability.\"\n |\n |                                   -- Edsger W. Dijkstra\n\n\n"
   }
  },
  {
   "id": "callouts-table-zebra",
   "title": "Zebra Table with Totals",
   "gallery": "callouts",
   "galleryTitle": "Callouts & Layout",
   "category": "Tables",
   "description": "A zebra-striped table with right-aligned numeric columns, rows arriving one by one and a bold totals row under a rule.",
   "tags": [
    "table",
    "zebra",
    "totals",
    "columns",
    "aligned"
   ],
   "kind": "widget",
   "size": {
    "w": 64,
    "h": 13
   },
   "minSize": {
    "w": 36,
    "h": 6
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/callouts/table-zebra.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play callouts-table-zebra",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play callouts-table-zebra",
    "snapshot": "prism-term snapshot callouts-table-zebra --size 64x13",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('callouts-table-zebra'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/callouts-table-zebra"
   },
   "preview": {
    "w": 60,
    "h": 13,
    "text": "    SERVICE        REGION          RPS   P95 MS   ERRORS\n    edge-lb        us-east-1     4,210      3.1    0.03%\n    api-gateway    us-east-1     4,198      8.4    0.04%\n    checkout       us-east-1       612    212.0    0.15%\n    payments       us-east-1       598    131.5    0.11%\n    catalog        us-west-2     2,214     41.2    0.09%\n    search         us-west-2     1,630     22.7    0.08%\n    rx-orders      us-east-1       188    309.4    0.21%\n    ... loading\n\n\n\n"
   }
  },
  {
   "id": "callouts-table-sorted",
   "title": "Sortable Table",
   "gallery": "callouts",
   "galleryTitle": "Callouts & Layout",
   "category": "Tables",
   "description": "A table whose sort column changes with ▲/▼ markers in the header while the rows glide to their new positions.",
   "tags": [
    "table",
    "sort",
    "columns",
    "reorder",
    "hosts"
   ],
   "kind": "tui",
   "size": {
    "w": 56,
    "h": 10
   },
   "minSize": {
    "w": 44,
    "h": 6
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/callouts/table-sorted.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play callouts-table-sorted",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play callouts-table-sorted",
    "snapshot": "prism-term snapshot callouts-table-sorted --size 56x10",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('callouts-table-sorted'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/callouts-table-sorted"
   },
   "preview": {
    "w": 56,
    "h": 10,
    "text": "   HOST                   CPU%v   MEM%   CONNS  UP(d)\n   catalog-01                41     77      30     12\n   checkout-01               64     48      20      6\n   checkout-02               58     47      20      6\n   payments-01               33     52      12     19\n   pg-orders-primary-01      18     61     136     41\n   redis-sessions-01          9     38     412     88\n   search-01                 27     69       8     33\n\n"
   }
  },
  {
   "id": "callouts-table-grid",
   "title": "Result Grid",
   "gallery": "callouts",
   "galleryTitle": "Callouts & Layout",
   "category": "Tables",
   "description": "A psql-style result grid with box-drawing junctions, a double rule under the header, a \"(5 rows)\" footer and a moving cell cursor.",
   "tags": [
    "table",
    "grid",
    "sql",
    "psql",
    "spreadsheet"
   ],
   "kind": "widget",
   "size": {
    "w": 60,
    "h": 13
   },
   "minSize": {
    "w": 52,
    "h": 8
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/callouts/table-grid.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play callouts-table-grid",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play callouts-table-grid",
    "snapshot": "prism-term snapshot callouts-table-grid --size 60x13",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('callouts-table-grid'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/callouts-table-grid"
   },
   "preview": {
    "w": 60,
    "h": 13,
    "text": "    +------+-----------+----------+--------+-----------+\n    |   id | order     | status   |  total | placed_at |\n    +======+===========+==========+========+===========+\n    | 1041 | ord-80211 | shipped  |  84.20 | 14:02:11  |\n    | 1042 | ord-80212 | paid     |  12.99 | 14:02:13  |\n    | 1043 | ord-80213 | pending  | 231.00 | 14:02:19  |\n    | 1044 | ord-80214 | refunded |   9.49 | 14:02:24  |\n    | 1045 | ord-80215 | paid     |  61.75 | 14:02:31  |\n    +------+-----------+----------+--------+-----------+\n    (5 rows)                                   C2 = paid\n\n\n"
   }
  },
  {
   "id": "callouts-dividers",
   "title": "Dividers & Rules",
   "gallery": "callouts",
   "galleryTitle": "Callouts & Layout",
   "category": "Dividers & Headers",
   "description": "Horizontal rules in light, heavy, double, dotted, dashed and dotted-bullet weights with left, centered and right labels and a sweep of light.",
   "tags": [
    "divider",
    "rule",
    "separator",
    "hr",
    "section"
   ],
   "kind": "widget",
   "size": {
    "w": 64,
    "h": 15
   },
   "minSize": {
    "w": 24,
    "h": 6
   },
   "animated": true,
   "loop": 4,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/callouts/dividers.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play callouts-dividers",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play callouts-dividers",
    "snapshot": "prism-term snapshot callouts-dividers --size 64x15",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('callouts-dividers'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/callouts-dividers"
   },
   "preview": {
    "w": 60,
    "h": 15,
    "text": "--- Section ------------------------------------------------\n\n========================== Heavy ===========================\n\n================================================= Double ===\n\n... Dotted .................................................\n\n-------------------------- Dashed --------------------------\n\n....................... Middle dots ........................\n\n* * * * * * * * * * * * * * * * * * * * * * * * * * * * * *\n\n--- Overline -----------------------------------------------"
   }
  },
  {
   "id": "callouts-section-headers",
   "title": "Section Headers",
   "gallery": "callouts",
   "galleryTitle": "Callouts & Layout",
   "category": "Dividers & Headers",
   "description": "Five header styles — numbered eyebrow, underlined, boxed, inverse bar and setext — that type in one after another, for reports and CLIs.",
   "tags": [
    "header",
    "heading",
    "section",
    "title",
    "typography"
   ],
   "kind": "text",
   "size": {
    "w": 60,
    "h": 18
   },
   "minSize": {
    "w": 30,
    "h": 8
   },
   "animated": true,
   "loop": 9,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/callouts/section-headers.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play callouts-section-headers",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play callouts-section-headers",
    "snapshot": "prism-term snapshot callouts-section-headers --size 60x18",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('callouts-section-headers'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/callouts-section-headers"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "01 / OVERVIEW\nSystem health at a glance\n\nIncidents\n------------------------------\n\n+-------------+\n| Deployments |\n+-------------+\n\n\n\n\n\n\n"
   }
  },
  {
   "id": "callouts-neofetch",
   "title": "System Summary (neofetch)",
   "gallery": "callouts",
   "galleryTitle": "Callouts & Layout",
   "category": "Key-Value",
   "description": "A neofetch-style summary: gradient logo art beside aligned key/value system facts and the ANSI color swatch row, with uptime ticking.",
   "tags": [
    "neofetch",
    "fastfetch",
    "system info",
    "key value",
    "motd"
   ],
   "kind": "widget",
   "size": {
    "w": 64,
    "h": 15
   },
   "minSize": {
    "w": 38,
    "h": 10
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/callouts/neofetch.js",
   "imports": [
    "./_kit.js",
    "../../palettes.js"
   ],
   "usage": {
    "cli": "prism-term play callouts-neofetch",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play callouts-neofetch",
    "snapshot": "prism-term snapshot callouts-neofetch --size 64x15",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('callouts-neofetch'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/callouts-neofetch"
   },
   "preview": {
    "w": 60,
    "h": 15,
    "text": "\n         /\\           ops@prism-edge-01\n        /  \\          -----------------\n       / /\\ \\         OS: Debian GNU/Linux 12 x86_64\n      / /  \\ \\        Host: c6i.2xlarge\n     / /    \\ \\       Kernel: 6.1.0-26-cloud-amd64\n    / /  /\\  \\ \\      Uptime: 6 days, 4 hours, 17 mins\n   / /  /  \\  \\ \\     Packages: 612 (dpkg)\n  /_/__/____\\__\\_\\    Shell: bash 5.2.15\n                      Terminal: tmux 3.3a\n                      CPU: Intel Xeon 8375C (8) @ 2.90GHz\n                      Memory: 6,256MiB / 15,733MiB\n\n\n"
   }
  },
  {
   "id": "callouts-key-value",
   "title": "Key-Value Details",
   "gallery": "callouts",
   "galleryTitle": "Callouts & Layout",
   "category": "Key-Value",
   "description": "Grouped key/value details with dotted leaders to right-aligned values, live-updating numbers and status pills — the \"describe\" view of every CLI.",
   "tags": [
    "key value",
    "details",
    "describe",
    "leaders",
    "properties"
   ],
   "kind": "widget",
   "size": {
    "w": 56,
    "h": 16
   },
   "minSize": {
    "w": 32,
    "h": 8
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/callouts/key-value.js",
   "imports": [
    "./_kit.js"
   ],
   "usage": {
    "cli": "prism-term play callouts-key-value",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play callouts-key-value",
    "snapshot": "prism-term snapshot callouts-key-value --size 56x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('callouts-key-value'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/callouts-key-value"
   },
   "preview": {
    "w": 56,
    "h": 16,
    "text": "SERVICE\n  Name ........................................ checkout\n  Version ...................................... v2.14.1\n  Replicas ....................................... 2 / 2\n  Status .....................................  Running\n\nTRAFFIC\n  Requests ..................................... 608 rps\n  p95 latency ................................... 213 ms\n  Error rate .................................... 0.15 %\n\nNETWORK\n  Address .............................. 10.20.2.31:8085\n  Upstream ........................ pg-orders-primary-01\n\n"
   }
  },
  {
   "id": "input-select",
   "title": "Select Prompt",
   "gallery": "input",
   "galleryTitle": "Input & Menus",
   "category": "Selects & Lists",
   "description": "An inquirer/gum-style single select: the pointer moves through regions with a hint line, Enter confirms and the answer collapses inline.",
   "tags": [
    "select",
    "prompt",
    "list",
    "choose",
    "inquirer"
   ],
   "kind": "tui",
   "size": {
    "w": 56,
    "h": 10
   },
   "minSize": {
    "w": 32,
    "h": 7
   },
   "animated": true,
   "loop": 6,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/input/select.js",
   "imports": [
    "./_input.js"
   ],
   "usage": {
    "cli": "prism-term play input-select",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play input-select",
    "snapshot": "prism-term snapshot input-select --size 56x10",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('input-select'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/input-select"
   },
   "preview": {
    "w": 56,
    "h": 10,
    "text": "? Deploy region\n   us-east-1       N. Virginia\n   us-east-2       Ohio\n   us-west-2       Oregon\n > eu-west-1       Ireland\n   ap-southeast-2  Sydney\n\n^/v move . enter select . esc cancel\n\n"
   }
  },
  {
   "id": "input-fuzzy-finder",
   "title": "Fuzzy Finder",
   "gallery": "input",
   "galleryTitle": "Input & Menus",
   "category": "Selects & Lists",
   "description": "An fzf-style finder: the query types itself, results re-rank live with matched characters highlighted and the match counter updating, best match nearest the prompt.",
   "tags": [
    "fzf",
    "fuzzy",
    "finder",
    "search",
    "filter"
   ],
   "kind": "tui",
   "size": {
    "w": 60,
    "h": 14
   },
   "minSize": {
    "w": 34,
    "h": 6
   },
   "animated": true,
   "loop": 7,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/input/fuzzy-finder.js",
   "imports": [
    "./_input.js"
   ],
   "usage": {
    "cli": "prism-term play input-fuzzy-finder",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play input-fuzzy-finder",
    "snapshot": "prism-term snapshot input-fuzzy-finder --size 60x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('input-fuzzy-finder'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/input-fuzzy-finder"
   },
   "preview": {
    "w": 60,
    "h": 14,
    "text": "\n\n  src/facets/callouts/table-grid.js\n  src/facets/diagrams/git-graph.js\n  src/facets/input/fuzzy-finder.js\n  src/facets/progress/odometer.js\n  src/facets/charts/sparkline.js\n  src/facets/ai/thinking.js\n  test/facets.test.js\n  src/facets/fx/plasma.js\n  src/facets/sim/life.js\n> src/facets/ops/htop.js\n  10/20 ----------------------------------------------------\n> facetsj_"
   }
  },
  {
   "id": "input-scroll-list",
   "title": "Scrolling List",
   "gallery": "input",
   "galleryTitle": "Input & Menus",
   "category": "Selects & Lists",
   "description": "A long pod list in a short framed window: the cursor runs down and back, the view scrolls with it and the scrollbar thumb tracks position.",
   "tags": [
    "list",
    "scroll",
    "scrollbar",
    "pods",
    "cursor"
   ],
   "kind": "tui",
   "size": {
    "w": 52,
    "h": 12
   },
   "minSize": {
    "w": 34,
    "h": 6
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/input/scroll-list.js",
   "imports": [
    "./_input.js"
   ],
   "usage": {
    "cli": "prism-term play input-scroll-list",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play input-scroll-list",
    "snapshot": "prism-term snapshot input-scroll-list --size 52x12",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('input-scroll-list'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/input-scroll-list"
   },
   "preview": {
    "w": 52,
    "h": 12,
    "text": "+ pods --------------------------------------------+\n|  catalog-7d9f6bi                  Running   13m ||\n|  search-7d9f5hh                   Running   26m ||\n|  cart-7d9f4ng                     Running   39m ||\n|  auth-7d9f3tf                     Pending   52m ||\n|  orders-7d9f2ze                   Running    6m #|\n|> notify-7d9f25d                   Running   19m #|\n|  checkout-7d9f1bc                 Running   32m #|\n|  payments-7d9f7fb                 Running   45m #|\n|  catalog-7d9f6la                  Pending   58m ||\n|  search-7d9f5r9                   Running   12m ||\n+------------------------------------------ 16/26 -+"
   }
  },
  {
   "id": "input-multiselect",
   "title": "Multi-select Checkboxes",
   "gallery": "input",
   "galleryTitle": "Input & Menus",
   "category": "Toggles & Checkboxes",
   "description": "A checkbox list where a scripted user moves and toggles items with space, with a live \"4 of 7 selected\" counter and select-all hint.",
   "tags": [
    "checkbox",
    "multiselect",
    "list",
    "toggle",
    "prompt"
   ],
   "kind": "tui",
   "size": {
    "w": 50,
    "h": 12
   },
   "minSize": {
    "w": 32,
    "h": 7
   },
   "animated": true,
   "loop": 6,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/input/multiselect.js",
   "imports": [
    "./_input.js"
   ],
   "usage": {
    "cli": "prism-term play input-multiselect",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play input-multiselect",
    "snapshot": "prism-term snapshot input-multiselect --size 50x12",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('input-multiselect'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/input-multiselect"
   },
   "preview": {
    "w": 50,
    "h": 12,
    "text": "? Services to restart\n   [x] checkout\n   [x] payments\n   [ ] inventory\n   [x] catalog\n > [ ] search\n   [ ] rx-orders\n   [ ] loyalty\n\n\n\n3 of 7 selected . space toggle . a all . enter"
   }
  },
  {
   "id": "input-toggles",
   "title": "Toggle Switches",
   "gallery": "input",
   "galleryTitle": "Input & Menus",
   "category": "Toggles & Checkboxes",
   "description": "A settings pane of on/off switches whose knobs slide across with color easing as a scripted user flips them, labels dimming when off.",
   "tags": [
    "toggle",
    "switch",
    "settings",
    "preferences",
    "on off"
   ],
   "kind": "tui",
   "size": {
    "w": 52,
    "h": 13
   },
   "minSize": {
    "w": 34,
    "h": 6
   },
   "animated": true,
   "loop": 6,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/input/toggles.js",
   "imports": [
    "./_input.js"
   ],
   "usage": {
    "cli": "prism-term play input-toggles",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play input-toggles",
    "snapshot": "prism-term snapshot input-toggles --size 52x13",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('input-toggles'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/input-toggles"
   },
   "preview": {
    "w": 52,
    "h": 13,
    "text": "+ Settings ----------------------------------------+\n| Auto-approve diagnostics                   [OFF] |\n|                                                  |\n| Stream reasoning summaries                 [ON ] |\n|                                                  |\n| Page on-call for sev1               locked [ON ] |\n|                                                  |\n| Compact log view                           [OFF] |\n|                                                  |\n| Telemetry to OTLP                          [ON ] |\n|                                                  |\n| One line per event; expand with enter.           |\n+------------------------ space toggle . ^/v move -+"
   }
  },
  {
   "id": "input-radio-group",
   "title": "Radio Groups",
   "gallery": "input",
   "galleryTitle": "Input & Menus",
   "category": "Toggles & Checkboxes",
   "description": "Three labeled radio groups — environment, rollout strategy and notification — with the selected dot hopping as choices change.",
   "tags": [
    "radio",
    "options",
    "choice",
    "form",
    "select"
   ],
   "kind": "tui",
   "size": {
    "w": 60,
    "h": 9
   },
   "minSize": {
    "w": 40,
    "h": 6
   },
   "animated": true,
   "loop": 5,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/input/radio-group.js",
   "imports": [
    "./_input.js"
   ],
   "usage": {
    "cli": "prism-term play input-radio-group",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play input-radio-group",
    "snapshot": "prism-term snapshot input-radio-group --size 60x9",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('input-radio-group'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/input-radio-group"
   },
   "preview": {
    "w": 60,
    "h": 9,
    "text": "Environment\n  ( ) dev   ( ) staging   (*) production\n\nStrategy\n  ( ) rolling   ( ) blue/green   (*) canary\n\nNotify\n  ( ) nobody   (*) team   ( ) on-call\n"
   }
  },
  {
   "id": "input-slider",
   "title": "Sliders",
   "gallery": "input",
   "galleryTitle": "Input & Menus",
   "category": "Toggles & Checkboxes",
   "description": "Range sliders for replicas, CPU and timeout: handles glide along ticked tracks, filled to the value, with the reading following the handle.",
   "tags": [
    "slider",
    "range",
    "control",
    "drag",
    "form"
   ],
   "kind": "tui",
   "size": {
    "w": 60,
    "h": 10
   },
   "minSize": {
    "w": 34,
    "h": 7
   },
   "animated": true,
   "loop": 6,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/input/slider.js",
   "imports": [
    "./_input.js"
   ],
   "usage": {
    "cli": "prism-term play input-slider",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play input-slider",
    "snapshot": "prism-term snapshot input-slider --size 60x10",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('input-slider'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/input-slider"
   },
   "preview": {
    "w": 60,
    "h": 10,
    "text": "Replicas                                                   9\n ========================================O---------------\n '    '    '    '    '    '    '    '    '    '    '    '\nCPU limit                                         3.08 cores\n =========================================O--------------\n '    '    '    '    '    '    '    '    '    '    '    '\nTimeout                                              2850 ms\n ===============================O------------------------\n '    '    '    '    '    '    '    '    '    '    '    '\n"
   }
  },
  {
   "id": "input-text-field",
   "title": "Text Field with Validation",
   "gallery": "input",
   "galleryTitle": "Input & Menus",
   "category": "Text Fields",
   "description": "A labeled input with placeholder, blinking caret and inline validation: the user types an invalid hostname, sees the error, fixes it and gets a ✔.",
   "tags": [
    "input",
    "text field",
    "validation",
    "form",
    "caret"
   ],
   "kind": "tui",
   "size": {
    "w": 56,
    "h": 8
   },
   "minSize": {
    "w": 34,
    "h": 5
   },
   "animated": true,
   "loop": 9,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/input/text-field.js",
   "imports": [
    "./_input.js"
   ],
   "usage": {
    "cli": "prism-term play input-text-field",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play input-text-field",
    "snapshot": "prism-term snapshot input-text-field --size 56x8",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('input-text-field'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/input-text-field"
   },
   "preview": {
    "w": 56,
    "h": 8,
    "text": "Hostname *\n payments_01.internal_                    x\n--------------------------------------------\nx Underscores aren't allowed in hostnames (RFC 1123)\n\n\n\nenter submit . esc cancel . ctrl-u clear"
   }
  },
  {
   "id": "input-password",
   "title": "Password Field",
   "gallery": "input",
   "galleryTitle": "Input & Menus",
   "category": "Text Fields",
   "description": "A masked password input whose strength meter climbs from weak to strong as rules tick off, with a brief reveal toggle at the end.",
   "tags": [
    "password",
    "masked",
    "strength",
    "security",
    "input"
   ],
   "kind": "tui",
   "size": {
    "w": 52,
    "h": 9
   },
   "minSize": {
    "w": 34,
    "h": 5
   },
   "animated": true,
   "loop": 6,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/input/password.js",
   "imports": [
    "./_input.js"
   ],
   "usage": {
    "cli": "prism-term play input-password",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play input-password",
    "snapshot": "prism-term snapshot input-password --size 52x9",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('input-password'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/input-password"
   },
   "preview": {
    "w": 52,
    "h": 9,
    "text": "Password\n **************                     -\n##############################.......... good\n+ 12+ characters\n+ a digit\n+ a symbol\n. not in breach lists\n\n"
   }
  },
  {
   "id": "input-number-stepper",
   "title": "Number Steppers",
   "gallery": "input",
   "galleryTitle": "Input & Menus",
   "category": "Text Fields",
   "description": "Numeric steppers with − and + controls: presses flash, values step, and the field refuses to go past its min/max with a clamped hint.",
   "tags": [
    "number",
    "stepper",
    "spinner",
    "increment",
    "form"
   ],
   "kind": "tui",
   "size": {
    "w": 48,
    "h": 9
   },
   "minSize": {
    "w": 32,
    "h": 5
   },
   "animated": true,
   "loop": 6,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/input/number-stepper.js",
   "imports": [
    "./_input.js"
   ],
   "usage": {
    "cli": "prism-term play input-number-stepper",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play input-number-stepper",
    "snapshot": "prism-term snapshot input-number-stepper --size 48x9",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('input-number-stepper'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/input-number-stepper"
   },
   "preview": {
    "w": 48,
    "h": 9,
    "text": "Workers                        -    11  +\n                              1-32\n\nPool size                      -    40  +\n                              ! max 40\n\nRetries                        -     3  +\n                              0-5\n"
   }
  },
  {
   "id": "input-textarea",
   "title": "Commit Message Editor",
   "gallery": "input",
   "galleryTitle": "Input & Menus",
   "category": "Text Fields",
   "description": "A multi-line textarea where a commit message is typed: line numbers, a 50/72 column guide, subject-length warning and a live character count.",
   "tags": [
    "textarea",
    "editor",
    "multiline",
    "commit",
    "git"
   ],
   "kind": "tui",
   "size": {
    "w": 64,
    "h": 12
   },
   "minSize": {
    "w": 40,
    "h": 7
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/input/textarea.js",
   "imports": [
    "./_input.js"
   ],
   "usage": {
    "cli": "prism-term play input-textarea",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play input-textarea",
    "snapshot": "prism-term snapshot input-textarea --size 64x12",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('input-textarea'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/input-textarea"
   },
   "preview": {
    "w": 60,
    "h": 12,
    "text": "+ COMMIT_EDITMSG ------------------------------------------+\n|  1  Raise checkout DB pool to 40                      :  |\n|  2                                                    :  |\n|  3  v2.14.1 raised worker concurrency to 32 bu        :  |\n|  4 ~                                                  :  |\n|  5 ~                                                  :  |\n|  6 ~                                                  :  |\n|  7 ~                                                  :  |\n|  8 ~                                                  :  |\n|  9 ~                                                  :  |\n| 10 ~                                                  :  |\n+----------------------------------------------- 70 chars -+"
   }
  },
  {
   "id": "input-command-palette",
   "title": "Command Palette",
   "gallery": "input",
   "galleryTitle": "Input & Menus",
   "category": "Command Palettes",
   "description": "A ctrl+shift+p palette over a dimmed app: the query fuzzy-filters commands with highlighted matches and key hints, Enter runs one and the status line confirms.",
   "tags": [
    "command palette",
    "fuzzy",
    "launcher",
    "overlay",
    "keyboard"
   ],
   "kind": "tui",
   "size": {
    "w": 66,
    "h": 16
   },
   "minSize": {
    "w": 40,
    "h": 10
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/input/command-palette.js",
   "imports": [
    "./_input.js"
   ],
   "usage": {
    "cli": "prism-term play input-command-palette",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play input-command-palette",
    "snapshot": "prism-term snapshot input-command-palette --size 66x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('input-command-palette'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/input-command-palette"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "12+------------------------------------------------------+n\n12| > restart                                    3 of 12 |\n12|------------------------------------------------------|\n12|  Service: Restart search-api                         |n\n12|  Service: Restart checkout-api                ctrl+r |\n12|> Service: Restart payments-worker                    |\n12+------------------------------------------------------+ a\n12:04:40 INFO  search-api      reindex shard 3/8 done\n12:04:41 INFO  checkout-api    GET /cart 200 41ms\n12:04:42 INFO  payments-worker charge ok order=88412 312ms\n12:04:43 WARN  checkout-api    db pool wait 840ms (20/20 in\n12:04:44 INFO  search-api      GET /search?q=tent 200 22ms\n12:04:45 INFO  gateway         POST /checkout 201 212ms\n12:04:46 ERROR checkout-api    timeout acquiring connection\n12:04:47 INFO  inventory       reserve sku=TNT-2P qty=1 ok\n ctrl+shift+p commands . prod-us-east-2"
   }
  },
  {
   "id": "input-autocomplete",
   "title": "Shell Autocomplete",
   "gallery": "input",
   "galleryTitle": "Input & Menus",
   "category": "Command Palettes",
   "description": "A shell prompt with a fish-style ghost suggestion from history, a zsh-style completion menu that Tab cycles, then → accepts and the command runs.",
   "tags": [
    "autocomplete",
    "completion",
    "shell",
    "zsh",
    "fish"
   ],
   "kind": "tui",
   "size": {
    "w": 64,
    "h": 12
   },
   "minSize": {
    "w": 40,
    "h": 8
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/input/autocomplete.js",
   "imports": [
    "./_input.js"
   ],
   "usage": {
    "cli": "prism-term play input-autocomplete",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play input-autocomplete",
    "snapshot": "prism-term snapshot input-autocomplete --size 64x12",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('input-autocomplete'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/input-autocomplete"
   },
   "preview": {
    "w": 60,
    "h": 12,
    "text": "~/infra $ kubectl get deploy -n checkout\nNAME           READY   UP-TO-DATE   AVAILABLE\ncheckout-api   2/3     3            2\n\n~/infra $ kubectl rollout pause_\n                     history  -- view rollout history\n                     pause    -- mark as paused\n                     restart  -- restart a resource\n                     resume   -- resume a paused resource\n                     status   -- show rollout status\n                     undo     -- undo a previous rollout\ntab next . enter accept . esc cancel"
   }
  },
  {
   "id": "input-slash-commands",
   "title": "Slash Command Menu",
   "gallery": "input",
   "galleryTitle": "Input & Menus",
   "category": "Command Palettes",
   "description": "A chat composer where typing \"/\" opens a command menu that filters as you type; Tab completes, an argument hint appears, and Enter posts it for approval.",
   "tags": [
    "slash command",
    "chat",
    "composer",
    "menu",
    "chatops"
   ],
   "kind": "tui",
   "size": {
    "w": 64,
    "h": 14
   },
   "minSize": {
    "w": 40,
    "h": 9
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/input/slash-commands.js",
   "imports": [
    "./_input.js"
   ],
   "usage": {
    "cli": "prism-term play input-slash-commands",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play input-slash-commands",
    "snapshot": "prism-term snapshot input-slash-commands --size 64x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('input-slash-commands'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/input-slash-commands"
   },
   "preview": {
    "w": 60,
    "h": 14,
    "text": "mainbrain checkout-api p99 is 2.4s since v2.14.1 (11 min).\nmainbrain Likely cause: DB pool 20/20 in use. Suggest rol...\n\n\n\n\n\n\n\n\n\n+----------------------------------------------------------+\n| /rollback chec                                           |\n+--------------------------------------------- enter send -+"
   }
  },
  {
   "id": "input-confirm-dialog",
   "title": "Confirm Dialog",
   "gallery": "input",
   "galleryTitle": "Input & Menus",
   "category": "Dialogs & Prompts",
   "description": "A destructive-action modal with a shadow: Cancel is focused by default and Delete unlocks only after the namespace is typed, then it runs and closes.",
   "tags": [
    "dialog",
    "modal",
    "confirm",
    "destructive",
    "buttons"
   ],
   "kind": "tui",
   "size": {
    "w": 64,
    "h": 15
   },
   "minSize": {
    "w": 42,
    "h": 10
   },
   "animated": true,
   "loop": 7,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/input/confirm-dialog.js",
   "imports": [
    "./_input.js"
   ],
   "usage": {
    "cli": "prism-term play input-confirm-dialog",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play input-confirm-dialog",
    "snapshot": "prism-term snapshot input-confirm-dialog --size 64x15",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('input-confirm-dialog'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/input-confirm-dialog"
   },
   "preview": {
    "w": 60,
    "h": 15,
    "text": "12:04:32 INFO  payments-worker charge ok order=88412 312ms\n12:04:33 WARN  checkout-api    db pool wait 840ms (20/20 in\n12:04+ ! Delete pods ---------------------------------+2ms\n12:04| Terminate 3 pods in checkout?                  |\n12:04| They'll be rescheduled; local logs are lost.   |tion\n12:04|                                                | ok\n12:04| Type checkout to confirm                       |\n12:04|  checkout                   +                  |415 a\n12:04|                                                |\n12:04|                  [ Cancel ]  [ Delete 3 pods ] |\n12:04+------------------------------------------------+2ms\n12:04:43 WARN  checkout-api    db pool wait 840ms (20/20 in\n12:04:44 INFO  search-api      GET /search?q=tent 200 22ms\n12:04:45 INFO  gateway         POST /checkout 201 212ms\n k9s . checkout . pods"
   }
  },
  {
   "id": "input-prompt-flow",
   "title": "Prompt Flow",
   "gallery": "input",
   "galleryTitle": "Input & Menus",
   "category": "Dialogs & Prompts",
   "description": "A clack-style CLI wizard: text, select, yes/no and multiselect prompts chain down one gutter, each collapsing to its answer before a deploy spinner.",
   "tags": [
    "prompts",
    "clack",
    "cli wizard",
    "confirm",
    "yes no"
   ],
   "kind": "tui",
   "size": {
    "w": 56,
    "h": 16
   },
   "minSize": {
    "w": 36,
    "h": 8
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/input/prompt-flow.js",
   "imports": [
    "./_input.js"
   ],
   "usage": {
    "cli": "prism-term play input-prompt-flow",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play input-prompt-flow",
    "snapshot": "prism-term snapshot input-prompt-flow --size 56x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('input-prompt-flow'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/input-prompt-flow"
   },
   "preview": {
    "w": 56,
    "h": 16,
    "text": "+   deploy   checkout-api\n|\no  Release tag\n|  v2.14.1\n|\no  Environment\n|  production\n|\n*  Run database migrations?\n|  (*) Yes / ( ) No\n+\n\n\n\n\n"
   }
  },
  {
   "id": "input-progress-dialog",
   "title": "Progress Dialog",
   "gallery": "input",
   "galleryTitle": "Input & Menus",
   "category": "Dialogs & Prompts",
   "description": "A modal that applies a human-approved fix: steps tick off with timings, the bar and ETA advance, Cancel turns into Close, and the result is verified.",
   "tags": [
    "progress",
    "dialog",
    "modal",
    "steps",
    "remediation"
   ],
   "kind": "tui",
   "size": {
    "w": 60,
    "h": 15
   },
   "minSize": {
    "w": 42,
    "h": 11
   },
   "animated": true,
   "loop": 9,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/input/progress-dialog.js",
   "imports": [
    "./_input.js"
   ],
   "usage": {
    "cli": "prism-term play input-progress-dialog",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play input-progress-dialog",
    "snapshot": "prism-term snapshot input-progress-dialog --size 60x15",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('input-progress-dialog'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/input-progress-dialog"
   },
   "preview": {
    "w": 60,
    "h": 15,
    "text": "12:04:33 WARN  checkout-api    db pool wait 840ms (20/20 in\n12:0+ Applying fix #142 -------------------------------+ms\n12:0| Raise checkout DB pool 20 -> 40                  |\n12:0| Approved by you . 12:05 . rollback ready         |ion\n12:0|                                                  |ok\n12:0| + Snapshot current config                   0.7s |\n12:0| + Write config (pool=40)                    0.7s |15 a\n12:0| | Rolling restart 2/3 pods                  1.3s |\n12:0| o Verify p99 < 500ms                             |\n12:0| #################.................  50% ETA 0:09 |ms\n12:0|                                                  | in\n12:0|                                       [ Cancel ] |ms\n12:0+--------------------------------------------------+\n12:04:46 ERROR checkout-api    timeout acquiring connection\n PROTEUS . fixes . 1 running"
   }
  },
  {
   "id": "input-date-picker",
   "title": "Date Range Picker",
   "gallery": "input",
   "galleryTitle": "Input & Menus",
   "category": "Dialogs & Prompts",
   "description": "A calendar range picker for a change freeze: arrows walk the month grid, Enter sets the start, the range previews as the cursor moves, Enter sets the end.",
   "tags": [
    "date picker",
    "calendar",
    "range",
    "schedule",
    "form"
   ],
   "kind": "tui",
   "size": {
    "w": 52,
    "h": 11
   },
   "minSize": {
    "w": 27,
    "h": 11
   },
   "animated": true,
   "loop": 7,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/input/date-picker.js",
   "imports": [
    "./_input.js"
   ],
   "usage": {
    "cli": "prism-term play input-date-picker",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play input-date-picker",
    "snapshot": "prism-term snapshot input-date-picker --size 52x11",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('input-date-picker'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/input-date-picker"
   },
   "preview": {
    "w": 52,
    "h": 11,
    "text": "+ Change freeze --------+\n|<    October 2026     >|  From\n| Mo Tu We Th Fr Sa Su  |  Mon 19 Oct 2026\n|           1  2  3  4  |\n|  5  6  7  8  9 10 11  |  To\n| 12 13 14 15 16 17 18  |  Fri 23 Oct 2026\n| 19 20 21 22 23 24 25  |\n| 26 27 28 29 30 31     |  5 days . no deploys\n|                       |\n|                       |\n+-----------------------+  <-^v-> move . enter pick"
   }
  },
  {
   "id": "input-wizard",
   "title": "Setup Wizard",
   "gallery": "input",
   "galleryTitle": "Input & Menus",
   "category": "Dialogs & Prompts",
   "description": "A four-step restore wizard: the stepper header fills as the user picks a snapshot, names the target, schedules it and reviews before pressing Restore.",
   "tags": [
    "wizard",
    "stepper",
    "multi-step",
    "form",
    "onboarding"
   ],
   "kind": "tui",
   "size": {
    "w": 64,
    "h": 12
   },
   "minSize": {
    "w": 44,
    "h": 11
   },
   "animated": true,
   "loop": 10,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/input/wizard.js",
   "imports": [
    "./_input.js"
   ],
   "usage": {
    "cli": "prism-term play input-wizard",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play input-wizard",
    "snapshot": "prism-term snapshot input-wizard --size 64x12",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('input-wizard'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/input-wizard"
   },
   "preview": {
    "w": 60,
    "h": 12,
    "text": "+ Restore database ----------------------------------------+\n|    +===============@---------------o---------------o     |\n| Source          Target         Schedule         Review   |\n|                                                          |\n|  Restore into a new instance named                       |\n|   check                                                  |\n|  lowercase, digits and hyphens                           |\n|                                                          |\n|                                                          |\n|                                                          |\n|  Step 2 of 4                          [ Back ]  [ Next ] |\n+----------------------------------------------------------+"
   }
  },
  {
   "id": "input-tabs",
   "title": "Tabs",
   "gallery": "input",
   "galleryTitle": "Input & Menus",
   "category": "Tabs & Bars",
   "description": "A tab strip with a count badge whose underline glides between tabs as the user switches; each tab swaps in its own overview, logs, trace or metrics view.",
   "tags": [
    "tabs",
    "tab bar",
    "navigation",
    "switcher",
    "underline"
   ],
   "kind": "tui",
   "size": {
    "w": 60,
    "h": 10
   },
   "minSize": {
    "w": 40,
    "h": 7
   },
   "animated": true,
   "loop": 7,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/input/tabs.js",
   "imports": [
    "./_input.js"
   ],
   "usage": {
    "cli": "prism-term play input-tabs",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play input-tabs",
    "snapshot": "prism-term snapshot input-tabs --size 60x10",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('input-tabs'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/input-tabs"
   },
   "preview": {
    "w": 60,
    "h": 10,
    "text": "  Overview   Logs 12   Traces   Metrics   Config\n---------------=========------------------------------------\n GET /checkout #####################################  2.40s\n  auth.verify   #                                     0.10s\n  db.acquire      ##########################          1.68s\n  db.query                                   ###      0.24s\n  render                                         ###  0.17s\n\n\n                                               <-/-> or 1-5"
   }
  },
  {
   "id": "input-menu-bar",
   "title": "Menu Bar",
   "gallery": "input",
   "galleryTitle": "Input & Menus",
   "category": "Tabs & Bars",
   "description": "A Turbo Vision-style menu bar with hotkey letters: F10 drops a shadowed menu, the highlight walks items past a disabled one, a submenu flies out, Esc closes.",
   "tags": [
    "menu bar",
    "dropdown",
    "turbo vision",
    "submenu",
    "hotkeys"
   ],
   "kind": "tui",
   "size": {
    "w": 64,
    "h": 14
   },
   "minSize": {
    "w": 40,
    "h": 11
   },
   "animated": true,
   "loop": 6,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/input/menu-bar.js",
   "imports": [
    "./_input.js"
   ],
   "usage": {
    "cli": "prism-term play input-menu-bar",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play input-menu-bar",
    "snapshot": "prism-term snapshot input-menu-bar --size 64x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('input-menu-bar'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/input-menu-bar"
   },
   "preview": {
    "w": 60,
    "h": 14,
    "text": "  File  View  Service  Incident  Help                 12:05\n12:04:32 INFO+--------------------+ge ok order=88412 312ms\n12:04:33 WARN| Restart...  ctrl+r |ool wait 840ms (20/20 in\n12:04:34 INFO| Scale...           |/search?q=tent 200 22ms\n12:04:35 INFO| Rollback...        | /checkout 201 212ms\n12:04:36 ERRO+--------------------+out acquiring connection\n12:04:37 INFO| Logs             > |rve sku=TNT-2P qty=1 ok\n12:04:38 INFO+--------------------+/account 200 34ms\n12:04:39 WARN| Delete   no access |ying charge order=88415 a\n12:04:40 INFO+--------------------+dex shard 3/8 done\n12:04:41 INFO  checkout-api    GET /cart 200 41ms\n12:04:42 INFO  payments-worker charge ok order=88412 312ms\n12:04:43 WARN  checkout-api    db pool wait 840ms (20/20 in\n ^v move  -> submenu  esc close"
   }
  },
  {
   "id": "input-shortcut-bar",
   "title": "Shortcut Bar",
   "gallery": "input",
   "galleryTitle": "Input & Menus",
   "category": "Tabs & Bars",
   "description": "A nano-style editor whose two-row shortcut bar flashes on each keypress and swaps to prompt keys for Where Is and Write Out while a deploy.yaml is edited.",
   "tags": [
    "shortcuts",
    "key bar",
    "nano",
    "editor",
    "hotkeys"
   ],
   "kind": "tui",
   "size": {
    "w": 72,
    "h": 14
   },
   "minSize": {
    "w": 40,
    "h": 9
   },
   "animated": true,
   "loop": 9,
   "glyphs": "unicode",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/input/shortcut-bar.js",
   "imports": [
    "./_input.js"
   ],
   "usage": {
    "cli": "prism-term play input-shortcut-bar",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play input-shortcut-bar",
    "snapshot": "prism-term snapshot input-shortcut-bar --size 72x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('input-shortcut-bar'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/input-shortcut-bar"
   },
   "preview": {
    "w": 60,
    "h": 14,
    "text": " nano                   deploy.yaml\nkind: Deployment\nmetadata:\n  name: checkout-api\n  namespace: checkout\nspec:\n  replicas: 3\n  selector:\n    matchLabels:\n      app: checkout-api\n  template:\n\n^G Help        ^O Write Out   ^W Where Is    ^K Cut\n^X Exit        ^R Read File   ^\\ Replace     ^U Paste"
   }
  },
  {
   "id": "input-modeline",
   "title": "Modal Statusline",
   "gallery": "input",
   "galleryTitle": "Input & Menus",
   "category": "Tabs & Bars",
   "description": "A vim-style editor whose statusline recolors per mode: NORMAL motions, an INSERT edit, a VISUAL LINE indent and :w, with showcmd key echo and relative numbers.",
   "tags": [
    "statusline",
    "vim",
    "modal",
    "editor",
    "modeline"
   ],
   "kind": "tui",
   "size": {
    "w": 66,
    "h": 13
   },
   "minSize": {
    "w": 40,
    "h": 8
   },
   "animated": true,
   "loop": 9,
   "glyphs": "unicode",
   "color": "256",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/input/modeline.js",
   "imports": [
    "./_input.js"
   ],
   "usage": {
    "cli": "prism-term play input-modeline",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play input-modeline",
    "snapshot": "prism-term snapshot input-modeline --size 66x13",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('input-modeline'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/input-modeline"
   },
   "preview": {
    "w": 60,
    "h": 13,
    "text": "  6 import { createPool } from './db.js';\n  5\n  4 const POOL_SIZE = 40;\n  3 const TIMEOUT_MS = 5000;\n  2\n  1 export function pool() {\n7   return createPool({\n  1   max: POOL_SIZE,\n  2   acquireTimeoutMillis: TIMEOUT_MS,\n  3 });\n  4 }\n NORMAL  main  pool.js [+]      utf-8  javascript  Top  7:1\n                                                        4j"
   }
  },
  {
   "id": "maps-world-cities",
   "title": "Data Center Map",
   "gallery": "maps",
   "galleryTitle": "Maps & Geo",
   "category": "World Maps",
   "description": "A dotted braille world map with pulsing data-center markers, health-colored ripples, region codes and a live traffic footer.",
   "tags": [
    "world map",
    "braille",
    "data centers",
    "pulse",
    "regions"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 22
   },
   "minSize": {
    "w": 40,
    "h": 10
   },
   "animated": true,
   "loop": 12,
   "glyphs": "braille",
   "color": "truecolor",
   "canvas": "line",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/maps/world.js",
   "imports": [
    "../../color.js",
    "../charts/_data.js",
    "./_world.js"
   ],
   "usage": {
    "cli": "prism-term play maps-world-cities",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play maps-world-cities",
    "snapshot": "prism-term snapshot maps-world-cities --size 80x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('maps-world-cities'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/maps-world-cities"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "     ::::::::::     +++...+         .       ++++\n  ++:++++++++++::++  +..  ++  +++++++..+++++    ++++++++++++\n   :    + - - ==::====.     ++@@- ++- . - - - - - -++++++\n  :      @@ =***@:++ =.   LHR@@@FRA+++.:::::::   ++ @@  ++\n  :   PDX@@**@@+**IAD .     ++-+  +++::+- - - :::+ +@@NRT +\n   :      DFW@@ :*    ..   +++++++++:.+ +@@+++  :+        +\n    :      ******  ===  ...+- - - -.: BOM@@   ++ ++     ++\n     :::::::::: ===++     ......... :+        @@## ++++++\n                 +---------     + - ::     SIN@@:#  ::::::::\n               ========+   -    + **+ ::::::::####::+      :\n             == --+ -@==    -   **@@**          ::++-++\n             =  -@@++@@GRU  -   *+@@JNB         :+  ++@@\n             =SCL@@    ==  -     *****          :     @@SYD\n             ==   +---==---                     ::\no 12 healthy\n12 regions - 36 zones - anycast 192.0.2.0/24"
   }
  },
  {
   "id": "maps-world-choropleth",
   "title": "Follow-the-Sun Choropleth",
   "gallery": "maps",
   "galleryTitle": "Maps & Geo",
   "category": "World Maps",
   "description": "Active users by region as a choropleth of pseudo-regions: demand rolls west with the daylight while a legend and UTC clock keep score.",
   "tags": [
    "choropleth",
    "world map",
    "regions",
    "diurnal",
    "half blocks"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 22
   },
   "minSize": {
    "w": 40,
    "h": 10
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "viridis",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/maps/world.js",
   "imports": [
    "../../color.js",
    "../charts/_data.js",
    "./_world.js"
   ],
   "usage": {
    "cli": "prism-term play maps-world-choropleth",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play maps-world-choropleth",
    "snapshot": "prism-term snapshot maps-world-choropleth --size 80x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('maps-world-choropleth'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/maps-world-choropleth"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "                    =::::::                 ....\n  ::--:::::-++++++=  ::   ::    .:.::.......................\n        :::-++++ +==         - ..:.::................  ..\n         :--++-+++==          -:... :. ..............\n          -:+--++            --:    ::............ ..\n           :+-             :::-::..-... ..........\n              -            :::-::::-...   .   ..\n                 +=::       ::-:-::-..           .\n                 +:::===        :::..               ..\n                 ++::===        -::..               .\n                  +::==         --:: .           ......\n                  +=:            ::              .  ...\n                  +                                       .\n                  +\nusers ##################                           UTC 20:00\n"
   }
  },
  {
   "id": "maps-world-daynight",
   "title": "Day / Night Terminator",
   "gallery": "maps",
   "galleryTitle": "Maps & Geo",
   "category": "World Maps",
   "description": "The sun sweeps west over a half-block world: a soft terminator with twilight, city lights on the night side and world clocks below.",
   "tags": [
    "terminator",
    "day night",
    "world clock",
    "sun",
    "city lights"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 22
   },
   "minSize": {
    "w": 40,
    "h": 10
   },
   "animated": true,
   "loop": 12,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/maps/world.js",
   "imports": [
    "../../color.js",
    "../charts/_data.js",
    "./_world.js"
   ],
   "usage": {
    "cli": "prism-term play maps-world-daynight",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play maps-world-daynight",
    "snapshot": "prism-term snapshot maps-world-daynight --size 80x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('maps-world-daynight'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/maps-world-daynight"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "~~~~~~~~~~~~~~~~~~~~:::::::                 ::::\n~~#################~~#:   ::    ::::::::::::::::::::::::::##\n~~~~~~~~########~###~~~      * ::::*:*::*:*::::::*:::  ::~~~\n~~~~~~~~~###########~~~~      :*::: :**::::::*::::::*   ~~~~\n~~~~~~~~~~#######~~~~~~~     :::    *:::**:::::**: ::*  ~~~~\n~~~~~~~~~~~###~~~~~~~~~~   ::::::::::*: :*::*::*::      ~~~~\n~~~~~~~~~~~~~~#~~~~~~~~~~  :::::::*::*:   **  :*       ~~~~~\n~~~~~~~~~~~~~~~~~####~~~~   **:*::*:::        ** :     ~~~~~\n~~~~~~~~~~~~~~~~~#######~       **:::               :: ~~~~~\n~~~~~~~~~~*~~~~~~#######~       :**::               :  ~~~~~\n~~~~~~~~~~~~~~~~~~#####~~~      ::** :           :::::#~~~~~\n~~~~~~~~~~~~~~~~~~###~~~~~       ::              :  ::#~~~~~\n~~~~~~~~~~~~~~~~~~#~~~~~~~                            ~~~~#~\n~~~~~~~~~~~~~~~~~~#~~~~~~~~                          ~~~~~~~\nPDX 12:00 IAD 15:00 LHR 20:00 BOM 01:00\n"
   }
  },
  {
   "id": "maps-radar",
   "title": "Radar Sweep",
   "gallery": "maps",
   "galleryTitle": "Maps & Geo",
   "category": "Radar & Sonar",
   "description": "A phosphor PPI radar: the sweep paints moving tracks that fade until the next pass, over range rings, with a live track table.",
   "tags": [
    "radar",
    "sweep",
    "ppi",
    "tracks",
    "phosphor"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 22
   },
   "minSize": {
    "w": 30,
    "h": 10
   },
   "animated": true,
   "loop": 12,
   "glyphs": "braille",
   "color": "truecolor",
   "canvas": "line",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/maps/radar.js",
   "imports": [
    "../../color.js",
    "../charts/_data.js"
   ],
   "usage": {
    "cli": "prism-term play maps-radar",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play maps-radar",
    "snapshot": "prism-term snapshot maps-radar --size 80x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('maps-radar'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/maps-radar"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n                         =====:=====\n                      =:=    000    =:=\n                   ===   :::::::::::   ===\n                  :=  :::     :     :::  =:\n                 ==: -:  :::::::::::  :: :==\n                 =  --- ::   :::   ::  ::  =\n                =   :-:::  :::--::= ::  :   =\n                %270%%%%%%%%%=--:===:::::090:\n                -------#-41431:424= ::  :   =\n                ----::##****+++:   ::  ::  =\n                 :::...#.****445::::  :: :==\n                  ......:  * :+     :::  =:\n                   ...   ::::++438::   ===\n                      =:=    180    =:=\n                         =====:====="
   }
  },
  {
   "id": "maps-sonar",
   "title": "Active Sonar",
   "gallery": "maps",
   "galleryTitle": "Maps & Geo",
   "category": "Radar & Sonar",
   "description": "An active sonar: pings ripple out, contacts light up and echo as the wavefront reaches them, and a bearing-time waterfall scrolls beside.",
   "tags": [
    "sonar",
    "ping",
    "waterfall",
    "contacts",
    "echo"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 22
   },
   "minSize": {
    "w": 30,
    "h": 10
   },
   "animated": true,
   "loop": 12,
   "glyphs": "braille",
   "color": "truecolor",
   "canvas": "line",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/maps/radar.js",
   "imports": [
    "../../color.js",
    "../charts/_data.js"
   ],
   "usage": {
    "cli": "prism-term play maps-sonar",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play maps-sonar",
    "snapshot": "prism-term snapshot maps-sonar --size 80x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('maps-sonar'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/maps-sonar"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "PING 000\n                       -------.-------\n                    ---       .       ---\n                  --      ::::.::::      --\n                 --    :::    .    :::==  --\n                -     --      .       == S1--\n               --    :-- S4:::.::::    :    -\n               -    :    ::   .   ::   ::    -\n               ...............................\n               -    :    ::   .   --   ::    -\n               -- .. :    ::::.:::--   :    -\n                -..+.S3:      .       :    --\n                 -...  :::    .    :::    --\n                  --      ::::.::::      --\n                    ---       .       ---\n3.0s                   -------.-------"
   }
  },
  {
   "id": "maps-flights",
   "title": "Great-Circle Flights",
   "gallery": "maps",
   "galleryTitle": "Maps & Geo",
   "category": "Routes & Arcs",
   "description": "Flights trace great-circle arcs between hubs on a dotted world: planes with fading contrails, arrival pulses and a live departures board.",
   "tags": [
    "flights",
    "great circle",
    "arcs",
    "world map",
    "routes"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 22
   },
   "minSize": {
    "w": 40,
    "h": 10
   },
   "animated": true,
   "loop": 12,
   "glyphs": "braille",
   "color": "truecolor",
   "canvas": "line",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/maps/routes.js",
   "imports": [
    "../../color.js",
    "../charts/_data.js",
    "./_world.js"
   ],
   "usage": {
    "cli": "prism-term play maps-flights",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play maps-flights",
    "snapshot": "prism-term snapshot maps-flights --size 80x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('maps-flights'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/maps-flights"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "                    =======                 ====\n  ==@===@==========  ==   =     ============    ===========\n:::@@@#@@@*       =@    **:::= ==  =@ =             =\n:###@#######:  : @@@@****   :::::**@@@*#  :  :  : == = :::::\n        ****##:=@@@@         LHR::  @::::##@     =   :::\n            ==  IAD:       ==   =:==  ==:::@....... NRT\n                  =::      ====  @  :=    :.:::@   ..\n                 = =:=         =@@@ =    BOM *@@@=  .\n                 =  ::==        =@# =     .   SIN:  .\n                  @  ::         =:#= =     ..    ::..==\n                 @@@:::          =**         ......=:::\n                  @# GRU          JNB                 ::\n                  =                                   SYD\nFLIGHT  ROUTE      PROGRESS              DIST\nPX 174  SIN>SYD    -------------------- 6,309 km\nPX 211  NRT>PDX    ==================-- 7,791 km"
   }
  },
  {
   "id": "maps-traceroute",
   "title": "Traceroute Map",
   "gallery": "maps",
   "galleryTitle": "Maps & Geo",
   "category": "Routes & Arcs",
   "description": "traceroute from Frankfurt to Singapore, hop by hop: each reply is plotted on the map as the classic output prints below with RTTs.",
   "tags": [
    "traceroute",
    "network",
    "hops",
    "latency",
    "map"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 22
   },
   "minSize": {
    "w": 44,
    "h": 12
   },
   "animated": true,
   "loop": 12,
   "glyphs": "braille",
   "color": "truecolor",
   "canvas": "line",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/maps/routes.js",
   "imports": [
    "../../color.js",
    "../charts/_data.js",
    "./_world.js"
   ],
   "usage": {
    "cli": "prism-term play maps-traceroute",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play maps-traceroute",
    "snapshot": "prism-term snapshot maps-traceroute --size 80x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('maps-traceroute'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/maps-traceroute"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": " ====    =1=================================================\n    =====3@===             ===                             =\n ===:= @@#    ======= : : =   = : : : : : : : : : : : : : :=\n  =   =##=           =     ===                            =\n==: : : : =========== = : : : :=======: : :==== : : :======\n=                    =      ===       =  ==    == = =\n==:====== : : : : : : : : ==           ==        = ===\n  =      =              ==                      = =      =\n          ==============                          ==   ===\ntraceroute to www.shop.example (192.0.2.80), 30 hops max\n 1  gw.office.example  0.8 ms  0.8 ms\n 2  edge1.fra.example.net  4.9 ms  4.9 ms\n 3  core2.mrs.example.net  15.0 ms  14.6 ms\n\n\n"
   }
  },
  {
   "id": "maps-subway",
   "title": "Transit Map",
   "gallery": "maps",
   "galleryTitle": "Maps & Geo",
   "category": "Routes & Arcs",
   "description": "A schematic metro map with four colored lines, interchanges and trains running end to end; the status board reports a delay.",
   "tags": [
    "subway",
    "metro",
    "transit",
    "schematic",
    "trains"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 22
   },
   "minSize": {
    "w": 44,
    "h": 12
   },
   "animated": true,
   "loop": 24,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/maps/routes.js",
   "imports": [
    "../../color.js",
    "../charts/_data.js",
    "./_world.js"
   ],
   "usage": {
    "cli": "prism-term play maps-subway",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play maps-subway",
    "snapshot": "prism-term snapshot maps-subway --size 80x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('maps-subway'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/maps-subway"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\nUniversity Library    o Northgate                o Stadium\n     o--------o---#---o---------------o Parkside |\n                      |               |          |\n                      +---+           |          |\nWest Docks        Central |           |          # East Gate\n  o----------o-#----------@-----------@----------@---#----o\n          Harbour         |           |Museum    |Mill Lane\n                          |           |          |\n        +-----o----#------@-----------o----------+\n        |  MarketQuayside +----o#-+   +-----------#-----o\n        |                  Riverside                 Airport\n        o Old Town                o Docklands\n\n Red    Good service           Blue   Good service\n Green  Good service           Amber  Good service"
   }
  },
  {
   "id": "maps-topo-trail",
   "title": "Topo Trail",
   "gallery": "maps",
   "galleryTitle": "Maps & Geo",
   "category": "Routes & Arcs",
   "description": "A GPS track climbing a contoured mountain: index contours, a hiker drawing the trail, and an elevation profile with distance and ascent.",
   "tags": [
    "topographic",
    "contours",
    "gps",
    "hiking",
    "elevation"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 22
   },
   "minSize": {
    "w": 40,
    "h": 10
   },
   "animated": true,
   "loop": 16,
   "glyphs": "braille",
   "color": "truecolor",
   "canvas": "line",
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/maps/routes.js",
   "imports": [
    "../../color.js",
    "../charts/_data.js",
    "./_world.js"
   ],
   "usage": {
    "cli": "prism-term play maps-topo-trail",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play maps-topo-trail",
    "snapshot": "prism-term snapshot maps-topo-trail --size 80x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('maps-topo-trail'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/maps-topo-trail"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "Ridge Loop:  : ===:::::::=: ::::==::::::::::::===::::::=::\n ::::  :   :: = :: : : := ::::==:::::::::::::::::==:::::=:\n  :::: :  :  = :  : : := ::::= ::::::: :::::::::::=:::::==:\n   : : : :   = : : : := :::: =:::  :       : : : :=::::::=:\n   : : :::: = ::::: :==: :::=::::::^ Cairn Peak 1954::: :=\n   : :: : := : : : :=  : :::==:::::::::::::  : : :=: :: :=\n:::   : : := : :::::== ::::::=:::::::::    :: ::::=: :: :=\n     : : : = ::::::::===:::::::==:::::::::: :::: =::: : :=\n    :  : ::== :::::::::== :::::::==::::::::: : ==:::::::= :\n    :::: @@@===:::::::::===::::::::=:::::::::==:::::::==::\n     : **@@@::===:::::::::==::::::::::=======:::::::==:: ::\n     **  @@@\n                         ...:==#########  elapsed\n               .::::===#################  0h 26m\n..:::====###############################  0.8 km/h\n0.3 km - ^ 107 m - 1195 m"
   }
  },
  {
   "id": "maps-datacenter",
   "title": "Data Hall Floorplan",
   "gallery": "maps",
   "galleryTitle": "Maps & Geo",
   "category": "Grids & Floorplans",
   "description": "A data-hall floorplan: rack rows with inlet temperatures, cold and hot aisles with moving airflow, CRAC units and a hotspot being cooled.",
   "tags": [
    "datacenter",
    "floorplan",
    "racks",
    "temperature",
    "airflow"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 22
   },
   "minSize": {
    "w": 44,
    "h": 12
   },
   "animated": true,
   "loop": 20,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/maps/floor.js",
   "imports": [
    "../../color.js",
    "../charts/_data.js"
   ],
   "usage": {
    "cli": "prism-term play maps-datacenter",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play maps-datacenter",
    "snapshot": "prism-term snapshot maps-datacenter --size 80x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('maps-datacenter'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/maps-datacenter"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": " +- HALL 2 - ROWS A-F -------------------------------+\n |C  .  .> .  .> .  .> .  .> .  .> .  .> .  .> .  .> #\n |  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .   #\n |A  22  21  21  22  21  21  21  22  22  22  22  22  |\n |B  22  23  21  21  22  21  22  22  22  23  23  23  |\n |H . <.  . <.  . <.  . <.  . <.  . <.  . <.  . <.   |\n |    .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  . |\n |C  21  22  21  22  23  24  25  24  22  22  23  22  |\n |D  22  21  21  21  21  23  24  22  20  22  23  23  |\n |C   .  >  .  >  .  >  .  >  .  >  .  >  .  >  .  > #\n |   .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  #\n |E  21  22  22  21  21  22  23  21  21  22  22  23  |\n |F  21  22  21  21  22  22  22  21  21  22  22  23  |\n |H  .< .  .< .  .< .  .< .  .< .  .< .  .< .  .< .  |\n |  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .   |\n +---------------------------------------------------+"
   }
  },
  {
   "id": "maps-warehouse-bots",
   "title": "Warehouse Robots",
   "gallery": "maps",
   "galleryTitle": "Maps & Geo",
   "category": "Grids & Floorplans",
   "description": "A goods-to-person warehouse floor: robots loop the aisles carrying shelf pods to pick stations, with a fleet list of tasks and battery.",
   "tags": [
    "warehouse",
    "robots",
    "fleet",
    "grid",
    "logistics"
   ],
   "kind": "scene",
   "size": {
    "w": 80,
    "h": 22
   },
   "minSize": {
    "w": 44,
    "h": 12
   },
   "animated": true,
   "loop": 24,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/maps/floor.js",
   "imports": [
    "../../color.js",
    "../charts/_data.js"
   ],
   "usage": {
    "cli": "prism-term play maps-warehouse-bots",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play maps-warehouse-bots",
    "snapshot": "prism-term snapshot maps-warehouse-bots --size 80x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('maps-warehouse-bots'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/maps-warehouse-bots"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "                  ...\n       :::::: ::::::v:::::: :::::: :::::: :::::: ::::::\n       :::::: :::::: :::::: :::::: :::::: :::::: ::::::\nPS-1 |        ...>\n       :::::: :::::: :::::: :::::: :::::: :::::: ::::::\n       :::::: :::::: :::::: :::::: :::::: :::::: ::::::\nPS-2 |^\n      .:::::: :::::: :::::: :::::: :::::: :::::: ::::::\n      .:::::: :::::: :::::: :::::: :::::: :::::: ::::::\nPS-3 |.......>\n      .:::::: :::::: :::::: :::::: :::::: :::::: ::::::\n      .:::::: :::::: :::::: :::::: :::::: :::::: ::::::\nPS-4 |<...                        <...\n\n\nZone B - 28 pod blocks - 4 pick stations"
   }
  },
  {
   "id": "lab-easing-gallery",
   "title": "Easing Curves",
   "gallery": "lab",
   "galleryTitle": "Animation Lab",
   "category": "Easing & Timing",
   "description": "Twelve standard easings plotted in braille, each with a dot running its curve and a bar showing the eased value.",
   "tags": [
    "easing",
    "curves",
    "tween",
    "timing",
    "ease in out",
    "overshoot",
    "braille"
   ],
   "kind": "chart",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 20,
    "h": 6
   },
   "animated": true,
   "loop": 2.3,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": "theme",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/lab/easing-gallery.js",
   "imports": [
    "../../easing.js",
    "../../color.js",
    "./_lab.js"
   ],
   "usage": {
    "cli": "prism-term play lab-easing-gallery",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play lab-easing-gallery",
    "snapshot": "prism-term snapshot lab-easing-gallery --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('lab-easing-gallery'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/lab-easing-gallery"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "linear              inQuad              outQuad\n\n- - - - - - -*** -  - - - - - - - ** -  - - - - -******* *\n        %%***    *             ****  *       ****%       *\n- -****** - - -  *  - - - **%%* - -  -  -**** - - - - -  *\n***                 *******             **\n\n\ninOutCubic          inOutSine           inExpo\n\n- - - - - ****** *  - - - - - -***** -  - - - - - - - -* -\n        %%*      *          %%**     *                ** -\n- - -**** - - -  *  - - ***** - - -  *  - - - - - -***-  *\n******              *****               ********%%**\n\n"
   }
  },
  {
   "id": "lab-easing-race",
   "title": "Easing Race",
   "gallery": "lab",
   "galleryTitle": "Animation Lab",
   "category": "Easing & Timing",
   "description": "Balls racing the same distance in the same time with different easings, over an animator-style spacing chart.",
   "tags": [
    "easing",
    "spacing chart",
    "timing",
    "race",
    "ease in",
    "ease out",
    "animation principles"
   ],
   "kind": "chart",
   "size": {
    "w": 64,
    "h": 18
   },
   "minSize": {
    "w": 24,
    "h": 6
   },
   "animated": true,
   "loop": 5.2,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "hires",
   "palette": "prism",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/lab/easing-race.js",
   "imports": [
    "../../easing.js",
    "../../color.js",
    "./_lab.js"
   ],
   "usage": {
    "cli": "prism-term play lab-easing-race",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play lab-easing-race",
    "snapshot": "prism-term snapshot lab-easing-race --size 64x18",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('lab-easing-race'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/lab-easing-race"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\nlinear      + .+. .+. .+. .+. .+. .+. .+. .+. + . + . + . **\n\ninQuad      +++ .+. + .+. .+. . + . . + . . + . . .+. . . **\n\noutQuad     + . . .+. . . + . . + . .+. . + . + . + .+.+.+++\n\ninOutCubic  + + . + . . .+. . . . .+. . . . .+. . .+. .+.+**\n\noutBack     + . . . . . . . + . . . . . + . . . . + . . + ##\n\noutBounce   + + . . .+. . . . . .+. . . . . . .+. .++ .+.+##\n\noutElastic  + . . . . . . . . . . . . . . . . . . . . + .+**\n\ninOutExpo   + + . .+. . . . . . . .+. . . . . . . .+. . ++**"
   }
  },
  {
   "id": "lab-bezier",
   "title": "Cubic-Bézier Timing",
   "gallery": "lab",
   "galleryTitle": "Animation Lab",
   "category": "Easing & Timing",
   "description": "A devtools-style cubic-bézier editor: handles glide between CSS presets while a dot and a swatch run the timing.",
   "tags": [
    "bezier",
    "cubic-bezier",
    "css",
    "timing function",
    "easing",
    "devtools"
   ],
   "kind": "chart",
   "size": {
    "w": 64,
    "h": 20
   },
   "minSize": {
    "w": 24,
    "h": 8
   },
   "animated": true,
   "loop": 18,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": "theme",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/lab/bezier.js",
   "imports": [
    "../../easing.js",
    "../../color.js",
    "./_lab.js"
   ],
   "usage": {
    "cli": "prism-term play lab-bezier",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play lab-bezier",
    "snapshot": "prism-term snapshot lab-bezier --size 64x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('lab-bezier'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/lab-bezier"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n                                 ease-in\n                                 cubic-bezier(\n                                  0.42, 0.00,\n  - - - - - - - - - - - - - - *** 1.00, 1.00)\n                            %%%%*\n  -                      %%%%  - t 0.00 > 0.00\n                     %%%%%\n  -              %%%%%         -\n            %%%%%%\n *-*%%%%%%%%%***- - - - - - - -   ****\n ***          **                  - - - - - - - - - - - - -\n\n\n\n time >"
   }
  },
  {
   "id": "lab-keyframes",
   "title": "Keyframe Timeline",
   "gallery": "lab",
   "galleryTitle": "Animation Lab",
   "category": "Easing & Timing",
   "description": "A keyframe timeline driving a sprite: per-property tracks with eased keys, value curves, a playhead and timecode.",
   "tags": [
    "keyframes",
    "timeline",
    "playhead",
    "interpolation",
    "tracks",
    "motion design"
   ],
   "kind": "tui",
   "size": {
    "w": 72,
    "h": 22
   },
   "minSize": {
    "w": 30,
    "h": 10
   },
   "animated": true,
   "loop": 4,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": "prism",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/lab/keyframes.js",
   "imports": [
    "../../easing.js",
    "../../color.js",
    "./_lab.js"
   ],
   "usage": {
    "cli": "prism-term play lab-keyframes",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play lab-keyframes",
    "snapshot": "prism-term snapshot lab-keyframes --size 72x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('lab-keyframes'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/lab-keyframes"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n\n\n                                     ..              ***\n                                           :    :  -*****\n.  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .*****.\n\n1.60s  +0s----------+1s-----|----+2s----------+3s---------+4\n                        ====|==================\nx      o===============*    |         *        ====*=======*\n       ==            =======|======                   ======\ny      o ========*===       |      ========*==========     *\n        ====================|=============\nscale  o               *    |            *=================o\n                            |          =====================\nhue    o====================|====o=====                    o"
   }
  },
  {
   "id": "lab-stagger",
   "title": "Stagger",
   "gallery": "lab",
   "galleryTitle": "Animation Lab",
   "category": "Easing & Timing",
   "description": "A grid of tiles staggering in and out four ways — from the left, from the center, as a wave, and shuffled.",
   "tags": [
    "stagger",
    "cascade",
    "delay",
    "outback",
    "choreography",
    "ui motion"
   ],
   "kind": "scene",
   "size": {
    "w": 60,
    "h": 20
   },
   "minSize": {
    "w": 20,
    "h": 7
   },
   "animated": true,
   "loop": 13.6,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "prism",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/lab/stagger.js",
   "imports": [
    "../../easing.js",
    "../../color.js",
    "./_lab.js"
   ],
   "usage": {
    "cli": "prism-term play lab-stagger",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play lab-stagger",
    "snapshot": "prism-term snapshot lab-stagger --size 60x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('lab-stagger'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/lab-stagger"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n                                                  -    ===\n\n\n\n                                                  -    ===\n\n\n\n                                                  -    ===\n\n\n\n                                                  -    ===\n\n stagger . from the left . 28 ms apart . outBack in, inQuad"
   }
  },
  {
   "id": "lab-motion-blur",
   "title": "Frame Rate vs Motion Blur",
   "gallery": "lab",
   "galleryTitle": "Animation Lab",
   "category": "Easing & Timing",
   "description": "One scene sampled four ways — 60 fps, 12 fps, 12 fps with a 180° shutter and 6 fps blurred — to compare strobe and blur.",
   "tags": [
    "motion blur",
    "frame rate",
    "shutter angle",
    "strobing",
    "temporal aliasing",
    "fps"
   ],
   "kind": "scene",
   "size": {
    "w": 72,
    "h": 22
   },
   "minSize": {
    "w": 30,
    "h": 10
   },
   "animated": true,
   "loop": 4,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "prism",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/lab/motion-blur.js",
   "imports": [
    "../../color.js",
    "./_lab.js"
   ],
   "usage": {
    "cli": "prism-term play lab-motion-blur",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play lab-motion-blur",
    "snapshot": "prism-term snapshot lab-motion-blur --size 72x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('lab-motion-blur'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/lab-motion-blur"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n               *                              *\n           ****                           ****\n               *                              **\n\n\n60 fps                         12 fps\n\n\n               *:                         .  :::.\n           ==-*                           :::*.\n               *.                         .  .::.\n\n\n12 fps . 180o shutter          6 fps . 360o shutter\n"
   }
  },
  {
   "id": "lab-spring",
   "title": "Spring & Damper",
   "gallery": "lab",
   "galleryTitle": "Animation Lab",
   "category": "Springs & Physics",
   "description": "Four springs differing only in damping ratio chase a stepping target, each plotting its response against it.",
   "tags": [
    "spring",
    "damping",
    "damping ratio",
    "critical damping",
    "overshoot",
    "physics animation"
   ],
   "kind": "chart",
   "size": {
    "w": 72,
    "h": 20
   },
   "minSize": {
    "w": 30,
    "h": 8
   },
   "animated": true,
   "loop": 5,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": "prism",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/lab/spring.js",
   "imports": [
    "../../color.js",
    "./_lab.js"
   ],
   "usage": {
    "cli": "prism-term play lab-spring",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play lab-spring",
    "snapshot": "prism-term snapshot lab-spring --size 72x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('lab-spring'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/lab-spring"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "           *                -                 *\n? 0.15     *# ## # # # ## #               -----------------*\nringing    # ##############*-*             **    ***     *\n             # ## # # ## #                 *\n           *                -\n? 0.40     *# ## # # # ## #               -----------------+\nlight      # ##############+-+             ++\n             # ## # # # ##                 +\n           *                -\n? 1.00     *# ## # # # ## #               -----------------#\ncritical   # ###############-#             ####\n             # ## # # # ##                 #\n           *                -\n? 2.00     *# ## # # # ## #               -----------------*\noverdamped # ##############*-*              ********\n             # ## # # ## #                 *"
   }
  },
  {
   "id": "lab-bounce",
   "title": "Bounce, Squash & Stretch",
   "gallery": "lab",
   "galleryTitle": "Animation Lab",
   "category": "Springs & Physics",
   "description": "The bouncing-ball exercise: decaying bounces, stretch in flight, squash on impact and an onion skin of its spacing.",
   "tags": [
    "bounce",
    "squash and stretch",
    "onion skin",
    "arcs",
    "spacing",
    "animation principles"
   ],
   "kind": "scene",
   "size": {
    "w": 64,
    "h": 20
   },
   "minSize": {
    "w": 24,
    "h": 8
   },
   "animated": true,
   "loop": 4.2,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "prism",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/lab/bounce.js",
   "imports": [
    "../../color.js",
    "./_lab.js"
   ],
   "usage": {
    "cli": "prism-term play lab-bounce",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play lab-bounce",
    "snapshot": "prism-term snapshot lab-bounce --size 64x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('lab-bounce'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/lab-bounce"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n\n\n\n\n\n\n\n          -      =   =\n                -      =\n                        +\n\n                         ##*+\n                         +==-\n                         .....\n squash & stretch . restitution 0.7 . onion skin every 0.1"
   }
  },
  {
   "id": "lab-lfo-scope",
   "title": "LFO Scope",
   "gallery": "lab",
   "galleryTitle": "Animation Lab",
   "category": "Loops & Cycles",
   "description": "A four-channel oscilloscope sweeping sine, triangle, saw and square LFOs over a graticule with phosphor persistence.",
   "tags": [
    "lfo",
    "oscilloscope",
    "sine",
    "saw",
    "square",
    "triangle",
    "waveform",
    "phosphor"
   ],
   "kind": "chart",
   "size": {
    "w": 64,
    "h": 20
   },
   "minSize": {
    "w": 24,
    "h": 8
   },
   "animated": true,
   "loop": 4,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": "prism",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/lab/lfo-scope.js",
   "imports": [
    "../../color.js",
    "../../palettes.js",
    "./_lab.js"
   ],
   "usage": {
    "cli": "prism-term play lab-lfo-scope",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play lab-lfo-scope",
    "snapshot": "prism-term snapshot lab-lfo-scope --size 64x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('lab-lfo-scope'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/lab-lfo-scope"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": " SIN 0.50 Hz  ***      .      .       .      .       .\n    **********   **********\n****.   .   .   .   .   .  *******  .   .   .   .   .   .:::\n        .      .       .      .   ********** . %%%:::::::\n TRI 1.00 Hz                                *** %\n   ##########                    ##########\n### .   .   .#####  .  ..   #####   . . .  #### %   ..  . ::\n                  ##########                   %%%::::::::\n SAW 1.50 Hz\n        .     ######   .      .   ######     .       .::::::\n.   .  #######  .  #.   .  #######  .  #.   .  %%%::::  .\n ######             #######             ####### %\n*SQR 2.00 Hz   ********.      ********. #    **%%%:::.\n       *       *      *       *      *       *  %   :\n.   .   *   .  *.   .  *.   . * .   . * .   .*  .   .:  .\n        *     *.       *     *.       *     *.       :"
   }
  },
  {
   "id": "lab-loading-choreo",
   "title": "Loading Choreography",
   "gallery": "lab",
   "galleryTitle": "Animation Lab",
   "category": "Loops & Cycles",
   "description": "A card loading in stages: shimmering skeleton, spinner and eased progress, staggered content, then a popping success mark.",
   "tags": [
    "loading",
    "skeleton",
    "shimmer",
    "spinner",
    "progress",
    "stagger",
    "choreography"
   ],
   "kind": "tui",
   "size": {
    "w": 56,
    "h": 14
   },
   "minSize": {
    "w": 30,
    "h": 9
   },
   "animated": true,
   "loop": 8,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "none",
   "palette": "theme",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/lab/loading-choreo.js",
   "imports": [
    "../../easing.js",
    "../../color.js",
    "../../widgets/index.js",
    "./_lab.js"
   ],
   "usage": {
    "cli": "prism-term play lab-loading-choreo",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play lab-loading-choreo",
    "snapshot": "prism-term snapshot lab-loading-choreo --size 56x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('lab-loading-choreo'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/lab-loading-choreo"
   },
   "preview": {
    "w": 56,
    "h": 14,
    "text": "+----------------------------------------------- fetch +\n|                                                      |\n|                                                      |\n|                                                      |\n|                                                      |\n|                                                      |\n|                                                      |\n|                                                      |\n|                                                      |\n|                                                      |\n|                                                      |\n| : fetching service health.                           |\n| ##############################################| 100% |\n+------------------------------------------------------+"
   }
  },
  {
   "id": "lab-lissajous",
   "title": "Lissajous Table",
   "gallery": "lab",
   "galleryTitle": "Animation Lab",
   "category": "Loops & Cycles",
   "description": "A Lissajous table — x frequency across, y frequency down — with drifting phase and a dot running every figure.",
   "tags": [
    "lissajous",
    "harmonics",
    "phase",
    "frequency ratio",
    "parametric",
    "braille"
   ],
   "kind": "chart",
   "size": {
    "w": 64,
    "h": 22
   },
   "minSize": {
    "w": 20,
    "h": 7
   },
   "animated": true,
   "loop": 20,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "line",
   "palette": "prism",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/lab/lissajous.js",
   "imports": [
    "../../palettes.js",
    "./_lab.js"
   ],
   "usage": {
    "cli": "prism-term play lab-lissajous",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play lab-lissajous",
    "snapshot": "prism-term snapshot lab-lissajous --size 64x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('lab-lissajous'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/lab-lissajous"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "        a1         a2         a3         a4         a5\n\n\n        ======  =========  =========  =========  =========\nb1    ==    ==      =====   ========  =========   ========\n     ==    ==       =====  ========   =========  ========\n    %%%====     ======%%=  ====%%===  %%%======  =======%%%\n                 =                           =\n\n     %                %%       %       %                 %\n     %+     ++     +++%%+  ++++%++++  +%+++++++  ++++++++%\nb2   +++   +++   ++    ++  +++   +++      +++++  +++   +++\n      +++ +++   ++    ++    +++++++       +++++   +++++++\n       +++++    ++++++     +++++++++  +++++++++  +++++++++\n                                       +\n"
   }
  },
  {
   "id": "lab-pendulum-wave",
   "title": "Pendulum Wave",
   "gallery": "lab",
   "galleryTitle": "Animation Lab",
   "category": "Loops & Cycles",
   "description": "Fifteen pendulums tuned to 20–34 swings per 30 s: snakes, interleaved rows, chaos, then perfect realignment.",
   "tags": [
    "pendulum wave",
    "harmonics",
    "phase",
    "beats",
    "realignment",
    "loop"
   ],
   "kind": "scene",
   "size": {
    "w": 60,
    "h": 20
   },
   "minSize": {
    "w": 20,
    "h": 7
   },
   "animated": true,
   "loop": 30,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "prism",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/lab/pendulum-wave.js",
   "imports": [
    "../../color.js",
    "./_lab.js"
   ],
   "usage": {
    "cli": "prism-term play lab-pendulum-wave",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play lab-pendulum-wave",
    "snapshot": "prism-term snapshot lab-pendulum-wave --size 60x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('lab-pendulum-wave'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/lab-pendulum-wave"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n                     *+\n                 *+ ---- #*                              *+\n                 ---    -=-                              =-\n                --         -                            --\n             *+-            -#*                      *+-\n              -               -                       -\n. . . . . . --. . . . . . . . .-. . . . . . . . . . --. . .\n           -                    --                 -\n         +=                      *+              ++\n        --                         -            --\n     #*-                            -%#      ##-\n    -+=                              *+-    -+=\n +=-                                    -*+-\n\n pendulum k swings 20+k times per 30 s . t =  3.0 s"
   }
  },
  {
   "id": "lab-wipe",
   "title": "Wipes",
   "gallery": "lab",
   "galleryTitle": "Animation Lab",
   "category": "Transitions",
   "description": "Four wipes between a daybreak and a night skyline: soft linear, diagonal, clock sweep and barn doors.",
   "tags": [
    "transition",
    "wipe",
    "clock wipe",
    "barn doors",
    "soft edge",
    "scene change"
   ],
   "kind": "scene",
   "size": {
    "w": 60,
    "h": 20
   },
   "minSize": {
    "w": 20,
    "h": 7
   },
   "animated": true,
   "loop": 16,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "theme",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/lab/wipe.js",
   "imports": [
    "./_lab.js"
   ],
   "usage": {
    "cli": "prism-term play lab-wipe",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play lab-wipe",
    "snapshot": "prism-term snapshot lab-wipe --size 60x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('lab-wipe'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/lab-wipe"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "....................=....#...::-============================\n..%.....=..#.......+.........::-============================\n.............................:--========+++++===============\n............%...............:-+***+==++*****++++============\n...........%%....=..........:=+*%%%%%%%@@@@@@%%#++==========\n............%%%.............:=+#%#####%@@@@@%%**++++++++++++\n............................:-=++++++***%%%%%***++++++++++++\n.............%.%...%........:--=+++++***********++++++++++++\n............................:-==++++++*********+++++++++++++\n.%.%.........#.#...%........:-=+*************************===\n.............................:-======********===============\n...%...........%...#.....#...::--------=====================\n.........%..................#::-----------------------------\n.%.................%.....%...:::----------------------------\n.........%...................:::::--------------------------\n 1/4  wipe . linear, soft edge                          50%"
   }
  },
  {
   "id": "lab-dissolve",
   "title": "Dissolves",
   "gallery": "lab",
   "galleryTitle": "Animation Lab",
   "category": "Transitions",
   "description": "Four dissolves between two scenes: cross-fade, ordered Bayer dither, random pixels and a glowing noise burn.",
   "tags": [
    "transition",
    "dissolve",
    "cross-fade",
    "dither",
    "bayer",
    "burn"
   ],
   "kind": "scene",
   "size": {
    "w": 60,
    "h": 20
   },
   "minSize": {
    "w": 20,
    "h": 7
   },
   "animated": true,
   "loop": 16,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "theme",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/lab/dissolve.js",
   "imports": [
    "./_lab.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play lab-dissolve",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play lab-dissolve",
    "snapshot": "prism-term snapshot lab-dissolve --size 60x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('lab-dissolve'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/lab-dissolve"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "::::::::::::::::::::=::::+::::::::::::::::::::::::::::::::::\n==%=---:=::*:::::::=::::::::::::::::::::::::::::::::::::::::\n=======---:::::::::::::::::::::::::::::::*--::::::::::::::=:\n=====---::::*:::--==============---::---------*-::::::::::::\n:::::::::::**::--+======+++++=========+++++++===--::::::::::\n------------***----=====++++===========++++++=--------------\n----------------------=========------+-========-------------\n-------------#-#---#--------------------====-#--------------\n-------------------------------------+----------------------\n-#-#---------*-*---#-------------------------------------:--\n::----------------------:--:----:------#-------:--::-----:::\n:::*--:::---:::*:::+:::::+:::::::::::::::::-:*-::-:*::::-:::\n:::::::::*::::::::::::::::::+::::*:*:::*::::::::::::::::::::\n:*:::::::::::::::::+:::::+:::::::::::::::::::*:::::::::*::+:\n:::::::::*::::::::::::::::::::::::::::::::::::::::::::::::::\n 1/4  dissolve . cross-fade                             50%"
   }
  },
  {
   "id": "lab-iris",
   "title": "Irises",
   "gallery": "lab",
   "galleryTitle": "Animation Lab",
   "category": "Transitions",
   "description": "Cinema irises between two scenes: circle, diamond, five-pointed star, and one that opens on a point of interest.",
   "tags": [
    "transition",
    "iris",
    "iris in",
    "iris out",
    "cinema",
    "mask"
   ],
   "kind": "scene",
   "size": {
    "w": 60,
    "h": 20
   },
   "minSize": {
    "w": 20,
    "h": 7
   },
   "animated": true,
   "loop": 16,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "theme",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/lab/iris.js",
   "imports": [
    "./_lab.js"
   ],
   "usage": {
    "cli": "prism-term play lab-iris",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play lab-iris",
    "snapshot": "prism-term snapshot lab-iris --size 60x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('lab-iris'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/lab-iris"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "==================-:=....#..............:-==================\n%###**+=========-..+......................:-================\n%%%%%##**+=====:.........................%..:===============\n%%###**+======:..............................:++============\n=============:...=............................=#++==========\n++++++++++++*%%................................=++++++++++++\n++++++++++++-........................+.........-++++++++++++\n++++++++++++-%.%...%.........................%.-++++++++++++\n++++++++++++-........................=.........-++++++++++++\n************=#.#...%...........................=*********===\n========*****-.........................%......-=============\n==============:%...#.....#...................*==============\n========-------:............%....%.%...%....:---------------\n-----------------:.%.....%................:-----------------\n------------------::....................::------------------\n 1/4  iris . circle                                     50%"
   }
  },
  {
   "id": "lab-slide",
   "title": "Slides & Pushes",
   "gallery": "lab",
   "galleryTitle": "Animation Lab",
   "category": "Transitions",
   "description": "Slide transitions between two scenes: push, cover, reveal and a vertical push, each eased out to land softly.",
   "tags": [
    "transition",
    "slide",
    "push",
    "cover",
    "reveal",
    "ui motion"
   ],
   "kind": "scene",
   "size": {
    "w": 60,
    "h": 20
   },
   "minSize": {
    "w": 20,
    "h": 7
   },
   "animated": true,
   "loop": 16,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "theme",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/lab/slide.js",
   "imports": [
    "./_lab.js",
    "../../easing.js"
   ],
   "usage": {
    "cli": "prism-term play lab-slide",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play lab-slide",
    "snapshot": "prism-term snapshot lab-slide --size 60x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('lab-slide'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/lab-slide"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "====================================================........\n====================================================..%.....\n*+==============================+++++===============........\n========+*##%%%%%%%%%%##**+==++*****++++============........\n=======+*##%%%%%%@@@%%%%%%%%%%%@@@@@@%%#++==========........\n+++++++++++##%%%%%%%%%%%%#####%@@@@@%%**++++++++++++........\n+++++++++++++**########*+++++***%%%%%***++++++++++++........\n+++++++++++++++++++++++++++++***********++++++++++++........\n++++++++++++++++++++++++++++++*********+++++++++++++........\n*************************************************===.%.%....\n***************==============********===============........\n========-----------------------=====================...%....\n----------------------------------------------------........\n----------------------------------------------------.%......\n-----------:::::::::::::::--------------------------........\n 1/4  slide . push left                                 13%"
   }
  },
  {
   "id": "lab-blinds",
   "title": "Blinds & Tiles",
   "gallery": "lab",
   "galleryTitle": "Animation Lab",
   "category": "Transitions",
   "description": "Many small transitions at once: venetian blinds, swept vertical blinds, a checkerboard and diagonally staggered tiles.",
   "tags": [
    "transition",
    "blinds",
    "venetian",
    "checkerboard",
    "tiles",
    "stagger"
   ],
   "kind": "scene",
   "size": {
    "w": 60,
    "h": 20
   },
   "minSize": {
    "w": 20,
    "h": 7
   },
   "animated": true,
   "loop": 16,
   "glyphs": "unicode",
   "color": "truecolor",
   "canvas": "field",
   "palette": "theme",
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/lab/blinds.js",
   "imports": [
    "./_lab.js"
   ],
   "usage": {
    "cli": "prism-term play lab-blinds",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play lab-blinds",
    "snapshot": "prism-term snapshot lab-blinds --size 60x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('lab-blinds'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/lab-blinds"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "....................=....#..................................\n%###**+=====================================================\n%%%%%##**+==============================+++++===============\n............%.................................%.............\n===============+*##%%%%%%@@@%%%%%%%%%%%@@@@@@%%#++==========\n+++++++++++++++++++##%%%%%%%%%%%%#####%@@@@@%%**++++++++++++\n.....................................+......................\n+++++++++++++++++++++++++++++++++++++***********++++++++++++\n++++++++++++++++++++++++++++++++++++++*********+++++++++++++\n.%.%.........#.#...%........................................\n========***************==============********===============\n================-----------------------=====================\n.........%..................%....%.%...%....................\n------------------------------------------------------------\n-------------------:::::::::::::::--------------------------\n 1/4  blinds . venetian                                 50%"
   }
  },
  {
   "id": "ascii-donut",
   "title": "donut.c",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "ASCII Animations",
   "description": "The canonical spinning ASCII torus: z-buffered, lit with the twelve-step .,-~:;=!*#$@ ramp, scaled to any window.",
   "tags": [
    "donut",
    "donut.c",
    "torus",
    "classic",
    "3d"
   ],
   "kind": "art",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 32,
    "h": 10
   },
   "animated": true,
   "loop": 10,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/animations.js",
   "imports": [
    "./_ascii.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-donut",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-donut",
    "snapshot": "prism-term snapshot ascii-donut --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-donut'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-donut"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n                           @@@@@@@@@@@\n                        $$###**#####$$$@@\n                      ##*!***!!!!!**###$$$$\n                    *****!=;;::;;==!***###$$#\n                   **!!==::~-..,-~:;=!**#####!\n                   ***!=;:-.......-:;=!**####*\n                  =**!!=;~,..    ..~;=!!*****!\n                  =!**#**!=.      ,:;=!******!;\n                  ;!*##$$$#*;    ;=!!!!*****!!:\n                  ~!*#$$$@@@@@###**********!!=\n                   ;!*##$@@@@@$$$##******!!==:\n                    ;!**##$$$$$###*****!!!=;~\n                     -;!*****##*****!!===;~\n                       -:==!!**!!!==;;;:~\n                          .-~:::::~~-,"
   }
  },
  {
   "id": "ascii-fire",
   "title": "ASCII Fire",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "ASCII Animations",
   "description": "Old-school text-mode fire: heat averages upward and cools through the \" .:*sS#$\" ramp, with gusting flicker.",
   "tags": [
    "fire",
    "aalib",
    "flames",
    "classic"
   ],
   "kind": "art",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 32,
    "h": 10
   },
   "animated": true,
   "loop": 6,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/animations.js",
   "imports": [
    "./_ascii.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-fire",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-fire",
    "snapshot": "prism-term snapshot ascii-fire --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-fire'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-fire"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "...........                                     ..:::::::...\n::..::::::...                                   ..:::::::...\n:::::::::::...                                 ...:::**:::..\n****::::*::::... ...           .....         ...::::***:::::\n******:***:::......       .....:::....      ..::::****::::::\n***********:::......... ...::*::::::...     ..::****s*****::\n************:::::::::.....:*****::.:..      ..:***ssssss**s*\n*******ssss***:::***::....::***::....... . ..::***ssssssssss\n**sssssSSsss*****s**:....::****:..   ....::*:::**sssssssssss\n**ssSSS#SSSsssssss*::..:::*sss*:.     ..:**s******sssSsSssss\nsssSSSSS####SSSSSS**::::::*sss*::...   .:*ss**ssssssSSSSSSss\nSSS#SSS###$#SSS#Sssss***:::sssss****:.  :*ss*SS#SSSS######SS\nSS##$###$$$$Ssss*:*ssSSss**sSSSSsSSs*.  :*sssS###S#$$$$$$Ss*\n##$$$$$$$$$$#Ss*..*SS##SS####SSsSS#s:..:sssS###SssS$$$$$#s*s\n$$$$$$$$$$$$$Ss..*#$$$s##S$$$s*S***:: s#$##s$$S*: s##$$$#Ss$\n$$$$$$$$$$$$$$#::#$$$$s##s#$#::s:  :ss#$$$#s#$$$#s#$s#$$$#s$"
   }
  },
  {
   "id": "ascii-rain",
   "title": "ASCII Rain",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "ASCII Animations",
   "description": "Slanting rain in three depths, splashing on a street line, with a lightning flash every few seconds.",
   "tags": [
    "rain",
    "storm",
    "weather",
    "splash"
   ],
   "kind": "art",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 32,
    "h": 10
   },
   "animated": true,
   "loop": 8,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/animations.js",
   "imports": [
    "./_ascii.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-rain",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-rain",
    "snapshot": "prism-term snapshot ascii-rain --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-rain'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-rain"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "/       /          ''\n                  '              '\n                                             '\n  /      : '                                    :        /\n                                         /         '\n       :     :                             ::\n             :  '          :                  '\n                                     /\n                                           :         :\n                              /                         :\n                    :      :        :         :\n               '                          :\n                :                       :    '     '\n            / .o.:          /           :    /    /\n____________________________________________________________\n ~                 ~         ~   ~   ~           ~"
   }
  },
  {
   "id": "ascii-starfield",
   "title": "ASCII Starfield",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "ASCII Animations",
   "description": "Stars fly out of the center of the screen, growing from . to + to * to @ as they near the viewer.",
   "tags": [
    "starfield",
    "space",
    "warp",
    "demoscene"
   ],
   "kind": "art",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 32,
    "h": 10
   },
   "animated": true,
   "loop": 8,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/animations.js",
   "imports": [
    "./_ascii.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-starfield",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-starfield",
    "snapshot": "prism-term snapshot ascii-starfield --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-starfield'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-starfield"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n                +                   .             @\n                  +           .          @    +\n                             .   *\n               .       *    ..  .     .\n          *    .  .     .     + .     .     + .  .\n                .   .                .\n                      *      + ..\n          +   .       .              .              +\n                  .             +  *. . ..\n               ..              .    .               +\n                    .  .        ..  .   .        +        +\n                +                    ..        .\n   @           .                                   +      *\n      *           +                      *\n                                   *     *"
   }
  },
  {
   "id": "ascii-plasma",
   "title": "ASCII Plasma",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "ASCII Animations",
   "description": "Four interfering sine fields mapped onto the \" .:-=+*#%@\" density ramp and cycled through the theme colors.",
   "tags": [
    "plasma",
    "demoscene",
    "ramp",
    "sine"
   ],
   "kind": "art",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 32,
    "h": 10
   },
   "animated": true,
   "loop": 10,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/animations.js",
   "imports": [
    "./_ascii.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-plasma",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-plasma",
    "snapshot": "prism-term snapshot ascii-plasma --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-plasma'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-plasma"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "***+==-----------------==+**######*+=--:::::::---=========++\n*+==-::...:::---------==++**#####*++=--:::.:::::-----=====++\n+=--:......:--===========++******++=--:::::::::-------===+++\n+=-:......:-==+++++=======+++++++==---::::-----------====+++\n+=-:.....:-=+*****++=============---:::---==++++++======++++\n+=-:...:-=+*######*+=============--::::--=+***#***+++==++++*\n+=-::::-=+*#%%%%%#*++=====++++++==--::--=+*##%%%##**+++++++*\n+=--::-=+*#%@@@@%#*+=====++****++==----=+*#%%@@@%##*++==+++*\n+=-----=+#%@@@@@%#*+=====+***##**+==---=+*#%@@@@%#**+=====++\n+=----=+*#%@@@@%#*+=----==+******+==--==+*#%%@@%%#*+=----==+\n==-----=+*#%%%%#*+=-:::::-=++**++==----=++*#%%%#**+=-::::--=\n==--::-=++*####*+=-:.....::-======-----==+**##**+=-::...::-=\n==--:::-==+****+=-:........:--====-----==+++*+++=-:......:-=\n+=--::::-==+++++=-::.......:--=======-====+++===-::.....::-=\n*+=--::::-==++++==-::...::--==++++++==========--:::....::-==\n**+=--:::--===++===-----==++**###***++=======----:::::--==++"
   }
  },
  {
   "id": "ascii-cube",
   "title": "ASCII Wireframe Cube",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "ASCII Animations",
   "description": "A tumbling wireframe cube in perspective, edges drawn with - | / \\ chosen by slope, vertices marked with o.",
   "tags": [
    "cube",
    "wireframe",
    "3d",
    "vector"
   ],
   "kind": "art",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 32,
    "h": 10
   },
   "animated": true,
   "loop": 12,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/animations.js",
   "imports": [
    "./_ascii.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-cube",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-cube",
    "snapshot": "prism-term snapshot ascii-cube --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-cube'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-cube"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n\n                                 --O\n                             ---/-/ \\\n                         -o-/  -/    \\\n                       -/  \\- /       \\\n                     -/     -/         \\\n                   -/     -/  \\-        \\\n                  O------O      o--------o\n                    \\-    \\-   /        /\n                      \\-    \\-/        /\n                        \\-  / \\       /\n                          \\o\\  \\-    /\n                             ---\\\\- /\n                                 --O\n"
   }
  },
  {
   "id": "ascii-bounce-logo",
   "title": "Bouncing Screensaver Logo",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "ASCII Animations",
   "description": "The screensaver everyone waited on: a logo drifts, changes color at every wall, and celebrates the corner hit.",
   "tags": [
    "screensaver",
    "bounce",
    "logo",
    "corner",
    "nostalgia"
   ],
   "kind": "art",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 30,
    "h": 8
   },
   "animated": true,
   "loop": 16,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/animations.js",
   "imports": [
    "./_ascii.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-bounce-logo",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-bounce-logo",
    "snapshot": "prism-term snapshot ascii-bounce-logo --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-bounce-logo'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-bounce-logo"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n\n\n\n\n\n                            ___  ___  ___  ___  __  __\n                           | _ \\| _ \\|_ _|/ __||  \\/  |\n                           |  _/|   / | | \\__ \\| |\\/| |\n                           |_|  |_|_\\|___||___/|_|  |_|\n                               ~~~ screensaver ~~~\n\n\n\n\n"
   }
  },
  {
   "id": "ascii-aquarium",
   "title": "ASCII Aquarium",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "ASCII Animations",
   "description": "Fish of five shapes cruise both ways past swaying seaweed and a sandcastle while bubbles wobble up to a rolling surface.",
   "tags": [
    "aquarium",
    "fish",
    "asciiquarium",
    "bubbles",
    "screensaver"
   ],
   "kind": "art",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 36,
    "h": 12
   },
   "animated": true,
   "loop": 20,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/scenes.js",
   "imports": [
    "./_ascii.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-aquarium",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-aquarium",
    "snapshot": "prism-term snapshot ascii-aquarium --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-aquarium'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-aquarium"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n^^^^^^~~~~---------~~~~~~~--------~~~^^^^^^^~~~~~~~~~^^^^^^^\n             O\n\n                     __\n          O   o    ><_ '>          Oo\n\n                             ><(((\">\n                                        )\n               )  ><(((\">       )       (\n           .  (                ><>      )   |>\n           .  )                  )      (  _|_        (\n              (                 (       ) |   |_      )\n         (  . )                  )       (|[] | |_     (\n        )     (                 (       )_______|_|   )\n_,._______.._.____..________,.___,__.,_,____.____,_..__..,__"
   }
  },
  {
   "id": "ascii-train",
   "title": "Steam Train",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "ASCII Animations",
   "description": "An original take on the sl gag: a steam express thunders across, spokes turning, smoke billowing back over the carriages.",
   "tags": [
    "train",
    "sl",
    "locomotive",
    "steam",
    "classic"
   ],
   "kind": "art",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 40,
    "h": 10
   },
   "animated": true,
   "loop": 14,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/scenes.js",
   "imports": [
    "./_ascii.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-train",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-train",
    "snapshot": "prism-term snapshot ascii-train --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-train'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-train"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n\n\n\n\n\n\n)( )(@@)\n@)\n       ___                  ____\n___   |   |   ___________  |[ ]|_____\n[] |  |___|__|  P R I S M |____|\n   |  |                              |_\n___|= |______________________________|_>\n)        (|)    (|)    (|)      (|)  (|)\n#===#===#===#===#===#===#===#===#===#===#===#===#===#===#==="
   }
  },
  {
   "id": "ascii-walker",
   "title": "Evening Walk",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "ASCII Animations",
   "description": "A stick figure strolls past lamp posts and drifting clouds — a four-frame walk cycle over three layers of parallax.",
   "tags": [
    "stick figure",
    "walk cycle",
    "parallax",
    "street"
   ],
   "kind": "art",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 30,
    "h": 8
   },
   "animated": true,
   "loop": 12,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/scenes.js",
   "imports": [
    "./_ascii.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-walker",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-walker",
    "snapshot": "prism-term snapshot ascii-walker --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-walker'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-walker"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n                .--.                                       .\n              (_____________)                            (__\n                      (_________)                         .-\n                                                        (___\n\n\n\n _O_                   _O_                   _O_\n  |                     |                     |\n  |                     |                     |\n  |                     | o                   |\n  |                     |/|-                  |\n  |                     | |                   |\n____________________________________________________________\n  .    ,    ,    .    .    .    .    .    .    .    ,    ,"
   }
  },
  {
   "id": "ascii-matrix",
   "title": "ASCII Digital Rain",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "ASCII Animations",
   "description": "Columns of falling ASCII code with bright heads and decaying trails — the digital-rain look on a 7-bit terminal.",
   "tags": [
    "matrix",
    "digital rain",
    "code",
    "hacker"
   ],
   "kind": "art",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 36,
    "h": 12
   },
   "animated": true,
   "loop": 8,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/scenes.js",
   "imports": [
    "./_ascii.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-matrix",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-matrix",
    "snapshot": "prism-term snapshot ascii-matrix --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-matrix'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-matrix"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "   ! R   B  6     (  W9       =  |         0  J K   ^ }\n   $ &      P     <  5(       0  M         [  * <   >\n   ? W      -     )  {.       .  T J       U  G *   {\n   G 6      W        /L       L  6 *          ^ F   R\n   8 ^      3        ';       1    /          _ 4   G\n   / L      + { S    R<    G  '    N          ?     \\\n E [ ]      Z ~ 8     ,    U  .    &                0\n # 8 % '    G~S |     1    ,  3    )                56\n O     \"    ]/{ V     1    F  :    E  A             .I\n D     (    )^* $     N    .  ;    I  G             LM     )\n(I     6     {W #     S    $  2   >|  7      _      5A     X\nX\"     J     N6 \\     G    !      R_  X      8      Y6     \\\nD-     -     &W [          E      03  *      L W     O     >\n6F[    6      K 6          K      5A  )      \\ J     -     X\n7BJ    (      A $       [  0      #Q  (      8 A     [     3\n L)    U      D 9       D  E    8  $  :      \" E     |   { I"
   }
  },
  {
   "id": "ascii-sea",
   "title": "Sailboat at Sea",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "ASCII Art",
   "description": "A sailboat rides rolling ~ and ^ swells under a low sun while gulls flap overhead between v and V.",
   "tags": [
    "boat",
    "sea",
    "waves",
    "seagulls",
    "sunset"
   ],
   "kind": "art",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 36,
    "h": 12
   },
   "animated": true,
   "loop": 12,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/art.js",
   "imports": [
    "./_ascii.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-sea",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-sea",
    "snapshot": "prism-term snapshot ascii-sea --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-sea'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-sea"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "                                         \\    |    /\n                                             ___\n      V                                 -  /     \\ -\n                  v       |\\               \\_____/\n                          | \\                             V\n                          |  \\  v\n                          |___\\\n                      ____|_____\n-----------------     \\  o o o /  ----~~~~~^^^^^=~=~~~------\n~~~~-------            \\______/---~~~~~~~~~~~~~=---=--------\n~-----                   -----~~~~~~~~--------=   -------~~~\n-                  ------------------          --=--~~~~^^^^\n             ----------------              -----~~=^^^^^^^^^\n     -----------------                 ----~~~~=^=^^^^~~~~~-\n------~~~-------                  -----~~~~~~~=~~~~~--------\n~~~~~~-----                  ------~~~~~~~~--=-=----- ------"
   }
  },
  {
   "id": "ascii-mountains",
   "title": "Mountain Range",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "ASCII Art",
   "description": "Three ridgelines of /\\ peaks drift past at different speeds, snow on the summits, a moon overhead and birds in flight.",
   "tags": [
    "mountains",
    "landscape",
    "parallax",
    "snow",
    "birds"
   ],
   "kind": "art",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 36,
    "h": 12
   },
   "animated": true,
   "loop": 20,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/art.js",
   "imports": [
    "./_ascii.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-mountains",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-mountains",
    "snapshot": "prism-term snapshot ascii-mountains --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-mountains'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-mountains"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "               .        .                    .\n             \\/._  *              .   .\n            ( (  `                                  .\n             `-.'                             *         .\n  .    *   .                    .     .         vv\n                       *\n                   \\/          *             .\n\n                                                    ^_______\n___^      ^___^  ^                 ^___^^____^     /********\n    \\____/     ^/*\\_^    ^____^ ^_/*****\\^    \\___/\n             ^/** ***\\__/******/***   : **\\^     ^____^\n_____^   ^__/   :    :    :    ^__^ :    ^______/******\\_^__\n#:#:#:\\_____^ :    :  ^_______/:#:#\\____/:#:#:#:#:#:#:#:#:\\_\n:#:#:#:#:#:#:\\_^ : ^_/:#:#:#:#:#:#:#:#:#:#:#:#:#:#:#:#:#:#:#\n#:#:#:#:#:#:#:#:\\_/:#:#:#:#:#:#:#:#:#:#:#:#:#:#:#:#:#:#:#:#:"
   }
  },
  {
   "id": "ascii-skyline",
   "title": "City at Night",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "ASCII Art",
   "description": "A night skyline whose windows switch on and off, an antenna beacon blinks, stars twinkle and a plane crosses the moon.",
   "tags": [
    "city",
    "skyline",
    "night",
    "windows",
    "plane"
   ],
   "kind": "art",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 36,
    "h": 12
   },
   "animated": true,
   "loop": 16,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/art.js",
   "imports": [
    "./_ascii.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-skyline",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-skyline",
    "snapshot": "prism-term snapshot ascii-skyline --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-skyline'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-skyline"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "               .                ..        .              .\n     *  .                                    _..._  .\n                       *    *              .'     '.\n        __|__          .    |              '._   _.'*\n      .* --o--  .        ______               \"\"\"   |\n                         |    |               ____________\n     . *                 |  []|      .        |          |\n       |                 |    | ______________|[][][][]  |\n    _______              |[]  | |    ||      ||          |\n    |     |       _______|    | |[]  ||      ||      []  |\n    |     |______ |     ||    | |    ||      ||          |\n____|     ||    | |  [] ||    | |    ||[][]  ||  []  [][]|__\n|  ||     ||[][]| |     ||[]  | |    ||      ||          ||\n|  ||     ||    | |[][] ||    | |[]  ||[]  []||          ||[\n|  ||[]   ||  []| |     ||    | |    ||      ||          ||\n|[]||     ||    | |[][] ||    | |[]  ||    []||    [][][]||"
   }
  },
  {
   "id": "ascii-rocket",
   "title": "Rocket Launch",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "ASCII Art",
   "description": "Countdown from T-5, ignition, a flickering exhaust plume and billowing smoke as the rocket clears the tower — then again.",
   "tags": [
    "rocket",
    "launch",
    "countdown",
    "space",
    "exhaust"
   ],
   "kind": "art",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 30,
    "h": 16
   },
   "animated": true,
   "loop": 12,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/art.js",
   "imports": [
    "./_ascii.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-rocket",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-rocket",
    "snapshot": "prism-term snapshot ascii-rocket --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-rocket'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-rocket"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n                           T - 2\n\n\n                     |-|\n                     |X|     /\\\n                     |-|    /  \\\n                     |X|   |    |\n                     |-|   | () |\n                     |X|   |    |\n                     |-|   | PR |\n                     |X|   | SM |\n                     |-|  /|    |\\\n                     |X| /_|____|_\\\n                     |-|    /||\\\n____________________________________________________________"
   }
  },
  {
   "id": "ascii-coffee",
   "title": "Fresh Coffee",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "ASCII Art",
   "description": "A cup on its saucer with three wisps of steam curling up and drifting — the universal \"build is running\" picture.",
   "tags": [
    "coffee",
    "steam",
    "cup",
    "cozy",
    "break"
   ],
   "kind": "art",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 26,
    "h": 14
   },
   "animated": true,
   "loop": 8,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/art.js",
   "imports": [
    "./_ascii.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-coffee",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-coffee",
    "snapshot": "prism-term snapshot ascii-coffee --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-coffee'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-coffee"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n                               )\n                           (  )\n                           (\n                           ))   (\n                            )    (\n                            (     (\n                     .--------------.\n                     |~~~-~~~-~~~-~~|__\n                     |              |  )\n                     |              | /\n                      \\            /--\n                       `----------'\n                    ___________________\n                   (___________________)\n                         brewing.."
   }
  },
  {
   "id": "ascii-spinners",
   "title": "ASCII Spinner Sampler",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "ASCII Spinners & Bars",
   "description": "Sixteen pure-ASCII spinners — |/-\\, bouncing brackets, scanners, meters and more — each at its own classic cadence.",
   "tags": [
    "spinner",
    "loader",
    "busy",
    "classic",
    "cli"
   ],
   "kind": "spinner",
   "size": {
    "w": 64,
    "h": 18
   },
   "minSize": {
    "w": 24,
    "h": 6
   },
   "animated": true,
   "loop": 4,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/bars.js",
   "imports": [
    "./_ascii.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-spinners",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-spinners",
    "snapshot": "prism-term snapshot ascii-spinners --size 64x18",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-spinners'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-spinners"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": " |        line\n   .      dots\n o        grow\n v        arrows\n [  = ]   bounce\n (    *)  scanner\n [/]      pipe\n [ ===]   fill\n +        toggle\n |        pulse\n _        flip\n [#####]  meter\n | o   |  ping\n /        clock\n >        beam\n q        dqpb"
   }
  },
  {
   "id": "ascii-progress-bars",
   "title": "Classic Tool Progress Bars",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "ASCII Spinners & Bars",
   "description": "The progress lines everyone recognizes — wget, curl, apt, tqdm, rsync, docker pull, pip and make — in pure ASCII.",
   "tags": [
    "progress",
    "wget",
    "curl",
    "apt",
    "tqdm",
    "rsync",
    "docker"
   ],
   "kind": "progress",
   "size": {
    "w": 80,
    "h": 18
   },
   "minSize": {
    "w": 40,
    "h": 8
   },
   "animated": true,
   "loop": 10,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/bars.js",
   "imports": [
    "./_ascii.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-progress-bars",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-progress-bars",
    "snapshot": "prism-term snapshot ascii-progress-bars --size 80x18",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-progress-bars'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-progress-bars"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "prism-term.tgz    68%[====>   ] 12.60M  874KB/s  eta 2s\n################################                      62.4%\nProgress: [ 58%] [####################..............]\n100%|##############| 100/100 [00:05<00:00, 18.20it/s]\n        794,853   66%    1.21MB/s    0:00:03\na3ed95caeb02: Downloading [>            ]  1.0MB/40.3MB\n   ----------------           6.0/9.6 MB 2.3 MB/s eta 0:00:0\n[ 84%] Building C object src/CMakeFiles/prism.dir/render.c.o\n\n\n\n\n\n\n\n"
   }
  },
  {
   "id": "ascii-hourglass",
   "title": "ASCII Hourglass",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "ASCII Spinners & Bars",
   "description": "Sand drains grain by grain through the neck of a 7-bit hourglass, piles up below, then the glass flips and starts again.",
   "tags": [
    "hourglass",
    "timer",
    "sand",
    "waiting"
   ],
   "kind": "spinner",
   "size": {
    "w": 30,
    "h": 14
   },
   "minSize": {
    "w": 14,
    "h": 12
   },
   "animated": true,
   "loop": 10,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/bars.js",
   "imports": [
    "./_ascii.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-hourglass",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-hourglass",
    "snapshot": "prism-term snapshot ascii-hourglass --size 30x14",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-hourglass'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-hourglass"
   },
   "preview": {
    "w": 30,
    "h": 14,
    "text": "\n         _____________\n         \\           /\n          \\         /\n           \\:.:::::/\n            \\.::../\n             \\::./\n             / . \\\n            /     \\\n           /   .   \\\n          /.::::....\\\n         /.:...:::...\\\n         -------------\n"
   }
  },
  {
   "id": "ascii-vu-meter",
   "title": "ASCII Spectrum Analyzer",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "ASCII Spinners & Bars",
   "description": "A hi-fi spectrum analyzer in #: bars bounce to a synthetic beat, peak markers hang and fall, labels sit on the axis.",
   "tags": [
    "spectrum",
    "vu meter",
    "equalizer",
    "audio",
    "bars"
   ],
   "kind": "chart",
   "size": {
    "w": 64,
    "h": 18
   },
   "minSize": {
    "w": 24,
    "h": 8
   },
   "animated": true,
   "loop": 8,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/bars.js",
   "imports": [
    "./_ascii.js",
    "../../color.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-vu-meter",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-vu-meter",
    "snapshot": "prism-term snapshot ascii-vu-meter --size 64x18",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-vu-meter'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-vu-meter"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n\n\n\n\n\n\n ==    == == ==    ==\n    ==                   ==       == ==    ==    ==\n                            ==                ==       ##\n    ##    ##    ##    ##       ##    ## == ##    ## == ##\n    ##    ##    ## ## ## ## ## ## ## ##    ##    ##    ##\n    ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ##\n ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ##\n ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ##\n-60-------150------400------1k-------2.5k-----6k-------15k--"
   }
  },
  {
   "id": "ascii-cowsay",
   "title": "Cow Says",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "Classics",
   "description": "A cowsay-style speech bubble over an original ASCII cow that blinks — cycling through operator wisdom one fortune at a time.",
   "tags": [
    "cowsay",
    "fortune",
    "speech bubble",
    "cow",
    "classic"
   ],
   "kind": "art",
   "size": {
    "w": 60,
    "h": 16
   },
   "minSize": {
    "w": 48,
    "h": 14
   },
   "animated": true,
   "loop": 21,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/classics.js",
   "imports": [
    "./_ascii.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-cowsay",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-cowsay",
    "snapshot": "prism-term snapshot ascii-cowsay --size 60x16",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-cowsay'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-cowsay"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "\n\n\n  __\n <  >\n  --\n         \\\n          \\   __     __\n           \\  \\ \\___/ /\n              |  o o  |\n              |       |____________________\n               \\     /     .-.       _      \\\n                (o_o)|    (   )     ( )      |\\\n                 `-' |____`-'_______'-'______| ~\n                      ||  ||           ||  ||\n                      ^^  ^^           ^^  ^^"
   }
  },
  {
   "id": "ascii-banner",
   "title": "banner(1) Sign",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "Classics",
   "description": "Huge letters built from # like the Unix banner command, scrolling past as a marquee with a dotted rule above and below.",
   "tags": [
    "banner",
    "big letters",
    "hash",
    "unix",
    "marquee"
   ],
   "kind": "art",
   "size": {
    "w": 80,
    "h": 12
   },
   "minSize": {
    "w": 30,
    "h": 9
   },
   "animated": true,
   "loop": 12,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/classics.js",
   "imports": [
    "./_ascii.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-banner",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-banner",
    "snapshot": "prism-term snapshot ascii-banner --size 80x12",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-banner'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-banner"
   },
   "preview": {
    "w": 60,
    "h": 12,
    "text": "\n. . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\n                  ##########  ##########  ########    ##\n                      ##      ##          ##      ##  ####\n                      ##      ##          ##      ##  ##  ##\n                      ##      ########    ########    ##  ##\n                      ##      ##          ##  ##      ##\n                      ##      ##          ##    ##    ##\n                      ##      ##########  ##      ##  ##\n. . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\n\n"
   }
  },
  {
   "id": "ascii-figlet",
   "title": "Figlet-Style Banner",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "Classics",
   "description": "Outlined big letters traced from Prism's bitmap fonts with | and _ — the figlet look — typed in one letter at a time.",
   "tags": [
    "figlet",
    "banner",
    "outline",
    "big text",
    "classic"
   ],
   "kind": "art",
   "size": {
    "w": 72,
    "h": 12
   },
   "minSize": {
    "w": 34,
    "h": 10
   },
   "animated": true,
   "loop": 15,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/classics.js",
   "imports": [
    "./_ascii.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-figlet",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-figlet",
    "snapshot": "prism-term snapshot ascii-figlet --size 72x12",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-figlet'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-figlet"
   },
   "preview": {
    "w": 60,
    "h": 12,
    "text": "\n\n       _____\n     _|_____|_\n    | |     | |\n    | |_____| |\n    | _______ |\n    | |     | |\n    | |     | |\n    |_|     |_|\n    $ figlet ASCII\n"
   }
  },
  {
   "id": "ascii-figlet-clock",
   "title": "Figlet Clock",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "Classics",
   "description": "HH:MM:SS in outlined figlet digits with a blinking colon — the tmux clock-mode idea in pure ASCII.",
   "tags": [
    "clock",
    "figlet",
    "time",
    "digits",
    "tmux"
   ],
   "kind": "art",
   "size": {
    "w": 72,
    "h": 10
   },
   "minSize": {
    "w": 40,
    "h": 8
   },
   "animated": true,
   "loop": 10,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/classics.js",
   "imports": [
    "./_ascii.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-figlet-clock",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-figlet-clock",
    "snapshot": "prism-term snapshot ascii-figlet-clock --size 72x10",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-figlet-clock'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-figlet-clock"
   },
   "preview": {
    "w": 60,
    "h": 10,
    "text": "\n\n   _____   _____       _   _     _           _     _____\n  | ___ | | ___ |  _  | | | |  _| |    _   _| |   | ___ |\n  | | | | | |_| | |_| | |_| | |__ |   |_| |__ |   | | | |\n  | | | | |____ |  _  |____ |   | |    _    | |   | | | |\n  | |_| |  ___| | |_|     | |  _| |_  |_|  _| |_  | |_| |\n  |_____| |_____|         |_| |_____|     |_____| |_____|\n                    Tue 13 Oct 2026  UTC\n"
   }
  },
  {
   "id": "ascii-clock",
   "title": "ASCII Wall Clock",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "Classics",
   "description": "A round wall clock in dots and digits; hour, minute and sweeping second hands drawn with slope-matched - | / \\ glyphs.",
   "tags": [
    "clock",
    "analog",
    "time",
    "hands"
   ],
   "kind": "art",
   "size": {
    "w": 44,
    "h": 22
   },
   "minSize": {
    "w": 24,
    "h": 12
   },
   "animated": true,
   "loop": 60,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/classics.js",
   "imports": [
    "./_ascii.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-clock",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-clock",
    "snapshot": "prism-term snapshot ascii-clock --size 44x22",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-clock'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-clock"
   },
   "preview": {
    "w": 44,
    "h": 16,
    "text": "\n                 ...........\n              ...    12     ...\n            .. 11           1  ..\n          ..                     ..\n         ..10                 --2 ..\n        ..      --         --/     ..\n        .         \\--   --/         .\n        .  9         \\O/          3 .\n        .            |              .\n        ..           /             ..\n         .. 8       /           4 ..\n          ..       |             ..\n            ..  7  /        5  ..\n              ... /   6     ...\n                 ..........."
   }
  },
  {
   "id": "ascii-life",
   "title": "Life in # and .",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "Classics",
   "description": "Conway's Game of Life the way it first ran on terminals — # for live cells, . for the dead — reseeding when it settles.",
   "tags": [
    "game of life",
    "conway",
    "cellular automata",
    "classic"
   ],
   "kind": "art",
   "size": {
    "w": 80,
    "h": 24
   },
   "minSize": {
    "w": 24,
    "h": 8
   },
   "animated": true,
   "loop": 12,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/classics.js",
   "imports": [
    "./_ascii.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-life",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-life",
    "snapshot": "prism-term snapshot ascii-life --size 80x24",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-life'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-life"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "gen   30\n......................#.#......###.......#.##..##...........\n......................###.......#.#......#..##..##..........\n.............................................##.............\n...............................##...........................\n..............................###.............##............\n...............................#.##.........................\n................................###.........................\n......#.#................#.......#....................#.#...\n.....#..#..............##.##.........................#...#..\n.....#...#............#.#.##........................#....#..\n....##....#...............##........................#.#.#...\n.....................#..#............................#.###..\n..##.......#...........##...................................\n..##....##.............#....................................\n....##................##.......###........###..............."
   }
  },
  {
   "id": "ascii-maze",
   "title": "Maze Builder",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "Classics",
   "description": "A depth-first backtracker carves a +--+ maze cell by cell, then breadth-first search lights the solution path.",
   "tags": [
    "maze",
    "backtracker",
    "bfs",
    "pathfinding",
    "algorithm"
   ],
   "kind": "art",
   "size": {
    "w": 61,
    "h": 21
   },
   "minSize": {
    "w": 25,
    "h": 11
   },
   "animated": true,
   "loop": 20,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/classics.js",
   "imports": [
    "./_ascii.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-maze",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-maze",
    "snapshot": "prism-term snapshot ascii-maze --size 61x21",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-maze'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-maze"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+\n         |::|::|           |           |           |     |\n+--+--+  +--+--+  +--+--+  +--+  +--+  +  +--+  +  +  +  +\n|::|::|  |        |     |        |     |  |     |  |  |  |\n+--+--+  +  +--+--+  +  +--+--+--+  +--+  +  +--+--+  +  +\n|::|::|     |::|::|  |              |     |  |   @@|  |  |\n+--+--+--+--+--+--+  +--+--+--+--+--+--+--+  +  +  +  +  +\n|::|::|::|::|::|::|     |           |     |     |     |  |\n+--+--+--+--+--+--+--+  +--+--+  +  +  +  +  +--+--+--+  +\n|::|::|::|::|::|::|::|  |        |     |  |  |     |::|  |\n+--+--+--+--+--+--+--+  +  +--+--+--+--+  +  +  +  +--+  +\n|::|::|::|::|::|::|::|  |  |           |  |  |  |  |::|  |\n+--+--+--+--+--+--+--+  +  +  +--+--+--+  +--+  +  +--+  +\n|::|::|::|::|::|::|::|     |                    |\n+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+\n"
   }
  },
  {
   "id": "ascii-snake",
   "title": "Self-Playing Snake",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "Classics",
   "description": "Snake plays itself: breadth-first search steers @ to the * while the o tail grows, until it boxes itself in and starts over.",
   "tags": [
    "snake",
    "game",
    "arcade",
    "ai",
    "bfs"
   ],
   "kind": "art",
   "size": {
    "w": 48,
    "h": 20
   },
   "minSize": {
    "w": 20,
    "h": 10
   },
   "animated": true,
   "loop": 20,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/classics.js",
   "imports": [
    "./_ascii.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-snake",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-snake",
    "snapshot": "prism-term snapshot ascii-snake --size 48x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-snake'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-snake"
   },
   "preview": {
    "w": 48,
    "h": 16,
    "text": " score 1  best 1\n+----------------------------------------------+\n|                           ooooo@             |\n|                                              |\n|                                              |\n|                                              |\n|                                              |\n|                                              |\n|                                              |\n|                                              |\n|                                              |\n|                                            * |\n|                                              |\n|                                              |\n|                                              |\n+----------------------------------------------+"
   }
  },
  {
   "id": "ascii-pong",
   "title": "Self-Playing Pong",
   "gallery": "ascii",
   "galleryTitle": "Pure ASCII",
   "category": "Classics",
   "description": "Two imperfect AI paddles rally an o across a dotted net; misses score, and the ball serves again from the center.",
   "tags": [
    "pong",
    "game",
    "arcade",
    "1972",
    "ai"
   ],
   "kind": "art",
   "size": {
    "w": 64,
    "h": 20
   },
   "minSize": {
    "w": 30,
    "h": 10
   },
   "animated": true,
   "loop": 20,
   "glyphs": "ascii",
   "color": "16",
   "canvas": null,
   "palette": null,
   "params": {},
   "since": "0.1.0",
   "credits": null,
   "module": "prism-term/src/facets/ascii/classics.js",
   "imports": [
    "./_ascii.js"
   ],
   "usage": {
    "cli": "prism-term play ascii-pong",
    "npx": "npx -y --package=https://prism.jeremyfhall.com/dl/prism-term.tgz prism-term play ascii-pong",
    "snapshot": "prism-term snapshot ascii-pong --size 64x20",
    "code": "import { play, findFacet } from 'prism-term';\nawait play(findFacet('ascii-pong'));",
    "curl": "curl -sL https://prism.jeremyfhall.com/f/ascii-pong"
   },
   "preview": {
    "w": 60,
    "h": 16,
    "text": "                           0   0\n------------------------------------------------------------\n                              :\n\n                              :\n\n                              :\n                           o                             |\n  |                           :                          |\n  |                                                      |\n  |                           :\n\n                              :\n\n                              :\n------------------------------------------------------------"
   }
  }
 ],
 "sources": {
  "prism-term/src/ansi.js": "// Raw VT/ANSI escape sequences. Everything prism-term writes to a terminal is\n// built from these, so this file is also the reference for what we depend on:\n// only widely-supported CSI/DEC private modes, nothing terminal-specific.\n\nexport const ESC = '\\x1b';\nexport const CSI = '\\x1b[';\n\nexport const reset = '\\x1b[0m';\n\nexport const cursor = {\n  hide: '\\x1b[?25l',\n  show: '\\x1b[?25h',\n  home: '\\x1b[H',\n  save: '\\x1b7',\n  restore: '\\x1b8',\n  /** Absolute move, 0-based. */\n  to: (x, y) => `\\x1b[${y + 1};${x + 1}H`,\n  /** Absolute column on the current row, 0-based. */\n  column: (x) => `\\x1b[${x + 1}G`,\n  up: (n = 1) => (n > 0 ? `\\x1b[${n}A` : ''),\n  down: (n = 1) => (n > 0 ? `\\x1b[${n}B` : ''),\n  right: (n = 1) => (n > 0 ? `\\x1b[${n}C` : ''),\n  left: (n = 1) => (n > 0 ? `\\x1b[${n}D` : ''),\n};\n\nexport const screen = {\n  altOn: '\\x1b[?1049h',\n  altOff: '\\x1b[?1049l',\n  clear: '\\x1b[2J',\n  clearDown: '\\x1b[J',\n  clearLine: '\\x1b[2K',\n  clearLineRight: '\\x1b[K',\n  // DEC mode 2026 \"synchronized output\": the terminal buffers everything between\n  // these and paints it atomically — no tearing. Terminals that don't know the\n  // mode ignore it, so it is always safe to send.\n  syncStart: '\\x1b[?2026h',\n  syncEnd: '\\x1b[?2026l',\n  // Autowrap off means writing the bottom-right cell can never scroll the screen.\n  wrapOff: '\\x1b[?7l',\n  wrapOn: '\\x1b[?7h',\n};\n\nexport const mouse = {\n  on: '\\x1b[?1000h\\x1b[?1006h',\n  off: '\\x1b[?1000l\\x1b[?1006l',\n};\n\n/** OSC 0 — set the terminal window/tab title. */\nexport const title = (s) => `\\x1b]0;${String(s).replace(/[\\x00-\\x1f]/g, '')}\\x07`;\n\n// CSI … final-byte, OSC … BEL/ST, and lone two-byte escapes.\nconst ANSI_RE = /\\x1b\\[[0-?]*[ -/]*[@-~]|\\x1b\\][^\\x07\\x1b]*(?:\\x07|\\x1b\\\\)|\\x1b[@-Z\\\\-_]/g;\n\nexport function stripAnsi(s) {\n  return String(s).replace(ANSI_RE, '');\n}\n",
  "prism-term/src/ascii.js": "// ASCII folding — how every facet degrades on a terminal without Unicode\n// (TERM=linux consoles, LANG=C, serial lines, old Windows consoles).\n//\n// foldChar() maps any single glyph to ONE printable 7-bit character, so a fold\n// never shifts a column: box drawing becomes + - |, blocks and braille become a\n// density ramp, arrows/marks become their closest ASCII shape, accented letters\n// lose their accent, and anything unknown becomes '?'. Wide (2-cell) glyphs fold\n// to two characters for the same reason.\n//\n// asciiSurface(surface) wraps any Surface so everything drawn through it is\n// folded. createContext() applies it automatically when caps.unicode is false,\n// so facets don't need their own ASCII paths (though bespoke ones look better).\n\nimport { Surface } from './buffer.js';\nimport { charWidth } from './width.js';\n\nconst MAP = new Map();\nconst put = (chars, a) => { for (const c of chars) MAP.set(c, a); };\n\n// Box drawing (U+2500–257F): straight runs keep their direction, everything else is a joint.\nfor (let cp = 0x2500; cp <= 0x257f; cp++) MAP.set(String.fromCodePoint(cp), '+');\nput('─━┄┅┈┉╌╍╴╶╸╺╼╾', '-');\nput('═', '=');\nput('│┃┆┇┊┋╎╏║╵╷╹╻╽╿', '|');\nput('╱', '/'); put('╲', '\\\\'); put('╳', 'X');\n\n// Block elements (U+2580–259F) by how much of the cell they fill.\nput('█▓▉▊▋■▪▮◼▇', '#');\nput('▒▚▞', ':');\nput('░▖▗▘▝▁▂', '.');\nput('▃▄', '_');\nput('▅▆▬', '=');\nput('▀▔', '\"');\nput('▏▎▍▌▐▕', '|');\nput('▙▛▜▟', '#');\n\n// Shapes, arrows, marks and punctuation.\nput('●◉⬤◍', 'o'); put('○◌◯', 'o'); put('◆◈❖', '*'); put('◇', 'o'); put('□▯▢', '[');\nput('★☆✦✧✶✸✺✹✷⁂☼', '*'); put('•∙⋅·', '.'); put('◐◑◒◓◔◕', 'o');\nput('→⇒⟶➜➔➝➞⇢⇨', '>'); put('←⇐⟵⇠⇦', '<'); put('↑⇑⇡⇧', '^'); put('↓⇓⇣⇩', 'v');\nput('↗↙', '/'); put('↖↘', '\\\\'); put('↔⇔', '-'); put('↕⇕', '|'); put('↻↺⟳⟲', '@');\nput('▶▸►▹⏵❯›»', '>'); put('◀◂◄◃⏴❮‹«', '<'); put('▲▴△▵⏶', '^'); put('▼▾▽▿⏷', 'v');\nput('✔✓☑', '+'); put('✖✗✘☒', 'x'); put('⚠', '!'); put('ℹ', 'i'); put('⚡', '!');\nput('—–‒―−', '-'); put('…', '.'); put('×', 'x'); put('÷', '/'); put('±', '+');\nput('≥≧', '>'); put('≤≦', '<'); put('≠', '#'); put('≈∼', '~'); put('°', 'o'); put('µμ', 'u');\nput('‘’‚′', \"'\"); put('“”„″', '\"'); put('¦‖', '|'); put('§', 'S'); put('¶', 'P');\nput('©', 'c'); put('®', 'r'); put('™', 't'); put('€', 'E'); put('£', 'L'); put('¥', 'Y');\nput('∞', '8'); put('√', 'v'); put('∑', 'E'); put('∆Δ', 'A'); put('λ', 'l'); put('π', 'p'); put('σ', 's');\nput('⎇', 'Y'); put('⎈', '*'); put('⊘⌀', '0'); put('␣', '_'); put('⏎↵', '<');\n\nconst BRAILLE_RAMP = ' ..::++##';\nconst SEXTANT_RAMP = ' ..::+##';\nconst popcount = (n) => { let k = 0; while (n) { k += n & 1; n >>= 1; } return k; };\n\n/** One printable ASCII character for any single glyph. */\nexport function foldChar(c) {\n  if (!c) return c;\n  const cp = c.codePointAt(0);\n  if (cp >= 0x20 && cp < 0x7f) return c;\n  if (cp < 0x20 || cp === 0x7f) return ' ';\n  const m = MAP.get(c);\n  if (m) return m;\n  if (cp >= 0x2800 && cp <= 0x28ff) return BRAILLE_RAMP[popcount(cp - 0x2800)];\n  if (cp >= 0x1fb00 && cp <= 0x1fb3b) {\n    // Invert canvas.js's sextant numbering (masks 21 and 42 are ▌▐, 63 is █).\n    let mask = cp - 0x1fb00 + 1;\n    if (mask >= 21) mask++;\n    if (mask >= 42) mask++;\n    return SEXTANT_RAMP[popcount(mask)];\n  }\n  const base = c.normalize('NFD')[0];\n  if (base && base.codePointAt(0) < 0x7f && base.codePointAt(0) >= 0x20) return base;\n  return '?';\n}\n\n/** Fold a string so it occupies exactly the same number of columns in 7-bit ASCII. */\nexport function foldText(s) {\n  let out = '';\n  for (const c of String(s)) {\n    const cp = c.codePointAt(0);\n    if (cp >= 0x20 && cp < 0x7f) { out += c; continue; }\n    const w = charWidth(cp);\n    if (w === 0) continue;\n    out += w === 2 ? '??' : foldChar(c);\n  }\n  return out;\n}\n\nclass AsciiSurface extends Surface {\n  constructor(inner) {\n    super();\n    this.inner = inner;\n    this.ascii = true;\n  }\n  get width() { return this.inner.width; }\n  get height() { return this.inner.height; }\n  set(x, y, ch, fg, bg, attr) {\n    // '' marks the right half of a wide glyph; once the glyph folds to one cell, it needs a filler.\n    if (ch !== undefined) ch = ch === '' ? '?' : foldChar(String.fromCodePoint(ch.codePointAt(0)));\n    return this.inner.set(x, y, ch, fg, bg, attr);\n  }\n  get(x, y) { return this.inner.get(x, y); }\n  clear(bg, fg) { this.inner.clear(bg, fg); return this; }\n  text(x, y, str, fg, bg, attr) { return super.text(x, y, foldText(str), fg, bg, attr); }\n}\n\n/** Wrap a surface so everything drawn through it is 7-bit ASCII (idempotent). */\nexport function asciiSurface(surface) {\n  return surface && surface.ascii ? surface : new AsciiSurface(surface);\n}\n",
  "prism-term/src/buffer.js": "// Cell buffers — the in-memory screen.\n//\n// A CellBuffer is a grid of cells (char, fg, bg, attr) stored as parallel typed\n// arrays so the renderer can diff whole frames cheaply. A View is a clipped,\n// offset window onto a CellBuffer (or another View) with the same drawing API —\n// widgets draw into views and never need to know where they sit on screen.\n//\n// Write semantics shared by every surface: an `undefined` fg/bg/attr keeps the\n// cell's current value (so text can be laid over an effect without erasing its\n// background); NONE (-1) means the terminal's default color.\n//\n// Wide characters (CJK, emoji) occupy two cells: the glyph in the first and an\n// empty-string continuation marker in the second, which the renderer skips.\n\nimport { NONE, fgParam, bgParam, normalizeDepth } from './color.js';\nimport { charWidth } from './width.js';\n\nexport const ATTR = Object.freeze({\n  BOLD: 1, DIM: 2, ITALIC: 4, UNDERLINE: 8, BLINK: 16, INVERSE: 32, STRIKE: 64,\n});\n\nconst ATTR_SGR = [[1, '1'], [2, '2'], [4, '3'], [8, '4'], [16, '5'], [32, '7'], [64, '9']];\n\n/**\n * True when `surface` (or the buffer it draws into) renders for a light terminal:\n * createContext() sets `light16` on the root buffer from the theme's mode. At 16\n * colors that keeps white / bright-white text off the default background (color.js\n * ansi16Text); other depths ignore it.\n */\nexport function lightTerminal(surface) {\n  for (let s = surface, i = 0; s && i < 16; s = s.parent || s.inner, i++) if (s.light16 !== undefined) return !!s.light16;\n  return false;\n}\n\n/**\n * The color the serializers send as the terminal's own foreground (SGR 39), or -2:\n * with --transparent createContext() sets `inkAsDefault` on the root buffer to the\n * theme's ink, so body text follows the terminal (readable on a light or a dark\n * background even when the theme guess is wrong).\n */\nexport function defaultInk(surface) {\n  for (let s = surface, i = 0; s && i < 16; s = s.parent || s.inner, i++) if (s.inkAsDefault !== undefined) return s.inkAsDefault ?? -2;\n  return -2;\n}\n\n/**\n * Is `ch` text (as opposed to a pixel glyph whose fg is a fill: block elements,\n * shades, braille, sextants)? Only text gets the light-terminal rule.\n */\nexport function lightText(ch) {\n  const cp = ch ? ch.codePointAt(0) : 32;\n  return !((cp >= 0x2580 && cp <= 0x259f) || (cp >= 0x2800 && cp <= 0x28ff) || (cp >= 0x1fb00 && cp <= 0x1fb3b));\n}\n\n/** SGR parameter fragment for an attribute bitmask (empty string for none). */\nexport function attrParams(a) {\n  if (!a) return '';\n  let s = '';\n  for (const [bit, code] of ATTR_SGR) if (a & bit) s += (s ? ';' : '') + code;\n  return s;\n}\n\n/** Parse 'bold,italic' / ['bold'] / number into an attribute mask. */\nexport function parseAttr(v) {\n  if (v == null) return undefined;\n  if (typeof v === 'number') return v;\n  const names = Array.isArray(v) ? v : String(v).split(/[\\s,|]+/);\n  let a = 0;\n  for (const n of names) {\n    const k = String(n).toUpperCase();\n    if (k === 'UNDERLINED') a |= ATTR.UNDERLINE;\n    else if (k === 'REVERSE') a |= ATTR.INVERSE;\n    else if (ATTR[k]) a |= ATTR[k];\n  }\n  return a;\n}\n\n/** Shared drawing API. Subclasses implement width/height/set/get. */\nexport class Surface {\n  /** Write `str` starting at (x,y). Returns the column after the last glyph. */\n  text(x, y, str, fg, bg, attr) {\n    x = Math.round(x); y = Math.round(y);\n    if (y < 0 || y >= this.height) return x + [...String(str)].length;\n    for (const ch of String(str)) {\n      const cp = ch.codePointAt(0);\n      if (ch === '\\n') continue;\n      const w = charWidth(cp);\n      if (w === 0) continue; // zero-width marks are dropped rather than mis-attached\n      if (x >= 0 && x < this.width) {\n        if (w === 2 && x + 1 >= this.width) { this.set(x, y, ' ', fg, bg, attr); x++; break; }\n        this.set(x, y, ch, fg, bg, attr);\n        if (w === 2) this.set(x + 1, y, '', fg, bg, attr);\n      }\n      x += w;\n    }\n    return x;\n  }\n\n  /** Fill a rectangle. `ch` undefined keeps characters (recolor only). */\n  fill(x, y, w, h, ch, fg, bg, attr) {\n    const x0 = Math.max(0, Math.round(x)), y0 = Math.max(0, Math.round(y));\n    const x1 = Math.min(this.width, Math.round(x + w)), y1 = Math.min(this.height, Math.round(y + h));\n    for (let yy = y0; yy < y1; yy++) for (let xx = x0; xx < x1; xx++) this.set(xx, yy, ch, fg, bg, attr);\n  }\n\n  /** A clipped window onto this surface. */\n  view(x, y, w, h) {\n    return new View(this, x, y, w, h);\n  }\n\n  /** Copy another surface onto this one. With `transparent`, NONE-bg spaces are skipped. */\n  blit(src, dx = 0, dy = 0, { transparent = false } = {}) {\n    for (let y = 0; y < src.height; y++) {\n      for (let x = 0; x < src.width; x++) {\n        const c = src.get(x, y);\n        if (!c) continue;\n        if (transparent && c.ch === ' ' && c.bg === NONE) continue;\n        this.set(x + dx, y + dy, c.ch, c.fg, c.bg, c.attr);\n      }\n    }\n  }\n\n  /** Plain text of the surface, one line per row, trailing spaces trimmed. */\n  toString() {\n    const lines = [];\n    for (let y = 0; y < this.height; y++) {\n      let line = '';\n      for (let x = 0; x < this.width; x++) line += this.get(x, y).ch;\n      lines.push(line.replace(/\\s+$/, ''));\n    }\n    return lines.join('\\n');\n  }\n\n  /**\n   * Serialize as an ANSI string (rows separated by '\\n', styles reset at line\n   * ends) — for printing static art into normal scrollback.\n   */\n  toAnsi(depth = 'truecolor') {\n    depth = normalizeDepth(depth);\n    const light = depth === '16' && lightTerminal(this), ink = defaultInk(this);\n    const lines = [];\n    for (let y = 0; y < this.height; y++) {\n      let line = '', fg = NONE, bg = NONE, at = 0, styled = false, fgP = '39';\n      let end = this.width;\n      // Trim trailing default-styled blanks so lines don't wrap in narrow terminals.\n      while (end > 0) { const c = this.get(end - 1, y); if (c.ch === ' ' && c.bg === NONE && !c.attr) end--; else break; }\n      for (let x = 0; x < end; x++) {\n        const c = this.get(x, y);\n        if (c.ch === '') continue;\n        if (c.fg === ink) c.fg = NONE;\n        // Light 16: the fg slot depends on the fill and on whether the glyph is text (color.js ansi16Text).\n        const fp = light ? fgParam(c.fg, depth, c.bg, lightText(c.ch)) : null;\n        if (depth === 'mono') {\n          if (c.attr !== at) { line += `\\x1b[0${c.attr ? ';' + attrParams(c.attr) : ''}m`; at = c.attr; styled = true; }\n        } else if (c.attr !== at) {\n          const ap = attrParams(c.attr);\n          line += `\\x1b[0${ap ? ';' + ap : ''};${fp ?? fgParam(c.fg, depth)};${bgParam(c.bg, depth)}m`;\n          fg = c.fg; bg = c.bg; at = c.attr; styled = true; fgP = fp;\n        } else if (light ? fp !== fgP || c.bg !== bg : c.fg !== fg || c.bg !== bg) {\n          const parts = [];\n          if (light ? fp !== fgP : c.fg !== fg) parts.push(fp ?? fgParam(c.fg, depth));\n          if (c.bg !== bg) parts.push(bgParam(c.bg, depth));\n          line += `\\x1b[${parts.join(';')}m`;\n          fg = c.fg; bg = c.bg; styled = true; fgP = fp;\n        }\n        line += c.ch;\n      }\n      if (styled) line += '\\x1b[0m';\n      lines.push(line);\n    }\n    return lines.join('\\n');\n  }\n}\n\nexport class CellBuffer extends Surface {\n  constructor(width, height) {\n    super();\n    this.resize(width, height);\n  }\n\n  resize(width, height) {\n    this.width = Math.max(0, width | 0);\n    this.height = Math.max(0, height | 0);\n    const n = this.width * this.height;\n    this.ch = new Array(n).fill(' ');\n    this.fg = new Int32Array(n).fill(NONE);\n    this.bg = new Int32Array(n).fill(NONE);\n    this.at = new Uint8Array(n);\n    return this;\n  }\n\n  clear(bg = NONE, fg = NONE) {\n    this.ch.fill(' ');\n    this.fg.fill(fg);\n    this.bg.fill(bg);\n    this.at.fill(0);\n    return this;\n  }\n\n  set(x, y, ch, fg, bg, attr) {\n    x |= 0; y |= 0;\n    if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;\n    const i = y * this.width + x;\n    if (ch !== undefined) this.ch[i] = ch;\n    if (fg !== undefined) this.fg[i] = fg;\n    if (bg !== undefined) this.bg[i] = bg;\n    if (attr !== undefined) this.at[i] = attr;\n  }\n\n  get(x, y) {\n    x |= 0; y |= 0;\n    if (x < 0 || y < 0 || x >= this.width || y >= this.height) return null;\n    const i = y * this.width + x;\n    return { ch: this.ch[i], fg: this.fg[i], bg: this.bg[i], attr: this.at[i] };\n  }\n\n  /** Deep copy (used for transitions / snapshots). */\n  clone() {\n    const b = new CellBuffer(this.width, this.height);\n    b.ch = this.ch.slice();\n    b.fg.set(this.fg);\n    b.bg.set(this.bg);\n    b.at.set(this.at);\n    return b;\n  }\n\n  /** Copy every cell of a same-sized buffer into this one (fast path). */\n  copyFrom(src) {\n    if (src.width !== this.width || src.height !== this.height) return this.resize(src.width, src.height).copyFrom(src);\n    for (let i = 0; i < src.ch.length; i++) this.ch[i] = src.ch[i];\n    this.fg.set(src.fg);\n    this.bg.set(src.bg);\n    this.at.set(src.at);\n    return this;\n  }\n}\n\nexport class View extends Surface {\n  constructor(parent, x, y, w, h) {\n    super();\n    this.parent = parent;\n    this.ox = Math.round(x);\n    this.oy = Math.round(y);\n    this.width = Math.max(0, Math.round(w));\n    this.height = Math.max(0, Math.round(h));\n  }\n\n  set(x, y, ch, fg, bg, attr) {\n    x |= 0; y |= 0;\n    if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;\n    this.parent.set(x + this.ox, y + this.oy, ch, fg, bg, attr);\n  }\n\n  get(x, y) {\n    x |= 0; y |= 0;\n    if (x < 0 || y < 0 || x >= this.width || y >= this.height) return null;\n    return this.parent.get(x + this.ox, y + this.oy);\n  }\n\n  clear(bg = NONE, fg = NONE) {\n    this.fill(0, 0, this.width, this.height, ' ', fg, bg, 0);\n    return this;\n  }\n}\n",
  "prism-term/src/canvas.js": "// Canvas — a sub-cell pixel framebuffer that paints into a cell buffer.\n//\n// You draw in pixels; `paint()` encodes those pixels into terminal glyphs using\n// one of five resolution modes (pixels per cell, W×H):\n//\n//   braille  2×4  ⣿  8 dots/cell, one color per cell — best for lines, plots, wireframes\n//   sextant  2×3  🬗  Unicode 13 legacy-computing blocks, two colors per cell\n//   quad     2×2  ▚  quadrant blocks, two colors per cell\n//   half     1×2  ▀  half blocks, both pixels keep exact color — best for color fields\n//   ascii    1×1  @  luminance ramp, works on anything\n//\n// So scenes are written once at pixel resolution and degrade gracefully on any\n// terminal. Pixel aspect differs per mode (a cell is ~1:2), exposed as `aspect`\n// (pixel height ÷ width) so geometry can stay round.\n//\n// Mono depth (NO_COLOR, pipes, TERM=dumb) has no color to carry a picture, so a\n// canvas built with `mono` paints every mode except braille as a luminance ramp\n// instead: each cell's ink (contrast against the theme background, coverage-\n// weighted) normalized to the canvas's brightest cell, as ' ░▒▓█' (Unicode) or the\n// 10-level ASCII_RAMP (7-bit). Braille keeps its dots: they already carry shape.\n//\n// The NONE color is the transparent pixel: transparent areas leave the target\n// buffer untouched, so canvases compose over text, panels and other canvases.\n\nimport { NONE, luminance, distance2 } from './color.js';\n\nexport const MODES = Object.freeze({\n  braille: { sx: 2, sy: 4 },\n  sextant: { sx: 2, sy: 3 },\n  quad: { sx: 2, sy: 2 },\n  half: { sx: 1, sy: 2 },\n  ascii: { sx: 1, sy: 1 },\n});\n\nexport const MODE_NAMES = Object.keys(MODES);\n\nconst BRAILLE_BITS = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]];\nconst QUAD_CHARS = [' ', '▘', '▝', '▀', '▖', '▌', '▞', '▛', '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█'];\nconst SEXTANT_CHARS = (() => {\n  const out = new Array(64);\n  for (let m = 0; m < 64; m++) {\n    if (m === 0) out[m] = ' ';\n    else if (m === 63) out[m] = '█';\n    else if (m === 21) out[m] = '▌';\n    else if (m === 42) out[m] = '▐';\n    else out[m] = String.fromCodePoint(0x1fb00 + m - 1 - (m > 21 ? 1 : 0) - (m > 42 ? 1 : 0));\n  }\n  return out;\n})();\nexport const ASCII_RAMP = ' .:-=+*#%@';\n/** Mono-depth shading ramp for Unicode terminals (blank → full block). */\nexport const MONO_RAMP = ' ░▒▓█';\nconst HALF_SHAPES = [' ', '▀', '▄', '█']; // bit 0 = top pixel, bit 1 = bottom\nconst popcount = (m) => { let c = 0; for (; m; m &= m - 1) c++; return c; };\n\n/**\n * The mono paint hint of a surface: createContext() sets `surface.monoPaint` =\n * { ramp, ref } at mono depth (null otherwise); views / wrappers inherit it from\n * the surface they draw into. So every canvas painted into a mono scene, also\n * ones a facet builds itself (banners, meters), gets the ramp.\n */\nexport function monoHint(surface) {\n  for (let s = surface, i = 0; s && i < 16; s = s.parent || s.inner, i++) if (s.monoPaint !== undefined) return s.monoPaint;\n  return null;\n}\n// Normalization floor: a canvas whose brightest cell has less ink than this is not\n// stretched to full blocks (keeps faint fields faint instead of amplifying noise).\nconst MONO_FLOOR = 0.3;\n// 4×4 Bayer matrix, normalized to (0,1), for ordered-dither braille shading.\nconst BAYER4 = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map((v) => (v + 0.5) / 16);\n\n/** Pick the best mode for a kind of content given terminal caps. */\nexport function pickMode(kind = 'field', caps = {}) {\n  if (MODES[kind]) return kind;\n  if (caps.unicode === false) return 'ascii';\n  if (kind === 'line' || kind === 'dots') return 'braille';\n  if (kind === 'hires') return caps.sextant ? 'sextant' : 'quad';\n  return 'half';\n}\n\nexport class Canvas {\n  /**\n   * @param {number} cols  width in terminal cells\n   * @param {number} rows  height in terminal cells\n   * @param {object} opts  { mode, dither (braille), threshold (braille), ramp (ascii),\n   *                         mono: { ramp, ref }: paint as a luminance ramp (mono depth; see top) }\n   */\n  constructor(cols, rows, { mode = 'half', dither = false, threshold = 0.08, ramp = ASCII_RAMP, merge = 24, mono = null } = {}) {\n    if (!MODES[mode]) throw new Error(`unknown canvas mode \"${mode}\" (expected ${MODE_NAMES.join('|')})`);\n    this.mode = mode;\n    // half mode: pixel pairs closer than this (weighted RGB distance²) paint as one\n    // background-only cell — invisible on screen, roughly halves bytes on smooth fields.\n    this.merge = merge;\n    this.dither = dither;\n    this.threshold = threshold;\n    this.ramp = ramp;\n    this.mono = mono && mode !== 'braille' ? { ramp: mono.ramp || MONO_RAMP, ref: mono.ref ?? NONE } : null;\n    this.resize(cols, rows);\n  }\n\n  resize(cols, rows) {\n    const { sx, sy } = MODES[this.mode];\n    this.cols = Math.max(0, cols | 0);\n    this.rows = Math.max(0, rows | 0);\n    this.sx = sx;\n    this.sy = sy;\n    this.width = this.cols * sx;\n    this.height = this.rows * sy;\n    this.aspect = (2 * sx) / sy;\n    this.px = new Int32Array(this.width * this.height).fill(NONE);\n    return this;\n  }\n\n  clear(color = NONE) {\n    this.px.fill(color);\n    return this;\n  }\n\n  set(x, y, c) {\n    x = Math.floor(x); y = Math.floor(y);\n    if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;\n    this.px[y * this.width + x] = c;\n  }\n\n  get(x, y) {\n    x = Math.floor(x); y = Math.floor(y);\n    if (x < 0 || y < 0 || x >= this.width || y >= this.height) return NONE;\n    return this.px[y * this.width + x];\n  }\n\n  /** Saturating additive plot — overlapping glows brighten instead of overwrite. */\n  add(x, y, c) {\n    x = Math.floor(x); y = Math.floor(y);\n    if (x < 0 || y < 0 || x >= this.width || y >= this.height || c === NONE) return;\n    const i = y * this.width + x, o = this.px[i];\n    if (o === NONE) { this.px[i] = c; return; }\n    const r = Math.min(255, ((o >> 16) & 255) + ((c >> 16) & 255));\n    const g = Math.min(255, ((o >> 8) & 255) + ((c >> 8) & 255));\n    const b = Math.min(255, (o & 255) + (c & 255));\n    this.px[i] = (r << 16) | (g << 8) | b;\n  }\n\n  /** Alpha-blend `c` over the existing pixel (a in 0..1). Over transparent, writes c scaled by a. */\n  blend(x, y, c, a) {\n    x = Math.floor(x); y = Math.floor(y);\n    if (x < 0 || y < 0 || x >= this.width || y >= this.height || c === NONE || a <= 0) return;\n    const i = y * this.width + x, o = this.px[i] === NONE ? 0 : this.px[i];\n    if (a >= 1) { this.px[i] = c; return; }\n    const r = ((o >> 16) & 255) + ((((c >> 16) & 255) - ((o >> 16) & 255)) * a);\n    const g = ((o >> 8) & 255) + ((((c >> 8) & 255) - ((o >> 8) & 255)) * a);\n    const b = (o & 255) + (((c & 255) - (o & 255)) * a);\n    this.px[i] = (r << 16) | (g << 8) | (b | 0);\n  }\n\n  /** Fill every pixel from fn(x, y) → color. */\n  shade(fn) {\n    const { width: W, height: H, px } = this;\n    for (let y = 0, i = 0; y < H; y++) for (let x = 0; x < W; x++, i++) px[i] = fn(x, y);\n    return this;\n  }\n\n  /** Multiply every pixel's brightness by k (trails / motion blur). Pixels below `floor` become transparent. */\n  fade(k, floor = 6) {\n    const px = this.px;\n    for (let i = 0; i < px.length; i++) {\n      const c = px[i];\n      if (c === NONE) continue;\n      const r = ((c >> 16) & 255) * k, g = ((c >> 8) & 255) * k, b = (c & 255) * k;\n      px[i] = r < floor && g < floor && b < floor ? NONE : (r << 16) | (g << 8) | (b | 0);\n    }\n    return this;\n  }\n\n  // ── primitives ───────────────────────────────────────────────────────────\n\n  line(x0, y0, x1, y1, c) {\n    x0 = Math.round(x0); y0 = Math.round(y0); x1 = Math.round(x1); y1 = Math.round(y1);\n    const dx = Math.abs(x1 - x0), dy = -Math.abs(y1 - y0);\n    const stx = x0 < x1 ? 1 : -1, sty = y0 < y1 ? 1 : -1;\n    let err = dx + dy;\n    // Guard against absurd coordinates (e.g. a projected vertex behind the camera).\n    for (let n = 0; n < 1e5; n++) {\n      this.set(x0, y0, c);\n      if (x0 === x1 && y0 === y1) break;\n      const e2 = 2 * err;\n      if (e2 >= dy) { err += dy; x0 += stx; }\n      if (e2 <= dx) { err += dx; y0 += sty; }\n    }\n    return this;\n  }\n\n  /** Polyline through [[x,y], …]. */\n  path(points, c, closed = false) {\n    for (let i = 1; i < points.length; i++) this.line(points[i - 1][0], points[i - 1][1], points[i][0], points[i][1], c);\n    if (closed && points.length > 2) this.line(points.at(-1)[0], points.at(-1)[1], points[0][0], points[0][1], c);\n    return this;\n  }\n\n  rect(x, y, w, h, c, fill = false) {\n    if (fill) {\n      for (let yy = Math.max(0, Math.floor(y)); yy < Math.min(this.height, Math.floor(y + h)); yy++)\n        for (let xx = Math.max(0, Math.floor(x)); xx < Math.min(this.width, Math.floor(x + w)); xx++) this.px[yy * this.width + xx] = c;\n      return this;\n    }\n    return this.path([[x, y], [x + w - 1, y], [x + w - 1, y + h - 1], [x, y + h - 1]], c, true);\n  }\n\n  /** Ellipse with radii rx, ry (pass ry = rx / aspect for a visually round circle). */\n  ellipse(cx, cy, rx, ry, c, fill = false) {\n    if (fill) {\n      for (let y = Math.ceil(cy - ry); y <= Math.floor(cy + ry); y++) {\n        const t = (y - cy) / ry, half = rx * Math.sqrt(Math.max(0, 1 - t * t));\n        for (let x = Math.ceil(cx - half); x <= Math.floor(cx + half); x++) this.set(x, y, c);\n      }\n      return this;\n    }\n    const steps = Math.max(16, Math.ceil(2 * Math.PI * Math.max(rx, ry)));\n    let px = cx + rx, py = cy;\n    for (let i = 1; i <= steps; i++) {\n      const a = (i / steps) * Math.PI * 2;\n      const nx = cx + Math.cos(a) * rx, ny = cy + Math.sin(a) * ry;\n      this.line(px, py, nx, ny, c);\n      px = nx; py = ny;\n    }\n    return this;\n  }\n\n  /** Aspect-corrected circle: radius r in horizontal pixels. */\n  circle(cx, cy, r, c, fill = false) {\n    return this.ellipse(cx, cy, r, r / this.aspect, c, fill);\n  }\n\n  /** Scanline-filled polygon ([[x,y], …], even-odd rule). */\n  polygon(points, c) {\n    if (points.length < 3) return this;\n    let minY = Infinity, maxY = -Infinity;\n    for (const p of points) { if (p[1] < minY) minY = p[1]; if (p[1] > maxY) maxY = p[1]; }\n    const y0 = Math.max(0, Math.ceil(minY)), y1 = Math.min(this.height - 1, Math.floor(maxY));\n    const xs = [];\n    for (let y = y0; y <= y1; y++) {\n      xs.length = 0;\n      const sy = y + 0.5;\n      for (let i = 0, j = points.length - 1; i < points.length; j = i++) {\n        const [xi, yi] = points[i], [xj, yj] = points[j];\n        if ((yi > sy) !== (yj > sy)) xs.push(xi + ((sy - yi) / (yj - yi)) * (xj - xi));\n      }\n      xs.sort((a, b) => a - b);\n      for (let k = 0; k + 1 < xs.length; k += 2) {\n        const xa = Math.max(0, Math.ceil(xs[k] - 0.5)), xb = Math.min(this.width - 1, Math.floor(xs[k + 1] - 0.5));\n        for (let x = xa; x <= xb; x++) this.px[y * this.width + x] = c;\n      }\n    }\n    return this;\n  }\n\n  triangle(a, b, c, color) {\n    return this.polygon([a, b, c], color);\n  }\n\n  /** Quadratic/cubic Bézier through control points [[x,y]×3|4]. */\n  bezier(pts, c, steps = 0) {\n    const n = steps || Math.max(8, Math.ceil(Math.hypot(pts.at(-1)[0] - pts[0][0], pts.at(-1)[1] - pts[0][1])));\n    const out = [];\n    for (let i = 0; i <= n; i++) {\n      const t = i / n, u = 1 - t;\n      if (pts.length === 3) out.push([u * u * pts[0][0] + 2 * u * t * pts[1][0] + t * t * pts[2][0], u * u * pts[0][1] + 2 * u * t * pts[1][1] + t * t * pts[2][1]]);\n      else out.push([\n        u * u * u * pts[0][0] + 3 * u * u * t * pts[1][0] + 3 * u * t * t * pts[2][0] + t * t * t * pts[3][0],\n        u * u * u * pts[0][1] + 3 * u * u * t * pts[1][1] + 3 * u * t * t * pts[2][1] + t * t * t * pts[3][1],\n      ]);\n    }\n    return this.path(out, c);\n  }\n\n  /** Composite another canvas (same mode or not) at pixel offset; NONE pixels are skipped. */\n  draw(src, dx = 0, dy = 0) {\n    for (let y = 0; y < src.height; y++)\n      for (let x = 0; x < src.width; x++) {\n        const c = src.px[y * src.width + x];\n        if (c !== NONE) this.set(x + dx, y + dy, c);\n      }\n    return this;\n  }\n\n  // ── encoding ─────────────────────────────────────────────────────────────\n\n  /**\n   * Encode pixels into glyphs on `target` (any Surface) with the top-left cell\n   * at (ox, oy). Transparent cells are left untouched.\n   */\n  paint(target, ox = 0, oy = 0) {\n    const mono = this.mono || (this.mode !== 'braille' ? monoHint(target) : null);\n    if (mono) return this._paintMono(target, ox, oy, mono);\n    switch (this.mode) {\n      case 'half': return this._paintHalf(target, ox, oy);\n      case 'braille': return this._paintBraille(target, ox, oy);\n      case 'ascii': return this._paintAscii(target, ox, oy);\n      default: return this._paintBlocks(target, ox, oy, this.mode === 'quad' ? QUAD_CHARS : SEXTANT_CHARS);\n    }\n  }\n\n  _paintHalf(target, ox, oy) {\n    const { cols, rows, width: W, px, merge } = this;\n    for (let cy = 0; cy < rows; cy++) {\n      const r0 = 2 * cy * W, r1 = r0 + W;\n      for (let cx = 0; cx < cols; cx++) {\n        const top = px[r0 + cx], bot = px[r1 + cx];\n        if (top === NONE && bot === NONE) continue;\n        if (top === bot || (top !== NONE && bot !== NONE && merge > 0 && distance2(top, bot) <= merge)) target.set(cx + ox, cy + oy, ' ', undefined, top);\n        else if (top === NONE) target.set(cx + ox, cy + oy, '▄', bot);\n        else if (bot === NONE) target.set(cx + ox, cy + oy, '▀', top);\n        else target.set(cx + ox, cy + oy, '▀', top, bot);\n      }\n    }\n    return target;\n  }\n\n  _paintBraille(target, ox, oy) {\n    const { cols, rows, width: W, px, dither, threshold } = this;\n    for (let cy = 0; cy < rows; cy++) {\n      for (let cx = 0; cx < cols; cx++) {\n        let mask = 0, r = 0, g = 0, b = 0, n = 0;\n        for (let dy = 0; dy < 4; dy++) {\n          const row = (cy * 4 + dy) * W + cx * 2;\n          for (let dx = 0; dx < 2; dx++) {\n            const c = px[row + dx];\n            if (c === NONE) continue;\n            if (dither) {\n              const x = cx * 2 + dx, y = cy * 4 + dy;\n              if (luminance(c) <= BAYER4[(y & 3) * 4 + (x & 3)]) continue;\n            } else if (threshold > 0 && luminance(c) < threshold) continue;\n            mask |= BRAILLE_BITS[dy][dx];\n            r += (c >> 16) & 255; g += (c >> 8) & 255; b += c & 255; n++;\n          }\n        }\n        if (!mask) continue;\n        let fr = r / n, fg = g / n, fb = b / n;\n        if (dither) {\n          // Dithering already encodes brightness as dot density — push the hue to full value.\n          const m = Math.max(fr, fg, fb, 1), k = 255 / m;\n          fr *= k; fg *= k; fb *= k;\n        }\n        target.set(cx + ox, cy + oy, String.fromCharCode(0x2800 + mask), (fr << 16) | (fg << 8) | (fb | 0));\n      }\n    }\n    return target;\n  }\n\n  _paintAscii(target, ox, oy) {\n    const { cols, rows, width: W, px, ramp } = this;\n    const last = ramp.length - 1;\n    for (let cy = 0; cy < rows; cy++) {\n      for (let cx = 0; cx < cols; cx++) {\n        const c = px[cy * W + cx];\n        if (c === NONE) continue;\n        const ch = ramp[Math.min(last, Math.max(1, Math.round(luminance(c) * last)))];\n        target.set(cx + ox, cy + oy, ch, c);\n      }\n    }\n    return target;\n  }\n\n  // Mono: each pixel's ink (|luminance − background luminance|) is normalized to\n  // the canvas's brightest pixel (at least MONO_FLOOR, so faint art stays faint);\n  // a fully opaque canvas (a color field) is also stretched up from its darkest\n  // pixel so mid-luminance pictures use the whole ramp. Then per cell:\n  //   • all pixels \"no ink\" or \"full ink\" → the shape glyph (▀ ▄ █ / quadrant /\n  //     sextant mask), so banners, bars and outlines stay crisp;\n  //   • an edge cell (some pixels transparent) whose visible pixels are bright →\n  //     the shape of the visible pixels (letters keep their half-block edges);\n  //   • anything else → the ramp glyph of the cell's average (shading).\n  // fg is the terminal default; bg is left as is.\n  _paintMono(target, ox, oy, mono) {\n    const { cols, rows, width: W, px, sx, sy, mode } = this;\n    const { ramp, ref } = mono;\n    const refL = ref === NONE || ref === undefined ? 0 : luminance(ref);\n    const n = sx * sy, last = ramp.length - 1;\n    const shapes = mode === 'half' ? HALF_SHAPES : mode === 'quad' ? QUAD_CHARS : mode === 'sextant' ? SEXTANT_CHARS : null;\n    const N = px.length;\n    const ink = this._monoInk?.length === N ? this._monoInk : (this._monoInk = new Float32Array(N));\n    let lo = Infinity, hi = 0;\n    for (let i = 0; i < N; i++) {\n      const c = px[i];\n      if (c === NONE) { ink[i] = -1; continue; }\n      const v = Math.abs(luminance(c) - refL);\n      ink[i] = v;\n      if (v < lo) lo = v;\n      if (v > hi) hi = v;\n    }\n    if (hi === 0 && lo === Infinity) return target; // fully transparent\n    let transparent = false;\n    for (let i = 0; i < N; i++) if (ink[i] < 0) { transparent = true; break; }\n    const top = Math.max(hi, MONO_FLOOR);\n    const bot = transparent ? 0 : Math.min(Math.max(0, lo), top - MONO_FLOOR);\n    const k = 1 / (top - bot);\n    for (let cy = 0; cy < rows; cy++) {\n      for (let cx = 0; cx < cols; cx++) {\n        let sum = 0, opaque = 0, full = 0, seen = 0, seenSum = 0, binary = true;\n        for (let dy = 0, j = 0; dy < sy; dy++) {\n          const row = (cy * sy + dy) * W + cx * sx;\n          for (let dx = 0; dx < sx; dx++, j++) {\n            const v = ink[row + dx];\n            if (v < 0) continue;\n            opaque++;\n            const q = Math.min(1, Math.max(0, (v - bot) * k));\n            sum += q;\n            const lv = Math.round(q * last);\n            if (lv === last) full |= 1 << j;\n            else if (lv !== 0) binary = false;\n            if (lv > 0) { seen |= 1 << j; seenSum += q; }\n          }\n        }\n        if (!opaque) continue;\n        let ch;\n        if (shapes && binary) ch = shapes[full];\n        else if (shapes && opaque < n && seen && seenSum / popcount(seen) >= 0.5) ch = shapes[seen];\n        else ch = ramp[Math.min(last, Math.round((sum / n) * last))];\n        target.set(cx + ox, cy + oy, ch, NONE);\n      }\n    }\n    return target;\n  }\n\n  // Two-color block encoding (quad 2×2 / sextant 2×3): split the cell's pixels\n  // into two clusters seeded by its most distant pair, then pick the glyph whose\n  // fg mask matches cluster A. Transparent pixels are always \"background\".\n  _paintBlocks(target, ox, oy, chars) {\n    const { cols, rows, width: W, px, sx, sy } = this;\n    const n = sx * sy;\n    const cell = new Int32Array(n);\n    for (let cy = 0; cy < rows; cy++) {\n      for (let cx = 0; cx < cols; cx++) {\n        let opaque = 0;\n        for (let dy = 0, k = 0; dy < sy; dy++)\n          for (let dx = 0; dx < sx; dx++, k++) {\n            const c = px[(cy * sy + dy) * W + cx * sx + dx];\n            cell[k] = c;\n            if (c !== NONE) opaque++;\n          }\n        if (!opaque) continue;\n\n        if (opaque < n) {\n          let mask = 0, r = 0, g = 0, b = 0;\n          for (let k = 0; k < n; k++) {\n            const c = cell[k];\n            if (c === NONE) continue;\n            mask |= 1 << k;\n            r += (c >> 16) & 255; g += (c >> 8) & 255; b += c & 255;\n          }\n          target.set(cx + ox, cy + oy, chars[mask], ((r / opaque) << 16) | ((g / opaque) << 8) | ((b / opaque) | 0));\n          continue;\n        }\n\n        let ia = 0, ib = 0, best = -1;\n        for (let i = 0; i < n; i++)\n          for (let j = i + 1; j < n; j++) {\n            const d = distance2(cell[i], cell[j]);\n            if (d > best) { best = d; ia = i; ib = j; }\n          }\n        if (best < 64) {\n          // Visually uniform cell: a plain background is cheapest and gap-free.\n          let r = 0, g = 0, b = 0;\n          for (let k = 0; k < n; k++) { r += (cell[k] >> 16) & 255; g += (cell[k] >> 8) & 255; b += cell[k] & 255; }\n          target.set(cx + ox, cy + oy, ' ', undefined, ((r / n) << 16) | ((g / n) << 8) | ((b / n) | 0));\n          continue;\n        }\n        const ca = cell[ia], cb = cell[ib];\n        let mask = 0, ar = 0, ag = 0, ab = 0, an = 0, br = 0, bg = 0, bb = 0, bn = 0;\n        for (let k = 0; k < n; k++) {\n          const c = cell[k];\n          if (distance2(c, ca) <= distance2(c, cb)) {\n            mask |= 1 << k; ar += (c >> 16) & 255; ag += (c >> 8) & 255; ab += c & 255; an++;\n          } else {\n            br += (c >> 16) & 255; bg += (c >> 8) & 255; bb += c & 255; bn++;\n          }\n        }\n        const fgc = ((ar / an) << 16) | ((ag / an) << 8) | ((ab / an) | 0);\n        const bgc = ((br / bn) << 16) | ((bg / bn) << 8) | ((bb / bn) | 0);\n        target.set(cx + ox, cy + oy, chars[mask], fgc, bgc);\n      }\n    }\n    return target;\n  }\n}\n",
  "prism-term/src/caps.js": "// Terminal capability detection.\n//\n// Answers three questions the renderer cares about:\n//   depth    — 'truecolor' | '256' | '16' | 'mono'\n//   unicode  — can we draw braille / block elements / box drawing?\n//   sextant  — does the font have the Unicode 13 \"Symbols for Legacy Computing\"\n//              2×3 block glyphs (U+1FB00…)? Only assumed for terminals known to\n//              ship or synthesize them; everything else falls back to quadrants.\n//\n// Environment overrides (handy in CI, tmux, or exotic terminals):\n//   NO_COLOR=1                    → mono\n//   FORCE_COLOR=0|1|2|3           → mono | 16 | 256 | truecolor\n//   PRISM_TERM_DEPTH=truecolor|256|16|mono\n//   PRISM_TERM_UNICODE=0|1, PRISM_TERM_SEXTANT=0|1\n\nimport { normalizeDepth } from './color.js';\n\n// node:os is only needed for the Windows build check; loading it lazily keeps\n// this module importable in browsers (the web catalog runs the same engine).\nconst os = globalThis.process?.versions?.node ? (await import('node:os')).default : null;\n\nconst TRUECOLOR_PROGRAMS = new Set(['iTerm.app', 'WezTerm', 'vscode', 'ghostty', 'Hyper', 'Tabby', 'rio', 'WarpTerminal', 'Orca']);\nconst TRUECOLOR_TERMS = /^(xterm-kitty|xterm-ghostty|alacritty|foot|wezterm|contour|rio)/;\n// Terminals that render sextants (bundled font coverage or built-in box/legacy-glyph rasterizers).\nconst SEXTANT_PROGRAMS = new Set(['WezTerm', 'vscode', 'ghostty', 'rio']);\nconst SEXTANT_TERMS = /^(xterm-kitty|xterm-ghostty|foot|wezterm|contour)/;\n\nfunction windowsBuild() {\n  if (!os) return 0;\n  const parts = os.release().split('.');\n  return Number(parts[2]) || 0;\n}\n\nfunction flag(v) {\n  if (v === undefined || v === '') return undefined;\n  return !/^(0|false|no|off)$/i.test(v);\n}\n\n/**\n * Detect capabilities of an output stream.\n * @param {object} opts { stream, env, platform }\n */\nexport function detect({ stream = process.stdout, env = process.env, platform = process.platform } = {}) {\n  const isTTY = !!(stream && stream.isTTY);\n  const program = env.TERM_PROGRAM || (env.WT_SESSION ? 'WindowsTerminal' : '') || '';\n  const term = env.TERM || '';\n\n  let depth;\n  if (env.PRISM_TERM_DEPTH) depth = normalizeDepth(env.PRISM_TERM_DEPTH);\n  else if (env.FORCE_COLOR !== undefined && env.FORCE_COLOR !== '') {\n    const f = env.FORCE_COLOR === 'true' ? '1' : env.FORCE_COLOR;\n    depth = ['mono', '16', '256', 'truecolor'][Math.min(3, Math.max(0, parseInt(f, 10) || 0))];\n  } else if (env.NO_COLOR) depth = 'mono';\n  else if (!isTTY) depth = 'mono';\n  else if (term === 'dumb') depth = 'mono';\n  else if (/^(truecolor|24bit)$/i.test(env.COLORTERM || '')) depth = 'truecolor';\n  else if (env.WT_SESSION || TRUECOLOR_PROGRAMS.has(program) || TRUECOLOR_TERMS.test(term) || env.KONSOLE_VERSION) depth = 'truecolor';\n  else if (platform === 'win32' && windowsBuild() >= 14931) depth = 'truecolor';\n  else if (/256/.test(term)) depth = '256';\n  else if (typeof stream?.getColorDepth === 'function') depth = normalizeDepth(stream.getColorDepth(env));\n  else depth = '16';\n\n  let unicode = flag(env.PRISM_TERM_UNICODE);\n  if (unicode === undefined) {\n    if (term === 'linux' || term === 'dumb') unicode = false;\n    else if (platform === 'win32') {\n      // Windows Terminal / VS Code / ConEmu render braille & blocks fine; so does\n      // Windows 11's default console host (build 22000+ hands off to Terminal).\n      unicode = !!(env.WT_SESSION || env.TERM_PROGRAM || env.ConEmuANSI || windowsBuild() >= 22000);\n    } else {\n      // POSIX precedence: the first non-empty of LC_ALL, LC_CTYPE, LANG decides\n      // (LC_ALL=C means 7-bit even when LANG says UTF-8); none set → assume UTF-8.\n      const lang = env.LC_ALL || env.LC_CTYPE || env.LANG || '';\n      unicode = !lang || /utf-?8/i.test(lang);\n    }\n  }\n\n  let sextant = flag(env.PRISM_TERM_SEXTANT);\n  if (sextant === undefined) {\n    // Windows Terminal ships Cascadia (which has Legacy Computing glyphs) and has\n    // rasterized block elements itself since 1.16.\n    sextant = unicode && !!(env.WT_SESSION || SEXTANT_PROGRAMS.has(program) || SEXTANT_TERMS.test(term));\n  }\n\n  return {\n    isTTY,\n    depth,\n    unicode,\n    braille: unicode,\n    sextant,\n    width: (stream && stream.columns) || Number(env.COLUMNS) || 80,\n    height: (stream && stream.rows) || Number(env.LINES) || 24,\n    program: program || term || 'unknown',\n    platform,\n  };\n}\n\n/** Merge explicit overrides (e.g. CLI flags) onto detected caps. */\nexport function withOverrides(caps, { depth, unicode, sextant, ascii } = {}) {\n  const c = { ...caps };\n  if (depth) c.depth = normalizeDepth(depth);\n  if (unicode !== undefined) c.unicode = c.braille = !!unicode;\n  if (sextant !== undefined) c.sextant = !!sextant;\n  if (ascii) { c.unicode = c.braille = c.sextant = false; }\n  if (!c.unicode) c.sextant = false;\n  return c;\n}\n",
  "prism-term/src/color.js": "// Color model for prism-term.\n//\n// Every color is a packed 24-bit integer (0xRRGGBB) so buffers can hold them in\n// Int32Arrays and the renderer can diff them with a single `!==`. NONE (-1) is\n// the \"terminal default\" color — and, on canvases, the transparent pixel.\n//\n// Gradients are evaluated through a 256-entry LUT built once (in Oklab by\n// default, so ramps stay perceptually even instead of going muddy through gray),\n// which keeps per-pixel palette lookups in hot scene loops to one array read.\n\nexport const NONE = -1;\n\nconst clamp255 = (v) => (v < 0 ? 0 : v > 255 ? 255 : Math.round(v));\nconst clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);\n\nexport const rgb = (r, g, b) => (clamp255(r) << 16) | (clamp255(g) << 8) | clamp255(b);\nexport const red = (c) => (c >> 16) & 255;\nexport const green = (c) => (c >> 8) & 255;\nexport const blue = (c) => c & 255;\nexport const toRgb = (c) => [(c >> 16) & 255, (c >> 8) & 255, c & 255];\n\nexport function toHex(c) {\n  if (c === NONE) return 'none';\n  return '#' + (c & 0xffffff).toString(16).padStart(6, '0');\n}\n\nexport function hex(str) {\n  let s = String(str).trim().replace(/^#/, '');\n  if (s.length === 3 || s.length === 4) s = s.slice(0, 3).split('').map((ch) => ch + ch).join('');\n  if (!/^[0-9a-f]{6}/i.test(s)) throw new Error(`invalid hex color: ${str}`);\n  return parseInt(s.slice(0, 6), 16);\n}\n\n/** HSL → packed. h in degrees, s/l in 0..1. */\nexport function hsl(h, s, l) {\n  h = ((h % 360) + 360) % 360 / 360;\n  const q = l < 0.5 ? l * (1 + s) : l + s - l * s;\n  const p = 2 * l - q;\n  const f = (t) => {\n    if (t < 0) t += 1;\n    if (t > 1) t -= 1;\n    if (t < 1 / 6) return p + (q - p) * 6 * t;\n    if (t < 1 / 2) return q;\n    if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;\n    return p;\n  };\n  return rgb(f(h + 1 / 3) * 255, f(h) * 255, f(h - 1 / 3) * 255);\n}\n\n/** HSV → packed. h in degrees, s/v in 0..1. */\nexport function hsv(h, s, v) {\n  h = ((h % 360) + 360) % 360 / 60;\n  const i = Math.floor(h), f = h - i;\n  const p = v * (1 - s), q = v * (1 - s * f), t = v * (1 - s * (1 - f));\n  const [r, g, b] = [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]][i % 6];\n  return rgb(r * 255, g * 255, b * 255);\n}\n\nconst NAMED = {\n  black: 0x000000, white: 0xffffff, red: 0xff0000, green: 0x00ff00, blue: 0x0000ff,\n  yellow: 0xffff00, cyan: 0x00ffff, magenta: 0xff00ff, orange: 0xffa500, purple: 0x800080,\n  pink: 0xffc0cb, gray: 0x808080, grey: 0x808080, transparent: NONE, none: NONE, default: NONE,\n};\n\n/** Accepts a packed int, '#hex', 'rgb(r,g,b)', 'r,g,b', a CSS basic name, or null/'none'. */\nexport function parse(v) {\n  if (v == null) return NONE;\n  if (typeof v === 'number') return v;\n  if (Array.isArray(v)) return rgb(v[0], v[1], v[2]);\n  const s = String(v).trim().toLowerCase();\n  if (s in NAMED) return NAMED[s];\n  if (s.startsWith('#') || /^[0-9a-f]{6}$/.test(s)) return hex(s);\n  const m = s.match(/^(?:rgba?\\()?\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)/);\n  if (m) return rgb(+m[1], +m[2], +m[3]);\n  throw new Error(`unrecognized color: ${v}`);\n}\n\n/** Linear sRGB-space mix; NONE on either side returns the other. */\nexport function mix(a, b, t) {\n  if (a === NONE) return b;\n  if (b === NONE) return a;\n  return rgb(\n    ((a >> 16) & 255) + ((((b >> 16) & 255) - ((a >> 16) & 255)) * t),\n    ((a >> 8) & 255) + ((((b >> 8) & 255) - ((a >> 8) & 255)) * t),\n    (a & 255) + (((b & 255) - (a & 255)) * t),\n  );\n}\n\n/** Multiply brightness (k=0 black, 1 unchanged, >1 brighter, clamped). */\nexport function scale(c, k) {\n  if (c === NONE) return NONE;\n  return rgb(((c >> 16) & 255) * k, ((c >> 8) & 255) * k, (c & 255) * k);\n}\n\n/** Saturating additive blend — the \"glow\" operator. */\nexport function add(a, b) {\n  if (a === NONE) return b;\n  if (b === NONE) return a;\n  return rgb(((a >> 16) & 255) + ((b >> 16) & 255), ((a >> 8) & 255) + ((b >> 8) & 255), (a & 255) + (b & 255));\n}\n\n/** Perceived luminance 0..1 (Rec. 709 weights on gamma-encoded values — cheap and good enough). */\nexport function luminance(c) {\n  if (c === NONE) return 0;\n  return (0.2126 * ((c >> 16) & 255) + 0.7152 * ((c >> 8) & 255) + 0.0722 * (c & 255)) / 255;\n}\n\n/** Squared RGB distance (weighted toward green like the eye). */\nexport function distance2(a, b) {\n  const dr = ((a >> 16) & 255) - ((b >> 16) & 255);\n  const dg = ((a >> 8) & 255) - ((b >> 8) & 255);\n  const db = (a & 255) - (b & 255);\n  return 2 * dr * dr + 4 * dg * dg + 3 * db * db;\n}\n\n// ── Oklab ────────────────────────────────────────────────────────────────────\nconst toLin = (u) => (u <= 0.04045 ? u / 12.92 : Math.pow((u + 0.055) / 1.055, 2.4));\nconst toGam = (u) => (u <= 0.0031308 ? 12.92 * u : 1.055 * Math.pow(u, 1 / 2.4) - 0.055);\n\nexport function toOklab(c) {\n  const r = toLin(((c >> 16) & 255) / 255), g = toLin(((c >> 8) & 255) / 255), b = toLin((c & 255) / 255);\n  const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);\n  const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);\n  const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);\n  return [\n    0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,\n    1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,\n    0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,\n  ];\n}\n\nexport function fromOklab([L, A, B]) {\n  const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;\n  const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;\n  const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;\n  return rgb(\n    toGam(clamp01(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s)) * 255,\n    toGam(clamp01(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s)) * 255,\n    toGam(clamp01(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s)) * 255,\n  );\n}\n\nexport function mixOklab(a, b, t) {\n  if (a === NONE) return b;\n  if (b === NONE) return a;\n  const x = toOklab(a), y = toOklab(b);\n  return fromOklab([x[0] + (y[0] - x[0]) * t, x[1] + (y[1] - x[1]) * t, x[2] + (y[2] - x[2]) * t]);\n}\n\n/**\n * Build a gradient function t∈[0,1] → color.\n * @param {Array} stops  colors, or [position, color] pairs (positions 0..1)\n * @param {object} opts  { cyclic: wrap t instead of clamping; space: 'oklab'|'rgb'; size: LUT entries }\n * The returned function carries `.lut` (Int32Array) and `.at(i)` for direct index access.\n */\nexport function gradient(stops, { cyclic = false, space = 'oklab', size = 256 } = {}) {\n  let pts = stops.map((s, i) =>\n    Array.isArray(s) && s.length === 2 && typeof s[0] === 'number' && s[0] <= 1\n      ? [s[0], parse(s[1])]\n      : [stops.length === 1 ? 0 : i / (stops.length - 1), parse(s)]);\n  if (cyclic) pts = [...pts.map(([p, c]) => [p * (pts.length - 1) / pts.length, c]), [1, pts[0][1]]];\n  const lerp = space === 'rgb' ? mix : mixOklab;\n  const lut = new Int32Array(size);\n  for (let i = 0; i < size; i++) {\n    const t = i / (size - 1);\n    let k = 0;\n    while (k < pts.length - 2 && t > pts[k + 1][0]) k++;\n    const [p0, c0] = pts[k], [p1, c1] = pts[Math.min(k + 1, pts.length - 1)];\n    lut[i] = p1 === p0 ? c0 : lerp(c0, c1, clamp01((t - p0) / (p1 - p0)));\n  }\n  const last = size - 1;\n  const fn = cyclic\n    ? (t) => lut[((Math.floor(t * size) % size) + size) % size]\n    : (t) => lut[t <= 0 ? 0 : t >= 1 ? last : (t * last) | 0];\n  fn.lut = lut;\n  fn.at = (i) => lut[i < 0 ? 0 : i > last ? last : i | 0];\n  return fn;\n}\n\n// ── Terminal quantization ───────────────────────────────────────────────────\n// xterm's 16 base colors (the values most terminals ship by default).\nexport const ANSI16 = [\n  0x000000, 0xcd0000, 0x00cd00, 0xcdcd00, 0x0000ee, 0xcd00cd, 0x00cdcd, 0xe5e5e5,\n  0x7f7f7f, 0xff0000, 0x00ff00, 0xffff00, 0x5c5cff, 0xff00ff, 0x00ffff, 0xffffff,\n];\nconst CUBE = [0, 95, 135, 175, 215, 255];\n\n// 16 colors are a palette of *roles*, not fixed values: every scheme repaints them.\n// So a color is classified in OKLCH, the way a person names it — near-grays by\n// lightness onto black / bright black / white / bright white, everything else by\n// hue onto the six chromatic families (boundaries follow Refraction's\n// hueToAnsi16: violet & rose → magenta, periwinkle & azure → blue, cerulean & cyan\n// → cyan, mint & jade → green, lime, citrine & amber → yellow — so xterm's own yellow\n// (OKLCH h ≈ 110) stays yellow — coral → red), normal or\n// bright by whichever of xterm's pair it is closer to in lightness. A pastel\n// lavender stays magenta instead of collapsing to the gray an RGB distance picks.\nconst FAMILY16 = [[18, 5], [50, 1], [116, 3], [178, 2], [232, 6], [283, 4], [360, 5]];\nlet BRIGHT_AT = null; // per family: the Oklab L halfway between xterm's normal and bright slot\nfunction classify16(L, a, b) {\n  const C = Math.sqrt(a * a + b * b);\n  if (L < 0.3 && C < 0.11) return 0;\n  if (C < 0.045) return L < 0.3 ? 0 : L < 0.76 ? 8 : L < 0.96 ? 7 : 15;\n  let h = Math.atan2(b, a) * 57.29577951308232;\n  if (h < 0) h += 360;\n  let fam = 5;\n  for (const [end, slot] of FAMILY16) if (h < end) { fam = slot; break; }\n  BRIGHT_AT ||= ANSI16.slice(0, 8).map((c, i) => (toOklab(c)[0] + toOklab(ANSI16[i + 8])[0]) / 2);\n  return L > BRIGHT_AT[fam] ? fam + 8 : fam;\n}\nfunction compute16(c) {\n  const [L, a, b] = toOklab(c);\n  return classify16(L, a, b);\n}\n\n// 18-bit LUTs (6 bits/channel) built lazily: quantizing a full-screen plasma per\n// frame then costs one shift+lookup per cell, and stays within 2 levels of exact.\nlet LUT256 = null, LUT16 = null;\nconst key18 = (c) => (((c >> 18) & 63) << 12) | (((c >> 10) & 63) << 6) | ((c >> 2) & 63);\nfunction buildLut(fn) {\n  const lut = new Uint8Array(1 << 18);\n  for (let k = 0; k < lut.length; k++) {\n    const r = ((k >> 12) & 63) * 4 + 2, g = ((k >> 6) & 63) * 4 + 2, b = (k & 63) * 4 + 2;\n    lut[k] = fn((r << 16) | (g << 8) | b);\n  }\n  return lut;\n}\n\n// 256 colors: the nearest of the 240 fixed xterm colors (16–231 cube, 232–255 gray\n// ramp) by Oklab distance — perceptual, so a lavender stays lavender instead of\n// collapsing to the gray an RGB metric prefers. Indexes 0–15 are never used: users\n// remap them, so brand hues would land wherever their scheme puts them. The LUT\n// search covers the bracketing cube levels (±1) and ±3 gray steps (identical to an\n// exhaustive search except at near-ties) and builds in ~25 ms on first use. Colors\n// registered with exact256() — every theme token, via palettes.js — get their LUT\n// cell resolved by an exhaustive search on their exact value. Colors with a\n// *designed* index (pin256: Prism's text tokens, spectrum and gallery inks, from\n// design/tokens.json) take that index instead: the nearest one that still clears\n// 4.5:1 on every 256-color surface (e.g. Day's teal ink → 23, not the too-light\n// nearest 30). Both cost nothing per frame.\nconst labInto = (R, G, B, out, o) => {\n  const l = Math.cbrt(0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B);\n  const m = Math.cbrt(0.2119034982 * R + 0.6806995451 * G + 0.1073969566 * B);\n  const s = Math.cbrt(0.0883024619 * R + 0.2817188376 * G + 0.6299787005 * B);\n  out[o] = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;\n  out[o + 1] = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;\n  out[o + 2] = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;\n};\nconst lin255 = (v) => toLin(v / 255);\nlet PAL256 = null; // Oklab of xterm 16..255, flat [L, a, b] × 240\nfunction pal256() {\n  if (PAL256) return PAL256;\n  PAL256 = new Float64Array(240 * 3);\n  for (let k = 0; k < 240; k++) {\n    const r = k < 216 ? CUBE[(k / 36) | 0] : 8 + (k - 216) * 10;\n    const g = k < 216 ? CUBE[((k / 6) | 0) % 6] : r;\n    const b = k < 216 ? CUBE[k % 6] : r;\n    labInto(lin255(r), lin255(g), lin255(b), PAL256, k * 3);\n  }\n  return PAL256;\n}\n/** Exact nearest xterm-256 index (16–255) for a packed color by Oklab distance (exhaustive). */\nexport function nearest256(c) {\n  const pal = pal256(), p = new Float64Array(3);\n  labInto(lin255((c >> 16) & 255), lin255((c >> 8) & 255), lin255(c & 255), p, 0);\n  let best = 0, bd = Infinity;\n  for (let k = 0, o = 0; k < 240; k++, o += 3) {\n    const dl = p[0] - pal[o], da = p[1] - pal[o + 1], db = p[2] - pal[o + 2];\n    const d = dl * dl + da * da + db * db;\n    if (d < bd) { bd = d; best = k; }\n  }\n  return 16 + best;\n}\nfunction buildLut256() {\n  const pal = pal256();\n  const L6 = new Float64Array(64), LV = new Uint8Array(64);\n  for (let i = 0; i < 64; i++) {\n    const v = Math.round(i * 255 / 63); // cell sample with exact endpoints: black → 16, white → 231\n    L6[i] = lin255(v);\n    let j = 0;\n    while (j < 5 && CUBE[j + 1] <= v) j++;\n    LV[i] = j;\n  }\n  const lut = new Uint8Array(1 << 18), p = new Float64Array(3);\n  for (let key = 0; key < lut.length; key++) {\n    const ri = key >> 12, gi = (key >> 6) & 63, bi = key & 63;\n    labInto(L6[ri], L6[gi], L6[bi], p, 0);\n    const P0 = p[0], P1 = p[1], P2 = p[2];\n    let best = 0, bd = Infinity;\n    const r1 = LV[ri] < 5 ? LV[ri] + 1 : 5, g1 = LV[gi] < 5 ? LV[gi] + 1 : 5, b1 = LV[bi] < 5 ? LV[bi] + 1 : 5;\n    for (let x = LV[ri] > 0 ? LV[ri] - 1 : 0; x <= r1; x++) {\n      for (let y = LV[gi] > 0 ? LV[gi] - 1 : 0; y <= g1; y++) {\n        for (let z = LV[bi] > 0 ? LV[bi] - 1 : 0; z <= b1; z++) {\n          const k = 36 * x + 6 * y + z, o = k * 3;\n          const dl = P0 - pal[o], da = P1 - pal[o + 1], db = P2 - pal[o + 2];\n          const d = dl * dl + da * da + db * db;\n          if (d < bd) { bd = d; best = k; }\n        }\n      }\n    }\n    const gc = Math.max(0, Math.min(23, Math.round(((ri + gi + bi) * 255 / 189 - 8) / 10)));\n    for (let j = gc > 3 ? gc - 3 : 0, je = gc < 20 ? gc + 3 : 23; j <= je; j++) {\n      const o = (216 + j) * 3;\n      const dl = P0 - pal[o], da = P1 - pal[o + 1], db = P2 - pal[o + 2];\n      const d = dl * dl + da * da + db * db;\n      if (d < bd) { bd = d; best = 216 + j; }\n    }\n    lut[key] = 16 + best;\n  }\n  for (const c of EXACT256) lut[key18(c)] = nearest256(c);\n  for (const [k, i] of PIN256) lut[k] = i;\n  return lut;\n}\nconst EXACT256 = new Set();\n/** Register colors (theme tokens) whose 256-color index must be exact, not LUT-approximate. */\nexport function exact256(...colors) {\n  for (const c of colors) {\n    if (c === NONE) continue;\n    EXACT256.add(c & 0xffffff);\n    if (LUT256 && !PIN256.has(key18(c))) LUT256[key18(c)] = nearest256(c & 0xffffff);\n  }\n}\nconst PIN256 = new Map(); // LUT cell → designed index\n/**\n * Pin an exact color to its designed xterm-256 index (16–255), overriding the\n * nearest. A later pin wins a LUT cell two pinned colors share. Registered by\n * palettes.js from src/themes.js (tokens256, ansi256, spectrum and gallery 256).\n */\nexport function pin256(c, index) {\n  if (c === NONE || !Number.isInteger(index) || index < 16 || index > 255) return;\n  const k = key18(c & 0xffffff);\n  PIN256.set(k, index);\n  if (LUT256) LUT256[k] = index;\n}\n/** The designed (pinned) xterm-256 index for c's LUT cell, or undefined. */\nexport function pinned256(c) { return PIN256.get(key18(c & 0xffffff)); }\nexport function to256(c) { return (LUT256 ||= buildLut256())[key18(c)]; }\nfunction buildLut16() {\n  const L6 = new Float64Array(64);\n  for (let i = 0; i < 64; i++) L6[i] = lin255(Math.round(i * 255 / 63));\n  const lut = new Uint8Array(1 << 18), p = new Float64Array(3);\n  for (let key = 0; key < lut.length; key++) {\n    labInto(L6[key >> 12], L6[(key >> 6) & 63], L6[key & 63], p, 0);\n    lut[key] = classify16(p[0], p[1], p[2]);\n  }\n  return lut;\n}\nexport function to16(c) { return (LUT16 ||= buildLut16())[key18(c)]; }\n\n// 16 colors: theme tokens carry a designed ANSI slot (\"role\") — e.g. Prism's accent\n// is always magenta (5), its background/ink the terminal's own default colors —\n// separately for text and for fills, so 16-color output reads as the theme intends\n// on any scheme instead of as whatever xterm default happens to be nearest.\n// Everything else falls back to the nearest xterm color (to16). Slot -1 = the\n// terminal default (SGR 39 / 49). Registered by palettes.js from src/themes.js.\nconst ROLE16 = new Map();\n/** Pin an exact color to an ANSI-16 slot (0–15, or -1 = terminal default) for text and fills. */\nexport function setAnsi16Role(c, fg, bg = fg) { ROLE16.set(c & 0xffffff, [fg, bg]); }\n/** ANSI-16 slot used for `c` as a text color: 0–15, or -1 for the terminal default foreground. */\nexport function ansi16Fg(c) { const r = ROLE16.get(c); return r ? r[0] : to16(c); }\n/** ANSI-16 slot used for `c` as a fill: 0–15, or -1 for the terminal default background. */\nexport function ansi16Bg(c) { const r = ROLE16.get(c); return r ? r[1] : to16(c); }\n\n/** Canonical depth names: 'truecolor' | '256' | '16' | 'mono'. */\nexport function normalizeDepth(d) {\n  if (d === 24 || d === '24' || d === 'truecolor' || d === '24bit' || d === 3 || d === '3') return 'truecolor';\n  if (d === 8 || d === '8' || d === '256' || d === 256 || d === 2 || d === '2') return '256';\n  if (d === 4 || d === '4' || d === '16' || d === 16 || d === 1 || d === '1') return '16';\n  return 'mono';\n}\n\n/**\n * The 16-color text slot for `c` drawn over `onBg` (a cell's background; undefined /\n * NONE = the terminal default). On a light terminal (`light`: a Day theme, whose 7 / 15\n * are light and every other slot dark):\n *   • white / bright white (7 / 15) text stays on dark fills only; on the default or a\n *     light background it would vanish, so it becomes bright black (8);\n *   • dark neutral text (default fg, 0, 8) on a dark fill becomes bright white (15),\n *     as status bars draw it (white on green, not near-black on green).\n * Colored text on colored fills (ANSI art) is left as designed.\n */\nexport function ansi16Text(c, onBg, light = false) {\n  const n = c === NONE ? -1 : ansi16Fg(c);\n  if (!light) return n;\n  const b = onBg === undefined || onBg === NONE ? -1 : ansi16Bg(onBg);\n  const lightFill = b < 0 || b === 7 || b === 15;\n  if (n === 7 || n === 15) return lightFill ? 8 : n;\n  if (n === -1 || n === 0 || n === 8) return lightFill ? n : 15;\n  return n;\n}\n\n/**\n * SGR parameter fragment (no ESC[ / m) for a foreground color at a given depth.\n * `onBg` / `light` only matter at 16 colors (see ansi16Text).\n */\nexport function fgParam(c, depth, onBg, light = false) {\n  if (depth === 'mono' || (c === NONE && !(light && depth === '16'))) return '39';\n  if (depth === 'truecolor') return `38;2;${(c >> 16) & 255};${(c >> 8) & 255};${c & 255}`;\n  if (depth === '256') return `38;5;${to256(c)}`;\n  const n = ansi16Text(c, onBg, light);\n  return n < 0 ? '39' : String(n < 8 ? 30 + n : 82 + n);\n}\n\n/** SGR parameter fragment for a background color at a given depth. */\nexport function bgParam(c, depth) {\n  if (c === NONE || depth === 'mono') return '49';\n  if (depth === 'truecolor') return `48;2;${(c >> 16) & 255};${(c >> 8) & 255};${c & 255}`;\n  if (depth === '256') return `48;5;${to256(c)}`;\n  const n = ansi16Bg(c);\n  return n < 0 ? '49' : String(n < 8 ? 40 + n : 92 + n);\n}\n",
  "prism-term/src/context.js": "// Scene context — everything a scene's setup() receives.\n//\n// Scene contract (src/scenes/*.js default-export):\n//   {\n//     id, title, category, description,\n//     canvas:  default canvas kind for ctx.canvas(): 'field' | 'line' | 'hires' | a mode name\n//     palette: default palette name for ctx.palette()\n//     order:   sort key within its category (optional)\n//     setup(ctx) → frame(t, dt)   // frame draws the whole picture into ctx.buffer\n//   }\n// The runner clears ctx.buffer to ctx.bg before every frame, and re-runs setup()\n// on resize / theme / mode changes, so scenes never handle those themselves.\n\nimport { NONE } from './color.js';\nimport { Canvas, pickMode, ASCII_RAMP, MONO_RAMP } from './canvas.js';\nimport { getTheme, palette as makePalette } from './palettes.js';\nimport { rng } from './easing.js';\nimport { createNoise } from './noise.js';\nimport { asciiSurface } from './ascii.js';\n\n/**\n * @param {object} o { scene, buffer, caps, theme, mode, transparent, params, seed }\n */\nexport function createContext({ scene = {}, buffer, caps = {}, theme, mode = null, transparent = false, params = {}, seed = 7 }) {\n  const th = getTheme(theme);\n  // Mono depth: canvases painted into this buffer (also ones a facet builds itself)\n  // shade by luminance instead of color (src/canvas.js monoHint / _paintMono).\n  const monoPaint = caps.depth === 'mono' ? { ramp: caps.unicode === false ? ASCII_RAMP : MONO_RAMP, ref: th.bg } : null;\n  if (buffer) buffer.monoPaint = monoPaint;\n  // Light terminal (Day themes): at 16 colors white / bright-white text stays off the default\n  // background (src/color.js ansi16Text). The serializers read it from the root buffer.\n  let root = buffer;\n  for (let i = 0; root && (root.parent || root.inner) && i < 16; i++) root = root.parent || root.inner;\n  if (root) root.light16 = th.mode === 'light';\n  // --transparent: the terminal's background shows through, so body text (ink) is sent as the\n  // terminal's own foreground (SGR 39): readable whatever the background (src/buffer.js defaultInk).\n  if (root) root.inkAsDefault = transparent ? th.ink : null;\n  // On terminals without Unicode every glyph a facet draws is folded to 7-bit\n  // ASCII on the way in (src/ascii.js), so no facet can break a TERM=linux console.\n  if (caps.unicode === false) buffer = asciiSurface(buffer);\n  const ctx = {\n    width: buffer.width,\n    height: buffer.height,\n    buffer,\n    theme: th,\n    caps,\n    mode,\n    params,\n    seed,\n    bg: transparent ? NONE : th.bg,\n    rng: rng(seed),\n    noise: createNoise(seed),\n    /** Full-screen (or cols×rows) canvas in the user's forced mode, else the best mode for `kind`. */\n    canvas(kind = scene.canvas || 'field', opts = {}) {\n      const { cols = ctx.width, rows = ctx.height, ...rest } = opts;\n      const m = mode || pickMode(kind, caps);\n      // A color field forced into braille would be a wall of ⣿ — ordered dithering\n      // turns brightness into dot density instead, which reads as halftone shading.\n      const dither = m === 'braille' && (kind === 'field' || kind === 'hires');\n      // Mono depth: no color to carry the picture, so block modes paint a luminance\n      // ramp instead of half-block / mosaic shapes (src/canvas.js _paintMono).\n      return new Canvas(cols, rows, { mode: m, dither, mono: monoPaint, ...rest });\n    },\n    /** Gradient: `--set palette=…` beats the scene default. */\n    palette(name, opts) {\n      return makePalette(params.palette || name || scene.palette || 'prism', th, opts);\n    },\n    /** Numeric/string param with default: ctx.param('speed', 1). */\n    param(key, def) {\n      if (!(key in params)) return def;\n      const v = params[key];\n      return typeof def === 'number' ? Number(v) : typeof def === 'boolean' ? !/^(0|false|no|off)$/i.test(v) : v;\n    },\n  };\n  return ctx;\n}\n",
  "prism-term/src/easing.js": "// Easing curves (t ∈ [0,1] → [0,1], some overshoot) and small interpolation helpers.\n\nconst { PI, sin, cos, pow, sqrt } = Math;\n\nexport const easing = {\n  linear: (t) => t,\n  inQuad: (t) => t * t,\n  outQuad: (t) => t * (2 - t),\n  inOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),\n  inCubic: (t) => t * t * t,\n  outCubic: (t) => 1 - pow(1 - t, 3),\n  inOutCubic: (t) => (t < 0.5 ? 4 * t * t * t : 1 - pow(-2 * t + 2, 3) / 2),\n  inQuart: (t) => t * t * t * t,\n  outQuart: (t) => 1 - pow(1 - t, 4),\n  inOutSine: (t) => -(cos(PI * t) - 1) / 2,\n  outSine: (t) => sin((t * PI) / 2),\n  inExpo: (t) => (t === 0 ? 0 : pow(2, 10 * t - 10)),\n  outExpo: (t) => (t === 1 ? 1 : 1 - pow(2, -10 * t)),\n  inOutExpo: (t) => (t === 0 ? 0 : t === 1 ? 1 : t < 0.5 ? pow(2, 20 * t - 10) / 2 : (2 - pow(2, -20 * t + 10)) / 2),\n  outCirc: (t) => sqrt(1 - pow(t - 1, 2)),\n  outBack: (t) => 1 + 2.70158 * pow(t - 1, 3) + 1.70158 * pow(t - 1, 2),\n  inOutBack: (t) => {\n    const c = 1.70158 * 1.525;\n    return t < 0.5 ? (pow(2 * t, 2) * ((c + 1) * 2 * t - c)) / 2 : (pow(2 * t - 2, 2) * ((c + 1) * (t * 2 - 2) + c) + 2) / 2;\n  },\n  outElastic: (t) => (t === 0 ? 0 : t === 1 ? 1 : pow(2, -10 * t) * sin((t * 10 - 0.75) * ((2 * PI) / 3)) + 1),\n  outBounce: (t) => {\n    const n = 7.5625, d = 2.75;\n    if (t < 1 / d) return n * t * t;\n    if (t < 2 / d) return n * (t -= 1.5 / d) * t + 0.75;\n    if (t < 2.5 / d) return n * (t -= 2.25 / d) * t + 0.9375;\n    return n * (t -= 2.625 / d) * t + 0.984375;\n  },\n};\n\nexport const clamp = (v, lo = 0, hi = 1) => (v < lo ? lo : v > hi ? hi : v);\nexport const lerp = (a, b, t) => a + (b - a) * t;\nexport const smoothstep = (e0, e1, x) => { const t = clamp((x - e0) / (e1 - e0)); return t * t * (3 - 2 * t); };\nexport const fract = (x) => x - Math.floor(x);\n/** Map v from [a,b] to [c,d]. */\nexport const remap = (v, a, b, c, d) => c + ((v - a) / (b - a)) * (d - c);\n/** Triangle wave 0→1→0 with period 1. */\nexport const pingpong = (t) => 1 - Math.abs((t % 2 + 2) % 2 - 1);\n\n/** Progress of a tween that starts at `start` seconds and lasts `dur`, eased. */\nexport function tween(t, start, dur, ease = easing.inOutCubic) {\n  return ease(clamp((t - start) / dur));\n}\n\n/** Seeded PRNG (mulberry32) — deterministic scenes and snapshots. */\nexport function rng(seed = 0x9e3779b9) {\n  let a = seed >>> 0;\n  const next = () => {\n    a = (a + 0x6d2b79f5) >>> 0;\n    let t = a;\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n  next.range = (lo, hi) => lo + next() * (hi - lo);\n  next.int = (lo, hi) => Math.floor(lo + next() * (hi - lo + 1));\n  next.pick = (arr) => arr[Math.floor(next() * arr.length)];\n  next.gauss = () => { const u = 1 - next(), v = next(); return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v); };\n  return next;\n}\n",
  "prism-term/src/facets/ai/_ai.js": "// Shared bits for the AI gallery: token-ish streaming, paragraph wrapping,\n// the \"thinking\" glyph cycle and a shimmer color sweep.\nexport * from '../callouts/_kit.js';\nimport { uni, mix } from '../callouts/_kit.js';\nimport { wrap } from '../../width.js';\n\n/** Split text into LLM-ish tokens: words with their leading space, punctuation on its own. */\nexport function tokenize(text) {\n  return String(text).match(/\\s*[A-Za-z]{1,6}|\\s*\\d{1,3}|\\s*[^\\sA-Za-z\\d]|\\s+/g) || [];\n}\n\n/** Reveal `text` token by token at `tps` tokens/second from `start`. */\nexport function reveal(text, t, start = 0, tps = 30) {\n  const toks = tokenize(text);\n  const n = Math.max(0, Math.min(toks.length, Math.floor((t - start) * tps)));\n  return { text: toks.slice(0, n).join(''), n, total: toks.length, done: n >= toks.length, last: n ? toks[n - 1] : '' };\n}\n\n/** Word-wrap keeping paragraph breaks ('\\n'). */\nexport const wrapLines = (text, w) => wrap(text, Math.max(1, w));\n\nconst SPIN_U = ['·', '✢', '✳', '✶', '✻', '✽', '✻', '✶', '✳', '✢'];\nconst SPIN_A = ['.', '+', '*', 'x', '*', '+'];\n/** The breathing asterisk used for \"thinking\" (· ✢ ✳ ✶ ✻ ✽ …). */\nexport const thinkGlyph = (ctx, t) => {\n  const s = uni(ctx) ? SPIN_U : SPIN_A;\n  return s[Math.floor(t * 8) % s.length];\n};\n\n/** Color for character i of an n-char label under a shimmer band sweeping every `period` seconds. */\nexport function shimmer(base, hi, i, n, t, period = 1.8, width = 4) {\n  const head = ((t % period) / period) * (n + width * 2) - width;\n  const d = Math.abs(i - head);\n  return d < width ? mix(base, hi, 1 - d / width) : base;\n}\n",
  "prism-term/src/facets/ai/approval-prompt.js": "// A human-in-the-loop approval prompt: the agent proposes a command, the\n// user reads it, moves to an option and approves; then it runs.\nimport { ATTR, glyphs, uni, phase, fit, spin, tint, panel, stepAt } from './_ai.js';\n\nconst CMD = 'kubectl -n checkout set env deploy/checkout-api DB_POOL_SIZE=40';\nconst OPTIONS = ['Yes', 'Yes, and don\\'t ask again for kubectl set env in checkout', 'No, and tell the agent what to do differently'];\nconst T = { show: 0.2, moves: [1.8, 2.3, 2.9], enter: 3.5, done: 5.0 };\n\nexport default {\n  id: 'ai-approval-prompt',\n  title: 'Approval Prompt',\n  category: 'Tool Calls',\n  description: 'A human-in-the-loop approval prompt: the agent proposes a production command with its reason, the user weighs the options and approves, then it runs.',\n  tags: ['approval', 'human in the loop', 'permission', 'confirm', 'agent'],\n  kind: 'tui',\n  size: { w: 70, h: 10 },\n  minSize: { w: 50, h: 10 },\n  loop: 7,\n  color: '256',\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 7);\n      if (p < T.enter) {\n        const inner = panel(ctx, b, 0, 0, W, H, { title: 'Bash command', color: th.warn, titleColor: th.warn });\n        const iw = inner.width;\n        let y = 0;\n        inner.text(1, y++, fit(CMD, iw - 2, undefined, ctx), th.ink, undefined, ATTR.BOLD);\n        inner.text(1, y++, fit('Raise the checkout DB pool to match 32 workers (fix #142).', iw - 2, undefined, ctx), th.muted);\n        y++;\n        inner.text(1, y++, fit('Do you want to proceed?', iw - 2, undefined, ctx), th.ink);\n        // Cursor: down, down, back up to Yes.\n        const k = stepAt(p, T.moves);\n        const cur = [0, 1, 2, 0][k + 1];\n        OPTIONS.forEach((o, i) => {\n          if (y >= inner.height - 1) return;\n          const on = i === cur;\n          if (on) inner.fill(0, y, iw, 1, ' ', undefined, tint(ctx, th.accent, 0.14));\n          inner.text(1, y, on ? g.pointer : ' ', th.accent, undefined, ATTR.BOLD);\n          inner.text(3, y++, fit(`${i + 1}. ${o}`, iw - 4, undefined, ctx).trimEnd(), on ? th.ink : th.muted, undefined, on ? ATTR.BOLD : 0);\n        });\n        inner.text(1, inner.height - 1, fit(`${g.up}${g.down} choose ${g.mid} enter confirm ${g.mid} esc deny`, iw - 2, undefined, ctx), th.dim);\n        return;\n      }\n      // Approved: the call runs and reports.\n      const running = p < T.done;\n      b.text(0, 0, running ? spin(ctx, t) : (u ? '●' : '*'), running ? th.warn : th.pos, undefined, ATTR.BOLD);\n      const x = b.text(2, 0, 'Bash', th.ink, undefined, ATTR.BOLD);\n      b.text(x, 0, fit(`(${CMD})`, W - x, undefined, ctx), th.muted);\n      b.text(2, 1, u ? '⎿' : '\\\\_', th.dim);\n      b.text(5, 1, fit(running ? `Running${g.ellipsis}` : 'deployment.apps/checkout-api env updated', W - 5, undefined, ctx).trimEnd(), running ? th.warn : th.ink);\n      b.text(0, 3, fit(`${g.check} approved by you ${g.mid} 12:05:41 ${g.mid} logged to the audit trail`, W, undefined, ctx), th.pos);\n      if (!running && H > 5) b.text(0, 5, fit(`${g.ellipsis} rolling restart 3/3 pods ${g.mid} watching p99`, W, undefined, ctx), th.dim);\n    };\n  },\n};\n",
  "prism-term/src/facets/ai/attention-heatmap.js": "// An attention heatmap for one sentence: rows attend to earlier columns\n// (causal mask), the query row walks down, and the view flips between a\n// previous-token head and a coreference head.\nimport { ATTR, uni, phase, mix, fit } from './_ai.js';\n\nconst TOK = ['The', 'pool', 'is', 'too', 'small', 'for', '32', 'worker', 's'];\nconst N = TOK.length;\n// Head 2: \"previous token\". Head 7: content links (small→pool, s→worker, 32→pool …).\nconst LINKS = { 2: [[1, 0.3]], 3: [[1, 0.25], [2, 0.35]], 4: [[1, 0.7]], 5: [[4, 0.5]], 6: [[1, 0.35], [5, 0.3]], 7: [[6, 0.55]], 8: [[7, 0.75]] };\nfunction weights(head) {\n  return TOK.map((_, i) => {\n    const row = TOK.map((__, j) => {\n      if (j > i) return 0;\n      if (head === 2) return j === i - 1 ? 0.7 : j === i ? 0.18 : j === 0 ? 0.08 : 0.02;\n      const l = (LINKS[i] || []).find(([k]) => k === j);\n      return (l ? l[1] : 0) + (j === 0 ? 0.12 : 0) + (j === i ? 0.1 : 0.02);\n    });\n    const s = row.reduce((a, c) => a + c, 0);\n    return row.map((v) => v / s);\n  });\n}\nconst HEADS = [[2, 'previous-token', weights(2)], [7, 'coreference', weights(7)]];\nconst LOOP = 10;\n\nexport default {\n  id: 'ai-attention-heatmap',\n  title: 'Attention Heatmap',\n  category: 'Model Internals',\n  description: 'An attention heatmap over one sentence with a causal mask: the query row walks down showing its strongest link, flipping between a previous-token and a coreference head.',\n  tags: ['attention', 'heatmap', 'transformer', 'heads', 'interpretability'],\n  kind: 'chart',\n  size: { w: 66, h: 13 },\n  minSize: { w: 50, h: 12 },\n  loop: LOOP,\n  color: '256',\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, u = uni(ctx);\n      const p = phase(t, LOOP);\n      const hi = p < LOOP / 2 ? 0 : 1;\n      const [hid, hname, A] = HEADS[hi];\n      const q = Math.min(N - 1, Math.floor(((p % (LOOP / 2)) / (LOOP / 2)) * N));\n      const lw = 7, cw = Math.max(4, Math.min(6, Math.floor((W - lw - 1) / N)));\n      const SH = u ? ' ░▒▓█' : ' .:+#';\n      // Column labels.\n      TOK.forEach((tk, j) => b.text(lw + j * cw, 0, fit(tk, cw - 1, undefined, ctx).trimEnd(), j === A[q].indexOf(Math.max(...A[q])) ? th.accent : th.muted, undefined, ATTR.BOLD));\n      for (let i = 0; i < N; i++) {\n        const y = 1 + i;\n        if (y >= H - 2) break;\n        const on = i === q;\n        b.text(0, y, fit(TOK[i], lw - 1, undefined, ctx), on ? th.ink : th.muted, undefined, on ? ATTR.BOLD : 0);\n        for (let j = 0; j < N; j++) {\n          const w = A[i][j];\n          const masked = j > i;\n          const level = masked ? 0 : Math.max(1, Math.min(4, Math.round(Math.sqrt(w) * 4.4)));\n          const c = masked ? th.line : mix(th.bg, on ? th.accent : th.info, 0.25 + Math.sqrt(w) * 0.75);\n          const ch = masked ? (u ? '·' : '.') : SH[level];\n          for (let k = 0; k < cw - 1; k++) b.set(lw + j * cw + k, y, ch, c);\n        }\n        if (on) b.text(lw + N * cw, y, u ? '◂' : '<', th.accent, undefined, ATTR.BOLD);\n      }\n      // Readout for the query row.\n      const best = A[q].indexOf(Math.max(...A[q]));\n      const msg = `head ${hid} (${hname}) ${u ? '·' : '.'} \"${TOK[q]}\" attends to \"${TOK[best]}\" ${(A[q][best] * 100).toFixed(0)}%`;\n      b.text(0, H - 1, fit(msg, W, undefined, ctx), th.ink);\n      b.text(0, H - 2, fit(`layer 14 ${u ? '·' : '.'} causal mask ${u ? '·' : '.'} darker = more attention`, W, undefined, ctx), th.dim);\n    };\n  },\n};\n",
  "prism-term/src/facets/ai/chat-transcript.js": "// A chat transcript: the user's question sits right-aligned, the assistant\n// streams its reply on the left with a tool chip, then a follow-up arrives.\nimport { ATTR, glyphs, uni, phase, fit, tint, reveal, wrapLines, stringWidth, thinkGlyph } from './_ai.js';\n\nconst MSGS = [\n  ['user', 0, 'Why is checkout slow since lunch?'],\n  ['tool', 1.0, 'searched traces, metrics, deploys'],\n  ['bot', 1.8, 'p99 went from 180ms to 2.4s at 12:02, right when v2.14.1 shipped. That release runs 32 workers against a 20-connection pool, so requests queue for a connection.'],\n  ['user', 5.4, 'Can you fix it?'],\n  ['bot', 6.1, 'Yes: raise DB_POOL_SIZE to 40. I have queued it as fix #142 for your approval.'],\n];\n\nexport default {\n  id: 'ai-chat-transcript',\n  title: 'Chat Transcript',\n  category: 'Agents & Pipelines',\n  description: 'A chat transcript: user questions sit right-aligned in tinted bubbles, the assistant streams replies on the left with a tool-use chip, and the view scrolls.',\n  tags: ['chat', 'transcript', 'assistant', 'conversation', 'streaming'],\n  kind: 'tui',\n  size: { w: 64, h: 16 },\n  minSize: { w: 40, h: 10 },\n  loop: 9.5,\n  color: '256',\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 9.5);\n      const rows = [];\n      const bw = Math.max(16, Math.floor(W * 0.62));\n      for (const [who, at, text] of MSGS) {\n        if (p < at) break;\n        if (who === 'user') {\n          const lines = wrapLines(text, bw - 4);\n          const w = Math.max(...lines.map((l) => stringWidth(l))) + 4;\n          rows.push({ kind: 'label', who: 'you', x: W - 3 });\n          for (const l of lines) rows.push({ kind: 'user', text: l, x: W - w, w });\n        } else if (who === 'tool') {\n          rows.push({ kind: 'tool', text });\n        } else {\n          const r = reveal(text, p, at, 24);\n          if (!r.n) { rows.push({ kind: 'think' }); continue; }\n          rows.push({ kind: 'label', who: 'mainbrain', x: 0 });\n          const lines = wrapLines(r.text, bw);\n          lines.forEach((l, i) => rows.push({ kind: 'bot', text: l, caret: !r.done && i === lines.length - 1 }));\n        }\n        rows.push({ kind: 'gap' });\n      }\n      const first = Math.max(0, rows.length - H);\n      rows.slice(first).forEach((row, y) => {\n        if (row.kind === 'label') {\n          const lab = row.who;\n          b.text(row.who === 'you' ? W - stringWidth(lab) : 0, y, lab, row.who === 'you' ? th.muted : th.accent, undefined, ATTR.BOLD);\n        } else if (row.kind === 'user') {\n          const bg = tint(ctx, th.accent, 0.22);\n          b.fill(row.x, y, row.w, 1, ' ', undefined, bg);\n          b.text(row.x + 2, y, row.text, th.ink, bg);\n        } else if (row.kind === 'bot') {\n          const x = b.text(0, y, row.text, th.ink);\n          if (row.caret) b.set(Math.min(W - 1, x), y, u ? '▍' : '_', th.accent);\n        } else if (row.kind === 'tool') {\n          const chip = ` ${g.spark} ${row.text} `;\n          b.text(0, y, fit(chip, W, undefined, ctx).trimEnd(), th.muted, tint(ctx, th.panel2, 0.9), ATTR.ITALIC);\n        } else if (row.kind === 'think') {\n          b.text(0, y, `${thinkGlyph(ctx, t)} thinking${g.ellipsis}`, th.dim, undefined, ATTR.ITALIC);\n        }\n      });\n    };\n  },\n};\n",
  "prism-term/src/facets/ai/context-window.js": "// A context window filling up: a stacked bar of system, tools, files,\n// history and output grows turn by turn until auto-compact squeezes the\n// history into a summary and frees room.\nimport { ATTR, glyphs, uni, phase, ease, fit, mix } from './_ai.js';\n\nconst MAX = 200; // k tokens\nconst SEGS = [['system', 'muted'], ['tools', 'info'], ['files', 'warn'], ['history', 'accent'], ['output', 'pos']];\nconst COMPACT = [5.8, 6.6];\n\nexport default {\n  id: 'ai-context-window',\n  title: 'Context Window',\n  category: 'Model Internals',\n  description: 'A 200k context window filling turn by turn as a stacked bar of system, tools, files, history and output, until auto-compact squeezes history into a summary.',\n  tags: ['context window', 'tokens', 'compaction', 'memory', 'budget'],\n  kind: 'chart',\n  size: { w: 64, h: 9 },\n  minSize: { w: 40, h: 7 },\n  loop: 9,\n  color: '256',\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 9);\n      // Usage in k tokens: history and files grow with each turn, then compaction.\n      const grow = Math.min(1, p / COMPACT[0]);\n      const squeeze = ease(p, COMPACT[0], COMPACT[1]);\n      const history = (6 + grow * 118) * (1 - squeeze) + 9 * squeeze;\n      const files = 4 + grow * 38 - squeeze * 14;\n      const output = 1 + grow * 9 + Math.max(0, p - COMPACT[1]) * 2.5;\n      const used = { system: 3.2, tools: 11.8, files, history, output };\n      const total = Object.values(used).reduce((a, c) => a + c, 0);\n      const pct = total / MAX;\n      b.text(0, 0, 'context', th.ink, undefined, ATTR.BOLD);\n      const head = `${total.toFixed(1)}k / ${MAX}k  ${Math.round(pct * 100)}%`;\n      b.text(W - head.length, 0, head, pct > 0.8 ? th.warn : th.muted, undefined, pct > 0.8 ? ATTR.BOLD : 0);\n      // Stacked bar, two rows tall.\n      const bw = W;\n      let x = 0;\n      SEGS.forEach(([key, tone], i) => {\n        const w = Math.round((used[key] / MAX) * bw);\n        const glyph = u ? '█▓▒░█'[i] : '#=+:%'[i];\n        for (let k = 0; k < w && x < bw; k++, x++) { b.set(x, 1, glyph, th[tone]); b.set(x, 2, glyph, mix(th[tone], th.bg, 0.25)); }\n      });\n      for (; x < bw; x++) { b.set(x, 1, u ? '·' : '.', th.line); b.set(x, 2, u ? '·' : '.', th.line); }\n      // Auto-compact threshold marker at 80%.\n      const tx = Math.round(0.8 * bw);\n      b.set(Math.min(W - 1, tx), 3, u ? '▲' : '^', th.warn);\n      if (tx + 14 <= W) b.text(tx + 2, 3, 'auto-compact', th.dim);\n      // Legend.\n      let lx = 0, ly = 4;\n      SEGS.forEach(([key, tone], i) => {\n        const item = `${key} ${used[key].toFixed(1)}k`;\n        if (lx + item.length + 2 > W) { lx = 0; ly++; }\n        if (ly >= H - 1) return;\n        b.set(lx, ly, u ? '█▓▒░█'[i] : '#=+:%'[i], th[tone]);\n        lx = b.text(lx + 2, ly, item, th.muted) + 3;\n      });\n      const msg = squeeze > 0 && squeeze < 1 ? `${g.spark} compacting history: ${Math.round(6 + 118)}k ${g.right} 9k summary`\n        : squeeze >= 1 ? `${g.check} compacted ${g.mid} freed 129k ${g.mid} summary keeps the incident timeline`\n          : `turn ${1 + Math.floor(grow * 23)} ${g.mid} history grows with every tool result`;\n      b.text(0, H - 1, fit(msg, W, undefined, ctx), squeeze > 0 ? th.pos : th.dim);\n    };\n  },\n};\n",
  "prism-term/src/facets/ai/diff-apply.js": "// An agent applying an edit: a unified diff streams in hunk by hunk with\n// line numbers and +/- gutters, then the edit is applied and summarized.\nimport { ATTR, glyphs, uni, phase, fit, spin, tint, stringWidth } from './_ai.js';\n\n// [old line no, new line no, kind, text]\nconst DIFF = [\n  [3, 3, ' ', \"import { createPool } from './db.js';\"],\n  [4, 4, ' ', ''],\n  [5, null, '-', 'const POOL_SIZE = 20;'],\n  [null, 5, '+', 'const POOL_SIZE = Number(process.env.DB_POOL_SIZE ?? 40);'],\n  [6, 6, ' ', 'const TIMEOUT_MS = 5000;'],\n  [null, 7, '+', \"const POOL_WAIT_ALERT_MS = 200; // alert: pool_wait_ms p99\"],\n  [7, 8, ' ', ''],\n  [8, 9, ' ', 'export function pool() {'],\n];\nconst T = { head: 0.3, line: 0.32, applied: 4.2 };\n\nexport default {\n  id: 'ai-diff-apply',\n  title: 'Diff Apply',\n  category: 'Tool Calls',\n  description: 'An agent applying an edit: the unified diff streams in line by line with old and new line numbers and +/- gutters, then the edit is applied and summarized.',\n  tags: ['diff', 'edit', 'patch', 'code change', 'agent'],\n  kind: 'tui',\n  size: { w: 72, h: 10 },\n  minSize: { w: 48, h: 9 },\n  loop: 8,\n  color: '256',\n  order: 40,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 8);\n      const applied = p >= T.applied;\n      b.text(0, 0, applied ? (u ? '●' : '*') : spin(ctx, t), applied ? th.pos : th.warn, undefined, ATTR.BOLD);\n      const x = b.text(2, 0, 'Update', th.ink, undefined, ATTR.BOLD);\n      b.text(x, 0, fit('(src/db/pool.js)', W - x, undefined, ctx).trimEnd(), th.muted);\n      b.text(2, 1, u ? '⎿' : '\\\\_', th.dim);\n      b.text(5, 1, fit(applied ? `Updated src/db/pool.js with 2 additions and 1 removal` : `Proposing edit${g.ellipsis}`, W - 5, undefined, ctx).trimEnd(), applied ? th.ink : th.warn);\n      const shown = Math.min(DIFF.length, Math.max(0, Math.floor((p - T.head) / T.line)));\n      const addBg = tint(ctx, th.pos, 0.16), delBg = tint(ctx, th.neg, 0.16);\n      DIFF.slice(0, shown).forEach(([o, n, kind, text], i) => {\n        const y = 2 + i;\n        if (y >= H) return;\n        const bg = kind === '+' ? addBg : kind === '-' ? delBg : undefined;\n        if (bg !== undefined) b.fill(5, y, W - 5, 1, ' ', undefined, bg);\n        b.text(5, y, String(o ?? '').padStart(3), th.dim, bg);\n        b.text(9, y, String(n ?? '').padStart(3), th.dim, bg);\n        b.text(13, y, kind, kind === '+' ? th.pos : kind === '-' ? th.neg : th.dim, bg, ATTR.BOLD);\n        const room = W - 15;\n        b.text(15, y, stringWidth(text) <= room ? text : fit(text, room, undefined, ctx), kind === '+' ? th.pos : kind === '-' ? th.neg : th.ink, bg);\n      });\n    };\n  },\n};\n",
  "prism-term/src/facets/ai/markdown-stream.js": "// Markdown rendered as it streams: headings, bold, inline code, bullets and\n// a fenced code block that is syntax-colored line by line as it arrives.\nimport { ATTR, glyphs, uni, phase, tint, fit, clip, codeLine, reveal, stringWidth } from './_ai.js';\n\nconst MD = [\n  '## Root cause',\n  'The **connection pool** is too small for the new worker count.',\n  '',\n  '- workers: `8 -> 32` in v2.14.1',\n  '- pool: `20`, unchanged',\n  '',\n  '## Fix',\n  '```js',\n  \"const pool = createPool({\",\n  '  max: Number(process.env.DB_POOL_SIZE ?? 40),',\n  '  acquireTimeoutMillis: 2000,',\n  '});',\n  '```',\n  'Then alert when `pool_wait_ms` p99 > 200.',\n].join('\\n');\n\n/** Inline spans: **bold** and `code`. Returns [[text, kind]]. */\nfunction inline(s) {\n  const out = [];\n  const re = /(\\*\\*[^*]+\\*\\*|\\*\\*[^*]*$|`[^`]+`|`[^`]*$)/g;\n  let i = 0, m;\n  while ((m = re.exec(s))) {\n    if (m.index > i) out.push([s.slice(i, m.index), 'text']);\n    const tok = m[0];\n    if (tok.startsWith('**')) out.push([tok.replace(/\\*\\*/g, ''), 'bold']);\n    else out.push([tok.replace(/`/g, ''), 'code']);\n    i = m.index + tok.length;\n  }\n  if (i < s.length) out.push([s.slice(i), 'text']);\n  return out;\n}\n\nexport default {\n  id: 'ai-markdown-stream',\n  title: 'Markdown Stream',\n  category: 'Token Streams',\n  description: 'Markdown rendered live as it streams: headings, bold and inline code spans, bullets, and a fenced code block that is syntax-colored line by line.',\n  tags: ['markdown', 'streaming', 'render', 'code block', 'llm'],\n  kind: 'text',\n  size: { w: 64, h: 17 },\n  minSize: { w: 44, h: 10 },\n  loop: 10,\n  color: '256',\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme;\n    const low = ctx.caps.depth === '16' || ctx.caps.depth === 'mono';\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 10);\n      const r = reveal(MD, p, -0.2, 19);\n      const src = r.text.split('\\n');\n      // Render each source line into one or more styled rows.\n      const rows = [];\n      let inCode = false, lang = '';\n      src.forEach((line, li) => {\n        const last = li === src.length - 1;\n        if (line.startsWith('```')) {\n          if (!inCode) { inCode = true; lang = line.slice(3); rows.push({ kind: 'fence-open', lang }); }\n          else { inCode = false; rows.push({ kind: 'fence-close' }); }\n          return;\n        }\n        if (inCode) { rows.push({ kind: 'code', text: line }); return; }\n        if (/^#{1,3} /.test(line)) { rows.push({ kind: 'h', text: line.replace(/^#+ /, '') }); return; }\n        if (/^- /.test(line)) { rows.push({ kind: 'li', spans: inline(line.slice(2)) }); return; }\n        if (!line && !last) { rows.push({ kind: 'blank' }); return; }\n        rows.push({ kind: 'p', spans: inline(line) });\n      });\n      const first = Math.max(0, rows.length - H);\n      const codeBg = tint(ctx, th.panel2, 0.85);\n      rows.slice(first, first + H).forEach((row, y) => {\n        if (row.kind === 'h') {\n          b.text(0, y, fit(row.text, W, undefined, ctx).trimEnd(), th.accent, undefined, ATTR.BOLD);\n          return;\n        }\n        if (row.kind === 'fence-open' || row.kind === 'code' || row.kind === 'fence-close') {\n          b.fill(0, y, W, 1, ' ', undefined, codeBg);\n          if (row.kind === 'fence-open') b.text(W - stringWidth(row.lang || 'text') - 1, y, row.lang || 'text', th.dim, codeBg);\n          if (row.kind === 'code') codeLine(ctx, b, 2, y, W - 3, row.text, codeBg);\n          if (row.kind === 'fence-open' || row.kind === 'fence-close') b.set(0, y, u ? '▏' : '|', th.line, codeBg);\n          else b.set(0, y, u ? '▏' : '|', th.accent, codeBg);\n          return;\n        }\n        if (row.kind === 'blank') return;\n        let x = 0;\n        if (row.kind === 'li') x = b.text(1, y, `${g.bullet} `, th.accent);\n        for (const [text, kind] of row.spans) {\n          const room = Math.max(0, W - x);\n          const s = u ? text.replace(/->/g, '→') : text;\n          const shown = stringWidth(s) <= room ? s : clip(ctx, s, room);\n          if (kind === 'code') x = b.text(x, y, shown, th.warn, low ? undefined : tint(ctx, th.warn, 0.15)); // 16 colors: a wash would be a solid block (black on Night), so code keeps the default bg\n          else x = b.text(x, y, shown, th.ink, undefined, kind === 'bold' ? ATTR.BOLD : 0);\n        }\n      });\n      // Streaming caret at the end of the last row.\n      if (!r.done && rows.length) {\n        const ly = Math.min(H, rows.length - first) - 1;\n        let lx = 0;\n        for (let x = W - 1; x >= 0; x--) { const c = b.get(x, ly); if (c && c.ch !== ' ' && c.ch !== '') { lx = x + 1; break; } }\n        b.set(Math.min(W - 1, lx), ly, u ? '▍' : '_', th.accent);\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/ai/multi-agent-log.js": "// A multi-agent message log: agents post findings and hand work to each\n// other, color-tagged, with handoffs marked and the newest line flashing.\nimport { ATTR, glyphs, uni, phase, fit, tint, stringWidth } from './_ai.js';\n\nconst AGENTS = { mainbrain: 'accent', collector: 'info', analyst: 'warn', fixer: 'pos' };\n// [time, from, to | '', text]\nconst LOG = [\n  [0.2, 'collector', '', 'ingesting checkout spans, 1.2k/s'],\n  [0.9, 'collector', 'analyst', 'p99 2.4s on POST /checkout since 12:02'],\n  [1.7, 'analyst', '', 'pool_wait_ms tracks p99 (r=0.94)'],\n  [2.4, 'analyst', '', 'deploy v2.14.1 at 12:02 raised workers 8 -> 32'],\n  [3.1, 'analyst', 'mainbrain', 'cause: db pool starvation (confidence 0.87)'],\n  [3.8, 'mainbrain', 'fixer', 'draft the smallest safe fix'],\n  [4.6, 'fixer', 'mainbrain', 'fix #142: DB_POOL_SIZE=40, needs approval'],\n  [5.4, 'mainbrain', '', 'waiting on a human for #142'],\n  [6.6, 'mainbrain', 'fixer', 'approved by on-call, apply #142'],\n  [7.4, 'fixer', '', 'applied; p99 380ms and falling'],\n];\n\nexport default {\n  id: 'ai-multi-agent-log',\n  title: 'Multi-Agent Log',\n  category: 'Agents & Pipelines',\n  description: 'A multi-agent message log: color-tagged agents post findings and hand work to one another, handoffs marked with arrows and the newest line flashing.',\n  tags: ['multi-agent', 'log', 'handoff', 'agents', 'orchestration'],\n  kind: 'tui',\n  size: { w: 72, h: 10 },\n  minSize: { w: 48, h: 6 },\n  loop: 9,\n  color: '256',\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 9);\n      const shown = LOG.filter(([at]) => p >= at);\n      const tagW = 9;\n      shown.slice(-H).forEach(([at, from, to, text], y) => {\n        const fresh = p - at < 0.5;\n        if (fresh) b.fill(0, y, W, 1, ' ', undefined, tint(ctx, th[AGENTS[from]], 0.14));\n        const ts = `12:04:${String(31 + Math.round(at * 2)).padStart(2, '0')}`;\n        let x = W >= 60 ? b.text(0, y, ts, th.dim) + 1 : 0;\n        x = b.text(x, y, from.padEnd(tagW), th[AGENTS[from]], undefined, ATTR.BOLD);\n        if (to) {\n          x = b.text(x, y, `${u ? '▸' : '>'} `, th.dim);\n          x = b.text(x, y, to, th[AGENTS[to]], undefined, ATTR.BOLD) + 1;\n        }\n        const msg = u ? text.replace('->', '→') : text;\n        b.text(x + 1, y, stringWidth(msg) <= W - x - 1 ? msg : fit(msg, Math.max(0, W - x - 1), undefined, ctx), fresh ? th.ink : th.muted, undefined, msg.startsWith('waiting') ? ATTR.ITALIC : 0);\n      });\n      if (!shown.length) b.text(0, 0, `${g.ellipsis} agents starting`, th.dim);\n    };\n  },\n};\n",
  "prism-term/src/facets/ai/parallel-tools.js": "// Parallel tool calls as a live waterfall: four calls fan out at once, bars\n// grow while each runs, finish at different times, then fan back in.\nimport { ATTR, glyphs, uni, phase, fit, spin, tint } from './_ai.js';\n\n// [tool, args, duration (s), result]\nconst CALLS = [\n  ['logs.search', 'checkout-api level>=warn 30m', 1.1, '214 matches'],\n  ['metrics.query', 'pool_wait_ms p99', 2.3, '840ms'],\n  ['traces.find', 'p99 > 2s, 15m', 3.1, '31 slow traces'],\n  ['deploys.list', 'checkout, last 24h', 0.7, 'v2.14.1 @ 12:02'],\n];\nconst START = 0.5, SCALE = 3.4;\n\nexport default {\n  id: 'ai-parallel-tools',\n  title: 'Parallel Tool Calls',\n  category: 'Tool Calls',\n  description: 'Four tool calls fanned out in parallel as a live waterfall: bars grow while each runs, finish at different times with their results, then fan back in.',\n  tags: ['parallel', 'tool calls', 'waterfall', 'fan out', 'agent'],\n  kind: 'tui',\n  size: { w: 70, h: 6 },\n  minSize: { w: 48, h: 6 },\n  loop: 7,\n  color: '256',\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 7);\n      const el = Math.max(0, p - START);\n      const all = Math.max(...CALLS.map((c) => c[2]));\n      const finished = el >= all;\n      b.text(0, 0, finished ? g.check : spin(ctx, t), finished ? th.pos : th.warn, undefined, ATTR.BOLD);\n      b.text(2, 0, fit(finished ? `4 tools finished in ${all.toFixed(1)}s (longest wins)` : `Running 4 tools in parallel ${g.mid} ${el.toFixed(1)}s`, W - 2, undefined, ctx), th.ink, undefined, ATTR.BOLD);\n      const lw = Math.min(16, Math.floor(W * 0.24));\n      const x0 = lw + 3, tw = Math.max(10, W - x0 - 18);\n      CALLS.forEach(([tool, args, dur, result], i) => {\n        const y = 2 + i;\n        if (y >= H) return;\n        const done = el >= dur;\n        b.text(0, y, u ? (i === CALLS.length - 1 ? '└' : '├') : (i === CALLS.length - 1 ? '`' : '|'), th.line);\n        b.text(2, y, fit(tool, lw, undefined, ctx), done ? th.ink : th.muted, undefined, done ? 0 : ATTR.BOLD);\n        // Waterfall bar on a shared time axis.\n        const len = Math.max(1, Math.round((Math.min(el, dur) / SCALE) * tw));\n        b.fill(x0, y, tw, 1, u ? '·' : '.', tint(ctx, th.line, 0.8));\n        b.fill(x0, y, Math.min(len, tw), 1, u ? '━' : '=', done ? th.pos : th.accent);\n        if (!done) b.set(x0 + Math.min(len, tw - 1), y, spin(ctx, t + i * 0.3), th.warn);\n        const tail = done ? `${g.check} ${result}` : `${(el).toFixed(1)}s`;\n        b.text(x0 + tw + 1, y, fit(tail, Math.max(0, W - x0 - tw - 1), undefined, ctx).trimEnd(), done ? th.pos : th.dim);\n      });\n    };\n  },\n};\n",
  "prism-term/src/facets/ai/reasoning-summary.js": "// A collapsible reasoning block: \"Thinking…\" expands with summary lines\n// streaming in dim italics, collapses to \"Thought for 14s\", then the answer streams.\nimport { ATTR, glyphs, uni, phase, fit, mix, reveal, wrapLines, thinkGlyph, shimmer } from './_ai.js';\n\nconst THOUGHTS = [\n  'p99 jumped at 12:02, same minute as the v2.14.1 deploy.',\n  'The diff raises worker concurrency 8 -> 32 but the pool stays at 20.',\n  'Pool wait time tracks p99 almost exactly, so requests queue for connections.',\n  'Smallest safe fix: size the pool to the workers, alert on pool wait.',\n];\nconst ANSWER = 'The p99 spike is connection-pool starvation: v2.14.1 raised workers to 32 while the DB pool stayed at 20. Raise DB_POOL_SIZE to 40 (Postgres max_connections is 200, so there is headroom) and add an alert on pool wait > 200ms.';\nconst T = { think: 0.2, collapse: 4.6, answer: 5.0 };\n\nexport default {\n  id: 'ai-reasoning-summary',\n  title: 'Reasoning Summary',\n  category: 'Thinking States',\n  description: 'A collapsible reasoning block: thought summaries stream in dim italics while the model works, fold to \"Thought for 14s\", then the final answer streams below.',\n  tags: ['reasoning', 'thinking', 'collapsible', 'summary', 'chain of thought'],\n  kind: 'text',\n  size: { w: 64, h: 11 },\n  minSize: { w: 40, h: 7 },\n  loop: 11,\n  color: '256',\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx);\n      const p = phase(t, 11);\n      const open = p < T.collapse;\n      let y = 0;\n      if (open) {\n        const label = `Thinking${g.ellipsis}`;\n        b.text(0, y, g.triDown, th.muted);\n        b.text(2, y, thinkGlyph(ctx, t), th.accent, undefined, ATTR.BOLD);\n        [...label].forEach((ch, i) => b.set(4 + i, y, ch, shimmer(th.muted, th.ink, i, label.length, t), undefined, ATTR.BOLD));\n        y++;\n        // Thought lines reveal one after another.\n        let at = T.think;\n        for (const line of THOUGHTS) {\n          const r = reveal(line, p, at, 22);\n          if (!r.n) break;\n          for (const l of wrapLines(r.text, W - 4)) {\n            if (y >= H) break;\n            b.text(0, y, g.v, mix(th.bg, th.line, 0.9));\n            b.text(2, y++, fit(l, W - 2, undefined, ctx).trimEnd(), th.dim, undefined, ATTR.ITALIC);\n          }\n          at += r.total / 22 + 0.25;\n        }\n        return;\n      }\n      b.text(0, y, g.tri, th.muted);\n      b.text(2, y, 'Thought for 14s', th.muted, undefined, ATTR.BOLD);\n      b.text(18, y, `${g.mid} 4 steps`, th.dim);\n      y += 2;\n      const r = reveal(ANSWER, p, T.answer, 26);\n      const lines = wrapLines(r.text, W);\n      lines.slice(0, H - y).forEach((l, i) => b.text(0, y + i, l, th.ink));\n      if (!r.done && lines.length) {\n        const last = lines[Math.min(lines.length, H - y) - 1];\n        const ly = y + Math.min(lines.length, H - y) - 1;\n        if (ly < H) b.set(Math.min(W - 1, last.length), ly, uni(ctx) ? '▍' : '_', th.accent);\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/ai/sampling-probs.js": "// Next-token sampling: top-k candidates as probability bars that sharpen or\n// flatten as the temperature slides, the sampled token flashing and joining\n// the text above.\nimport { ATTR, glyphs, uni, phase, fit, mix, hbar, ease } from './_ai.js';\n\nconst PROMPT = 'The p99 spike is caused by DB';\n// Steps: [candidates with logits, chosen index]\nconst STEPS = [\n  [[[' pool', 3.1], [' connection', 1.9], [' lock', 0.9], [' query', 0.6], [' index', 0.1], [' CPU', -0.4]], 0],\n  [[[' exhaustion', 2.6], [' starvation', 2.2], [' limits', 0.9], [' size', 0.7], [' wait', 0.4], [' churn', -0.6]], 1],\n  [[['.', 3.4], [' after', 1.3], [' since', 1.1], [',', 0.8], [' under', 0.2], [' when', -0.2]], 0],\n];\nconst STEP = 2.6;\n\nconst softmax = (ls, T) => { const e = ls.map((l) => Math.exp(l / T)); const s = e.reduce((a, c) => a + c, 0); return e.map((v) => v / s); };\n\nexport default {\n  id: 'ai-sampling-probs',\n  title: 'Next-Token Sampling',\n  category: 'Model Internals',\n  description: 'Next-token sampling: top-k candidates as probability bars that sharpen or flatten as temperature slides, then the sampled token flashes and joins the text.',\n  tags: ['sampling', 'temperature', 'probabilities', 'top-k', 'logits'],\n  kind: 'chart',\n  size: { w: 60, h: 10 },\n  minSize: { w: 40, h: 9 },\n  loop: STEP * 3,\n  color: '256',\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, STEP * 3);\n      const k = Math.floor(p / STEP), local = p - k * STEP;\n      const [cands, pick] = STEPS[k];\n      // Temperature sweeps 0.4 -> 1.4 -> 0.8 within each step.\n      const T = 0.4 + ease(local, 0.1, 1.0) * 1.0 - ease(local, 1.1, 1.6) * 0.6;\n      const probs = softmax(cands.map((c) => c[1]), T);\n      const sampled = local >= 1.8;\n      // Text so far.\n      const text = PROMPT + STEPS.slice(0, k).map(([c, i]) => c[i][0]).join('');\n      let x = b.text(0, 0, fit(text, W - 12, undefined, ctx).trimEnd(), th.ink);\n      if (sampled) x = b.text(x, 0, cands[pick][0], th.bg, th.accent, ATTR.BOLD);\n      else b.set(x, 0, u ? '▍' : '_', th.accent);\n      // Temperature slider.\n      const sw = Math.min(20, W - 20);\n      b.text(0, 2, 'temperature', th.muted);\n      for (let i = 0; i < sw; i++) b.set(12 + i, 2, u ? '─' : '-', th.line);\n      b.set(12 + Math.round(((T - 0.2) / 1.4) * (sw - 1)), 2, u ? '●' : 'O', th.warn, undefined, ATTR.BOLD);\n      b.text(13 + sw, 2, T.toFixed(2), th.warn, undefined, ATTR.BOLD);\n      // Candidate bars.\n      const bw = Math.max(8, W - 26);\n      cands.forEach(([tok, ], i) => {\n        const y = 4 + i;\n        if (y >= H) return;\n        const on = sampled && i === pick;\n        const label = JSON.stringify(tok).slice(1, -1).replace(/^ /, u ? '·' : '_');\n        b.text(0, y, fit(label, 12, undefined, ctx), on ? th.accent : th.ink, undefined, on ? ATTR.BOLD : 0);\n        hbar(ctx, b, 13, y, bw, probs[i], on ? th.accent : mix(th.info, th.accent, probs[i]));\n        b.text(14 + bw, y, `${(probs[i] * 100).toFixed(1)}%`.padStart(6), on ? th.accent : th.muted, undefined, on ? ATTR.BOLD : 0);\n        if (on && 21 + bw < W) b.text(21 + bw, y, g.left, th.accent, undefined, ATTR.BOLD);\n      });\n    };\n  },\n};\n",
  "prism-term/src/facets/ai/status-orb.js": "// A voice-assistant style status orb on a hi-res canvas: it breathes when\n// idle, ripples while listening, swirls while thinking, rings while speaking.\nimport { ATTR, glyphs, phase, mix, span } from './_ai.js';\n\n// [start, state, tone, caption]\nconst STATES = [\n  [0, 'idle', 'muted', 'Ready'], [1.6, 'listening', 'info', 'Listening'], [3.4, 'thinking', 'accent', 'Thinking'],\n  [5.6, 'speaking', 'pos', 'Speaking'], [7.6, 'idle', 'muted', 'Ready'],\n];\n\nexport default {\n  id: 'ai-status-orb',\n  title: 'Status Orb',\n  category: 'Thinking States',\n  description: 'An assistant status orb on a hi-res canvas that breathes when idle, ripples while listening, swirls while thinking and pulses rings while speaking.',\n  tags: ['orb', 'voice', 'status', 'assistant', 'canvas'],\n  kind: 'scene',\n  canvas: 'hires',\n  size: { w: 36, h: 14 },\n  minSize: { w: 20, h: 8 },\n  loop: 9,\n  color: 'truecolor',\n  order: 40,\n  setup(ctx) {\n    const th = ctx.theme;\n    let cv = null;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx);\n      const p = phase(t, 9);\n      let k = 0;\n      STATES.forEach(([at], i) => { if (p >= at) k = i; });\n      const [at, state, tone, caption] = STATES[k];\n      const prevTone = STATES[Math.max(0, k - 1)][2];\n      const blendIn = span(p, at, at + 0.4);\n      const col = mix(th[prevTone], th[tone], blendIn);\n      if (!cv || cv.cols !== W || cv.rows !== H - 1) cv = ctx.canvas('hires', { cols: W, rows: H - 1 });\n      cv.clear();\n      const cx = cv.width / 2, cy = cv.height / 2;\n      // Pixels aren't square: measure y in x-pixel units so the orb reads as a circle.\n      const ay = cv.aspect;\n      const R = Math.min(cv.width / 2, (cv.height / 2) * ay) * 0.62;\n      const w = (n) => (2 * Math.PI * n) / 9;\n      for (let y = 0; y < cv.height; y++) {\n        for (let x = 0; x < cv.width; x++) {\n          const dx = x - cx, dy = (y - cy) * ay;\n          const d = Math.hypot(dx, dy), a = Math.atan2(dy, dx);\n          let r = R, glow = 0;\n          if (state === 'idle') r = R * (0.86 + 0.06 * Math.sin(w(3) * p));\n          if (state === 'listening') { r = R * (0.9 + 0.05 * Math.sin(a * 6 + w(9) * p)); glow = Math.max(0, 1 - Math.abs(((d - R * 1.05 - ((p * 9) % 6)) / 2))) * 0.6; }\n          if (state === 'thinking') r = R * (0.88 + 0.07 * Math.sin(a * 3 - w(6) * p) * Math.cos(a * 2 + w(3) * p));\n          if (state === 'speaking') { r = R * (0.88 + 0.08 * Math.abs(Math.sin(w(13) * p))); glow = Math.max(0, 1 - Math.abs(d - R * (1.15 + 0.25 * ((p * 1.5) % 1))) / 1.4) * (1 - ((p * 1.5) % 1)); }\n          if (d <= r) {\n            // Lit from the upper left, with a swirl while thinking.\n            const shade = 0.55 + 0.45 * Math.max(0, 1 - Math.hypot(dx + r * 0.35, dy + r * 0.35) / (r * 1.4));\n            const swirl = state === 'thinking' ? 0.18 * Math.sin(a * 2 + d * 0.35 - w(9) * p) : 0;\n            cv.set(x, y, mix(th.bg, col, Math.min(1, shade + swirl)));\n          } else if (glow > 0.05) cv.set(x, y, mix(th.bg, col, glow * 0.7));\n        }\n      }\n      cv.paint(b);\n      const label = state === 'thinking' || state === 'listening' ? `${caption}${g.ellipsis}` : caption;\n      b.text(Math.max(0, Math.floor((W - label.length) / 2)), H - 1, label, th[tone], undefined, ATTR.BOLD);\n    };\n  },\n};\n",
  "prism-term/src/facets/ai/structured-output.js": "// Structured output streaming: JSON arrives token by token, pretty-printed\n// with syntax color, while a schema checklist ticks off completed fields.\nimport { ATTR, glyphs, uni, phase, fit, reveal, stringWidth } from './_ai.js';\n\nconst OBJ = {\n  incident: 'INC-3127', severity: 'sev2', service: 'checkout-api', cause: 'db pool starvation', confidence: 0.87,\n  actions: [{ type: 'config', set: 'DB_POOL_SIZE=40' }, { type: 'alert', on: 'pool_wait_ms > 200' }],\n  needs_approval: true,\n};\n// Pretty-printed, with each action object kept on one line.\nconst JSON_TEXT = JSON.stringify(OBJ, null, 2).replace(/\\{\\n\\s+\"type\"/g, '{ \"type\"').replace(/,\\n\\s+\"(set|on)\"/g, ', \"$1\"').replace(/\"\\n\\s+\\}/g, '\" }');\nconst FIELDS = Object.keys(OBJ);\n// Where each top-level field's value ends in the text (it's complete once revealed past this).\nconst ENDS = FIELDS.map((k, i) => {\n  const at = JSON_TEXT.indexOf(`\"${k}\"`);\n  const next = i + 1 < FIELDS.length ? JSON_TEXT.indexOf(`\"${FIELDS[i + 1]}\"`) : JSON_TEXT.lastIndexOf('}');\n  return [at, next];\n});\n\nexport default {\n  id: 'ai-structured-output',\n  title: 'Structured Output',\n  category: 'Token Streams',\n  description: 'Structured output streaming in: JSON arrives token by token, pretty-printed with syntax color, while a schema checklist ticks off each completed field.',\n  tags: ['json', 'structured output', 'schema', 'streaming', 'tool use'],\n  kind: 'text',\n  size: { w: 72, h: 14 },\n  minSize: { w: 44, h: 10 },\n  loop: 9,\n  color: '16',\n  order: 40,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 9);\n      const r = reveal(JSON_TEXT, p, 0.3, 30);\n      const shown = r.text;\n      const side = W >= 64;\n      const jw = side ? W - 20 : W;\n      const lines = shown.split('\\n');\n      const first = Math.max(0, lines.length - (H - 1));\n      lines.slice(first).forEach((line, y) => {\n        let x = 0;\n        // Tokenize one line of JSON for color.\n        for (const m of line.matchAll(/(\"(?:[^\"\\\\]|\\\\.)*\"?)(\\s*:)?|(-?\\d+(?:\\.\\d+)?)|(true|false|null)|([{}[\\],])|(\\s+)|(.)/g)) {\n          const [tok, str, colon, num, lit, punct] = m;\n          let c = th.ink;\n          if (str) c = colon ? th.accent : th.pos;\n          else if (num) c = th.warn;\n          else if (lit) c = th.crit;\n          else if (punct) c = th.dim;\n          const room = Math.max(0, jw - x);\n          if (!room) break;\n          const piece = stringWidth(tok) <= room ? tok : tok.slice(0, room);\n          if (str && colon) { x = b.text(x, y, piece.slice(0, piece.length - colon.length), c); x = b.text(x, y, piece.slice(piece.length - colon.length), th.dim); }\n          else x = b.text(x, y, piece, c);\n        }\n      });\n      if (!r.done && lines.length) {\n        const ly = lines.length - 1 - first;\n        b.set(Math.min(jw - 1, lines[lines.length - 1].length), ly, u ? '▍' : '_', th.accent);\n      }\n      // Schema checklist.\n      const done = FIELDS.map((_, i) => shown.length >= ENDS[i][1]);\n      const started = FIELDS.map((_, i) => shown.length > ENDS[i][0]);\n      if (side) {\n        const sx = W - 18;\n        for (let y = 0; y < H - 1; y++) b.set(sx - 2, y, u ? '│' : '|', th.line);\n        b.text(sx, 0, fit('IncidentSummary', W - sx, undefined, ctx), th.ink, undefined, ATTR.BOLD);\n        FIELDS.forEach((k, i) => {\n          const y = 2 + i;\n          if (y >= H - 1) return;\n          const mark = done[i] ? g.check : started[i] ? g.tri : g.ring;\n          b.text(sx, y, mark, done[i] ? th.pos : started[i] ? th.accent : th.dim, undefined, ATTR.BOLD);\n          b.text(sx + 2, y, fit(k, W - sx - 2, undefined, ctx).trimEnd(), done[i] ? th.ink : th.muted);\n        });\n      }\n      const n = done.filter(Boolean).length;\n      const msg = r.done ? `${g.check} valid against IncidentSummary ${g.mid} ${FIELDS.length}/${FIELDS.length} fields ${g.mid} needs_approval: true`\n        : `streaming ${g.mid} ${n}/${FIELDS.length} fields ${g.mid} ${r.n}/${r.total} tokens`;\n      b.text(0, H - 1, fit(msg, W, undefined, ctx), r.done ? th.pos : th.muted);\n    };\n  },\n};\n",
  "prism-term/src/facets/ai/task-plan.js": "// An agent's task plan as a live checklist: items move from pending to\n// in progress (spinner, bold) to done (ticked, dimmed), with timings.\nimport { ATTR, glyphs, uni, phase, fit, spin } from './_ai.js';\n\n// [task, start, end] in seconds; the approval step waits on a human.\nconst TASKS = [\n  ['Reproduce the p99 spike from traces', 0.3, 1.4],\n  ['Correlate p99 with deploys and pool metrics', 1.4, 2.9],\n  ['Draft a fix: DB_POOL_SIZE 20 -> 40', 2.9, 4.0],\n  ['Get approval from on-call', 4.0, 5.6],\n  ['Apply and verify p99 < 300ms', 5.6, 7.4],\n];\n\nexport default {\n  id: 'ai-task-plan',\n  title: 'Task Plan',\n  category: 'Agents & Pipelines',\n  description: 'An agent\\'s plan as a live checklist: each task moves from pending to in progress with a spinner to done and dimmed, with timings and a waiting-on-human step.',\n  tags: ['plan', 'todo', 'checklist', 'agent', 'progress'],\n  kind: 'tui',\n  size: { w: 60, h: 8 },\n  minSize: { w: 40, h: 7 },\n  loop: 9,\n  color: '16',\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 9);\n      const n = TASKS.filter(([, , z]) => p >= z).length;\n      b.text(0, 0, u ? '●' : '*', n === TASKS.length ? th.pos : th.accent, undefined, ATTR.BOLD);\n      const x = b.text(2, 0, 'Plan', th.ink, undefined, ATTR.BOLD);\n      b.text(x + 1, 0, `${n}/${TASKS.length} done`, th.muted);\n      TASKS.forEach(([task, a, z], i) => {\n        const y = 1 + i;\n        if (y >= H - (H > TASKS.length + 2 ? 1 : 0)) return;\n        const state = p >= z ? 'done' : p >= a ? 'run' : 'todo';\n        const waiting = state === 'run' && i === 3;\n        b.text(2, y, i === 0 ? (u ? '⎿' : '\\\\_') : '  ', th.dim);\n        const mark = state === 'done' ? g.check : state === 'run' ? (waiting ? (u ? '‖' : '=') : spin(ctx, t)) : (u ? '☐' : '[ ]');\n        const mx = b.text(5, y, mark, state === 'done' ? th.pos : waiting ? th.warn : state === 'run' ? th.accent : th.dim, undefined, ATTR.BOLD);\n        const label = u ? task.replace('->', '→') : task;\n        const tail = state === 'done' ? `${(z - a).toFixed(1)}s` : waiting ? 'waiting on you' : state === 'run' ? `${(p - a).toFixed(1)}s` : '';\n        const room = W - mx - 2 - (tail ? tail.length + 1 : 0);\n        b.text(mx + 1, y, fit(label, Math.max(0, room), undefined, ctx).trimEnd(),\n          state === 'done' ? th.dim : state === 'run' ? th.ink : th.muted, undefined,\n          state === 'done' ? ATTR.STRIKE : state === 'run' ? ATTR.BOLD : 0);\n        if (tail) b.text(W - tail.length, y, tail, waiting ? th.warn : th.dim);\n      });\n      if (H > TASKS.length + 2) {\n        const msg = n === TASKS.length ? `${g.check} incident mitigated in 7.1s of agent time` : `${g.mid} ctrl+t to hide plan`;\n        b.text(0, H - 1, fit(msg, W, undefined, ctx), n === TASKS.length ? th.pos : th.dim);\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/ai/thinking-shimmer.js": "// A working indicator: a breathing glyph, a status verb with a shimmer band\n// sweeping across it, elapsed time and token count, finished steps above.\nimport { ATTR, glyphs, phase, fit, mix, thinkGlyph, shimmer, stringWidth } from './_ai.js';\n\n// [start, verb, finished-step summary shown once the next verb starts]\nconst STEPS = [\n  [0, 'Reading the trace', 'Read trace 4bf92f35 (31 spans)'],\n  [2.2, 'Correlating spans with pool metrics', 'Correlated p99 with pool wait (r=0.94)'],\n  [4.6, 'Checking the deploy diff', 'v2.14.1 raised workers to 32, pool left at 20'],\n  [7.0, 'Drafting a fix', ''],\n];\n\nexport default {\n  id: 'ai-thinking-shimmer',\n  title: 'Thinking Shimmer',\n  category: 'Thinking States',\n  description: 'An agent working indicator: a breathing glyph and a status verb with a shimmer sweeping across it, elapsed time and tokens, finished steps ticking off above.',\n  tags: ['thinking', 'shimmer', 'working', 'spinner', 'agent'],\n  kind: 'spinner',\n  size: { w: 64, h: 6 },\n  minSize: { w: 36, h: 3 },\n  loop: 9.5,\n  color: '256',\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx);\n      const p = phase(t, 9.5);\n      let k = 0;\n      STEPS.forEach(([at], i) => { if (p >= at) k = i; });\n      // Finished steps above the live line (as many as fit).\n      const done = STEPS.slice(0, k).map((s) => s[2]);\n      const rows = done.slice(-(H - 1));\n      rows.forEach((text, r) => {\n        b.text(0, r, g.check, th.pos, undefined, ATTR.BOLD);\n        b.text(2, r, fit(text, W - 2, undefined, ctx), th.muted);\n      });\n      const y = rows.length;\n      const verb = `${STEPS[k][1]}${g.ellipsis}`;\n      b.text(0, y, thinkGlyph(ctx, t), th.accent, undefined, ATTR.BOLD);\n      const vw = Math.min(stringWidth(verb), W - 2);\n      [...verb].slice(0, vw).forEach((ch, i) => b.set(2 + i, y, ch, shimmer(mix(th.bg, th.accent, 0.75), th.ink, i, vw, t), undefined, ATTR.BOLD));\n      const secs = Math.floor(p * 1.6) + 3;\n      const toks = (0.4 + p * 0.31).toFixed(1);\n      const meta = `(${secs}s ${g.mid} ${g.down} ${toks}k tokens ${g.mid} esc to interrupt)`;\n      if (2 + vw + 1 + stringWidth(meta) <= W) b.text(3 + vw, y, meta, th.dim);\n      else if (y + 1 < H) b.text(2, y + 1, fit(meta, W - 2, undefined, ctx), th.dim);\n    };\n  },\n};\n",
  "prism-term/src/facets/ai/throughput-meter.js": "// An inference throughput panel: concurrent streams with progress and\n// per-stream rates, an aggregate tok/s sparkline, TTFT, cache hits and cost.\nimport { ATTR, glyphs, uni, phase, fit, hbar, panel, mix } from './_ai.js';\n\n// [id, output tokens, start, tok/s, ttft ms]\nconst REQS = [['7f3a', 900, 0.0, 54, 280], ['2c91', 700, 0.8, 61, 340], ['9be0', 480, -4.0, 48, 310], ['d41e', 620, 3.2, 66, 260]];\nconst LOOP = 10;\n\nexport default {\n  id: 'ai-throughput-meter',\n  title: 'Throughput Meter',\n  category: 'Token Streams',\n  description: 'An inference panel with concurrent streams, per-stream progress and tok/s, an aggregate throughput sparkline, TTFT, cache hit rate and a running cost.',\n  tags: ['throughput', 'tok/s', 'inference', 'dashboard', 'latency'],\n  kind: 'widget',\n  size: { w: 70, h: 8 },\n  minSize: { w: 50, h: 8 },\n  loop: LOOP,\n  color: '256',\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, LOOP);\n      const state = (r, at) => {\n        const [, total, start, rate, ttft] = r;\n        const el = at - start - ttft / 1000;\n        if (at < start) return { s: 'queued', n: 0 };\n        if (el < 0) return { s: 'ttft', n: 0 };\n        const n = Math.min(total, Math.floor(el * rate * (1 + 0.15 * Math.sin(el * 3))));\n        return { s: n >= total ? 'done' : 'stream', n };\n      };\n      const aggregate = (at) => REQS.reduce((sum, r) => sum + (state(r, at).s === 'stream' ? r[3] : 0), 0);\n      const inner = panel(ctx, b, 0, 0, W, H, { title: `reasoning-large ${g.mid} ${REQS.filter((r) => state(r, p).s === 'stream').length} streaming`, footer: `$${(0.0112 + p * 0.0031).toFixed(4)}`, color: th.line });\n      const iw = inner.width;\n      REQS.forEach((r, i) => {\n        const [id, total, , rate, ttft] = r;\n        const st = state(r, p);\n        const y = i;\n        if (y >= inner.height - 2) return;\n        inner.text(0, y, `req ${id}`, st.s === 'done' ? th.dim : th.ink, undefined, st.s === 'stream' ? ATTR.BOLD : 0);\n        const bw = Math.max(6, iw - 40);\n        hbar(ctx, inner, 9, y, bw, st.n / total, st.s === 'done' ? th.pos : th.accent);\n        let x = 10 + bw;\n        x = inner.text(x, y, `${String(st.n).padStart(3)}/${total}`, th.muted) + 2;\n        const tail = st.s === 'done' ? `${g.check} done` : st.s === 'ttft' ? `waiting ${ttft}ms` : st.s === 'queued' ? 'queued' : `${rate} t/s  ttft ${ttft}ms`;\n        inner.text(x, y, fit(tail, Math.max(0, iw - x), undefined, ctx).trimEnd(), st.s === 'done' ? th.pos : st.s === 'stream' ? th.ink : th.dim);\n      });\n      // Aggregate throughput sparkline (last ~5 s).\n      const sy = inner.height - 2;\n      const LV = u ? ' ▁▂▃▄▅▆▇█' : ' .:-=+*#%';\n      inner.text(0, sy, 'throughput', th.muted);\n      const sw = Math.max(8, iw - 26);\n      for (let i = 0; i < sw; i++) {\n        const v = aggregate(p - (sw - 1 - i) * 0.2) / 200;\n        inner.set(11 + i, sy, LV[Math.max(1, Math.min(8, Math.round(v * 8)))], mix(th.info, th.accent, v));\n      }\n      inner.text(12 + sw, sy, `${String(aggregate(p)).padStart(3)} tok/s`, th.ink, undefined, ATTR.BOLD);\n      inner.text(0, sy + 1, fit(`cache hits 62% ${g.mid} input 18.2k ${g.mid} output ${(1.1 + p * 0.09).toFixed(1)}k ${g.mid} p95 ttft 340ms`, iw, undefined, ctx), th.dim);\n    };\n  },\n};\n",
  "prism-term/src/facets/ai/token-stream.js": "// A response streaming token by token: each new token flashes, the rate\n// wobbles like a real stream, then a tokenizer view tints token boundaries.\nimport { ATTR, glyphs, uni, phase, mix, fit, tint, tokenize } from './_ai.js';\n\nconst TEXT = 'Checkout latency is up because the DB pool is exhausted. Since v2.14.1 the service runs 32 workers but still opens only 20 connections, so about a third of requests wait in the pool queue. Raising the pool to 40 should bring p99 back under 300ms.';\nconst TOKS = tokenize(TEXT);\nconst START = 0.4, TOKVIEW = 6.2, RATE = 14;\n\nexport default {\n  id: 'ai-token-stream',\n  title: 'Token Stream',\n  category: 'Token Streams',\n  description: 'A response streaming token by token with each new token flashing, a live tok/s sparkline and TTFT, then a tokenizer view that tints every token boundary.',\n  tags: ['tokens', 'streaming', 'llm', 'tokenizer', 'tok/s'],\n  kind: 'text',\n  size: { w: 64, h: 9 },\n  minSize: { w: 36, h: 6 },\n  loop: 9,\n  color: '256',\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme;\n    const PAL = ['accent', 'pos', 'warn', 'info', 'crit'];\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 9);\n      // Warped clock so the rate wobbles: bursts and short stalls.\n      const tp = Math.max(0, p - START);\n      const warped = tp + 0.12 * Math.sin(tp * 4.1) + 0.06 * Math.sin(tp * 9.3);\n      const n = Math.min(TOKS.length, Math.max(0, Math.floor(warped * RATE)));\n      const tokView = p >= TOKVIEW;\n      // Lay tokens out with word wrap, keeping each token whole.\n      const bodyH = H - 2;\n      let x = 0, y = 0;\n      const cells = [];\n      for (let i = 0; i < n; i++) {\n        let tok = TOKS[i];\n        // Wrap whole words: a word runs until the next token with a leading space.\n        if (/^\\s/.test(tok) || i === 0) {\n          let wl = tok.length;\n          for (let j = i + 1; j < TOKS.length && !/^\\s/.test(TOKS[j]); j++) wl += TOKS[j].length;\n          if (x + wl > W && x > 0) { y++; x = 0; tok = tok.replace(/^\\s+/, ''); }\n        }\n        cells.push([x, y, tok, i]);\n        x += tok.length;\n      }\n      const first = Math.max(0, y - bodyH + 1);\n      for (const [cx, cy, tok, i] of cells) {\n        if (cy < first) continue;\n        const age = n - 1 - i;\n        let fg = th.ink, bg;\n        if (tokView) { bg = tint(ctx, th[PAL[i % PAL.length]], 0.3); }\n        else if (age < 3) { bg = tint(ctx, th.accent, 0.35 - age * 0.1); fg = mix(th.ink, th.accent, 0.3); }\n        b.text(cx, cy - first, tok, fg, bg, tokView ? 0 : age === 0 ? ATTR.BOLD : 0);\n        // Mono-safe boundary marks in tokenizer view.\n        if (tokView && !u) b.set(cx, cy - first, tok[0] === ' ' ? '|' : tok[0], fg, bg);\n      }\n      if (n < TOKS.length && n > 0) {\n        const [lx, ly, lt] = cells[cells.length - 1];\n        if (ly - first < bodyH) b.set(Math.min(W - 1, lx + lt.length), ly - first, u ? '▍' : '_', th.accent);\n      }\n      // Stats.\n      const sy = H - 1;\n      b.fill(0, sy - 1, W, 1, u ? '─' : '-', th.line);\n      const rate = n >= TOKS.length ? 0 : Math.round(RATE * (1 + 0.48 * Math.cos(tp * 4.1) + 0.56 * Math.cos(tp * 9.3)));\n      const stats = tokView\n        ? `view: tokens ${g.mid} ${TOKS.length} tokens ${g.mid} ${TEXT.length} chars ${g.mid} ${(TEXT.length / TOKS.length).toFixed(1)} chars/token`\n        : `TTFT 380ms ${g.mid} ${String(Math.max(0, rate)).padStart(2)} tok/s ${g.mid} ${n}/${TOKS.length} tokens`;\n      let sx = b.text(0, sy, fit(stats, W, undefined, ctx).trimEnd(), tokView ? th.info : th.muted);\n      // Rate sparkline on the right while streaming.\n      if (!tokView && sx + 14 <= W) {\n        const LV = u ? '▁▂▃▄▅▆▇█' : '.:-=+*#%';\n        for (let i = 0; i < 12; i++) {\n          const tt = tp - (11 - i) * 0.25;\n          const r = tt < 0 || n >= TOKS.length && tt > TOKS.length / RATE ? 0 : 1 + 0.48 * Math.cos(tt * 4.1) + 0.56 * Math.cos(tt * 9.3);\n          b.set(W - 12 + i, sy, LV[Math.max(0, Math.min(7, Math.round(r * 3)))], r ? th.accent : th.dim);\n        }\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/ai/tool-calls.js": "// An agent's tool calls stacking up, coding-agent style: each call shows a\n// spinner while it runs, then collapses to a one-line result under a ⎿ elbow.\nimport { ATTR, glyphs, uni, phase, fit, spin, stringWidth } from './_ai.js';\n\n// [start, duration, tool, args, result, extra lines]\nconst CALLS = [\n  [0, 0.9, 'Read', 'src/db/pool.js', 'Read 24 lines', 0],\n  [1.5, 1.2, 'Bash', 'kubectl top pods -n checkout', '3 pods, cpu 41-58%', 12],\n  [3.0, 0.8, 'Grep', 'pattern: \"POOL_SIZE\"', 'Found 3 files', 0],\n  [4.1, 1.4, 'Query', 'pool_wait_ms p99 [30m]', '840ms (was 12ms before 12:02)', 0],\n];\n\nexport default {\n  id: 'ai-tool-call-card',\n  title: 'Tool Calls',\n  category: 'Tool Calls',\n  description: 'An agent\\'s tool calls stacking up: each shows a spinner and its arguments while running, then collapses to a one-line result, with long output folded away.',\n  tags: ['tool calls', 'agent', 'function calling', 'coding agent', 'spinner'],\n  kind: 'tui',\n  size: { w: 64, h: 12 },\n  minSize: { w: 40, h: 8 },\n  loop: 8,\n  color: '16',\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 8);\n      const rows = [];\n      for (const [at, dur, tool, args, result, extra] of CALLS) {\n        if (p < at) break;\n        const running = p < at + dur;\n        rows.push({ kind: 'call', tool, args, running });\n        if (running) rows.push({ kind: 'wait', text: tool === 'Bash' ? 'Running' : tool === 'Query' ? 'Querying metrics' : 'Working' });\n        else {\n          rows.push({ kind: 'result', text: result });\n          if (extra) rows.push({ kind: 'more', text: `${g.ellipsis} +${extra} lines (ctrl+o to expand)` });\n        }\n        rows.push({ kind: 'gap' });\n      }\n      const first = Math.max(0, rows.length - H);\n      rows.slice(first).forEach((row, y) => {\n        if (row.kind === 'call') {\n          b.text(0, y, row.running ? spin(ctx, t) : (u ? '●' : '*'), row.running ? th.warn : th.pos, undefined, ATTR.BOLD);\n          const x = b.text(2, y, row.tool, th.ink, undefined, ATTR.BOLD);\n          const a = `(${row.args})`;\n          b.text(x, y, stringWidth(a) <= W - x ? a : fit(a, W - x, undefined, ctx), th.muted);\n        } else if (row.kind === 'result' || row.kind === 'wait' || row.kind === 'more') {\n          b.text(2, y, u ? '⎿' : '\\\\_', th.dim);\n          const c = row.kind === 'wait' ? th.warn : row.kind === 'more' ? th.dim : th.ink;\n          b.text(5, y, fit(row.kind === 'wait' ? `${row.text}${g.ellipsis}` : row.text, W - 5, undefined, ctx).trimEnd(), c, undefined, row.kind === 'more' ? ATTR.ITALIC : 0);\n        }\n      });\n    };\n  },\n};\n",
  "prism-term/src/facets/ai/typing-indicators.js": "// A sampler of \"the model is working\" indicators, each labeled:\n// bouncing dots, a braille wave, a pulse, an orbit, a scanner and a meter.\nimport { ATTR, uni, phase, mix, fit } from './_ai.js';\n\nconst BRAILLE_LV = ['⡀', '⡄', '⡆', '⡇', '⡏', '⡟', '⡿', '⣿'];\n\nexport default {\n  id: 'ai-typing-indicators',\n  title: 'Typing Indicators',\n  category: 'Thinking States',\n  description: 'Six labeled \"model is working\" indicators side by side: a chat bubble of bouncing dots, a braille wave, a pulse, an orbit, a scanner and an equalizer.',\n  tags: ['typing indicator', 'dots', 'wave', 'loading', 'chat'],\n  kind: 'spinner',\n  size: { w: 66, h: 4 },\n  minSize: { w: 33, h: 4 },\n  loop: 2.4,\n  color: '256',\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, u = uni(ctx);\n      const p = phase(t, 2.4);\n      // Whole cycles per loop, so every indicator repeats seamlessly.\n      const w = (k) => (2 * Math.PI * k) / 2.4;\n      const cells = [\n        ['bounce', (s, x, y) => {\n          // Chat bubble with three dots rising in turn.\n          s.text(x, y, u ? '╭───────╮' : '+-------+', th.line);\n          s.text(x, y + 1, u ? '│       │' : '|       |', th.line);\n          s.text(x, y + 2, u ? '╰─┬─────╯' : '+-+-----+', th.line);\n          for (let i = 0; i < 3; i++) {\n            const k = Math.max(0, Math.sin((p / 2.4) * Math.PI * 4 - i * 0.9));\n            s.set(x + 2 + i * 2, y + 1, u ? (k > 0.6 ? '●' : k > 0.2 ? '•' : '∙') : (k > 0.6 ? 'O' : k > 0.2 ? 'o' : '.'), mix(th.muted, th.accent, k), undefined, ATTR.BOLD);\n          }\n        }],\n        ['wave', (s, x, y) => {\n          for (let i = 0; i < 9; i++) {\n            const v = (Math.sin(w(2) * p - i * 0.7) + 1) / 2;\n            s.set(x + i, y + 1, u ? BRAILLE_LV[Math.round(v * 7)] : ' .:-=+*#'[Math.round(v * 7)], mix(th.info, th.accent, v));\n          }\n        }],\n        ['pulse', (s, x, y) => {\n          const k = (Math.sin(w(2) * p) + 1) / 2;\n          const ring = u ? ['∙', '•', '●', '◉', '◎'][Math.round(k * 4)] : ['.', 'o', 'O', '@', '0'][Math.round(k * 4)];\n          s.set(x + 4, y + 1, ring, mix(th.dim, th.accent, k), undefined, ATTR.BOLD);\n          s.text(x, y + 1, u ? '·  ' : '.  ', mix(th.bg, th.accent, k * 0.6));\n          s.text(x + 6, y + 1, u ? '  ·' : '  .', mix(th.bg, th.accent, k * 0.6));\n        }],\n        ['orbit', (s, x, y) => {\n          const pts = [[4, 0], [6, 1], [4, 2], [2, 1]];\n          pts.forEach(([dx, dy], i) => {\n            const lead = Math.floor(p * 5) % 4;\n            const age = (lead - i + 4) % 4;\n            s.set(x + dx, y + dy, u ? (age === 0 ? '●' : '∙') : (age === 0 ? 'O' : '.'), age === 0 ? th.accent : mix(th.bg, th.accent, 0.6 - age * 0.15), undefined, ATTR.BOLD);\n          });\n          s.set(x + 4, y + 1, u ? '✳' : '*', th.muted);\n        }],\n        ['scan', (s, x, y) => {\n          const n = 9, pos = Math.round((Math.sin(w(1) * p) + 1) / 2 * (n - 1));\n          for (let i = 0; i < n; i++) {\n            const d = Math.abs(i - pos);\n            s.set(x + i, y + 1, u ? (d === 0 ? '█' : d === 1 ? '▓' : d === 2 ? '▒' : '░') : (d === 0 ? '#' : d === 1 ? '=' : '-'), d < 3 ? mix(th.accent, th.bg, d * 0.3) : th.line);\n          }\n        }],\n        ['meter', (s, x, y) => {\n          const LV = u ? ' ▁▂▃▄▅▆▇█' : ' ..::==##';\n          for (let i = 0; i < 7; i++) {\n            const v = (Math.sin(w(2 + (i % 3)) * p + i) + 1) / 2;\n            s.set(x + 1 + i, y + 1, LV[1 + Math.round(v * 7)], mix(th.pos, th.warn, v));\n          }\n        }],\n      ];\n      // Lay the cells out in as many columns as fit (11 columns each).\n      const cw = 11, cols = Math.max(1, Math.floor(W / cw)), rowH = 4;\n      const rows = Math.max(1, Math.floor(H / rowH));\n      const n = Math.min(cells.length, cols * rows);\n      const x0 = Math.max(0, Math.floor((W - Math.min(cols, n) * cw) / 2));\n      for (let i = 0; i < n; i++) {\n        const [name, draw] = cells[i];\n        const cx = x0 + (i % cols) * cw, cy = Math.floor(i / cols) * rowH;\n        draw(b, cx, cy);\n        b.text(cx, cy + 3, fit(name, cw - 1, undefined, ctx).trimEnd(), th.dim);\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/ascii/_ascii.js": "// Shared helpers for the Pure ASCII gallery. Everything here emits printable\n// 7-bit ASCII only (0x20–0x7e), so facets built on it run on a VT100, a serial\n// console, TERM=linux or LANG=C without a single byte of mojibake.\n\nimport { layoutText } from '../../text/banner.js';\n\nexport const RAMP = ' .:-=+*#%@';\nexport const RAMP_DONUT = '.,-~:;=!*#$@';\nexport const RAMP_FIRE = ' .:*sS#$';\n\n/** Deterministic hash of up to three ints → [0, 1). */\nexport function hash(a, b = 0, c = 0) {\n  let h = Math.imul((a | 0) ^ 0x9e3779b9, 0x85ebca6b) ^ Math.imul((b | 0) + 0x632be5ab, 0xc2b2ae35) ^ Math.imul((c | 0) + 0x165667b1, 0x27d4eb2f);\n  h ^= h >>> 15;\n  h = Math.imul(h, 0x2c1b3c6d);\n  h ^= h >>> 12;\n  return (h >>> 0) / 4294967296;\n}\n\n/** Replace anything outside printable ASCII with '?' (defensive; art is authored ASCII). */\nexport const ascii = (s) => String(s).replace(/[^\\x20-\\x7e]/g, '?');\n\n/**\n * Draw multi-line ASCII art at (x, y). Spaces are transparent unless `opaque`.\n * `color` is a packed color or a function (ch, col, row) → color.\n */\nexport function sprite(b, x, y, lines, color, { opaque = false, attr } = {}) {\n  x = Math.round(x); y = Math.round(y);\n  for (let r = 0; r < lines.length; r++) {\n    const line = lines[r];\n    for (let c = 0; c < line.length; c++) {\n      const ch = line[c];\n      if (ch === ' ' && !opaque) continue;\n      const fg = typeof color === 'function' ? color(ch, c, r) : color;\n      b.set(x + c, y + r, ch, fg, undefined, attr);\n    }\n  }\n}\n\nexport const spriteWidth = (lines) => Math.max(0, ...lines.map((l) => l.length));\n\n/** Mirror ASCII art horizontally, swapping directional glyphs. */\nexport function mirror(lines) {\n  const swap = { '/': '\\\\', '\\\\': '/', '(': ')', ')': '(', '<': '>', '>': '<', '[': ']', ']': '[', '{': '}', '}': '{', '`': \"'\", \"'\": '`' };\n  const w = spriteWidth(lines);\n  return lines.map((l) => [...l.padEnd(w)].reverse().map((ch) => swap[ch] || ch).join(''));\n}\n\n/** Character for a line segment's direction (cells are ~2× taller than wide). */\nexport function slopeChar(dx, dy) {\n  const ax = Math.abs(dx), ay = Math.abs(dy) * 2;\n  if (ax > ay * 2.2) return '-';\n  if (ay > ax * 2.2) return '|';\n  return (dx > 0) === (dy > 0) ? '\\\\' : '/';\n}\n\n/**\n * Bresenham line in cells. Each cell's glyph follows the step it takes to the\n * next cell — x-only → '-', y-only → '|', diagonal → '/' or '\\' — so shallow\n * and steep lines read as \"--/--/\" and \"|/|/\" instead of doubled slashes.\n * Pass `ch` to force one glyph.\n */\nexport function line(b, x0, y0, x1, y1, color, ch) {\n  x0 = Math.round(x0); y0 = Math.round(y0); x1 = Math.round(x1); y1 = Math.round(y1);\n  const dx = Math.abs(x1 - x0), dy = -Math.abs(y1 - y0);\n  const sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1;\n  const diag = sx === sy ? '\\\\' : '/';\n  let err = dx + dy;\n  for (let n = 0; n < 4000; n++) {\n    if (x0 === x1 && y0 === y1) { b.set(x0, y0, ch || (dx > -dy * 2 ? '-' : dx * 2 < -dy ? '|' : diag), color); break; }\n    const e2 = 2 * err;\n    const mx = e2 >= dy, my = e2 <= dx;\n    b.set(x0, y0, ch || (mx && my ? diag : mx ? '-' : '|'), color);\n    if (mx) { err += dy; x0 += sx; }\n    if (my) { err += dx; y0 += sy; }\n  }\n}\n\n/** Centered text on a row (clipped by the surface). */\nexport function center(b, y, s, color, attr) {\n  const str = ascii(s);\n  b.text(Math.floor((b.width - str.length) / 2), y, str, color, undefined, attr);\n}\n\n/** ASCII box: +---+ / |   | / +---+ with an optional title. */\nexport function box(b, x, y, w, h, color, title) {\n  if (w < 2 || h < 2) return;\n  b.text(x, y, '+' + '-'.repeat(w - 2) + '+', color);\n  for (let r = 1; r < h - 1; r++) { b.set(x, y + r, '|', color); b.set(x + w - 1, y + r, '|', color); }\n  b.text(x, y + h - 1, '+' + '-'.repeat(w - 2) + '+', color);\n  if (title && w > title.length + 4) b.text(x + 2, y, ` ${ascii(title)} `, color);\n}\n\n/** Triangle wave in [0, span] with the given speed (cells/second); also returns the bounce count. */\nexport function bounce(t, span, speed, phase = 0) {\n  if (span <= 0) return { pos: 0, hits: 0 };\n  const d = Math.abs(t * speed + phase);\n  const hits = Math.floor(d / span);\n  const r = d - hits * span;\n  return { pos: hits % 2 === 0 ? r : span - r, hits };\n}\n\n// ── bitmap text helpers ─────────────────────────────────────────────────────\n\n/** Rasterize text with a Prism bitmap font into rows of 0/1 (optionally x-stretched). */\nexport function bitmap(text, font, stretch = 1) {\n  const lay = layoutText(text, { font });\n  const w = lay.width * stretch, h = lay.height;\n  const px = Array.from({ length: h }, () => new Uint8Array(w));\n  for (const l of lay.lines) for (const { gl, x } of l.glyphs) {\n    for (let gy = 0; gy < gl.h; gy++) for (let gx = 0; gx < gl.w; gx++) {\n      if (!gl.bits[gy * gl.w + gx]) continue;\n      for (let s = 0; s < stretch; s++) px[l.y + gy][(l.x + x + gx) * stretch + s] = 1;\n    }\n  }\n  return { w, h, px };\n}\n\n// Marching squares over the pixel lattice → a figlet-style outline in _ and |.\n// Index = TL·8 + TR·4 + BL·2 + BR.\nconst OUTLINE = [' ', ' ', ' ', '_', '|', '|', '|', '|', '|', '|', '|', '|', '_', '_', '_', ' '];\n\n/** Figlet-style outline lines for text. */\nexport function figlet(text, font = 'standard') {\n  const { w, h, px } = bitmap(text, font, 2);\n  const at = (x, y) => (x >= 0 && y >= 0 && x < w && y < h ? px[y][x] : 0);\n  const out = [];\n  for (let y = 0; y <= h; y++) {\n    let s = '';\n    for (let x = 0; x <= w; x++) s += OUTLINE[at(x - 1, y - 1) * 8 + at(x, y - 1) * 4 + at(x - 1, y) * 2 + at(x, y)];\n    out.push(s);\n  }\n  return out;\n}\n\n/** banner(1)-style block letters made of a single character. */\nexport function blockText(text, font, ch = '#') {\n  const { w, h, px } = bitmap(text, font, 2);\n  return px.map((row) => Array.from(row, (v) => (v ? ch : ' ')).join('')).slice(0, h);\n}\n",
  "prism-term/src/facets/ascii/animations.js": "// Pure ASCII — animations. Every glyph is 7-bit ASCII; color is optional\n// (each facet still reads in mono). Classics re-implemented from first\n// principles: donut.c, the aalib-era fire, a demo starfield, plasma, and more.\nimport { RAMP, RAMP_DONUT, RAMP_FIRE, hash, line, bounce } from './_ascii.js';\nimport { mix } from '../../color.js';\n\nconst shared = { gallery: 'ascii', glyphs: 'ascii', color: '16', size: { w: 80, h: 24 }, minSize: { w: 32, h: 10 } };\n\nconst donut = {\n  ...shared,\n  id: 'ascii-donut',\n  title: 'donut.c',\n  category: 'ASCII Animations',\n  kind: 'art',\n  tags: ['donut', 'donut.c', 'torus', 'classic', '3d'],\n  order: 10,\n  loop: 10,\n  description: 'The canonical spinning ASCII torus: z-buffered, lit with the twelve-step .,-~:;=!*#$@ ramp, scaled to any window.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const z = new Float32Array(W * H);\n    const lum = new Int8Array(W * H);\n    const k = Math.min(W / 80, H / 22);\n    const sx = 30 * k, sy = 15 * k;\n    const di = 0.02 / Math.max(1, k), dj = 0.07 / Math.max(1, k);\n    const tones = [th.dim, th.dim, th.muted, th.muted, th.muted, th.ink, th.ink, th.ink, th.accent, th.accent, th.info, th.info];\n    return (t) => {\n      z.fill(0); lum.fill(-1);\n      const A = t * 1.15, B = t * 0.55;\n      const sA = Math.sin(A), cA = Math.cos(A), sB = Math.sin(B), cB = Math.cos(B);\n      for (let j = 0; j < 6.28; j += dj) {\n        const sj = Math.sin(j), cj = Math.cos(j), h = cj + 2;\n        for (let i = 0; i < 6.28; i += di) {\n          const si = Math.sin(i), ci = Math.cos(i);\n          const D = 1 / (si * h * sA + sj * cA + 5);\n          const tt = si * h * cA - sj * sA;\n          const x = Math.round(W / 2 + sx * D * (ci * h * cB - tt * sB));\n          const y = Math.round(H / 2 + sy * D * (ci * h * sB + tt * cB));\n          if (x < 0 || y < 0 || x >= W || y >= H) continue;\n          const o = x + W * y;\n          if (D <= z[o]) continue;\n          z[o] = D;\n          const N = Math.round(8 * ((sj * sA - si * cj * cA) * cB - si * cj * sA - sj * cA - ci * cj * sB));\n          lum[o] = Math.max(0, Math.min(11, N));\n        }\n      }\n      for (let o = 0; o < lum.length; o++) {\n        if (lum[o] < 0) continue;\n        ctx.buffer.set(o % W, (o / W) | 0, RAMP_DONUT[lum[o]], tones[lum[o]]);\n      }\n    };\n  },\n};\n\nconst fire = {\n  ...shared,\n  id: 'ascii-fire',\n  title: 'ASCII Fire',\n  category: 'ASCII Animations',\n  kind: 'art',\n  tags: ['fire', 'aalib', 'flames', 'classic'],\n  order: 20,\n  loop: 6,\n  description: 'Old-school text-mode fire: heat averages upward and cools through the \" .:*sS#$\" ramp, with gusting flicker.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th, rng } = ctx;\n    const heat = new Float32Array(W * (H + 2));\n    const ramp = RAMP_FIRE, last = ramp.length - 1;\n    const colors = [th.dim, mix(th.neg, th.bg, 0.45), th.neg, th.neg, th.warn, th.warn, th.ink, th.ink];\n    let acc = 0;\n    const step = () => {\n      for (let x = 0; x < W; x++) {\n        const base = 0.6 + 0.4 * Math.sin(x * 0.21) * Math.sin(x * 0.07 + 1.3);\n        heat[(H + 1) * W + x] = rng() < 0.7 * base + 0.25 ? 1.25 : 0.2 * rng();\n        heat[H * W + x] = heat[(H + 1) * W + x] * (0.8 + 0.2 * rng());\n      }\n      const cool = 0.95 / H;\n      for (let y = 0; y < H; y++) {\n        for (let x = 0; x < W; x++) {\n          const l = x > 0 ? x - 1 : x, r = x < W - 1 ? x + 1 : x;\n          const v = (heat[(y + 1) * W + l] + heat[(y + 1) * W + x] + heat[(y + 1) * W + r] + heat[(y + 2) * W + x]) / 4.02 - cool * (0.6 + rng() * 0.8);\n          heat[y * W + x] = v > 0 ? v : 0;\n        }\n      }\n    };\n    for (let i = 0; i < H * 2; i++) step();\n    return (t, dt) => {\n      acc += Math.min(0.25, dt || 0.05);\n      while (acc > 0.04) { step(); acc -= 0.04; }\n      for (let y = 0; y < H; y++) {\n        for (let x = 0; x < W; x++) {\n          const v = heat[y * W + x];\n          const i = Math.min(last, Math.floor(v * last * 1.15));\n          if (i <= 0) continue;\n          ctx.buffer.set(x, y, ramp[i], colors[i]);\n        }\n      }\n    };\n  },\n};\n\nconst rain = {\n  ...shared,\n  id: 'ascii-rain',\n  title: 'ASCII Rain',\n  category: 'ASCII Animations',\n  kind: 'art',\n  tags: ['rain', 'storm', 'weather', 'splash'],\n  order: 30,\n  loop: 8,\n  description: 'Slanting rain in three depths, splashing on a street line, with a lightning flash every few seconds.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th, rng } = ctx;\n    const ground = H - 2;\n    const drops = Array.from({ length: Math.round(W * H / 18) }, () => ({\n      x: rng() * W, y: rng() * ground, z: rng(), off: rng() * 100,\n    }));\n    const flashes = [2.6, 5.9, 7.1];\n    return (t) => {\n      const b = ctx.buffer;\n      const wind = 0.35 + 0.15 * Math.sin(t * 0.4);\n      const flash = flashes.reduce((m, f) => Math.max(m, 1 - Math.abs(((t % 8) - f) * 7)), 0);\n      if (flash > 0.2) b.fill(0, 0, W, ground, ' ', undefined, mix(th.bg, th.muted, flash * 0.35));\n      for (const d of drops) {\n        const speed = 10 + d.z * 22;\n        const yy = ((d.y + t * speed) % (ground + 4)) - 2;\n        const xx = ((d.x + yy * wind + W * 4) % W);\n        const ch = d.z > 0.66 ? '|' : d.z > 0.33 ? ':' : \"'\";\n        const glyph = wind > 0.42 && d.z > 0.66 ? '/' : ch;\n        if (yy >= 0 && yy < ground) b.set(Math.floor(xx), Math.floor(yy), glyph, d.z > 0.66 ? th.info : d.z > 0.33 ? th.muted : th.dim);\n        const landing = (t * speed + d.y + d.off) % (ground + 4);\n        if (d.z > 0.5 && landing > ground + 2.2 && landing < ground + 3.6) {\n          const sx = Math.floor((d.x + ground * wind + W * 4) % W);\n          b.set(sx - 1, ground - 1, '.', th.muted);\n          b.set(sx + 1, ground - 1, '.', th.muted);\n          b.set(sx, ground - 1, landing < ground + 2.9 ? 'o' : '.', th.info);\n        }\n      }\n      b.text(0, ground, '_'.repeat(W), th.dim);\n      for (let x = 0; x < W; x += 1) if (hash(x, Math.floor(t * 3)) < 0.06) b.set(x, ground + 1, '~', th.dim);\n      if (flash > 0.6) {\n        let x = Math.floor(W * (0.3 + 0.4 * hash(Math.floor(t / 8))));\n        for (let y = 0; y < ground * 0.7; y++) { const dx = hash(y, Math.floor(t)) < 0.5 ? -1 : 1; b.set(x, y, dx < 0 ? '/' : '\\\\', th.ink); x += dx; }\n      }\n    };\n  },\n};\n\nconst starfield = {\n  ...shared,\n  id: 'ascii-starfield',\n  title: 'ASCII Starfield',\n  category: 'ASCII Animations',\n  kind: 'art',\n  tags: ['starfield', 'space', 'warp', 'demoscene'],\n  order: 40,\n  loop: 8,\n  description: 'Stars fly out of the center of the screen, growing from . to + to * to @ as they near the viewer.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th, rng } = ctx;\n    const N = Math.round(W * H / 9);\n    const stars = Array.from({ length: N }, () => ({ x: rng() * 2 - 1, y: rng() * 2 - 1, z: rng() }));\n    const glyphs = ['.', '.', '+', '*', '@'];\n    const colors = [th.dim, th.muted, th.ink, th.ink, th.info];\n    return (t) => {\n      const b = ctx.buffer;\n      const speed = 0.35 + 0.25 * Math.sin(t * 0.6) ** 2;\n      for (const s of stars) {\n        const z = 1 - ((s.z + t * speed) % 1);\n        const px = W / 2 + (s.x / z) * W * 0.25;\n        const py = H / 2 + (s.y / z) * H * 0.25;\n        if (px < 0 || py < 0 || px >= W || py >= H) continue;\n        const i = Math.min(4, Math.floor((1 - z) * 5));\n        b.set(Math.floor(px), Math.floor(py), glyphs[i], colors[i]);\n      }\n    };\n  },\n};\n\nconst plasma = {\n  ...shared,\n  id: 'ascii-plasma',\n  title: 'ASCII Plasma',\n  category: 'ASCII Animations',\n  kind: 'art',\n  tags: ['plasma', 'demoscene', 'ramp', 'sine'],\n  order: 50,\n  loop: 10,\n  description: 'Four interfering sine fields mapped onto the \" .:-=+*#%@\" density ramp and cycled through the theme colors.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const cols = [th.info, th.accent, th.crit, th.neg, th.warn, th.pos];\n    const k = 17 / Math.max(W, H * 2);\n    return (t) => {\n      const b = ctx.buffer;\n      for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {\n        const fx = x * k, fy = y * 2 * k;\n        const v = Math.sin(fx * 1.3 + t) + Math.sin(fy + t * 1.2) + Math.sin((fx + fy) * 0.7 + t * 0.6) + Math.sin(Math.hypot(fx - 8, fy - 5) * 1.4 - t * 1.6);\n        const u = (v + 4) / 8;\n        const ch = RAMP[Math.min(9, Math.max(1, Math.floor(u * 10)))];\n        b.set(x, y, ch, cols[Math.floor(((u * 3 + t * 0.15) % 1) * cols.length)]);\n      }\n    };\n  },\n};\n\nconst cube = {\n  ...shared,\n  id: 'ascii-cube',\n  title: 'ASCII Wireframe Cube',\n  category: 'ASCII Animations',\n  kind: 'art',\n  tags: ['cube', 'wireframe', '3d', 'vector'],\n  order: 60,\n  loop: 12,\n  description: 'A tumbling wireframe cube in perspective, edges drawn with - | / \\\\ chosen by slope, vertices marked with o.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const V = [];\n    for (const x of [-1, 1]) for (const y of [-1, 1]) for (const z of [-1, 1]) V.push([x, y, z]);\n    const E = [];\n    for (let i = 0; i < 8; i++) for (let j = i + 1; j < 8; j++) {\n      const d = V[i].reduce((n, v, k) => n + (v !== V[j][k] ? 1 : 0), 0);\n      if (d === 1) E.push([i, j]);\n    }\n    const s = Math.min(W / 2.6, H * 1.1);\n    return (t) => {\n      const b = ctx.buffer;\n      const ax = t * 0.7, ay = t * 0.9, az = t * 0.3;\n      const P = V.map(([x, y, z]) => {\n        let c = Math.cos(ax), sn = Math.sin(ax);\n        [y, z] = [y * c - z * sn, y * sn + z * c];\n        c = Math.cos(ay); sn = Math.sin(ay);\n        [x, z] = [x * c + z * sn, -x * sn + z * c];\n        c = Math.cos(az); sn = Math.sin(az);\n        [x, y] = [x * c - y * sn, x * sn + y * c];\n        const p = 3.2 / (z + 4.2);\n        return [W / 2 + x * s * p * 0.5, H / 2 + y * s * p * 0.25, z];\n      });\n      const order = E.map(([i, j]) => [i, j, (P[i][2] + P[j][2]) / 2]).sort((a, c) => c[2] - a[2]);\n      for (const [i, j, z] of order) line(b, P[i][0], P[i][1], P[j][0], P[j][1], z > 0 ? th.muted : th.accent);\n      for (const p of P) b.set(Math.round(p[0]), Math.round(p[1]), p[2] > 0 ? 'o' : 'O', p[2] > 0 ? th.muted : th.ink);\n    };\n  },\n};\n\n// Our own logo, not a disc-format trademark — the corner-hit joke is what matters.\nconst LOGO = [\n  ' ___  ___  ___  ___  __  __ ',\n  '| _ \\\\| _ \\\\|_ _|/ __||  \\\\/  |',\n  '|  _/|   / | | \\\\__ \\\\| |\\\\/| |',\n  '|_|  |_|_\\\\|___||___/|_|  |_|',\n  '    ~~~ screensaver ~~~     ',\n];\n\nconst dvd = {\n  ...shared,\n  id: 'ascii-bounce-logo',\n  title: 'Bouncing Screensaver Logo',\n  category: 'ASCII Animations',\n  kind: 'art',\n  tags: ['screensaver', 'bounce', 'logo', 'corner', 'nostalgia'],\n  order: 70,\n  loop: 16,\n  minSize: { w: 30, h: 8 },\n  description: 'The screensaver everyone waited on: a logo drifts, changes color at every wall, and celebrates the corner hit.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const lw = LOGO[0].length, lh = LOGO.length;\n    const spanX = Math.max(0, W - lw), spanY = Math.max(0, H - lh);\n    const palette = [th.accent, th.pos, th.warn, th.crit, th.info, th.neg];\n    return (t) => {\n      const b = ctx.buffer;\n      const X = bounce(t, spanX, 9.0);\n      const Y = bounce(t, spanY, 4.0, spanY * 0.35);\n      const col = palette[(X.hits + Y.hits) % palette.length];\n      for (let r = 0; r < lh; r++) b.text(Math.round(X.pos), Math.round(Y.pos) + r, LOGO[r].slice(0, W), col);\n      const nearX = Math.min(X.pos, spanX - X.pos), nearY = Math.min(Y.pos, spanY - Y.pos);\n      if (nearX < 1.2 && nearY < 0.8 && H > lh + 1) {\n        const msg = '* CORNER! *';\n        b.text(Math.floor((W - msg.length) / 2), Y.pos < spanY / 2 ? H - 1 : 0, msg, th.ink);\n      }\n    };\n  },\n};\n\nexport default [donut, fire, rain, starfield, plasma, cube, dvd];\n",
  "prism-term/src/facets/ascii/art.js": "// Pure ASCII — art: little living pictures in 7-bit characters.\nimport { sprite, spriteWidth, hash, center } from './_ascii.js';\nimport { mix } from '../../color.js';\n\nconst shared = { gallery: 'ascii', category: 'ASCII Art', glyphs: 'ascii', color: '16', kind: 'art', size: { w: 80, h: 24 }, minSize: { w: 36, h: 12 } };\n\n// ── sea ─────────────────────────────────────────────────────────────────────\nconst BOAT = [\n  '     |\\\\     ',\n  '     | \\\\    ',\n  '     |  \\\\   ',\n  '     |___\\\\  ',\n  ' ____|_____ ',\n  ' \\\\  o o o / ',\n  '  \\\\______/  ',\n];\n\nconst sea = {\n  ...shared,\n  id: 'ascii-sea',\n  title: 'Sailboat at Sea',\n  tags: ['boat', 'sea', 'waves', 'seagulls', 'sunset'],\n  order: 10,\n  loop: 12,\n  description: 'A sailboat rides rolling ~ and ^ swells under a low sun while gulls flap overhead between v and V.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const horizon = Math.floor(H * 0.55);\n    const waveAt = (x, t) => Math.sin(x * 0.18 + t * 1.3) * 0.8 + Math.sin(x * 0.07 - t * 0.7) * 0.6;\n    return (t) => {\n      const b = ctx.buffer;\n      // Sun\n      const sx = Math.floor(W * 0.78), sy = Math.max(1, horizon - 6);\n      b.text(sx - 2, sy - 1, ' ___ ', th.warn);\n      b.text(sx - 3, sy, '/     \\\\', th.warn);\n      b.text(sx - 3, sy + 1, '\\\\_____/', th.warn);\n      for (const [dx, dy, ch] of [[-6, 0, '-'], [5, 0, '-'], [0, -2, '|'], [-5, -2, '\\\\'], [5, -2, '/']]) if (Math.floor(t * 2) % 2 === 0) b.set(sx + dx, sy + dy, ch, th.warn);\n      // Gulls\n      for (let k = 0; k < 4; k++) {\n        const gx = Math.floor(((k * 23 + t * (3 + k)) % (W + 6)) - 3);\n        const gy = 2 + ((k * 3) % Math.max(1, horizon - 4));\n        b.set(gx, gy, Math.floor(t * 4 + k) % 2 ? 'v' : 'V', th.muted);\n      }\n      // Sea surface and swells\n      for (let y = horizon; y < H; y++) {\n        const depth = (y - horizon) / Math.max(1, H - horizon);\n        for (let x = 0; x < W; x++) {\n          const v = waveAt(x + y * 3.1, t + y * 0.4);\n          const ch = v > 1 ? '^' : v > 0.4 ? '~' : v > -0.4 ? '-' : ' ';\n          if (ch !== ' ') b.set(x, y, ch, depth < 0.35 ? th.info : depth < 0.7 ? mix(th.info, th.bg, 0.3) : mix(th.info, th.bg, 0.55));\n        }\n      }\n      // Sun glitter on the water\n      for (let y = horizon; y < H; y++) for (let k = -2; k <= 2; k++) if (hash(y, k, Math.floor(t * 5)) < 0.35) b.set(sx + k * 2 + (y % 2), y, '=', th.warn);\n      // Boat rides the swell\n      const bx = Math.floor(W * 0.3 + Math.sin(t * 0.25) * W * 0.08);\n      const bob = Math.round(waveAt(bx + 5, t) * 0.8);\n      sprite(b, bx, horizon - BOAT.length + 2 + bob, BOAT, (ch, c, r) => (r < 4 ? th.ink : th.warn));\n    };\n  },\n};\n\n// ── mountains ───────────────────────────────────────────────────────────────\nconst mountains = {\n  ...shared,\n  id: 'ascii-mountains',\n  title: 'Mountain Range',\n  tags: ['mountains', 'landscape', 'parallax', 'snow', 'birds'],\n  order: 20,\n  loop: 20,\n  description: 'Three ridgelines of /\\\\ peaks drift past at different speeds, snow on the summits, a moon overhead and birds in flight.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const ridge = (x, seed, amp) => amp * (0.55 + 0.25 * Math.sin(x * 0.11 + seed) + 0.15 * Math.sin(x * 0.27 + seed * 2.1) + 0.08 * Math.sin(x * 0.61 + seed * 3.3));\n    const layers = [\n      { speed: 0.6, seed: 1.7, amp: H * 0.55, base: H - 1, col: mix(th.muted, th.bg, 0.45), fill: '.' },\n      { speed: 1.4, seed: 4.2, amp: H * 0.42, base: H - 1, col: th.muted, fill: ':' },\n      { speed: 3.2, seed: 7.9, amp: H * 0.26, base: H - 1, col: th.ink, fill: '#' },\n    ];\n    return (t) => {\n      const b = ctx.buffer;\n      // Stars + moon\n      for (let k = 0; k < W * 0.4; k++) {\n        const x = Math.floor(hash(k, 1) * W), y = Math.floor(hash(k, 2) * H * 0.4);\n        if (hash(k, Math.floor(t * 2)) < 0.85) b.set(x, y, hash(k, 3) < 0.2 ? '*' : '.', th.dim);\n      }\n      const mx = Math.floor(W * 0.2), my = 1;\n      b.text(mx, my, ' _.._', th.ink);\n      b.text(mx, my + 1, \"( (  `\", th.ink);\n      b.text(mx, my + 2, \" `-.'\", th.ink);\n      for (const L of layers) {\n        const off = t * L.speed;\n        const tops = [];\n        for (let x = -1; x <= W; x++) tops.push(Math.round(L.base - ridge(x + off, L.seed, L.amp)));\n        for (let x = 0; x < W; x++) {\n          const top = tops[x + 1], left = tops[x], right = tops[x + 2];\n          // Occlude whatever is behind, then texture the face.\n          for (let y = top + 1; y <= L.base; y++) {\n            const face = L.fill === '#' ? ((x + y) % 2 ? '#' : ':') : L.fill === ':' ? ((x * 3 + y) % 5 === 0 ? ':' : ' ') : ' ';\n            b.set(x, y, face, L.col);\n          }\n          // Keep the outline continuous across steep steps.\n          for (let y = Math.min(top, right) + 1; y < Math.max(top, right); y++) b.set(x, y, right < top ? '/' : '\\\\', L.col);\n          const peak = top <= left && top <= right && (top < left || top < right);\n          const ch = peak ? '^' : right < top ? '/' : right > top ? '\\\\' : left > top ? '/' : left < top ? '\\\\' : '_';\n          const high = top < L.base - L.amp * 0.74;\n          b.set(x, top, ch, high ? th.ink : L.col);\n          if (high && top + 1 <= L.base) b.set(x, top + 1, '*', th.ink);\n        }\n      }\n      // Birds\n      for (let k = 0; k < 3; k++) {\n        const x = Math.floor((k * 29 + t * (5 + k * 2)) % (W + 4)) - 2;\n        const y = Math.floor(H * 0.18) + k * 2 + Math.round(Math.sin(t * 1.5 + k) * 0.6);\n        b.text(x, y, Math.floor(t * 5 + k) % 2 ? '\\\\/' : 'vv', th.muted);\n      }\n    };\n  },\n};\n\n// ── skyline ─────────────────────────────────────────────────────────────────\nconst skyline = {\n  ...shared,\n  id: 'ascii-skyline',\n  title: 'City at Night',\n  tags: ['city', 'skyline', 'night', 'windows', 'plane'],\n  order: 30,\n  loop: 16,\n  description: 'A night skyline whose windows switch on and off, an antenna beacon blinks, stars twinkle and a plane crosses the moon.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th, rng } = ctx;\n    const buildings = [];\n    for (let x = 0; x < W;) {\n      const w = 4 + Math.floor(rng() * 9), h = Math.floor(H * (0.25 + rng() * 0.5));\n      buildings.push({ x, w, h, antenna: rng() < 0.25, seed: Math.floor(rng() * 1e6) });\n      x += w + (rng() < 0.3 ? 1 : 0);\n    }\n    return (t) => {\n      const b = ctx.buffer;\n      for (let k = 0; k < W * 0.5; k++) {\n        const x = Math.floor(hash(k, 4) * W), y = Math.floor(hash(k, 5) * H * 0.5);\n        const tw = hash(k, Math.floor(t * 3));\n        if (tw < 0.8) b.set(x, y, tw < 0.1 ? '*' : '.', th.dim);\n      }\n      const mx = Math.floor(W * 0.72), my = 1;\n      b.text(mx, my, '  _..._ ', th.ink);\n      b.text(mx, my + 1, '.\\'     \\'.', th.ink);\n      b.text(mx, my + 2, '\\'._   _.\\'', th.ink);\n      b.text(mx, my + 3, '   \"\"\"  ', th.ink);\n      // Plane crossing with blinking lights\n      const px = Math.floor(((t * 6) % (W + 20)) - 10);\n      b.text(px, 3, '__|__', th.muted);\n      b.text(px + 1, 4, '--o--', th.muted);\n      if (Math.floor(t * 3) % 2) b.set(px - 1, 4, '*', th.neg);\n      const ground = H - 1;\n      for (const bl of buildings) {\n        const top = ground - bl.h;\n        b.text(bl.x, top, '_'.repeat(bl.w), th.muted);\n        for (let y = top + 1; y <= ground; y++) {\n          b.set(bl.x, y, '|', th.muted);\n          b.set(bl.x + bl.w - 1, y, '|', th.muted);\n          for (let x = bl.x + 1; x < bl.x + bl.w - 1; x += 2) {\n            const lit = hash(bl.seed + x, y, Math.floor(t / 2.5 + hash(x, y) * 4)) < 0.42;\n            if ((y - top) % 2 === 0 && x + 1 < bl.x + bl.w - 1) b.text(x, y, lit ? '[]' : '  ', lit ? th.warn : th.dim);\n          }\n        }\n        if (bl.antenna && top > 2) {\n          b.set(bl.x + Math.floor(bl.w / 2), top - 1, '|', th.muted);\n          b.set(bl.x + Math.floor(bl.w / 2), top - 2, Math.floor(t * 2 + bl.seed) % 2 ? '*' : '.', th.neg);\n        }\n      }\n    };\n  },\n};\n\n// ── rocket launch ───────────────────────────────────────────────────────────\nconst ROCKET = [\n  '    /\\\\    ',\n  '   /  \\\\   ',\n  '  |    |  ',\n  '  | () |  ',\n  '  |    |  ',\n  '  | PR |  ',\n  '  | SM |  ',\n  ' /|    |\\\\ ',\n  '/_|____|_\\\\',\n  '   /||\\\\   ',\n];\n\nconst rocket = {\n  ...shared,\n  id: 'ascii-rocket',\n  title: 'Rocket Launch',\n  tags: ['rocket', 'launch', 'countdown', 'space', 'exhaust'],\n  order: 40,\n  loop: 12,\n  minSize: { w: 30, h: 16 },\n  description: 'Countdown from T-5, ignition, a flickering exhaust plume and billowing smoke as the rocket clears the tower — then again.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const rw = spriteWidth(ROCKET);\n    const pad = H - 1;\n    return (t) => {\n      const b = ctx.buffer;\n      const p = t % 12;\n      const rx = Math.floor((W - rw) / 2);\n      const lift = p < 6 ? 0 : Math.pow(p - 6, 2) * 1.6;\n      const ry = Math.round(pad - ROCKET.length - lift);\n      // Tower\n      for (let y = pad - ROCKET.length - 1; y < pad; y++) b.text(rx - 4, y, y % 2 ? '|X|' : '|-|', th.dim);\n      b.text(0, pad, '_'.repeat(W), th.muted);\n      // Countdown\n      if (p < 6) {\n        const n = Math.max(0, 5 - Math.floor(p));\n        center(b, 1, n > 0 ? `T - ${n}` : 'IGNITION', n > 0 ? th.ink : th.warn, 1);\n      } else if (p < 8.5) center(b, 1, 'LIFTOFF', th.pos, 1);\n      // Exhaust plume\n      if (p > 4.6) {\n        const len = p < 6 ? 2 : 5 + Math.min(4, (p - 6) * 2);\n        for (let k = 0; k < len; k++) {\n          const y = ry + ROCKET.length + k;\n          if (y >= pad) break;\n          const wv = Math.min(4, 1 + k);\n          for (let dx = -wv; dx <= wv; dx++) {\n            const r = hash(dx, k, Math.floor(t * 14));\n            if (r < 0.7 - Math.abs(dx) / (wv + 2)) b.set(rx + 5 + dx, y, k < 2 ? '*' : r < 0.3 ? '^' : '\"', k < 2 ? th.ink : r < 0.3 ? th.warn : th.neg);\n          }\n        }\n        // Smoke at the pad\n        const spread = p < 6 ? (p - 4.6) * 4 : 6 + (p - 6) * 3;\n        for (let dx = -spread; dx <= spread; dx++) {\n          const r = hash(Math.round(dx), Math.floor(t * 6));\n          if (r < 0.6) b.set(Math.round(rx + 5 + dx), pad - 1 - (r < 0.25 ? 1 : 0), r < 0.2 ? '@' : r < 0.4 ? 'O' : 'o', mix(th.muted, th.bg, Math.min(0.6, Math.abs(dx) / (spread + 1))));\n        }\n      }\n      if (ry > -ROCKET.length) sprite(b, rx, ry, ROCKET, (ch, c, r) => (r === 5 || r === 6 ? th.accent : th.ink));\n    };\n  },\n};\n\n// ── coffee ──────────────────────────────────────────────────────────────────\nconst CUP = [\n  '  .--------------.  ',\n  '  |              |__',\n  '  |              |  )',\n  '  |              | / ',\n  '   \\\\            /--  ',\n  '    `----------\\'    ',\n  ' ___________________ ',\n  '(___________________)',\n];\n\nconst coffee = {\n  ...shared,\n  id: 'ascii-coffee',\n  title: 'Fresh Coffee',\n  tags: ['coffee', 'steam', 'cup', 'cozy', 'break'],\n  order: 50,\n  loop: 8,\n  minSize: { w: 26, h: 14 },\n  description: 'A cup on its saucer with three wisps of steam curling up and drifting — the universal \"build is running\" picture.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const cw = spriteWidth(CUP);\n    return (t) => {\n      const b = ctx.buffer;\n      const cx = Math.floor((W - cw) / 2), cy = H - CUP.length - 1;\n      for (let k = 0; k < 3; k++) {\n        for (let y = 0; y < cy - 1; y++) {\n          const age = (cy - 1 - y) / Math.max(1, cy - 1);\n          const phase = t * 2.2 + k * 2 + y * 0.55;\n          const x = cx + 6 + k * 4 + Math.round(Math.sin(phase) * (1 + age * 1.5));\n          const ch = Math.cos(phase) > 0 ? '(' : ')';\n          if (hash(k, y, Math.floor(t * 4)) < 0.92 - age * 0.5) b.set(x, y + 1, ch, mix(th.ink, th.bg, 0.2 + age * 0.55));\n        }\n      }\n      sprite(b, cx, cy, CUP, (ch, c, r) => (r >= 6 ? th.muted : th.ink));\n      // Coffee surface\n      b.text(cx + 3, cy + 1, '~'.repeat(14).split('').map((c, i) => ((i + Math.floor(t * 3)) % 4 === 0 ? '-' : '~')).join(''), th.warn);\n      if (H > CUP.length + 2) center(b, H - 1, 'brewing...'.slice(0, 7 + (Math.floor(t * 2) % 4)), th.dim);\n    };\n  },\n};\n\nexport default [sea, mountains, skyline, rocket, coffee];\n",
  "prism-term/src/facets/ascii/bars.js": "// Pure ASCII — spinners and bars: the progress vocabulary of classic Unix tools,\n// all in 7-bit characters.\nimport { hash, center } from './_ascii.js';\nimport { mix } from '../../color.js';\n\nconst shared = { gallery: 'ascii', category: 'ASCII Spinners & Bars', glyphs: 'ascii', color: '16' };\n\nconst SPINNERS = [\n  ['line', ['|', '/', '-', '\\\\'], 100],\n  ['dots', ['.  ', '.. ', '...', ' ..', '  .', '   '], 160],\n  ['grow', ['.', 'o', 'O', '0', 'O', 'o'], 120],\n  ['arrows', ['<', '^', '>', 'v'], 140],\n  ['bounce', ['[=   ]', '[ =  ]', '[  = ]', '[   =]', '[  = ]', '[ =  ]'], 110],\n  ['scanner', ['(*    )', '( *   )', '(  *  )', '(   * )', '(    *)', '(   * )', '(  *  )', '( *   )'], 80],\n  ['pipe', ['[|]', '[/]', '[-]', '[\\\\]'], 120],\n  ['fill', ['[    ]', '[=   ]', '[==  ]', '[=== ]', '[====]', '[ ===]', '[  ==]', '[   =]'], 120],\n  ['toggle', ['+', 'x'], 250],\n  ['pulse', ['.', ':', '|', ':'], 150],\n  ['flip', ['_', '_', '-', '`', '`', \"'\", '-', '_'], 90],\n  ['meter', ['[     ]', '[#    ]', '[##   ]', '[###  ]', '[#### ]', '[#####]'], 140],\n  ['ping', ['|o    |', '| o   |', '|  o  |', '|   o |', '|    o|', '|   o |', '|  o  |', '| o   |'], 90],\n  ['clock', ['-', '\\\\', '|', '/'], 220],\n  ['beam', ['>    ', '>>   ', '>>>  ', ' >>> ', '  >>>', '   >>', '    >', '     '], 100],\n  ['dqpb', ['d', 'q', 'p', 'b'], 120],\n];\n\nconst spinners = {\n  ...shared,\n  id: 'ascii-spinners',\n  title: 'ASCII Spinner Sampler',\n  kind: 'spinner',\n  tags: ['spinner', 'loader', 'busy', 'classic', 'cli'],\n  order: 10,\n  loop: 4,\n  size: { w: 64, h: 18 },\n  minSize: { w: 24, h: 6 },\n  description: 'Sixteen pure-ASCII spinners — |/-\\\\, bouncing brackets, scanners, meters and more — each at its own classic cadence.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const colW = W >= 60 ? 32 : W >= 40 ? Math.floor(W / 2) : W;\n    const cols = Math.max(1, Math.floor(W / colW));\n    const rows = Math.max(1, Math.ceil(SPINNERS.length / cols));\n    const accents = [th.accent, th.pos, th.warn, th.info, th.crit];\n    return (t) => {\n      const b = ctx.buffer;\n      SPINNERS.forEach(([name, frames, ms], i) => {\n        const c = Math.floor(i / rows), r = i % rows;\n        if (r >= H || c >= cols) return;\n        const x = c * colW + 1;\n        const f = frames[Math.floor((t * 1000) / ms) % frames.length];\n        b.text(x, r, f, accents[i % accents.length], undefined, 1);\n        b.text(x + 9, r, name, th.muted);\n      });\n    };\n  },\n};\n\n// Classic tool progress lines. Each loops: run → finish → short hold.\nconst TOOLS = [\n  { id: 'wget', total: 18.4, rate: 4.2, render: (p, s, W) => {\n    const barW = Math.max(6, Math.min(40, W - 52));\n    const fill = Math.round(barW * p);\n    const bar = '='.repeat(Math.max(0, fill - 1)) + (p < 1 ? '>' : '=') + ' '.repeat(barW - fill);\n    return `prism-term.tgz   ${String(Math.round(p * 100)).padStart(3)}%[${bar}] ${(18.4 * p).toFixed(2)}M  ${s.rate}KB/s  ${p < 1 ? `eta ${s.eta}s ` : 'in 4.4s '}`;\n  } },\n  { id: 'curl', total: 1, rate: 0.16, render: (p, s, W) => {\n    const barW = Math.max(10, W - 8);\n    return `${'#'.repeat(Math.round(barW * p)).padEnd(barW)} ${(p * 100).toFixed(1).padStart(5)}%`;\n  } },\n  { id: 'apt', total: 1, rate: 0.12, render: (p, s, W) => {\n    const barW = Math.max(10, W - 26);\n    const f = Math.round(barW * p);\n    return `Progress: [${String(Math.round(p * 100)).padStart(3)}%] [${'#'.repeat(f)}${'.'.repeat(barW - f)}]`;\n  } },\n  { id: 'tqdm', total: 100, rate: 18, render: (p, s, W) => {\n    const barW = Math.max(6, Math.min(30, W - 46));\n    const exact = barW * p, full = Math.floor(exact), frac = Math.floor((exact - full) * 10);\n    const bar = ('#'.repeat(full) + (full < barW ? String(frac) : '')).padEnd(barW).slice(0, barW);\n    const n = Math.round(p * 100);\n    return `${String(n).padStart(3)}%|${bar}| ${n}/100 [00:0${Math.floor(p * 5)}<00:0${Math.max(0, 5 - Math.floor(p * 5))}, 18.20it/s]`;\n  } },\n  { id: 'rsync', total: 1, rate: 0.1, render: (p) => `     ${Math.round(1204322 * p).toLocaleString('en-US').padStart(10)}  ${String(Math.round(p * 100)).padStart(3)}%    1.21MB/s    0:00:${String(Math.max(0, Math.round((1 - p) * 9))).padStart(2, '0')}` },\n  { id: 'docker', total: 40.3, rate: 7, render: (p, s, W) => {\n    const barW = Math.max(6, Math.min(40, W - 48));\n    const f = Math.round(barW * p);\n    return p < 1\n      ? `a3ed95caeb02: Downloading [${'='.repeat(Math.max(0, f - 1))}>${' '.repeat(barW - f)}]  ${(40.3 * p).toFixed(1)}MB/40.3MB`\n      : 'a3ed95caeb02: Pull complete';\n  } },\n  { id: 'pip', total: 9.6, rate: 2.3, render: (p, s, W) => {\n    const barW = Math.max(6, Math.min(40, W - 34));\n    const f = Math.round(barW * p);\n    return `   ${'-'.repeat(f).padEnd(barW)} ${(9.6 * p).toFixed(1)}/9.6 MB ${p < 1 ? '2.3 MB/s eta 0:00:0' + Math.max(0, Math.round((1 - p) * 4)) : ''}`;\n  } },\n  { id: 'make', total: 1, rate: 0.09, render: (p) => `[${String(Math.round(p * 100)).padStart(3)}%] Building C object src/CMakeFiles/prism.dir/render.c.o` },\n];\n\nconst progress = {\n  ...shared,\n  id: 'ascii-progress-bars',\n  title: 'Classic Tool Progress Bars',\n  kind: 'progress',\n  tags: ['progress', 'wget', 'curl', 'apt', 'tqdm', 'rsync', 'docker'],\n  order: 20,\n  loop: 10,\n  size: { w: 80, h: 18 },\n  minSize: { w: 40, h: 8 },\n  description: 'The progress lines everyone recognizes — wget, curl, apt, tqdm, rsync, docker pull, pip and make — in pure ASCII.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const gap = H >= TOOLS.length * 2 + 1 ? 2 : 1;\n    return (t) => {\n      const b = ctx.buffer;\n      TOOLS.forEach((tool, i) => {\n        const y = i * gap;\n        if (y + (gap === 2 ? 1 : 0) >= H) return;\n        const dur = tool.total / tool.rate;\n        const cycle = dur + 1.6;\n        const local = (t + i * 0.9) % cycle;\n        const p = Math.min(1, local / dur);\n        const s = { rate: Math.round(820 + hash(i, Math.floor(t * 2)) * 60), eta: Math.max(0, Math.ceil((1 - p) * dur)) };\n        if (gap === 2) b.text(0, y, `$ ${tool.id}`, th.dim);\n        const line = tool.render(p, s, W).slice(0, W);\n        const yy = gap === 2 ? y + 1 : y;\n        b.text(0, yy, line, p >= 1 ? th.pos : th.ink);\n        // Color the bar itself\n        for (let x = 0; x < line.length; x++) if ('#=>-'.includes(line[x]) && p < 1) b.set(x, yy, undefined, th.accent);\n      });\n    };\n  },\n};\n\nconst hourglass = {\n  ...shared,\n  id: 'ascii-hourglass',\n  title: 'ASCII Hourglass',\n  kind: 'spinner',\n  tags: ['hourglass', 'timer', 'sand', 'waiting'],\n  order: 30,\n  loop: 10,\n  size: { w: 30, h: 14 },\n  minSize: { w: 14, h: 12 },\n  description: 'Sand drains grain by grain through the neck of a 7-bit hourglass, piles up below, then the glass flips and starts again.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const rows = 5;\n    return (t) => {\n      const b = ctx.buffer;\n      const p = (t % 10) / 9;\n      const k = Math.min(1, p);\n      const cx = Math.floor(W / 2), y0 = Math.max(0, Math.floor((H - (rows * 2 + 2)) / 2));\n      b.text(cx - rows - 1, y0, '_'.repeat(rows * 2 + 3), th.muted);\n      // Top bulb drains from its upper rows; bottom bulb fills from the base.\n      for (let r = 0; r < rows; r++) {\n        const w = (rows - r) * 2 - 1;\n        const y = y0 + 1 + r;\n        b.set(cx - rows + r - 1, y, '\\\\', th.muted);\n        b.set(cx + rows - r + 1, y, '/', th.muted);\n        const level = r / rows;\n        const sand = level >= k ? '.'.repeat(w + 2).replace(/./g, (c, i) => (hash(i, r) < 0.6 ? ':' : '.')) : ' '.repeat(w + 2);\n        b.text(cx - Math.floor((w + 2) / 2), y, sand, th.warn);\n      }\n      for (let r = 0; r < rows; r++) {\n        const w = r * 2 + 1;\n        const y = y0 + 1 + rows + r;\n        b.set(cx - r - 2, y, '/', th.muted);\n        b.set(cx + r + 2, y, '\\\\', th.muted);\n        const filled = (rows - 1 - r) / rows < k;\n        if (filled) b.text(cx - Math.floor((w + 2) / 2), y, '.'.repeat(w + 2).replace(/./g, (c, i) => (hash(i, r, 9) < 0.6 ? ':' : '.')), th.warn);\n      }\n      b.text(cx - rows - 1, y0 + 1 + rows * 2, '-'.repeat(rows * 2 + 3), th.muted);\n      // The falling stream\n      if (k < 1) for (let y = y0 + rows; y < y0 + rows + 3 + Math.round((1 - k) * (rows - 2)); y++) if ((y + Math.floor(t * 12)) % 2) b.set(cx, y, '.', th.warn);\n      if (H > rows * 2 + 4) center(b, Math.min(H - 1, y0 + rows * 2 + 3), `${String(Math.max(0, Math.ceil((1 - k) * 9))).padStart(1)}s`, th.dim);\n    };\n  },\n};\n\nconst BANDS = ['60', '150', '400', '1k', '2.5k', '6k', '15k'];\n\nconst vu = {\n  ...shared,\n  id: 'ascii-vu-meter',\n  title: 'ASCII Spectrum Analyzer',\n  kind: 'chart',\n  tags: ['spectrum', 'vu meter', 'equalizer', 'audio', 'bars'],\n  order: 40,\n  loop: 8,\n  size: { w: 64, h: 18 },\n  minSize: { w: 24, h: 8 },\n  description: 'A hi-fi spectrum analyzer in #: bars bounce to a synthetic beat, peak markers hang and fall, labels sit on the axis.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const n = Math.max(6, Math.floor((W - 2) / 3));\n    const maxH = H - 2;\n    const peaks = new Float32Array(n);\n    let last = 0;\n    return (t) => {\n      const b = ctx.buffer;\n      const dt = Math.max(0, Math.min(0.2, t - last)); last = t;\n      const beat = Math.pow(Math.max(0, Math.sin(t * Math.PI * 2 * 2)), 8);\n      for (let i = 0; i < n; i++) {\n        const f = i / n;\n        const v = Math.min(1, 0.15 + 0.55 * (1 - f) * beat + 0.3 * Math.abs(Math.sin(t * (2.1 + f * 5) + i * 0.7)) * (0.6 + 0.4 * hash(i, Math.floor(t * 8))));\n        const hgt = Math.round(v * maxH);\n        peaks[i] = Math.max(hgt, peaks[i] - dt * 6);\n        const x = 1 + i * 3;\n        for (let y = 0; y < hgt; y++) {\n          const level = y / maxH;\n          b.text(x, H - 2 - y, '##', level > 0.8 ? th.neg : level > 0.55 ? th.warn : th.pos);\n        }\n        const py = H - 2 - Math.round(peaks[i]);\n        if (py >= 0 && Math.round(peaks[i]) > hgt) b.text(x, py, '==', th.ink);\n      }\n      b.text(0, H - 1, '-'.repeat(W), th.dim);\n      BANDS.forEach((l, k) => {\n        const x = Math.round(1 + (k / (BANDS.length - 1)) * (n - 1) * 3);\n        b.text(Math.min(W - l.length, x), H - 1, l, th.muted);\n      });\n    };\n  },\n};\n\nexport default [spinners, progress, hourglass, vu];\n",
  "prism-term/src/facets/ascii/classics.js": "// Pure ASCII — the classics: cowsay, banner(1), figlet, clocks, Life, a maze,\n// and two self-playing arcade games. Original art and code throughout.\nimport { sprite, line, center, box, hash, figlet, blockText } from './_ascii.js';\n\nconst shared = { gallery: 'ascii', category: 'Classics', glyphs: 'ascii', color: '16', kind: 'art' };\n\n// ── cowsay ──────────────────────────────────────────────────────────────────\nconst COW = [\n  '        \\\\',\n  '         \\\\   __     __',\n  '          \\\\  \\\\ \\\\___/ /',\n  '             |  {E}  |',\n  '             |       |____________________',\n  '              \\\\     /     .-.       _      \\\\',\n  '               (o_o)|    (   )     ( )      |\\\\',\n  \"                `-' |____`-'_______'-'______| ~\",\n  '                     ||  ||           ||  ||',\n  '                     ^^  ^^           ^^  ^^',\n];\nconst SAYINGS = [\n  'It works on my machine.',\n  \"There's no place like 127.0.0.1\",\n  'Have you tried turning it off and on again?',\n  'Moo. Your build is green.',\n  'rm -rf is not a backup strategy.',\n  'Always read the logs before the dashboards.',\n  'The cache was it. It is always the cache.',\n];\n\nfunction bubble(text, max) {\n  const words = text.split(' '), lines = [];\n  let cur = '';\n  for (const w of words) {\n    if (cur && (cur + ' ' + w).length > max) { lines.push(cur); cur = w; } else cur = cur ? cur + ' ' + w : w;\n  }\n  if (cur || !lines.length) lines.push(cur);\n  const width = Math.max(...lines.map((l) => l.length));\n  const out = [' ' + '_'.repeat(width + 2)];\n  lines.forEach((l, i) => {\n    const [a, z] = lines.length === 1 ? ['<', '>'] : i === 0 ? ['/', '\\\\'] : i === lines.length - 1 ? ['\\\\', '/'] : ['|', '|'];\n    out.push(`${a} ${l.padEnd(width)} ${z}`);\n  });\n  out.push(' ' + '-'.repeat(width + 2));\n  return out;\n}\n\nconst cowsay = {\n  ...shared,\n  id: 'ascii-cowsay',\n  title: 'Cow Says',\n  tags: ['cowsay', 'fortune', 'speech bubble', 'cow', 'classic'],\n  order: 10,\n  loop: 21,\n  size: { w: 60, h: 16 },\n  minSize: { w: 48, h: 14 },\n  description: 'A cowsay-style speech bubble over an original ASCII cow that blinks — cycling through operator wisdom one fortune at a time.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    return (t) => {\n      const b = ctx.buffer;\n      const i = Math.floor(t / 3) % SAYINGS.length;\n      const full = SAYINGS[i];\n      const typed = full.slice(0, Math.min(full.length, Math.floor((t % 3) * 24)));\n      const lines = bubble(typed, Math.max(12, Math.min(36, W - 6)));\n      const top = Math.max(0, H - COW.length - lines.length);\n      sprite(b, 1, top, lines, th.ink, { opaque: true });\n      const blink = (t % 4) > 3.75;\n      const cow = COW.map((l) => l.replace('{E}', blink ? '- -' : 'o o'));\n      sprite(b, 1, top + lines.length, cow, (ch, c, r) => {\n        if (r < 3 && c <= 10) return th.muted; // bubble tail\n        if (r === 3 && c >= 16 && c <= 18) return th.accent; // eyes\n        if (ch === '^' || (r >= 5 && r <= 7 && c >= 25 && c <= 39 && ch !== '_')) return th.muted; // hooves, patches\n        return th.ink;\n      });\n    };\n  },\n};\n\n// ── banner(1) ───────────────────────────────────────────────────────────────\nconst bannerSign = {\n  ...shared,\n  id: 'ascii-banner',\n  title: 'banner(1) Sign',\n  tags: ['banner', 'big letters', 'hash', 'unix', 'marquee'],\n  order: 20,\n  loop: 12,\n  size: { w: 80, h: 12 },\n  minSize: { w: 30, h: 9 },\n  description: 'Huge letters built from # like the Unix banner command, scrolling past as a marquee with a dotted rule above and below.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const msg = String(ctx.param('text', 'PRISM  TERMINAL  FACETS  ')).toUpperCase();\n    const art = blockText(msg + msg.slice(0, 12), 'standard');\n    const aw = blockText(msg, 'standard')[0].length;\n    const top = Math.max(1, Math.floor((H - art.length) / 2));\n    return (t) => {\n      const b = ctx.buffer;\n      const off = Math.floor((t * 18) % Math.max(1, aw));\n      const rule = ''.padEnd(W, '. ');\n      b.text(0, top - 1, rule, th.dim);\n      if (top + art.length < H) b.text(0, top + art.length, rule, th.dim);\n      for (let r = 0; r < art.length; r++) {\n        const row = (art[r] + art[r]).slice(off, off + W);\n        for (let x = 0; x < row.length; x++) if (row[x] !== ' ') b.set(x, top + r, '#', r < 2 ? th.ink : r < 5 ? th.accent : th.info);\n      }\n    };\n  },\n};\n\n// ── figlet ──────────────────────────────────────────────────────────────────\nconst WORDS = ['PRISM', 'ASCII', 'Hello', 'shell', '1977'];\n\nconst figletBanner = {\n  ...shared,\n  id: 'ascii-figlet',\n  title: 'Figlet-Style Banner',\n  tags: ['figlet', 'banner', 'outline', 'big text', 'classic'],\n  order: 30,\n  loop: 15,\n  size: { w: 72, h: 12 },\n  minSize: { w: 34, h: 10 },\n  description: 'Outlined big letters traced from Prism\\'s bitmap fonts with | and _ — the figlet look — typed in one letter at a time.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const custom = ctx.param('text', '');\n    const words = custom ? [String(custom)] : WORDS;\n    const renders = words.map((w) => {\n      for (const font of ['standard', 'tiny']) {\n        const full = figlet(w, font);\n        if (full[0].length <= W) return { w, font };\n      }\n      return { w, font: 'tiny' };\n    });\n    return (t) => {\n      const b = ctx.buffer;\n      const i = Math.floor(t / 3) % renders.length;\n      const { w, font } = renders[i];\n      const n = Math.min(w.length, 1 + Math.floor((t % 3) * 4));\n      const art = figlet(w.slice(0, n), font);\n      const full = figlet(w, font);\n      const x = Math.max(0, Math.floor((W - full[0].length) / 2));\n      const y = Math.max(0, Math.floor((H - art.length) / 2));\n      sprite(b, x, y, art, (ch, c, r) => (r <= 1 ? th.ink : r < art.length - 2 ? th.accent : th.info));\n      if (H > art.length + 2) b.text(x, y + art.length, `$ figlet ${w}`, th.dim);\n    };\n  },\n};\n\nconst figletClock = {\n  ...shared,\n  id: 'ascii-figlet-clock',\n  title: 'Figlet Clock',\n  tags: ['clock', 'figlet', 'time', 'digits', 'tmux'],\n  order: 40,\n  loop: 10,\n  size: { w: 72, h: 10 },\n  minSize: { w: 40, h: 8 },\n  description: 'HH:MM:SS in outlined figlet digits with a blinking colon — the tmux clock-mode idea in pure ASCII.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const start = 9 * 3600 + 41 * 60 + 7;\n    return (t) => {\n      const b = ctx.buffer;\n      const s = Math.floor(start + t);\n      const hh = String(Math.floor(s / 3600) % 24).padStart(2, '0'), mm = String(Math.floor(s / 60) % 60).padStart(2, '0'), ss = String(s % 60).padStart(2, '0');\n      const colon = t % 1 < 0.5 ? ':' : ' ';\n      let art = figlet(`${hh}${colon}${mm}${colon}${ss}`, 'standard');\n      if (art[0].length > W) art = figlet(`${hh}${colon}${mm}${colon}${ss}`, 'tiny');\n      const x = Math.max(0, Math.floor((W - art[0].length) / 2)), y = Math.max(0, Math.floor((H - art.length) / 2));\n      sprite(b, x, y, art, th.accent);\n      if (H > art.length + 1) center(b, y + art.length, 'Tue 13 Oct 2026  UTC', th.dim);\n    };\n  },\n};\n\n// ── analog clock ────────────────────────────────────────────────────────────\nconst analog = {\n  ...shared,\n  id: 'ascii-clock',\n  title: 'ASCII Wall Clock',\n  tags: ['clock', 'analog', 'time', 'hands'],\n  order: 50,\n  loop: 60,\n  size: { w: 44, h: 22 },\n  minSize: { w: 24, h: 12 },\n  description: 'A round wall clock in dots and digits; hour, minute and sweeping second hands drawn with slope-matched - | / \\\\ glyphs.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const ry = Math.floor((H - 2) / 2), rx = Math.min(Math.floor((W - 2) / 2), ry * 2);\n    const cx = Math.floor(W / 2), cy = Math.floor(H / 2);\n    const start = 10 * 3600 + 8 * 60 + 30;\n    return (t) => {\n      const b = ctx.buffer;\n      for (let a = 0; a < 360; a += 3) {\n        const r = (a * Math.PI) / 180;\n        b.set(Math.round(cx + Math.cos(r) * rx), Math.round(cy + Math.sin(r) * ry), '.', th.dim);\n      }\n      for (let hNum = 1; hNum <= 12; hNum++) {\n        const r = (hNum / 12) * Math.PI * 2 - Math.PI / 2;\n        const label = String(hNum);\n        b.text(Math.round(cx + Math.cos(r) * (rx - 2.5)) - (label.length > 1 ? 1 : 0), Math.round(cy + Math.sin(r) * (ry - 1.2)), label, hNum % 3 === 0 ? th.ink : th.muted);\n      }\n      const s = start + t;\n      const hand = (frac, len, color, ch) => {\n        const r = frac * Math.PI * 2 - Math.PI / 2;\n        line(b, cx, cy, cx + Math.cos(r) * rx * len, cy + Math.sin(r) * ry * len, color, ch);\n      };\n      hand(((s / 3600) % 12) / 12, 0.5, th.ink);\n      hand(((s / 60) % 60) / 60, 0.78, th.accent);\n      hand((Math.floor(s) % 60) / 60, 0.86, th.neg);\n      b.set(cx, cy, 'O', th.ink);\n    };\n  },\n};\n\n// ── Life ────────────────────────────────────────────────────────────────────\nconst life = {\n  ...shared,\n  id: 'ascii-life',\n  title: 'Life in # and .',\n  tags: ['game of life', 'conway', 'cellular automata', 'classic'],\n  order: 60,\n  loop: 12,\n  size: { w: 80, h: 24 },\n  minSize: { w: 24, h: 8 },\n  description: \"Conway's Game of Life the way it first ran on terminals — # for live cells, . for the dead — reseeding when it settles.\",\n  setup(ctx) {\n    const { width: W, height: H, theme: th, rng } = ctx;\n    const GH = H - 1;\n    let cur = new Uint8Array(W * GH), nxt = new Uint8Array(W * GH);\n    let gen = 0, still = 0, acc = 0;\n    const seed = () => { for (let i = 0; i < cur.length; i++) cur[i] = rng() < 0.3 ? 1 : 0; gen = 0; still = 0; };\n    seed();\n    const step = () => {\n      let changes = 0, pop = 0;\n      for (let y = 0; y < GH; y++) for (let x = 0; x < W; x++) {\n        let n = 0;\n        for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) if (dx || dy) n += cur[((y + dy + GH) % GH) * W + ((x + dx + W) % W)];\n        const i = y * W + x, v = n === 3 || (n === 2 && cur[i]) ? 1 : 0;\n        nxt[i] = v; pop += v;\n        if (v !== cur[i]) changes++;\n      }\n      [cur, nxt] = [nxt, cur];\n      gen++;\n      still = changes < W * GH * 0.004 ? still + 1 : 0;\n      if (still > 30 || pop < W * GH * 0.02 || gen > 600) seed();\n    };\n    return (t, dt) => {\n      acc += Math.min(0.25, dt || 0.05);\n      while (acc > 0.1) { step(); acc -= 0.1; }\n      const b = ctx.buffer;\n      for (let y = 0; y < GH; y++) for (let x = 0; x < W; x++) {\n        const live = cur[y * W + x];\n        b.set(x, y + 1, live ? '#' : '.', live ? th.pos : th.dim);\n      }\n      b.text(0, 0, `gen ${String(gen).padStart(4)}  `, th.muted);\n    };\n  },\n};\n\n// ── maze ────────────────────────────────────────────────────────────────────\nconst maze = {\n  ...shared,\n  id: 'ascii-maze',\n  title: 'Maze Builder',\n  tags: ['maze', 'backtracker', 'bfs', 'pathfinding', 'algorithm'],\n  order: 70,\n  loop: 20,\n  size: { w: 61, h: 21 },\n  minSize: { w: 25, h: 11 },\n  description: 'A depth-first backtracker carves a +--+ maze cell by cell, then breadth-first search lights the solution path.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th, rng } = ctx;\n    const C = Math.max(2, Math.floor((W - 1) / 3)), R = Math.max(2, Math.floor((H - 1) / 2));\n    let open, seen, stack, path, phase, hold, acc, head;\n    const reset = () => {\n      open = new Uint8Array(C * R); // bit 1 = east open, bit 2 = south open\n      seen = new Uint8Array(C * R);\n      stack = [0]; seen[0] = 1; path = []; phase = 'carve'; hold = 0; acc = 0; head = 0;\n    };\n    reset();\n    const carve = () => {\n      const c = stack[stack.length - 1];\n      const x = c % C, y = (c / C) | 0;\n      const opts = [];\n      if (x > 0 && !seen[c - 1]) opts.push(c - 1);\n      if (x < C - 1 && !seen[c + 1]) opts.push(c + 1);\n      if (y > 0 && !seen[c - C]) opts.push(c - C);\n      if (y < R - 1 && !seen[c + C]) opts.push(c + C);\n      if (!opts.length) { stack.pop(); if (!stack.length) solve(); return; }\n      const n = opts[Math.floor(rng() * opts.length)];\n      if (n === c + 1) open[c] |= 1; else if (n === c - 1) open[n] |= 1; else if (n === c + C) open[c] |= 2; else open[n] |= 2;\n      seen[n] = 1; stack.push(n);\n    };\n    const solve = () => {\n      const prev = new Int32Array(C * R).fill(-1), goal = C * R - 1;\n      const q = [0]; prev[0] = 0;\n      while (q.length) {\n        const c = q.shift(); if (c === goal) break;\n        const x = c % C;\n        const nb = [];\n        if (open[c] & 1) nb.push(c + 1);\n        if (x > 0 && open[c - 1] & 1) nb.push(c - 1);\n        if (open[c] & 2) nb.push(c + C);\n        if (c - C >= 0 && open[c - C] & 2) nb.push(c - C);\n        for (const n of nb) if (prev[n] < 0) { prev[n] = c; q.push(n); }\n      }\n      path = []; for (let c = goal; c !== 0; c = prev[c]) path.unshift(c);\n      path.unshift(0);\n      phase = 'solve';\n    };\n    return (t, dt) => {\n      acc += Math.min(0.25, dt || 0.05);\n      const rate = phase === 'carve' ? 1 / Math.max(40, C * R / 6) : 1 / 30;\n      while (acc > rate) {\n        acc -= rate;\n        if (phase === 'carve') carve();\n        else if (phase === 'solve') { head++; if (head >= path.length) phase = 'hold'; }\n        else if ((hold += rate) > 2.5) reset();\n      }\n      const b = ctx.buffer;\n      for (let y = 0; y <= R; y++) for (let x = 0; x <= C; x++) b.set(x * 3, y * 2, '+', th.muted);\n      for (let y = 0; y < R; y++) for (let x = 0; x < C; x++) {\n        const c = y * C + x, X = x * 3, Y = y * 2;\n        if (y === 0) b.text(X + 1, Y, '--', th.muted);\n        if (x === 0 && c !== 0) b.set(X, Y + 1, '|', th.muted);\n        if (!(open[c] & 1) && !(x === C - 1 && y === R - 1)) b.set(X + 3, Y + 1, '|', th.muted);\n        if (!(open[c] & 2)) b.text(X + 1, Y + 2, '--', th.muted);\n        if (!seen[c]) b.text(X + 1, Y + 1, '::', th.dim);\n      }\n      if (phase === 'carve' && stack.length) { const c = stack[stack.length - 1]; b.text((c % C) * 3 + 1, ((c / C) | 0) * 2 + 1, '@@', th.warn); }\n      for (let k = 0; k < Math.min(head, path.length); k++) { const c = path[k]; b.text((c % C) * 3 + 1, ((c / C) | 0) * 2 + 1, 'oo', th.pos); }\n    };\n  },\n};\n\n// ── snake ───────────────────────────────────────────────────────────────────\nconst snake = {\n  ...shared,\n  id: 'ascii-snake',\n  title: 'Self-Playing Snake',\n  tags: ['snake', 'game', 'arcade', 'ai', 'bfs'],\n  order: 80,\n  loop: 20,\n  size: { w: 48, h: 20 },\n  minSize: { w: 20, h: 10 },\n  description: 'Snake plays itself: breadth-first search steers @ to the * while the o tail grows, until it boxes itself in and starts over.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th, rng } = ctx;\n    const GW = W - 2, GH = H - 3;\n    let body, dir, food, score, best = 0, acc = 0, dead = 0, grow = 0;\n    const key = (x, y) => y * GW + x;\n    const placeFood = () => {\n      for (let k = 0; k < 500; k++) {\n        const x = Math.floor(rng() * GW), y = Math.floor(rng() * GH);\n        if (!body.some(([bx, by]) => bx === x && by === y)) { food = [x, y]; return; }\n      }\n    };\n    const reset = () => { body = [[Math.floor(GW / 2), Math.floor(GH / 2)]]; dir = [1, 0]; score = 0; dead = 0; grow = 2; placeFood(); };\n    reset();\n    const DIRS = [[1, 0], [-1, 0], [0, 1], [0, -1]];\n    const step = () => {\n      if (dead) { if ((dead += 1) > 25) reset(); return; }\n      const [hx, hy] = body[0];\n      const blocked = new Set(body.slice(0, -1).map(([x, y]) => key(x, y)));\n      // BFS to food\n      const prev = new Map([[key(hx, hy), null]]);\n      const q = [[hx, hy]];\n      let found = null;\n      while (q.length && !found) {\n        const [x, y] = q.shift();\n        for (const [dx, dy] of DIRS) {\n          const nx = x + dx, ny = y + dy, k = key(nx, ny);\n          if (nx < 0 || ny < 0 || nx >= GW || ny >= GH || blocked.has(k) || prev.has(k)) continue;\n          prev.set(k, [x, y]); q.push([nx, ny]);\n          if (nx === food[0] && ny === food[1]) { found = [nx, ny]; break; }\n        }\n      }\n      if (found) {\n        let c = found;\n        while (prev.get(key(c[0], c[1])) && (prev.get(key(c[0], c[1]))[0] !== hx || prev.get(key(c[0], c[1]))[1] !== hy)) c = prev.get(key(c[0], c[1]));\n        dir = [c[0] - hx, c[1] - hy];\n      } else {\n        const safe = DIRS.filter(([dx, dy]) => { const nx = hx + dx, ny = hy + dy; return nx >= 0 && ny >= 0 && nx < GW && ny < GH && !blocked.has(key(nx, ny)); });\n        if (safe.length) dir = safe[0];\n      }\n      const nx = hx + dir[0], ny = hy + dir[1];\n      if (nx < 0 || ny < 0 || nx >= GW || ny >= GH || blocked.has(key(nx, ny))) { dead = 1; best = Math.max(best, score); return; }\n      body.unshift([nx, ny]);\n      if (nx === food[0] && ny === food[1]) { score++; grow += 3; placeFood(); }\n      if (grow > 0) grow--; else body.pop();\n    };\n    return (t, dt) => {\n      acc += Math.min(0.25, dt || 0.05);\n      while (acc > 1 / 20) { step(); acc -= 1 / 20; }\n      const b = ctx.buffer;\n      box(b, 0, 1, W, H - 1, th.muted);\n      b.text(1, 0, `score ${score}  best ${Math.max(best, score)}`, th.ink);\n      b.set(food[0] + 1, food[1] + 2, '*', th.warn);\n      body.forEach(([x, y], i) => b.set(x + 1, y + 2, i === 0 ? '@' : 'o', i === 0 ? th.pos : th.accent));\n      if (dead) center(b, Math.floor(H / 2), ' GAME OVER ', th.neg);\n    };\n  },\n};\n\n// ── pong ────────────────────────────────────────────────────────────────────\nconst pong = {\n  ...shared,\n  id: 'ascii-pong',\n  title: 'Self-Playing Pong',\n  tags: ['pong', 'game', 'arcade', '1972', 'ai'],\n  order: 90,\n  loop: 20,\n  size: { w: 64, h: 20 },\n  minSize: { w: 30, h: 10 },\n  description: 'Two imperfect AI paddles rally an o across a dotted net; misses score, and the ball serves again from the center.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const top = 2, bottom = H - 1, ph = Math.max(2, Math.floor((bottom - top) / 4));\n    let bx, by, vx, vy, lp, rp, ls = 0, rs = 0, acc = 0, serve = 0, rallies = 0;\n    const reset = (dirX) => { bx = W / 2; by = (top + bottom) / 2; vx = 22 * dirX; vy = 9 * (hash(rallies, 3) < 0.5 ? -1 : 1); serve = 0.6; };\n    lp = rp = (top + bottom) / 2;\n    reset(1);\n    const tick = (dt) => {\n      if (serve > 0) { serve -= dt; return; }\n      bx += vx * dt; by += vy * dt;\n      if (by <= top) { by = top + (top - by); vy = Math.abs(vy); }\n      if (by >= bottom - 1) { by = bottom - 1 - (by - (bottom - 1)); vy = -Math.abs(vy); }\n      const err = (side) => (hash(rallies, side) - 0.5) * ph * 2.2;\n      const follow = (p, side) => { const target = by + err(side); return p + Math.max(-14 * dt, Math.min(14 * dt, target - p)); };\n      const clamp = (p) => Math.max(top + ph / 2, Math.min(bottom - ph / 2, p));\n      lp = clamp(follow(lp, 1)); rp = clamp(follow(rp, 2));\n      if (bx <= 3 && vx < 0) {\n        if (Math.abs(by - lp) <= ph / 2 + 0.5) { vx = Math.abs(vx) * 1.03; vy += (by - lp) * 3; rallies++; } else if (bx < 0) { rs++; rallies++; reset(1); }\n      }\n      if (bx >= W - 4 && vx > 0) {\n        if (Math.abs(by - rp) <= ph / 2 + 0.5) { vx = -Math.abs(vx) * 1.03; vy += (by - rp) * 3; rallies++; } else if (bx > W - 1) { ls++; rallies++; reset(-1); }\n      }\n      vx = Math.sign(vx) * Math.min(40, Math.abs(vx));\n      vy = Math.max(-16, Math.min(16, vy));\n    };\n    return (t, dt) => {\n      acc += Math.min(0.25, dt || 0.05);\n      while (acc > 1 / 60) { tick(1 / 60); acc -= 1 / 60; }\n      const b = ctx.buffer;\n      b.text(0, top - 1, '-'.repeat(W), th.muted);\n      b.text(0, bottom, '-'.repeat(W), th.muted);\n      for (let y = top; y < bottom; y += 2) b.set(Math.floor(W / 2), y, ':', th.dim);\n      center(b, 0, `${String(ls).padStart(2)}   ${String(rs).padEnd(2)}`, th.ink);\n      for (let k = 0; k < ph; k++) {\n        b.set(2, Math.round(lp - ph / 2 + k), '|', th.accent);\n        b.set(W - 3, Math.round(rp - ph / 2 + k), '|', th.crit);\n      }\n      if (serve <= 0) b.set(Math.round(bx), Math.round(by), 'o', th.ink);\n    };\n  },\n};\n\nexport default [cowsay, bannerSign, figletBanner, figletClock, analog, life, maze, snake, pong];\n",
  "prism-term/src/facets/ascii/scenes.js": "// Pure ASCII — living scenes: an aquarium, a steam train, a walker and digital rain.\n// Original takes on the asciiquarium and sl ideas, drawn from scratch.\nimport { sprite, spriteWidth, mirror, hash } from './_ascii.js';\nimport { mix } from '../../color.js';\n\nconst shared = { gallery: 'ascii', category: 'ASCII Animations', glyphs: 'ascii', color: '16', kind: 'art', size: { w: 80, h: 24 }, minSize: { w: 36, h: 12 } };\n\n// ── aquarium ────────────────────────────────────────────────────────────────\nconst FISH = [\n  ['><>'],\n  ['><(((\">'],\n  ['  __  ', \"><_ '>\"],\n  ['   .--.  ', '><(  o  >', \"   `--'  \"],\n  ['  _____  ', '>=(  o   >', \"  `---'  \"],\n];\nconst CASTLE = [\n  '    |>     ',\n  '   _|_     ',\n  '  |   |_   ',\n  ' _|[] | |_ ',\n  '|_______|_|',\n];\n\nconst aquarium = {\n  ...shared,\n  id: 'ascii-aquarium',\n  title: 'ASCII Aquarium',\n  tags: ['aquarium', 'fish', 'asciiquarium', 'bubbles', 'screensaver'],\n  order: 80,\n  loop: 20,\n  description: 'Fish of five shapes cruise both ways past swaying seaweed and a sandcastle while bubbles wobble up to a rolling surface.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th, rng } = ctx;\n    const colors = [th.warn, th.accent, th.crit, th.pos, th.info, th.neg];\n    const top = 1, floor = H - 1;\n    const fish = Array.from({ length: Math.max(3, Math.round(W * H / 260)) }, (_, i) => {\n      const shape = FISH[Math.floor(rng() * FISH.length)];\n      const dir = rng() < 0.5 ? 1 : -1;\n      return { art: dir > 0 ? shape : mirror(shape), dir, y: top + 2 + Math.floor(rng() * Math.max(1, floor - top - 5 - shape.length)), speed: 3 + rng() * 7, off: rng() * 400, color: colors[i % colors.length] };\n    });\n    const weeds = Array.from({ length: Math.max(2, Math.round(W / 11)) }, () => ({ x: Math.floor(rng() * W), h: 2 + Math.floor(rng() * Math.min(6, H / 3)), ph: rng() * 6 }));\n    const castleX = Math.floor(W * 0.68);\n    return (t) => {\n      const b = ctx.buffer;\n      // Surface\n      for (let x = 0; x < W; x++) {\n        const v = Math.sin(x * 0.35 + t * 2.2) + Math.sin(x * 0.11 - t * 1.3);\n        b.set(x, top, v > 0.8 ? '^' : v > -0.6 ? '~' : '-', th.info);\n      }\n      // Sand\n      for (let x = 0; x < W; x++) b.set(x, floor, hash(x, 7) < 0.25 ? '.' : hash(x, 9) < 0.15 ? ',' : '_', mix(th.warn, th.bg, 0.4));\n      if (W > 40 && H > 12) sprite(b, castleX, floor - CASTLE.length, CASTLE, th.muted);\n      // Seaweed\n      for (const w of weeds) for (let r = 0; r < w.h; r++) {\n        const sway = Math.floor(t * 1.6 + w.ph + r * 0.7) % 2;\n        b.set(w.x + (r % 2 === 0 ? 0 : sway ? 1 : 0), floor - 1 - r, (r + Math.floor(t * 1.6 + w.ph)) % 2 ? '(' : ')', th.pos);\n      }\n      // Fish + their bubbles\n      for (const f of fish) {\n        const fw = spriteWidth(f.art), span = W + fw * 2;\n        const p = ((f.off + t * f.speed) % span + span) % span;\n        const x = f.dir > 0 ? p - fw : W + fw - p - fw;\n        sprite(b, x, f.y, f.art, f.color);\n        const mouthX = f.dir > 0 ? x + fw : x - 1;\n        const cycle = (t * 0.5 + f.off * 0.013) % 3;\n        if (cycle < 1.4) {\n          const rise = cycle * 4;\n          const by = Math.round(f.y - rise);\n          if (by > top) b.set(Math.round(mouthX + Math.sin(cycle * 6) * 0.6), by, rise < 1.5 ? '.' : rise < 3.5 ? 'o' : 'O', th.ink);\n        }\n      }\n      // Vent bubbles from the sand\n      for (let k = 0; k < 7; k++) {\n        const ph = (t * (0.32 + (k % 3) * 0.07) + hash(k, 3)) % 1;\n        const y = Math.round(floor - 1 - ph * (floor - top - 1));\n        const x = Math.round(W * 0.2 + (k % 3) - 1 + Math.sin(ph * 14 + k * 2.1) * 1.6);\n        if (y > top) b.set(x, y, ph < 0.3 ? '.' : ph < 0.7 ? 'o' : 'O', th.ink);\n      }\n    };\n  },\n};\n\n// ── steam train ─────────────────────────────────────────────────────────────\nconst ENGINE = [\n  '  ___                  ____        ',\n  ' |   |   ___________  |[ ]|_____   ',\n  ' |___|__|  P R I S M |____|     \\  ',\n  ' |                              |\\_',\n  ' |______________________________|_>',\n  '    {W}    {W}    {W}      {W}  {W}  ',\n];\nconst CAR = [\n  '                          ',\n  '  ______________________  ',\n  ' | [] [] [] [] [] [] [] | ',\n  ' |                      | ',\n  '=|______________________|=',\n  '    {W}            {W}    ',\n];\nconst SPOKES = ['(-)', '(\\)', '(|)', '(/)'];\n\nconst train = {\n  ...shared,\n  id: 'ascii-train',\n  title: 'Steam Train',\n  tags: ['train', 'sl', 'locomotive', 'steam', 'classic'],\n  order: 90,\n  loop: 14,\n  minSize: { w: 40, h: 10 },\n  description: 'An original take on the sl gag: a steam express thunders across, spokes turning, smoke billowing back over the carriages.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const cw = spriteWidth(CAR), ew = spriteWidth(ENGINE);\n    const total = cw * 2 + ew;\n    const groundY = H - 2;\n    const y0 = groundY - ENGINE.length + 1;\n    const speed = 14;\n    return (t) => {\n      const b = ctx.buffer;\n      const period = (W + total + 24) / speed;\n      const x = Math.round((t % period) * speed - total);\n      // Rails and sleepers scroll under the wheels\n      b.text(0, groundY + 1, '='.repeat(W), th.muted);\n      for (let sx = 0; sx < W; sx += 4) b.set(sx, groundY + 1, '#', th.dim);\n      const spoke = SPOKES[Math.floor(t * 10) % 4];\n      const wheel = (lines) => lines.map((l) => l.replaceAll('{W}', spoke));\n      const color = (ch, c, r) => (r === 2 && /[A-Z]/.test(ch) ? th.warn : r === 5 ? th.ink : th.accent);\n      sprite(b, x, y0, wheel(CAR), (ch, c, r) => (r === 2 && ch === '[' || ch === ']' ? th.warn : r === 5 ? th.ink : th.muted));\n      sprite(b, x + cw, y0, wheel(CAR), (ch, c, r) => (r === 2 && (ch === '[' || ch === ']') ? th.warn : r === 5 ? th.ink : th.muted));\n      sprite(b, x + cw * 2, y0, wheel(ENGINE), color);\n      // Smoke from the stack (engine col 2), drifting back over the train\n      const stackX = x + cw * 2 + 2;\n      for (let k = 0; k < 10; k++) {\n        const age = (t * 1.2 + k * 0.31) % 3.1;\n        const px = stackX - age * 7 - k * 0.8;\n        const py = y0 - 1 - age * 1.3;\n        if (py < 0) continue;\n        const puff = age < 0.7 ? '(@@)' : age < 1.6 ? '( )' : age < 2.4 ? '()' : '.';\n        b.text(Math.round(px), Math.round(py), puff, mix(th.muted, th.bg, Math.min(0.8, age / 3.4)));\n      }\n    };\n  },\n};\n\n// ── walker ──────────────────────────────────────────────────────────────────\nconst LEGS = ['/ \\\\', ' |\\\\', ' | ', '/| '];\nconst ARMS = ['/|\\\\', '-|\\\\', '/|-', '-|-'];\n\nconst walker = {\n  ...shared,\n  id: 'ascii-walker',\n  title: 'Evening Walk',\n  tags: ['stick figure', 'walk cycle', 'parallax', 'street'],\n  order: 100,\n  loop: 12,\n  minSize: { w: 30, h: 8 },\n  description: 'A stick figure strolls past lamp posts and drifting clouds — a four-frame walk cycle over three layers of parallax.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const ground = H - 2;\n    return (t) => {\n      const b = ctx.buffer;\n      // Clouds (slow)\n      for (let k = 0; k < 4; k++) {\n        const cw = 9 + k * 2, span = W + cw;\n        const x = Math.round(((k * 37 - t * (1 + k * 0.4)) % span + span) % span - cw);\n        const y = 1 + (k % 3);\n        if (y < ground - 4) { b.text(x + 2, y, '.--.', th.dim); b.text(x, y + 1, '(' + '_'.repeat(cw - 2) + ')', th.dim); }\n      }\n      // Lamp posts (fast)\n      for (let lx = ((-Math.floor(t * 6)) % 22 + 22) % 22 - 2; lx < W; lx += 22) {\n        for (let y = ground - 5; y < ground; y++) b.set(lx, y, '|', th.muted);\n        if (ground - 6 >= 0) b.text(lx - 1, ground - 6, '_O_', th.warn);\n      }\n      // Ground\n      for (let x = 0; x < W; x++) b.set(x, ground, '_', th.dim);\n      for (let x = ((-Math.floor(t * 6)) % 5 + 5) % 5; x < W; x += 5) b.set(x, ground + 1, hash(x + Math.floor(t * 6), 3) < 0.5 ? ',' : '.', th.dim);\n      // The walker, bobbing on each step\n      const f = Math.floor(t * 6) % 4;\n      const wx = Math.floor(W * 0.42), wy = ground - 3 - (f % 2 === 0 ? 0 : 0);\n      b.text(wx, wy, ' o ', th.ink);\n      b.text(wx, wy + 1, ARMS[f], th.ink);\n      b.text(wx, wy + 2, LEGS[f], th.ink);\n    };\n  },\n};\n\n// ── digital rain ────────────────────────────────────────────────────────────\nconst GLYPHS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ$+-*/=%\"\\'#&_(),.;:?!\\\\|{}<>[]^~';\n\nconst rainCode = {\n  ...shared,\n  id: 'ascii-matrix',\n  title: 'ASCII Digital Rain',\n  tags: ['matrix', 'digital rain', 'code', 'hacker'],\n  order: 110,\n  loop: 8,\n  description: 'Columns of falling ASCII code with bright heads and decaying trails — the digital-rain look on a 7-bit terminal.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th, rng } = ctx;\n    const cols = Array.from({ length: W }, () => ({ speed: 6 + rng() * 14, len: 5 + Math.floor(rng() * H * 0.8), off: rng() * 60, on: rng() < 0.7 }));\n    const head = mix(th.pos, 0xffffff, 0.75);\n    return (t) => {\n      const b = ctx.buffer;\n      for (let x = 0; x < W; x++) {\n        const c = cols[x];\n        if (!c.on) continue;\n        const span = H + c.len;\n        const hy = ((c.off + t * c.speed) % span) | 0;\n        for (let k = 0; k < c.len; k++) {\n          const y = hy - k;\n          if (y < 0 || y >= H) continue;\n          const g = GLYPHS[Math.floor(hash(x, y, Math.floor(t * (k === 0 ? 12 : 3)) + k) * GLYPHS.length)];\n          const fade = k / c.len;\n          b.set(x, y, g, k === 0 ? head : fade < 0.3 ? th.pos : fade < 0.7 ? mix(th.pos, th.bg, 0.4) : th.dim, undefined, k === 0 ? 1 : 0);\n        }\n      }\n    };\n  },\n};\n\nexport default [aquarium, train, walker, rainCode];\n",
  "prism-term/src/facets/callouts/_admon.js": "// Text wrapping + the kit, re-exported for prose-heavy callouts.\nexport * from './_kit.js';\nimport { stringWidth } from './_kit.js';\n\n/** Greedy word wrap to `w` columns; hard-splits words longer than a line. */\nexport function wrapText(text, w) {\n  const out = [];\n  let line = '';\n  for (const word of String(text).split(/\\s+/).filter(Boolean)) {\n    let wd = word;\n    while (stringWidth(wd) > w) { if (line) { out.push(line); line = ''; } out.push(wd.slice(0, w)); wd = wd.slice(w); }\n    if (!line) line = wd;\n    else if (stringWidth(line) + 1 + stringWidth(wd) <= w) line += ' ' + wd;\n    else { out.push(line); line = wd; }\n  }\n  if (line) out.push(line);\n  return out;\n}\n",
  "prism-term/src/facets/callouts/_kit.js": "// Shared TUI kit for the ai / diagrams / callouts / input galleries.\n//\n// Glyph tables with 7-bit ASCII twins (so every facet degrades cleanly on\n// TERM=linux / LANG=C), bordered panels that switch to +-| when Unicode is\n// off, timing helpers for scripted \"user\" interactions, and a few small\n// drawing primitives (bars, centered text, scrollbars) these facets share.\n\nimport { box as rawBox } from '../../widgets/box.js';\nimport { ATTR } from '../../buffer.js';\nimport { NONE, mix } from '../../color.js';\nimport { stringWidth, truncate } from '../../width.js';\n\nexport { ATTR, NONE, mix, stringWidth, truncate };\n\n/** True unless the terminal can't do Unicode. */\nexport const uni = (ctx) => ctx.caps?.unicode !== false;\n\nconst UNI = {\n  check: '✔', cross: '✖', dot: '●', ring: '○', radioOn: '◉', radioOff: '○',\n  boxOn: '[✓]', boxOff: '[ ]', boxPart: '[-]',\n  pointer: '❯', caret: '›', tri: '▸', triDown: '▾', triUp: '▴', triLeft: '◂',\n  right: '→', left: '←', up: '↑', down: '↓', enter: '⏎',\n  bullet: '•', mid: '·', ellipsis: '…', star: '★', diamond: '◆', spark: '✦',\n  bar: '▌', lbar: '▎', block: '█', shade: '░', shade2: '▒', half: '▄',\n  h: '─', v: '│', hh: '━', vv: '┃', dh: '═', dash: '╌', dot3: '┄',\n  tee: '├', elbow: '└', teeEnd: '└', branch: '├──', last: '└──', pipe: '│  ', gap: '   ',\n  sortUp: '▲', sortDown: '▼', info: 'ℹ', warn: '▲', bang: '!', lock: '●',\n  knob: '●', trackOn: '━', trackOff: '─', cursor: '▌', blockCursor: '█',\n};\n\nconst ASC = {\n  check: '+', cross: 'x', dot: '*', ring: 'o', radioOn: '(*)', radioOff: '( )',\n  boxOn: '[x]', boxOff: '[ ]', boxPart: '[-]',\n  pointer: '>', caret: '>', tri: '>', triDown: 'v', triUp: '^', triLeft: '<',\n  right: '->', left: '<-', up: '^', down: 'v', enter: 'RET',\n  bullet: '*', mid: '.', ellipsis: '...', star: '*', diamond: '*', spark: '*',\n  bar: '|', lbar: '|', block: '#', shade: '.', shade2: ':', half: '_',\n  h: '-', v: '|', hh: '=', vv: '|', dh: '=', dash: '-', dot3: '.',\n  tee: '|', elbow: '`', teeEnd: '`', branch: '|--', last: '`--', pipe: '|  ', gap: '   ',\n  sortUp: '^', sortDown: 'v', info: 'i', warn: '!', bang: '!', lock: '*',\n  knob: 'O', trackOn: '=', trackOff: '-', cursor: '_', blockCursor: '#',\n};\n\n/** Glyph table for this terminal. Multi-cell ASCII twins are noted in callers. */\nexport const glyphs = (ctx) => (uni(ctx) ? UNI : ASC);\n\n/** Border style name, falling back to ASCII when Unicode is off. */\nexport const bstyle = (ctx, style = 'round') => (uni(ctx) ? style : 'ascii');\n\n/** Ellipsis for this terminal: '…', or '...' when Unicode is off. */\nexport const ell = (ctx) => (uni(ctx) ? '…' : '...');\n\n/** truncate() to `w` columns with this terminal's ellipsis. */\nexport function clip(ctx, str, w) {\n  const s = String(str);\n  if (uni(ctx) || stringWidth(s) <= w) return truncate(s, Math.max(0, w));\n  return w >= 6 ? truncate(s, w, '...') : truncate(s, Math.max(0, w - 1), '') + (w > 0 ? '.' : '');\n}\n\n/** Bordered panel (widgets/box) that knows about ctx (theme + ASCII fallback). Returns the interior View. */\nexport function panel(ctx, s, x, y, w, h, opts = {}) {\n  const o = { theme: ctx.theme, ...opts, style: bstyle(ctx, opts.style || 'round') };\n  // box.js truncates labels with '…'; pre-clip so ASCII terminals stay 7-bit.\n  if (!uni(ctx)) for (const k of ['title', 'footer']) if (o[k]) o[k] = clip(ctx, o[k], Math.max(0, w - 4));\n  return rawBox(s, x, y, w, h, o);\n}\n\n/** Seconds into the current loop. */\nexport const phase = (t, loop) => ((t % loop) + loop) % loop;\n\n/** Characters of `text` revealed at `cps` chars/second starting at `start`. */\nexport function typed(text, t, start = 0, cps = 18) {\n  if (t < start) return '';\n  return [...text].slice(0, Math.floor((t - start) * cps)).join('');\n}\n\n/** Cursor blink: on for the first ~60% of each period. */\nexport const blink = (t, period = 1) => phase(t, period) < period * 0.6;\n\n/** 0→1 over [a, b], clamped. */\nexport const span = (t, a, b) => (t <= a ? 0 : t >= b ? 1 : (t - a) / (b - a));\n\n/** Smoothstep 0→1 over [a, b]. */\nexport const ease = (t, a, b) => { const k = span(t, a, b); return k * k * (3 - 2 * k); };\n\n/** Index of the last schedule entry whose time is ≤ t (−1 before the first). */\nexport function stepAt(t, times) {\n  let i = -1;\n  for (let k = 0; k < times.length; k++) if (t >= times[k]) i = k;\n  return i;\n}\n\nconst BRAILLE_SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';\nconst ASCII_SPIN = '|/-\\\\';\n/** One-cell spinner glyph. */\nexport const spin = (ctx, t, speed = 1) => (uni(ctx) ? BRAILLE_SPIN[Math.floor(t * 12 * speed) % 10] : ASCII_SPIN[Math.floor(t * 8 * speed) % 4]);\n\n/** Pad / clip a plain string to exactly `w` columns (pass ctx for an ASCII-safe ellipsis). */\nexport function fit(str, w, how = 'left', ctx) {\n  const s = ctx ? clip(ctx, str, Math.max(0, w)) : truncate(String(str), Math.max(0, w));\n  const gap = Math.max(0, w - stringWidth(s));\n  if (how === 'right') return ' '.repeat(gap) + s;\n  if (how === 'center') return ' '.repeat(gap >> 1) + s + ' '.repeat(gap - (gap >> 1));\n  return s + ' '.repeat(gap);\n}\n\n/** Write text centered in [x, x+w) (pass ctx for an ASCII-safe ellipsis). */\nexport function center(s, x, y, w, str, fg, bg, attr, ctx) {\n  const t = ctx ? clip(ctx, str, Math.max(0, w)) : truncate(String(str), Math.max(0, w));\n  return s.text(x + Math.max(0, Math.floor((w - stringWidth(t)) / 2)), y, t, fg, bg, attr);\n}\n\nconst EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];\n/** Horizontal bar with eighth-cell precision (#/. in ASCII). Returns the filled cell count. */\nexport function hbar(ctx, s, x, y, w, frac, fg, trackColor) {\n  const f = Math.max(0, Math.min(1, frac)) * w;\n  const full = Math.floor(f), rem = f - full;\n  const u = uni(ctx);\n  const tc = trackColor ?? mix(ctx.theme.bg, fg, 0.28);\n  for (let i = 0; i < w; i++) {\n    if (i < full) s.set(x + i, y, u ? '█' : '#', fg);\n    else if (i === full && u && rem >= 1 / 16) s.set(x + i, y, EIGHTHS[Math.max(1, Math.round(rem * 8))], fg);\n    else s.set(x + i, y, u ? '░' : '.', tc);\n  }\n  return full;\n}\n\n/** Vertical scrollbar in column x for a list window. */\nexport function scrollbar(ctx, s, x, y, h, total, first, visible) {\n  if (total <= visible || h < 2) return;\n  const u = uni(ctx);\n  const th = Math.max(1, Math.round((visible / total) * h));\n  const top = Math.round((first / Math.max(1, total - visible)) * (h - th));\n  for (let i = 0; i < h; i++) {\n    const on = i >= top && i < top + th;\n    s.set(x, y + i, on ? (u ? '┃' : '#') : (u ? '│' : '|'), on ? ctx.theme.muted : ctx.theme.line);\n  }\n}\n\n/** Soft background tint of a theme color. */\nexport const tint = (ctx, c, k = 0.18) => mix(ctx.theme.bg, c, k);\n\n/** Draw a `[ Label ]` button; focused buttons are filled with the accent. Returns width. */\nexport function button(ctx, s, x, y, label, { focused = false, color, pressed = false } = {}) {\n  const th = ctx.theme;\n  const c = color ?? th.accent;\n  const text = uni(ctx) ? ` ${label} ` : `[ ${label} ]`;\n  if (focused) s.text(x, y, text, pressed ? th.ink : th.bg, pressed ? mix(c, th.bg, 0.35) : c, ATTR.BOLD);\n  else s.text(x, y, text, th.ink, uni(ctx) ? th.panel2 : undefined, 0);\n  return stringWidth(text);\n}\n\n/** Simple keyword-ish syntax coloring for one line of JS/TS/Python/shell-like code. */\nconst KW = /\\b(const|let|var|function|return|if|else|for|while|import|from|export|default|async|await|class|new|def|in|of|true|false|null|None|self|try|catch|throw)\\b/g;\nexport function codeLine(ctx, s, x, y, w, line, bg) {\n  const th = ctx.theme;\n  const str = clip(ctx, line, Math.max(0, w));\n  const colors = new Array([...str].length).fill(th.ink);\n  const chars = [...str];\n  const mark = (re, c) => { for (const m of str.matchAll(re)) for (let i = m.index; i < m.index + m[0].length && i < colors.length; i++) colors[i] = c; };\n  mark(/\\b\\d+(\\.\\d+)?\\b/g, th.warn);\n  mark(KW, th.crit);\n  mark(/\\b[A-Za-z_]\\w*(?=\\()/g, th.info);\n  mark(/(['\"`]).*?\\1/g, th.pos);\n  mark(/(\\/\\/|#).*$/g, th.dim);\n  let cx = x;\n  chars.forEach((ch, i) => { cx = s.text(cx, y, ch, colors[i], bg); });\n  return cx;\n}\n",
  "prism-term/src/facets/callouts/admonitions.js": "// GitHub-style alerts — NOTE, TIP, IMPORTANT, WARNING, CAUTION — revealed in turn.\nimport { glyphs, uni, ATTR, wrapText, tint, phase, span } from './_admon.js';\n\nconst KINDS = [\n  { key: 'NOTE', icon: ['ℹ', 'i'], color: 'info', body: 'The agent runs read-only diagnostics without asking. Changes always wait for a human.' },\n  { key: 'TIP', icon: ['✦', '*'], color: 'pos', body: 'Pipe any page of the terminal site through `less -R` to scroll it with color.' },\n  { key: 'IMPORTANT', icon: ['◆', '*'], color: 'crit', body: 'Facets must be deterministic: derive everything from t, ctx.rng and ctx.noise.' },\n  { key: 'WARNING', icon: ['▲', '!'], color: 'warn', body: 'tmux without Tc quantizes truecolor to 256 colors. Test both.' },\n  { key: 'CAUTION', icon: ['✖', 'x'], color: 'neg', body: 'Rolling back the schema migration drops the orders.v2 column and its data.' },\n];\n\nexport default {\n  id: 'callouts-admonitions',\n  title: 'Admonitions',\n  category: 'Admonitions',\n  description: 'NOTE, TIP, IMPORTANT, WARNING and CAUTION blocks in the GitHub alert style — colored gutter bar, icon, title and wrapped body.',\n  tags: ['admonition', 'alert', 'note', 'warning', 'callout', 'github'],\n  kind: 'widget',\n  size: { w: 72, h: 22 },\n  minSize: { w: 34, h: 6 },\n  loop: 10,\n  color: '16',\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme;\n    // 16 colors / mono: inline code keeps the default background (a 14% wash would be a solid black block on Night)\n    const low = ctx.caps.depth === '16' || ctx.caps.depth === 'mono';\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height;\n      const u = uni(ctx);\n      const p = phase(t, 10);\n      // Lay out as many blocks as fit; at small sizes, cycle one at a time.\n      const blocks = KINDS.map((k) => ({ ...k, lines: wrapText(k.body, Math.max(10, W - 4)) }));\n      const need = blocks.reduce((n, k) => n + k.lines.length + 2, 0);\n      const list = need <= H ? blocks : [blocks[Math.floor(p / 2) % blocks.length]];\n      let y = 0;\n      list.forEach((k, i) => {\n        const c = th[k.color];\n        const shown = list.length === 1 ? 1 : span(p, i * 0.45, i * 0.45 + 0.5);\n        if (shown <= 0 || y >= H) return;\n        const h = k.lines.length + 1;\n        for (let r = 0; r < h && y + r < H; r++) b.set(0, y + r, u ? '▌' : '|', c);\n        const reveal = Math.ceil(shown * (W - 2));\n        b.text(2, y, `${k.icon[u ? 0 : 1]} ${k.key[0]}${k.key.slice(1).toLowerCase()}`.slice(0, reveal), c, undefined, ATTR.BOLD);\n        k.lines.forEach((line, j) => {\n          if (y + 1 + j >= H) return;\n          // Inline `code` spans: drop the backticks, tint the span.\n          let x = 2, inCode = false, n = 0;\n          for (const ch of line) {\n            if (ch === '`') { inCode = !inCode; continue; }\n            if (n++ >= reveal) break;\n            x = b.text(x, y + 1 + j, ch, inCode ? th.accent : th.ink, inCode && !low ? tint(ctx, th.accent, 0.14) : undefined);\n          }\n        });\n        if (list.length === 1) b.fill(1, y, W - 1, h, undefined, undefined, tint(ctx, c, 0.08));\n        y += h + 1;\n      });\n    };\n  },\n};\n",
  "prism-term/src/facets/callouts/alert-banners.js": "// Full-width alert banners that slide in, stack, and get dismissed.\nimport { glyphs, uni, ATTR, fit, tint, ease, phase } from './_kit.js';\n\nconst BANNERS = [\n  { tone: 'info', icon: ['ℹ', 'i'], title: 'Maintenance', text: 'pg-orders-replica-01 patch window starts at 02:00 UTC' },\n  { tone: 'pos', icon: ['✔', '+'], title: 'Resolved', text: 'checkout latency back to baseline (p95 212 ms)' },\n  { tone: 'warn', icon: ['▲', '!'], title: 'Degraded', text: 'payments p95 above SLO for 3 minutes' },\n  { tone: 'neg', icon: ['✖', 'x'], title: 'Outage', text: 'insurance-claims.example.net timing out; rx refills queued' },\n];\n\nexport default {\n  id: 'callouts-alert-banners',\n  title: 'Alert Banners',\n  category: 'Admonitions',\n  description: 'Full-width info, success, warning and error banners that slide in from the edge, stack, pulse when critical and get dismissed.',\n  tags: ['banner', 'alert', 'notification', 'status', 'slide'],\n  kind: 'widget',\n  size: { w: 76, h: 12 },\n  minSize: { w: 36, h: 4 },\n  loop: 9,\n  color: '16',\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 9);\n      const rowH = H >= 10 ? 3 : H >= 6 ? 2 : 1;\n      const fitN = Math.max(1, Math.floor(H / rowH));\n      BANNERS.slice(0, fitN).forEach((bn, i) => {\n        const inAt = i === 0 ? -1 : 0.3 + i * 0.9, outAt = 7.2 + i * 0.25;\n        const slide = ease(p, inAt, inAt + 0.45) * (1 - ease(p, outAt, outAt + 0.35));\n        if (slide <= 0.001) return;\n        const c = th[bn.tone];\n        const x = Math.round((1 - slide) * W);\n        const y = i * rowH;\n        const crit = bn.tone === 'neg' && Math.sin(t * 5) > 0;\n        const bg = tint(ctx, c, crit ? 0.32 : 0.2);\n        for (let r = 0; r < Math.min(rowH, 2); r++) b.fill(x, y + r, W - x, 1, ' ', undefined, bg);\n        b.set(x, y, u ? '▌' : '|', c, bg);\n        if (rowH > 1) b.set(x, y + 1, u ? '▌' : '|', c, bg);\n        const line = `${bn.icon[u ? 0 : 1]}  ${bn.title}`;\n        b.text(x + 2, y, line, c, bg, ATTR.BOLD);\n        const tx = rowH > 1 ? x + 5 : x + 4 + line.length;\n        const ty = rowH > 1 ? y + 1 : y;\n        b.text(tx, ty, fit(bn.text, Math.max(0, W - tx - 4), undefined, ctx), th.ink, bg);\n        b.text(W - 3, y, u ? '✕' : 'x', th.muted, bg);\n      });\n    };\n  },\n};\n",
  "prism-term/src/facets/callouts/block-quote.js": "// A block quote that types itself out, then hands over to the next.\nimport { uni, ATTR, phase, typed, blink, mix } from './_kit.js';\nimport { wrapText } from './_admon.js';\n\nconst QUOTES = [\n  ['Simplicity is prerequisite for reliability.', 'Edsger W. Dijkstra'],\n  ['Write programs that do one thing and do it well. Write programs to work together.', 'Doug McIlroy'],\n  ['Premature optimization is the root of all evil.', 'Donald Knuth'],\n];\n\nexport default {\n  id: 'callouts-block-quote',\n  title: 'Block Quote',\n  category: 'Admonitions',\n  description: 'A pull quote with an accent gutter that types itself out, attributes its author, then fades to the next — for READMEs and MOTDs.',\n  tags: ['quote', 'blockquote', 'typewriter', 'motd', 'pull quote'],\n  kind: 'text',\n  size: { w: 64, h: 9 },\n  minSize: { w: 30, h: 5 },\n  loop: 21,\n  color: '16',\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, u = uni(ctx);\n      const p = phase(t, 21);\n      const k = Math.floor(p / 7) % QUOTES.length, local = p - k * 7;\n      const [q, who] = QUOTES[k];\n      const lines = wrapText(`“${q}”`, Math.max(10, W - 6));\n      const fade = local > 6.2 ? 1 - (local - 6.2) / 0.8 : 1;\n      const ink = mix(th.bg, th.ink, Math.max(0, fade));\n      const y0 = Math.max(0, Math.floor((H - lines.length - 2) / 2));\n      for (let r = 0; r < lines.length + 2 && y0 + r < H; r++) b.set(1, y0 + r, u ? '┃' : '|', mix(th.bg, th.accent, fade));\n      let budget = typed(lines.join('\\n'), local, -0.15, 34);\n      lines.forEach((line, r) => {\n        const part = budget.slice(0, line.length);\n        budget = budget.slice(line.length + 1);\n        if (y0 + r < H) b.text(4, y0 + r, u ? part : part.replace(/[“”]/g, '\"'), ink, undefined, ATTR.ITALIC);\n        if (part.length && part.length < line.length && blink(t, 0.6)) b.set(4 + part.length, y0 + r, u ? '▌' : '_', th.accent);\n      });\n      const done = typed(lines.join('\\n'), local, -0.15, 34).length >= lines.join('\\n').length;\n      if (done && y0 + lines.length + 1 < H) {\n        const attr = `${u ? '—' : '--'} ${who}`;\n        b.text(Math.max(4, W - attr.length - 2), y0 + lines.length + 1, attr, mix(th.bg, th.muted, fade));\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/callouts/border-styles.js": "// Every border style prism-term draws, side by side, with a focus ring walking the grid.\nimport { panel, ATTR, center, phase } from './_kit.js';\n\nconst STYLES = [\n  ['round', 'the default'],\n  ['single', 'classic square'],\n  ['double', 'for emphasis'],\n  ['heavy', 'for focus'],\n  ['dashed', 'secondary panes'],\n  ['ascii', '7-bit, anywhere'],\n];\n\nexport default {\n  id: 'callouts-border-styles',\n  title: 'Border Styles',\n  category: 'Panels & Borders',\n  description: 'All six border styles — round, single, double, heavy, dashed and ASCII — with a focus ring stepping through them.',\n  tags: ['border', 'box', 'panel', 'box-drawing', 'frame'],\n  kind: 'widget',\n  size: { w: 72, h: 18 },\n  minSize: { w: 34, h: 9 },\n  loop: 7.2,\n  color: '16',\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height;\n      const cols = W >= 60 ? 3 : 2;\n      const rows = Math.ceil(STYLES.length / cols);\n      const cw = Math.floor((W - (cols - 1)) / cols), ch = Math.max(3, Math.floor((H - (rows - 1)) / rows));\n      const sel = Math.floor(phase(t, 7.2) / 1.2) % STYLES.length;\n      STYLES.forEach(([style, note], i) => {\n        const x = (i % cols) * (cw + 1), y = Math.floor(i / cols) * (ch + 1);\n        if (y + 3 > H) return;\n        const on = i === sel;\n        const pulse = 0.55 + 0.45 * Math.sin(t * 6);\n        const inner = panel(ctx, b, x, y, cw, Math.min(ch, H - y), {\n          style,\n          title: style, titleAlign: 'center', titleColor: on ? th.accent : th.muted,\n          color: on ? (pulse > 0.5 ? th.accent : th.accent2) : th.line,\n        });\n        if (inner.height >= 1) center(inner, 0, Math.floor((inner.height - 1) / 2), inner.width, note, on ? th.ink : th.dim, undefined, on ? ATTR.BOLD : 0, ctx);\n      });\n    };\n  },\n};\n",
  "prism-term/src/facets/callouts/code-block.js": "// A code panel stepping like a debugger: gutter line numbers, syntax color,\n// a breakpoint, diff markers and the current-line highlight walking down.\nimport { ATTR, uni, panel, codeLine, phase, mix, fit } from './_kit.js';\n\nconst CODE = [\n  \"import { connect } from './db.js';\",\n  '',\n  'export async function reserve(order) {',\n  '  const pool = await connect({ size: 40 });',\n  \"  const rows = await pool.query('SELECT * FROM stock');\",\n  '  if (rows.length === 0) throw new Error(\"out of stock\");',\n  '  // hold the items for ten minutes',\n  \"  await pool.query('UPDATE stock SET held = true');\",\n  '  return { ok: true, held: rows.length };',\n  '}',\n];\nconst DIFF = { 3: '+', 7: '~' };\nconst BREAK = 5;\n\nexport default {\n  id: 'callouts-code-block',\n  title: 'Code Block',\n  category: 'Panels & Borders',\n  description: 'A titled code panel with line numbers, syntax coloring, a breakpoint, diff markers and a debugger-style current line stepping through.',\n  tags: ['code', 'syntax', 'line numbers', 'debugger', 'snippet'],\n  kind: 'widget',\n  size: { w: 66, h: 14 },\n  minSize: { w: 36, h: 7 },\n  loop: 9,\n  color: '256',\n  order: 50,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, u = uni(ctx);\n      const p = phase(t, 9);\n      const lines = CODE.length;\n      const cur = [2, 3, 4, 5, 7, 8][Math.floor(p / 1.5) % 6];\n      const inner = panel(ctx, b, 0, 0, W, H, { title: 'inventory/reserve.js', footer: `Ln ${cur + 1}, Col 3 ${u ? '·' : '-'} JavaScript`, color: th.line });\n      const iw = inner.width, ih = inner.height;\n      const first = Math.max(0, Math.min(lines - ih, cur - Math.floor(ih / 2)));\n      for (let r = 0; r < ih; r++) {\n        const i = first + r;\n        if (i >= lines) break;\n        const isCur = i === cur;\n        const bg = isCur ? mix(th.bg, th.accent, 0.16) : undefined;\n        if (bg !== undefined) inner.fill(0, r, iw, 1, ' ', undefined, bg);\n        const gut = i === BREAK ? (u ? '●' : '*') : isCur ? (u ? '▶' : '>') : ' ';\n        inner.set(0, r, gut, i === BREAK ? th.neg : th.accent, bg);\n        inner.text(1, r, fit(String(i + 1), 3, 'right', ctx), isCur ? th.ink : th.dim, bg);\n        const mark = DIFF[i];\n        inner.set(5, r, mark || ' ', mark === '+' ? th.pos : th.warn, bg);\n        codeLine(ctx, inner, 7, r, iw - 7, CODE[i], bg);\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/callouts/dividers.js": "// Labeled rules in every weight, with a light sweeping along them.\nimport { ATTR, uni, phase, mix } from './_kit.js';\n\nconst RULES = [\n  { ch: ['─', '-'], label: 'Section', align: 'left' },\n  { ch: ['━', '='], label: 'Heavy', align: 'center' },\n  { ch: ['═', '='], label: 'Double', align: 'right' },\n  { ch: ['┄', '.'], label: 'Dotted', align: 'left' },\n  { ch: ['╌', '-'], label: 'Dashed', align: 'center' },\n  { ch: ['·', '.'], label: 'Middle dots', align: 'center' },\n  { ch: ['•', '*'], spaced: true, label: '', align: 'center' },\n  { ch: ['▔', '-'], label: 'Overline', align: 'left' },\n];\n\nexport default {\n  id: 'callouts-dividers',\n  title: 'Dividers & Rules',\n  category: 'Dividers & Headers',\n  description: 'Horizontal rules in light, heavy, double, dotted, dashed and dotted-bullet weights with left, centered and right labels and a sweep of light.',\n  tags: ['divider', 'rule', 'separator', 'hr', 'section'],\n  kind: 'widget',\n  size: { w: 64, h: 15 },\n  minSize: { w: 24, h: 6 },\n  loop: 4,\n  color: '256',\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, u = uni(ctx);\n      const step = H >= RULES.length * 2 - 1 ? 2 : 1;\n      const sweep = (phase(t, 4) / 4) * (W + 20) - 10;\n      RULES.forEach((r, i) => {\n        const y = i * step;\n        if (y >= H) return;\n        const ch = r.ch[u ? 0 : 1];\n        for (let x = 0; x < W; x++) {\n          if (r.spaced && x % 2) continue;\n          const k = Math.max(0, 1 - Math.abs(x - sweep - i * 2) / 6);\n          b.set(x, y, ch, mix(th.line, th.accent, k));\n        }\n        if (r.label) {\n          const text = ` ${r.label} `;\n          const lx = r.align === 'left' ? 3 : r.align === 'right' ? W - text.length - 3 : Math.floor((W - text.length) / 2);\n          b.text(Math.max(0, lx), y, text, i === 0 ? th.ink : th.muted, undefined, ATTR.BOLD);\n        }\n      });\n    };\n  },\n};\n",
  "prism-term/src/facets/callouts/focus-panels.js": "// A four-pane layout where focus moves like Tab in a real TUI: the focused\n// pane gets a heavy accent border, the rest recede.\nimport { panel, glyphs, ATTR, fit, phase, uni } from './_kit.js';\n\nconst LOGS = ['GET /cart 200 41ms', 'POST /checkout 201 212ms', 'GET /search?q=tent 200 22ms', 'healthcheck ok', 'GET /account 200 34ms', 'POST /login 200 88ms'];\n\nexport default {\n  id: 'callouts-focus-panels',\n  title: 'Focus Ring Layout',\n  category: 'Panels & Borders',\n  description: 'A four-pane dashboard where keyboard focus cycles with Tab — heavy accent border on the active pane, dimmed borders on the rest.',\n  tags: ['focus', 'layout', 'panes', 'tab', 'dashboard'],\n  kind: 'tui',\n  size: { w: 76, h: 20 },\n  minSize: { w: 40, h: 10 },\n  loop: 8,\n  color: '16',\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx);\n      const focus = Math.floor(phase(t, 8) / 2) % 4;\n      const lw = Math.floor(W * 0.42), rw = W - lw - 1;\n      const PH = H - 1; // last row is the key hint\n      const th1 = Math.floor((PH - 1) / 2), th2 = PH - 1 - th1 - 1;\n      const panes = [\n        { x: 0, y: 0, w: lw, h: th1, title: 'Services' },\n        { x: lw + 1, y: 0, w: rw, h: th1, title: 'Logs' },\n        { x: 0, y: th1 + 1, w: lw, h: th2 + 1, title: 'Alerts' },\n        { x: lw + 1, y: th1 + 1, w: rw, h: th2 + 1, title: 'Metrics' },\n      ];\n      panes.forEach((p, i) => {\n        const on = i === focus;\n        const inner = panel(ctx, b, p.x, p.y, p.w, p.h, { style: on ? 'heavy' : 'round', title: on ? `${g.tri} ${p.title}` : p.title, titleColor: on ? th.accent : th.muted, color: on ? th.accent : th.line });\n        const ink = on ? th.ink : th.muted;\n        for (let r = 0; r < inner.height; r++) {\n          if (i === 0) {\n            const svc = ['edge-lb', 'api-gateway', 'checkout', 'payments', 'catalog', 'search'][r];\n            if (!svc) break;\n            const bad = svc === 'payments' && Math.sin(t * 0.8) > 0.2;\n            inner.text(1, r, g.dot, bad ? th.warn : th.pos);\n            inner.text(3, r, fit(svc, Math.max(0, inner.width - 12), undefined, ctx), ink);\n            inner.text(inner.width - 8, r, bad ? 'degraded' : 'running', bad ? th.warn : th.dim);\n          } else if (i === 1) {\n            const k = (Math.floor(t * 1.5) + r) % LOGS.length;\n            inner.text(1, r, fit(LOGS[k], inner.width - 2, undefined, ctx), r === inner.height - 1 ? th.ink : ink);\n          } else if (i === 2) {\n            const al = [['warn', 'payments p95 above SLO for 3m'], ['info', 'deploy search v2.3.1 finished'], ['ok', 'cert renewed for edge-lb']][r];\n            if (!al) break;\n            const c = al[0] === 'warn' ? th.warn : al[0] === 'ok' ? th.pos : th.info;\n            inner.text(1, r, al[0].toUpperCase().padEnd(5), c, undefined, ATTR.BOLD);\n            inner.text(7, r, fit(al[1], Math.max(0, inner.width - 8), undefined, ctx), ink);\n          } else {\n            const bars = ['cpu', 'mem', 'disk', 'net'][r];\n            if (!bars) break;\n            const v = 0.3 + 0.25 * Math.sin(t * (0.6 + r * 0.3) + r) + r * 0.08;\n            inner.text(1, r, bars.padEnd(5), th.muted);\n            const bw = Math.max(1, inner.width - 13);\n            for (let k = 0; k < bw; k++) inner.set(7 + k, r, k < v * bw ? (uni(ctx) ? '▮' : '#') : (uni(ctx) ? '▯' : '.'), k < v * bw ? (on ? th.info : th.dim) : th.line);\n            inner.text(8 + bw, r, `${Math.round(v * 100)}%`.padStart(4), ink);\n          }\n        }\n      });\n      const hint = `Tab next pane ${g.mid} Shift-Tab previous`;\n      b.text(Math.max(0, W - hint.length - 1), H - 1, hint, th.dim);\n    };\n  },\n};\n",
  "prism-term/src/facets/callouts/key-value.js": "// Grouped key/value blocks with dotted leaders, live values and status pills.\nimport { ATTR, uni, phase } from './_kit.js';\n\nexport default {\n  id: 'callouts-key-value',\n  title: 'Key-Value Details',\n  category: 'Key-Value',\n  description: 'Grouped key/value details with dotted leaders to right-aligned values, live-updating numbers and status pills — the \"describe\" view of every CLI.',\n  tags: ['key value', 'details', 'describe', 'leaders', 'properties'],\n  kind: 'widget',\n  size: { w: 56, h: 16 },\n  minSize: { w: 32, h: 8 },\n  loop: 10,\n  color: '16',\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, u = uni(ctx);\n      const p = phase(t, 10);\n      const healthy = !(p > 5.5 && p < 8);\n      const groups = [\n        ['Service', [\n          ['Name', 'checkout'],\n          ['Version', 'v2.14.1'],\n          ['Replicas', `${healthy ? 2 : 1} / 2`, healthy ? 'pos' : 'warn'],\n          ['Status', healthy ? 'Running' : 'Degraded', healthy ? 'pos' : 'warn', true],\n        ]],\n        ['Traffic', [\n          ['Requests', `${(612 + Math.round(Math.sin(t * 1.1) * 24)).toLocaleString('en-US')} rps`],\n          ['p95 latency', `${Math.round(healthy ? 212 + Math.sin(t) * 9 : 840 + Math.sin(t * 3) * 60)} ms`, healthy ? null : 'warn'],\n          ['Error rate', `${(healthy ? 0.15 : 2.4 + Math.sin(t * 2) * 0.3).toFixed(2)} %`, healthy ? null : 'neg'],\n        ]],\n        ['Network', [\n          ['Address', '10.20.2.31:8085'],\n          ['Upstream', 'pg-orders-primary-01'],\n        ]],\n      ];\n      let y = 0;\n      for (const [name, rows] of groups) {\n        if (y >= H) break;\n        b.text(0, y, name.toUpperCase(), th.muted, undefined, ATTR.BOLD);\n        y++;\n        for (const [k, v, tone, pill] of rows) {\n          if (y >= H) break;\n          const vw = v.length + (pill ? 2 : 0);\n          const lead = Math.max(1, W - 2 - k.length - vw - 2);\n          b.text(2, y, k, th.ink);\n          b.text(2 + k.length + 1, y, (u ? '·' : '.').repeat(lead), th.line);\n          const vx = W - vw;\n          if (pill) {\n            const c = th[tone];\n            b.text(vx, y, ` ${v} `, th.bg, c, ATTR.BOLD);\n          } else b.text(vx, y, v, tone ? th[tone] : th.ink, undefined, tone ? ATTR.BOLD : 0);\n          y++;\n        }\n        y++;\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/callouts/neofetch.js": "// A neofetch-style system summary: logo art on the left, key/value facts on the\n// right, the color swatch row underneath. The facts tick like a live box.\nimport { ATTR, uni, mix } from './_kit.js';\nimport { palette } from '../../palettes.js';\n\nconst LOGO = [\n  '        /\\\\        ',\n  '       /  \\\\       ',\n  '      / /\\\\ \\\\      ',\n  '     / /  \\\\ \\\\     ',\n  '    / /    \\\\ \\\\    ',\n  '   / /  /\\\\  \\\\ \\\\   ',\n  '  / /  /  \\\\  \\\\ \\\\  ',\n  ' /_/__/____\\\\__\\\\_\\\\ ',\n];\n\nexport default {\n  id: 'callouts-neofetch',\n  title: 'System Summary (neofetch)',\n  category: 'Key-Value',\n  description: 'A neofetch-style summary: gradient logo art beside aligned key/value system facts and the ANSI color swatch row, with uptime ticking.',\n  tags: ['neofetch', 'fastfetch', 'system info', 'key value', 'motd'],\n  kind: 'widget',\n  size: { w: 64, h: 15 },\n  minSize: { w: 38, h: 10 },\n  loop: 12,\n  color: '256',\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme;\n    const grad = palette('prism', th);\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, u = uni(ctx);\n      const showLogo = W >= 56;\n      const lx = 1, ly = Math.max(0, Math.floor((H - Math.max(LOGO.length, 12)) / 2));\n      if (showLogo) LOGO.forEach((row, y) => [...row].forEach((ch, x) => { if (ch !== ' ') b.set(lx + x, ly + y, ch, grad((x + y) / 28 + t * 0.05), undefined, ATTR.BOLD); }));\n      const kx = showLogo ? lx + 21 : 1;\n      const up = 6 * 86400 + 4 * 3600 + 17 * 60 + Math.floor(t);\n      const upS = `${Math.floor(up / 86400)} days, ${Math.floor(up / 3600) % 24} hours, ${Math.floor(up / 60) % 60} mins`;\n      const mem = 6120 + Math.round(Math.sin(t * 0.6) * 140);\n      const facts = [\n        ['', 'ops@prism-edge-01', true],\n        ['', u ? '─'.repeat(17) : '-'.repeat(17)],\n        ['OS', 'Debian GNU/Linux 12 x86_64'],\n        ['Host', 'c6i.2xlarge'],\n        ['Kernel', '6.1.0-26-cloud-amd64'],\n        ['Uptime', upS],\n        ['Packages', '612 (dpkg)'],\n        ['Shell', 'bash 5.2.15'],\n        ['Terminal', 'tmux 3.3a'],\n        ['CPU', 'Intel Xeon 8375C (8) @ 2.90GHz'],\n        ['Memory', `${mem.toLocaleString('en-US')}MiB / 15,733MiB`],\n      ];\n      let y = ly;\n      for (const [k, v, title] of facts) {\n        if (y >= H - 2) break;\n        if (title) {\n          const [user, host] = v.split('@');\n          let x = b.text(kx, y, user, th.accent, undefined, ATTR.BOLD);\n          x = b.text(x, y, '@', th.ink);\n          b.text(x, y, host, th.accent, undefined, ATTR.BOLD);\n        } else if (!k) b.text(kx, y, v, th.muted);\n        else {\n          const x = b.text(kx, y, k, th.accent, undefined, ATTR.BOLD);\n          b.text(x, y, ': ', th.ink);\n          b.text(x + 2, y, v.slice(0, Math.max(0, W - x - 3)), th.ink);\n        }\n        y++;\n      }\n      // Color swatches: the 8 ANSI-ish tones, then their bright twins.\n      const tones = [th.bg, th.neg, th.pos, th.warn, th.info, th.crit, th.accent, th.ink];\n      const sy = Math.min(H - 2, y + 1);\n      tones.forEach((c, i) => {\n        b.text(kx + i * 3, sy, '   ', undefined, c);\n        if (sy + 1 < H) b.text(kx + i * 3, sy + 1, '   ', undefined, mix(c, 0xffffff, 0.3));\n      });\n    };\n  },\n};\n",
  "prism-term/src/facets/callouts/section-headers.js": "// Section header styles that type themselves in: numbered, underlined, boxed,\n// inverse bar and markdown-setext.\nimport { ATTR, uni, phase, typed, blink, mix, fit } from './_kit.js';\n\nexport default {\n  id: 'callouts-section-headers',\n  title: 'Section Headers',\n  category: 'Dividers & Headers',\n  description: 'Five header styles — numbered eyebrow, underlined, boxed, inverse bar and setext — that type in one after another, for reports and CLIs.',\n  tags: ['header', 'heading', 'section', 'title', 'typography'],\n  kind: 'text',\n  size: { w: 60, h: 18 },\n  minSize: { w: 30, h: 8 },\n  loop: 9,\n  color: '16',\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, u = uni(ctx);\n      const p = phase(t, 9);\n      const typer = (text, at) => typed(text, p, at, 28);\n      const caret = (x, y, text, full) => { if (text.length < full.length && text.length && blink(t, 0.5)) b.set(x + text.length, y, u ? '▌' : '_', th.accent); };\n      const blocks = [];\n      // 1. Numbered eyebrow + title.\n      blocks.push((y, at) => {\n        const eyebrow = '01 / OVERVIEW', title = 'System health at a glance';\n        const e = typer(eyebrow, at), tt = typer(title, at + 0.4);\n        b.text(0, y, e, th.accent, undefined, ATTR.BOLD); caret(0, y, e, eyebrow);\n        b.text(0, y + 1, tt, th.ink, undefined, ATTR.BOLD); caret(0, y + 1, tt, title);\n        return 3;\n      });\n      // 2. Underlined.\n      blocks.push((y, at) => {\n        const title = 'Incidents';\n        const tt = typer(title, at);\n        b.text(0, y, tt, th.ink, undefined, ATTR.BOLD);\n        b.fill(0, y + 1, Math.min(W, Math.round(tt.length / title.length * (W * 0.5))), 1, u ? '─' : '-', th.accent);\n        return 3;\n      });\n      // 3. Boxed.\n      blocks.push((y, at) => {\n        const title = 'Deployments';\n        const tt = typer(title, at);\n        const w = Math.min(W, title.length + 4);\n        if (tt.length) {\n          b.text(0, y, (u ? '┌' : '+') + (u ? '─' : '-').repeat(w - 2) + (u ? '┐' : '+'), th.line);\n          b.text(0, y + 1, (u ? '│' : '|') + fit(` ${tt}`, w - 2, undefined, ctx) + (u ? '│' : '|'), th.line);\n          b.text(2, y + 1, tt, th.ink, undefined, ATTR.BOLD);\n          b.text(0, y + 2, (u ? '└' : '+') + (u ? '─' : '-').repeat(w - 2) + (u ? '┘' : '+'), th.line);\n        }\n        return 4;\n      });\n      // 4. Inverse bar.\n      blocks.push((y, at) => {\n        const title = 'CAPACITY';\n        const tt = typer(title, at);\n        if (tt.length) {\n          b.fill(0, y, W, 1, ' ', undefined, mix(th.bg, th.accent, 0.22));\n          b.set(0, y, u ? '▌' : '|', th.accent, mix(th.bg, th.accent, 0.22));\n          b.text(2, y, tt, th.ink, mix(th.bg, th.accent, 0.22), ATTR.BOLD);\n          b.text(W - 9, y, '3 alerts', th.warn, mix(th.bg, th.accent, 0.22));\n        }\n        return 2;\n      });\n      // 5. Setext.\n      blocks.push((y, at) => {\n        const title = 'Runbook: rotate certificates';\n        const tt = typer(title, at);\n        b.text(0, y, tt, th.ink, undefined, ATTR.BOLD);\n        if (tt.length) b.fill(0, y + 1, Math.min(W, tt.length), 1, '=', th.muted);\n        return 3;\n      });\n      let y = 0, at = -0.3;\n      const fitAll = blocks.length * 3 + 1 <= H;\n      const list = fitAll ? blocks : [blocks[Math.floor(p / 1.8) % blocks.length]];\n      list.forEach((draw) => { if (y < H) { y += draw(y, fitAll ? at : 0.05); at += 1.3; } });\n    };\n  },\n};\n",
  "prism-term/src/facets/callouts/shadow-card.js": "// A raised card with a drop shadow that lifts and settles, and a glint across its title.\nimport { panel, glyphs, ATTR, uni, mix, ease, phase, button } from './_kit.js';\n\nexport default {\n  id: 'callouts-shadow-card',\n  title: 'Shadow Card',\n  category: 'Panels & Borders',\n  description: 'A card with a shaded drop shadow that lifts on hover and settles, with a glint sweeping its title — depth with nothing but shade blocks.',\n  tags: ['card', 'shadow', 'elevation', 'depth', 'hover'],\n  kind: 'widget',\n  size: { w: 52, h: 14 },\n  minSize: { w: 34, h: 9 },\n  loop: 6,\n  color: '256',\n  order: 40,\n  setup(ctx) {\n    const th = ctx.theme;\n    const title = 'Deploy complete';\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx);\n      const p = phase(t, 6);\n      const lift = ease(p, 0.6, 1.4) * (1 - ease(p, 3.8, 4.8));\n      const cw = Math.min(W - 4, 44), chh = Math.min(H - 3, 9);\n      const x = Math.floor((W - cw) / 2) - Math.round(lift), y = Math.floor((H - chh) / 2) - Math.round(lift);\n      const off = 1 + Math.round(lift);\n      // Turbo Vision-style shadow: a darkened band offset down-right, textured with shade blocks.\n      // Refraction: depth from light, not heavy drop shadows — a soft band, lighter by Day.\n      const shadowBg = mix(th.bg, 0x000000, th.mode === 'light' ? 0.07 : 0.4);\n      const shadowFg = mix(shadowBg, th.line, 0.45);\n      const shade = uni(ctx) ? (lift > 0.5 ? '▒' : '░') : ':';\n      for (let yy = y + off; yy < y + off + chh; yy++) for (let xx = x + off * 2; xx < x + off * 2 + cw; xx++) b.set(xx, yy, shade, shadowFg, shadowBg);\n      const inner = panel(ctx, b, x, y, cw, chh, { title: '', color: lift > 0.5 ? th.accent : th.line, bg: th.panel });\n      // Title with a moving glint.\n      const tx = 2, gl = (p * 9) % (title.length + 14) - 7;\n      [...title].forEach((ch, i) => {\n        const k = Math.max(0, 1 - Math.abs(i - gl) / 3);\n        inner.text(tx + i, 1, ch, mix(th.ink, th.mode === 'light' ? th.accent : 0xffffff, k), th.panel, ATTR.BOLD);\n      });\n      inner.text(tx + title.length + 1, 1, g.check, th.pos, th.panel);\n      if (inner.height > 4) {\n        inner.text(tx, 3, 'checkout v2.14.1 is live on 12/12 hosts', th.muted, th.panel);\n        inner.text(tx, 4, `MTTR 0s ${g.mid} 0 errors during rollout`, th.dim, th.panel);\n      }\n      if (inner.height > 6) {\n        const bx = inner.width - 22;\n        const w1 = button(ctx, inner, Math.max(tx, bx), inner.height - 1, 'View logs', { focused: false });\n        button(ctx, inner, Math.max(tx, bx) + w1 + 1, inner.height - 1, 'Done', { focused: true, pressed: p > 4.8 && p < 5.2 });\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/callouts/table-grid.js": "// A psql-style result grid: full box-drawing junctions, double rule under the\n// header, and a spreadsheet cursor moving cell to cell.\nimport { ATTR, uni, fit, phase, mix } from './_kit.js';\n\nconst HEAD = ['id', 'order', 'status', 'total', 'placed_at'];\nconst ROWS = [\n  ['1041', 'ord-80211', 'shipped', '84.20', '14:02:11'],\n  ['1042', 'ord-80212', 'paid', '12.99', '14:02:13'],\n  ['1043', 'ord-80213', 'pending', '231.00', '14:02:19'],\n  ['1044', 'ord-80214', 'refunded', '9.49', '14:02:24'],\n  ['1045', 'ord-80215', 'paid', '61.75', '14:02:31'],\n];\nconst ALIGN = ['r', 'l', 'l', 'r', 'l'];\n\nexport default {\n  id: 'callouts-table-grid',\n  title: 'Result Grid',\n  category: 'Tables',\n  description: 'A psql-style result grid with box-drawing junctions, a double rule under the header, a \"(5 rows)\" footer and a moving cell cursor.',\n  tags: ['table', 'grid', 'sql', 'psql', 'spreadsheet'],\n  kind: 'widget',\n  size: { w: 60, h: 13 },\n  minSize: { w: 52, h: 8 },\n  loop: 10,\n  color: '16',\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme;\n    const widths = HEAD.map((h, i) => Math.max(h.length, ...ROWS.map((r) => r[i].length)) + 2);\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, u = uni(ctx);\n      const J = u ? { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│', t: '┬', bt: '┴', l: '├', r: '┤', x: '┼', dl: '╞', dr: '╡', dx: '╪', dh: '═' }\n        : { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|', t: '+', bt: '+', l: '+', r: '+', x: '+', dl: '+', dr: '+', dx: '+', dh: '=' };\n      const total = widths.reduce((n, w) => n + w, 0) + widths.length + 1;\n      const x0 = Math.max(0, Math.floor((W - total) / 2));\n      const maxRows = Math.max(0, Math.min(ROWS.length, H - 5));\n      const p = phase(t, 10);\n      const cell = Math.floor(p / 0.4) % (maxRows * HEAD.length || 1);\n      const cr = Math.floor(cell / HEAD.length), cc = cell % HEAD.length;\n      const rule = (y, l, m, r, h) => {\n        if (y >= H) return;\n        let x = x0;\n        b.set(x++, y, l, th.line);\n        widths.forEach((w, i) => { for (let k = 0; k < w; k++) b.set(x++, y, h, th.line); b.set(x++, y, i === widths.length - 1 ? r : m, th.line); });\n      };\n      const line = (y, cells, fg, attr, hiCol = -1) => {\n        if (y >= H) return;\n        let x = x0;\n        b.set(x++, y, J.v, th.line);\n        cells.forEach((c, i) => {\n          const hi = i === hiCol;\n          b.text(x, y, fit(` ${c} `, widths[i], ALIGN[i] === 'r' ? 'right' : 'left', ctx), hi ? th.bg : typeof fg === 'function' ? fg(i, c) : fg, hi ? th.accent : undefined, hi ? ATTR.BOLD : attr);\n          x += widths[i];\n          b.set(x++, y, J.v, th.line);\n        });\n      };\n      rule(0, J.tl, J.t, J.tr, J.h);\n      line(1, HEAD, th.muted, ATTR.BOLD);\n      rule(2, J.dl, J.dx, J.dr, J.dh);\n      for (let r = 0; r < maxRows; r++) line(3 + r, ROWS[r], (i, c) => (i === 2 ? ({ shipped: th.pos, paid: th.info, pending: th.warn, refunded: th.dim }[c]) : th.ink), 0, r === cr ? cc : -1);\n      rule(3 + maxRows, J.bl, J.bt, J.br, J.h);\n      if (4 + maxRows < H) b.text(x0, 4 + maxRows, `(${maxRows} rows)`, th.dim);\n      if (4 + maxRows < H) {\n        const ref = `${String.fromCharCode(65 + cc)}${cr + 1} = ${ROWS[cr]?.[cc] ?? ''}`;\n        b.text(Math.max(x0, x0 + total - ref.length), 4 + maxRows, ref, mix(th.bg, th.accent, 0.85));\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/callouts/table-sorted.js": "// A sortable table: the sort column changes and rows glide to their new places.\nimport { ATTR, glyphs, fit, phase, ease, mix } from './_kit.js';\n\nconst DATA = [\n  { name: 'pg-orders-primary-01', cpu: 18, mem: 61, conns: 136, up: 41 },\n  { name: 'checkout-01', cpu: 64, mem: 48, conns: 20, up: 6 },\n  { name: 'checkout-02', cpu: 58, mem: 47, conns: 20, up: 6 },\n  { name: 'payments-01', cpu: 33, mem: 52, conns: 12, up: 19 },\n  { name: 'catalog-01', cpu: 41, mem: 77, conns: 30, up: 12 },\n  { name: 'search-01', cpu: 27, mem: 69, conns: 8, up: 33 },\n  { name: 'redis-sessions-01', cpu: 9, mem: 38, conns: 412, up: 88 },\n];\nconst SORTS = [['name', 1], ['cpu', -1], ['mem', -1], ['conns', -1]];\nconst COLS = [['name', 'HOST', 21, 'l'], ['cpu', 'CPU%', 6, 'r'], ['mem', 'MEM%', 6, 'r'], ['conns', 'CONNS', 7, 'r'], ['up', 'UP(d)', 6, 'r']];\n\nconst order = (key, dir) => DATA.map((d, i) => i).sort((a, b) => {\n  const x = DATA[a][key], y = DATA[b][key];\n  return (typeof x === 'string' ? x.localeCompare(y) : x - y) * dir;\n});\n\nexport default {\n  id: 'callouts-table-sorted',\n  title: 'Sortable Table',\n  category: 'Tables',\n  description: 'A table whose sort column changes with ▲/▼ markers in the header while the rows glide to their new positions.',\n  tags: ['table', 'sort', 'columns', 'reorder', 'hosts'],\n  kind: 'tui',\n  size: { w: 56, h: 10 },\n  minSize: { w: 44, h: 6 },\n  loop: 12,\n  color: '16',\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme;\n    const orders = SORTS.map(([k, d]) => order(k, d));\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx);\n      const p = phase(t, 12);\n      const si = Math.floor(p / 3) % SORTS.length, local = p - si * 3;\n      const prev = orders[(si + SORTS.length - 1) % SORTS.length], cur = orders[si];\n      const k = ease(local, 0, 0.6);\n      const cols = W >= 52 ? COLS : COLS.slice(0, 4);\n      const total = cols.reduce((n, c) => n + c[2], 0) + cols.length - 1;\n      const x0 = Math.max(0, Math.floor((W - total) / 2));\n      let x = x0;\n      b.fill(x0, 0, total, 1, ' ', undefined, th.panel2);\n      cols.forEach(([key, title, w, a]) => {\n        const sorted = key === SORTS[si][0];\n        const label = sorted ? `${title}${SORTS[si][1] > 0 ? g.sortUp : g.sortDown}` : title;\n        b.text(x, 0, fit(label, w, a === 'r' ? 'right' : 'left', ctx), sorted ? th.accent : th.muted, th.panel2, ATTR.BOLD);\n        x += w + 1;\n      });\n      const rows = Math.min(DATA.length, H - 1);\n      DATA.forEach((d, i) => {\n        const from = prev.indexOf(i), to = cur.indexOf(i);\n        const yf = from + (to - from) * k;\n        const y = 1 + Math.round(yf);\n        if (y > rows) return;\n        const moving = Math.abs(to - from) > 0 && k < 1;\n        let cx = x0;\n        cols.forEach(([key, , w, a]) => {\n          const v = key === 'name' ? d.name : String(d[key]);\n          const hot = (key === 'cpu' && d.cpu > 60) || (key === 'mem' && d.mem > 75);\n          b.text(cx, y, fit(v, w, a === 'r' ? 'right' : 'left', ctx), hot ? th.warn : key === SORTS[si][0] ? th.ink : moving ? mix(th.bg, th.ink, 0.6) : th.muted, to === 0 && !moving ? mix(th.bg, th.accent, 0.12) : undefined, key === SORTS[si][0] ? ATTR.BOLD : 0);\n          cx += w + 1;\n        });\n      });\n    };\n  },\n};\n",
  "prism-term/src/facets/callouts/table-zebra.js": "// Zebra-striped service table: right-aligned numbers, rows arriving, a totals row.\nimport { ATTR, glyphs, uni, fit, phase, mix } from './_kit.js';\n\nconst ROWS = [\n  ['edge-lb', 'us-east-1', 4210, 3.1, 0.0003],\n  ['api-gateway', 'us-east-1', 4198, 8.4, 0.0004],\n  ['checkout', 'us-east-1', 612, 212.0, 0.0015],\n  ['payments', 'us-east-1', 598, 131.5, 0.0011],\n  ['catalog', 'us-west-2', 2214, 41.2, 0.0009],\n  ['search', 'us-west-2', 1630, 22.7, 0.0008],\n  ['rx-orders', 'us-east-1', 188, 309.4, 0.0021],\n  ['loyalty', 'eu-west-1', 402, 37.9, 0.0007],\n];\n\nexport default {\n  id: 'callouts-table-zebra',\n  title: 'Zebra Table with Totals',\n  category: 'Tables',\n  description: 'A zebra-striped table with right-aligned numeric columns, rows arriving one by one and a bold totals row under a rule.',\n  tags: ['table', 'zebra', 'totals', 'columns', 'aligned'],\n  kind: 'widget',\n  size: { w: 64, h: 13 },\n  minSize: { w: 36, h: 6 },\n  loop: 8,\n  color: '16',\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 8);\n      const shown = Math.min(ROWS.length, 1 + Math.floor(p / 0.45));\n      const wide = W >= 56;\n      const cols = wide ? [['SERVICE', 14, 'l'], ['REGION', 11, 'l'], ['RPS', 7, 'r'], ['P95 MS', 8, 'r'], ['ERRORS', 8, 'r']] : [['SERVICE', 12, 'l'], ['RPS', 6, 'r'], ['P95', 7, 'r']];\n      const total = cols.reduce((n, c) => n + c[1], 0) + cols.length - 1;\n      const x0 = Math.max(0, Math.floor((W - total) / 2));\n      const row = (y, cells, fg, bg, attr) => {\n        if (y >= H) return;\n        if (bg !== undefined) b.fill(x0, y, total, 1, ' ', undefined, bg);\n        let x = x0;\n        cols.forEach(([, w, a], i) => { b.text(x, y, fit(cells[i], w, a === 'r' ? 'right' : 'left', ctx), typeof fg === 'function' ? fg(i) : fg, bg, attr); x += w + 1; });\n      };\n      row(0, cols.map((c) => c[0]), th.muted, th.panel2, ATTR.BOLD);\n      const vis = ROWS.slice(0, Math.min(shown, Math.max(0, H - 3)));\n      vis.forEach((r, i) => {\n        const cells = wide ? [r[0], r[1], r[2].toLocaleString('en-US'), r[3].toFixed(1), `${(r[4] * 100).toFixed(2)}%`] : [r[0], r[2].toLocaleString('en-US'), r[3].toFixed(1)];\n        const hot = r[3] > 200;\n        const fresh = i === shown - 1 && p < ROWS.length * 0.45 + 0.4;\n        row(1 + i, cells, (c) => (c === 0 ? th.ink : (wide ? c === 3 : c === 2) && hot ? th.warn : th.ink), i % 2 ? th.panel : fresh ? mix(th.bg, th.accent, 0.18) : undefined, 0);\n      });\n      const ty = 1 + vis.length;\n      if (shown === ROWS.length && ty + 1 < H) {\n        b.fill(x0, ty, total, 1, u ? '─' : '-', th.line);\n        const rps = ROWS.reduce((n, r) => n + r[2], 0);\n        const p95 = Math.max(...ROWS.map((r) => r[3]));\n        const err = ROWS.reduce((n, r) => n + r[4] * r[2], 0) / rps;\n        row(ty + 1, wide ? ['total', `${ROWS.length} services`, rps.toLocaleString('en-US'), `${p95.toFixed(1)}`, `${(err * 100).toFixed(2)}%`] : ['total', rps.toLocaleString('en-US'), p95.toFixed(1)], th.ink, undefined, ATTR.BOLD);\n      } else if (ty < H) b.text(x0, ty, `${g.ellipsis} loading`, th.dim);\n    };\n  },\n};\n",
  "prism-term/src/facets/callouts/titled-panels.js": "// Panels with titles and footers in every alignment — the footers carry live state.\nimport { panel, glyphs, ATTR, fit, spin } from './_kit.js';\n\nconst pad2 = (n) => String(n).padStart(2, '0');\n\nexport default {\n  id: 'callouts-titled-panels',\n  title: 'Titled Panels',\n  category: 'Panels & Borders',\n  description: 'Panels with left, centered and right titles and live footers — a clock, a pager and a status — the bread and butter of TUI layout.',\n  tags: ['panel', 'title', 'footer', 'layout', 'box'],\n  kind: 'widget',\n  size: { w: 72, h: 14 },\n  minSize: { w: 36, h: 8 },\n  loop: 10,\n  color: '16',\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx);\n      const three = W >= 60;\n      const n = three ? 3 : 2;\n      const pw = Math.floor((W - (n - 1)) / n);\n      const sec = Math.floor(t) + 14 * 3600 + 3 * 60;\n      const clock = `${pad2(Math.floor(sec / 3600) % 24)}:${pad2(Math.floor(sec / 60) % 60)}:${pad2(sec % 60)}`;\n      const page = 1 + (Math.floor(t / 2) % 5);\n      const panes = [\n        { title: 'Overview', align: 'left', footer: clock, lines: [['requests', `${(1840 + Math.round(Math.sin(t) * 60)).toLocaleString('en-US')}/s`], ['p95', `${Math.round(212 + Math.sin(t * 0.7) * 18)} ms`], ['errors', `${(0.21 + Math.max(0, Math.sin(t * 0.4)) * 0.08).toFixed(2)} %`]] },\n        { title: 'Deployments', align: 'center', footer: `${page}/5`, lines: [['checkout', 'v2.14.0'], ['payments', 'v4.2.7'], ['search', page >= 3 ? 'v2.3.1' : 'v2.3.0']] },\n        { title: 'Health', align: 'right', footer: `${spin(ctx, t)} syncing`, lines: [['edge', 'ok'], ['app', 'ok'], ['data', t % 10 > 6 ? 'degraded' : 'ok']] },\n      ].slice(0, n);\n      panes.forEach((p, i) => {\n        const inner = panel(ctx, b, i * (pw + 1), 0, pw, H, { title: p.title, titleAlign: p.align, footer: p.footer, footerAlign: p.align === 'right' ? 'left' : 'right', color: th.line });\n        p.lines.forEach(([k, v], j) => {\n          if (j * 2 + 1 >= inner.height) return;\n          const y = 1 + j * 2;\n          const status = v === 'ok' ? th.pos : v === 'degraded' ? th.warn : th.ink;\n          inner.text(1, y, fit(k, Math.max(0, inner.width - 2 - Math.min(12, v.length + 2)), undefined, ctx), th.muted);\n          const vx = inner.width - 1 - v.length - (v === 'ok' || v === 'degraded' ? 2 : 0);\n          if (v === 'ok' || v === 'degraded') inner.text(vx, y, `${g.dot} `, status);\n          inner.text(inner.width - 1 - v.length, y, v, status, undefined, ATTR.BOLD);\n        });\n      });\n    };\n  },\n};\n",
  "prism-term/src/facets/charts/_data.js": "// Shared helpers for the charts, status and maps galleries: deterministic\n// telemetry streams, glyph sets with ASCII fallbacks, and small drawing utilities.\n// Everything derives from t and ctx.noise, so frames are reproducible per seed.\n\nimport { NONE, mix } from '../../color.js';\nimport { ATTR, CellBuffer, Surface } from '../../buffer.js';\nimport { stringWidth, truncate } from '../../width.js';\n\nexport const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);\nexport const lerp = (a, b, k) => a + (b - a) * k;\nexport const smooth = (k) => k * k * (3 - 2 * k);\n\n/**\n * A believable metric stream. `at(k)` is the k-th sample (one per 1/rate s),\n * `window(t, n)` the last n samples at time t, `now(t)` the interpolated value.\n */\nexport function signal(ctx, { seed = 1, base = 50, amp = 20, rate = 4, spike = 0, min = -Infinity, max = Infinity, trend = 0 } = {}) {\n  const n2 = ctx.noise.noise2;\n  const s = seed * 17.31 + 3.7;\n  const at = (k) => {\n    // Perlin output mostly sits within ±0.4, so the noise terms are scaled up to make `amp` the real swing.\n    let v = base + amp * (1.15 * n2(k * 0.085, s) + 0.7 * n2(k * 0.29, s + 9.1)) + amp * 0.3 * Math.sin(k * 0.047 + s) + trend * k;\n    if (spike) { const p = n2(k * 0.021, s + 33.3); if (p > 0.38) v += spike * (p - 0.38) * 6; }\n    return clamp(v, min, max);\n  };\n  return {\n    at,\n    window(t, n) { const k0 = Math.floor(t * rate); const out = new Array(n); for (let i = 0; i < n; i++) out[i] = at(k0 - n + 1 + i); return out; },\n    now(t) { const x = t * rate, k = Math.floor(x); return lerp(at(k), at(k + 1), x - k); },\n  };\n}\n\n/** Glyphs with 7-bit fallbacks for terminals without Unicode. */\nexport function glyphs(ctx) {\n  const u = ctx.caps?.unicode !== false;\n  return u\n    ? { u, h: '─', v: '│', tl: '╭', tr: '╮', bl: '╰', br: '╯', dot: '●', ring: '○', up: '▲', down: '▼', bullet: '•', full: '█', shade: '░', mid: '▒', check: '✔', cross: '✖', warn: '▲', info: 'ℹ', arrowR: '▶', tick: '┼', cornerL: '└', dash: '┄', block: '▌', sep: '│' }\n    : { u, h: '-', v: '|', tl: '+', tr: '+', bl: '+', br: '+', dot: 'o', ring: 'o', up: '^', down: 'v', bullet: '*', full: '#', shade: '.', mid: ':', check: '+', cross: 'x', warn: '!', info: 'i', arrowR: '>', tick: '+', cornerL: '+', dash: '-', block: '|', sep: '|' };\n}\n\nconst FOLD = { '→': '->', '←': '<-', '↑': '^', '↓': 'v', '–': '-', '—': '-', '·': '-', '…': '.', '×': 'x', '±': '+/-', '≥': '>=', '≤': '<=', '°': 'o', 'µ': 'u', '•': '*', '✔': '+', '✓': '+', '✖': 'x', '✗': 'x', '▲': '^', '▼': 'v', '●': 'o', '○': 'o', '│': '|', '─': '-', '⇡': '^', '⇣': 'v', '★': '*' };\n// Glyph classes for single-cell folding: boxes, blocks and shapes.\nfor (const c of '╭╮╰╯┌┐└┘├┤┬┴┼┏┓┗┛╋╔╗╚╝') FOLD[c] = '+';\nfor (const c of '━┄┈╌═▔') FOLD[c] = c === '═' ? '=' : '-';\nfor (const c of '┃┆┊║▏▎▍▌▐▕') FOLD[c] = '|';\nfor (const c of '█▓▉▊▋■▪▮◼') FOLD[c] = '#';\nObject.assign(FOLD, { '▒': ':', '░': '.', '▀': '\"', '▁': '.', '▂': '.', '▃': '_', '▄': '_', '▅': '=', '▆': '=', '▇': '#', '▬': '=', '▯': '.', '◆': '*', '◇': 'o', '◉': '@', '◌': '.', '◀': '<', '▶': '>', '▸': '>', '◂': '<', '▴': '^', '▾': 'v', '✦': '*', '☼': '*', '☾': 'C', '⎇': 'Y', '⎈': '*', '‹': '<', '›': '>', '∙': '.', '❯': '>', '⊘': '-', '‖': '=', 'ℹ': 'i' });\n// Braille folds by dot count, so plotted lines degrade to a dotted ASCII trail.\nconst BRAILLE = ' ..::++##';\nconst foldChar = (c) => {\n  const cp = c.codePointAt(0);\n  if (cp >= 0x2800 && cp <= 0x28ff) { let n = cp - 0x2800, k = 0; while (n) { k += n & 1; n >>= 1; } return BRAILLE[k]; }\n  return FOLD[c] ?? '?';\n};\n/** 7-bit transliteration for LANG=C terminals; unknown characters become '?'. */\nexport const fold = (s) => { let out = ''; for (const c of String(s)) out += c.codePointAt(0) < 128 ? c : foldChar(c); return out; };\n/** Identity on Unicode terminals, `fold` otherwise. */\nexport const textFor = (g) => (g.u ? (s) => s : fold);\n\n/** A pass-through surface that folds every glyph and string to 7-bit ASCII. */\nclass Folded extends Surface {\n  constructor(inner) { super(); this.inner = inner; }\n  get width() { return this.inner.width; }\n  get height() { return this.inner.height; }\n  set(x, y, ch, fg, bg, attr) {\n    if (ch && ch.length && ch.codePointAt(0) > 127) ch = foldChar(ch)[0];\n    return this.inner.set(x, y, ch, fg, bg, attr);\n  }\n  get(x, y) { return this.inner.get(x, y); }\n  clear(bg, fg) { return this.inner.clear(bg, fg); }\n  text(x, y, str, fg, bg, attr) { return super.text(x, y, fold(str), fg, bg, attr); }\n}\n/** The surface itself on Unicode terminals; an ASCII-folding wrapper otherwise. */\nexport const safe = (s, g) => (g.u ? s : new Folded(s));\n\n/** Eighth blocks, bottom-anchored (index 0..8) and left-anchored. */\nexport const VBLOCKS = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];\nexport const HBLOCKS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];\nconst ASCII_V = [' ', '.', '.', '_', '_', '=', '=', '#', '#'];\n\n/** A vertical bar of fractional height (cells) growing upward from y0 (bottom row). */\nexport function vbar(s, x, y0, height, color, g, bg) {\n  const full = Math.floor(height), rem = Math.round((height - full) * 8);\n  for (let i = 0; i < full; i++) s.set(x, y0 - i, g.u ? '█' : '#', color, bg);\n  if (rem > 0) s.set(x, y0 - full, g.u ? VBLOCKS[rem] : ASCII_V[rem], color, bg);\n}\n\n/** A horizontal bar of fractional length (cells) starting at x0. */\nexport function hbar(s, x0, y, len, color, g, bg) {\n  const full = Math.floor(len), rem = Math.round((len - full) * 8);\n  for (let i = 0; i < full; i++) s.set(x0 + i, y, g.u ? '█' : '#', color, bg);\n  if (rem > 0) s.set(x0 + full, y, g.u ? HBLOCKS[rem] : (rem > 4 ? '#' : '-'), color, bg);\n}\n\n/** Text clipped to `w` columns. */\nexport function put(s, x, y, text, w, fg, bg, attr) {\n  if (w <= 0) return x;\n  return s.text(x, y, stringWidth(text) > w ? truncate(text, w) : text, fg, bg, attr);\n}\n\n/** Right-aligned text ending at column x (exclusive). */\nexport function putR(s, xEnd, y, text, fg, bg, attr) {\n  return s.text(xEnd - stringWidth(text), y, text, fg, bg, attr);\n}\n\n/** A rounded frame with an optional title; returns the inner rect. */\nexport function frame(s, x, y, w, h, color, g, title, titleColor) {\n  if (w < 2 || h < 2) return { x, y, w: Math.max(0, w), h: Math.max(0, h) };\n  s.text(x, y, g.tl + g.h.repeat(w - 2) + g.tr, color);\n  for (let yy = y + 1; yy < y + h - 1; yy++) { s.set(x, yy, g.v, color); s.set(x + w - 1, yy, g.v, color); }\n  s.text(x, y + h - 1, g.bl + g.h.repeat(w - 2) + g.br, color);\n  if (title && w > 6) s.text(x + 2, y, ` ${truncate(title, w - 6)} `, titleColor ?? color, undefined, ATTR.BOLD);\n  return { x: x + 1, y: y + 1, w: w - 2, h: h - 2 };\n}\n\n/** Compact number formatting. */\nexport function num(v, d = 1) {\n  const a = Math.abs(v);\n  if (a >= 1e9) return (v / 1e9).toFixed(d) + 'B';\n  if (a >= 1e6) return (v / 1e6).toFixed(d) + 'M';\n  if (a >= 1e4) return (v / 1e3).toFixed(d) + 'k';\n  if (a >= 100) return v.toFixed(0);\n  return v.toFixed(d);\n}\n\n/** Health → theme color. */\nexport const healthColor = (th, h) => (h >= 0.8 ? th.pos : h >= 0.5 ? th.warn : th.neg);\n\n/**\n * The theme's \"white light\" for highlights — peaks, markers, needles, the newest\n * sample. White on Night glass; ink on Day pearl, where a white mark would vanish.\n */\nexport const light = (th) => (th.mode === 'light' ? th.ink : 0xffffff);\n\n/** Brighten a color toward the light at Night, deepen it toward ink by Day. */\nexport const lift = (th, c, k) => mix(c, light(th), k);\n\n/** WCAG relative luminance (0 black … 1 white). */\nexport function relLum(c) {\n  const f = (v) => { v /= 255; return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };\n  return 0.2126 * f((c >> 16) & 255) + 0.7152 * f((c >> 8) & 255) + 0.0722 * f(c & 255);\n}\n\n/** Text that reads on a fill: white on dark fills, the theme's darkest on light ones. */\nexport const onFill = (th, fill) => (relLum(fill) > 0.18 ? (th.mode === 'light' ? th.ink : th.bg) : 0xffffff);\n\n/** Soft tint of color c over the background. */\nexport const tint = (th, c, k = 0.18) => mix(th.bg, c, k);\n\n/**\n * Memoised drawing: `draw(buf)` renders into a private offscreen buffer only when\n * `key` changes (e.g. a new data sample), and the cached cells are stamped onto\n * the target every frame. Expensive widget renders then cost a blit per frame.\n */\nexport function memo() {\n  let key = null, buf = null;\n  return (target, x, y, w, h, k, draw) => {\n    if (w <= 0 || h <= 0) return;\n    if (!buf || buf.width !== w || buf.height !== h || key !== k) {\n      buf = new CellBuffer(w, h);\n      draw(buf);\n      key = k;\n    }\n    for (let yy = 0; yy < h; yy++) {\n      for (let xx = 0; xx < w; xx++) {\n        const i = yy * w + xx, ch = buf.ch[i];\n        if (ch === ' ' && buf.bg[i] === NONE && !buf.at[i]) continue;\n        target.set(x + xx, y + yy, ch, buf.fg[i], buf.bg[i], buf.at[i]);\n      }\n    }\n  };\n}\n\nexport { NONE, ATTR };\n",
  "prism-term/src/facets/charts/bars.js": "// Bars & Histograms: eased vertical bars, a re-ranking leaderboard, stacked\n// segments, a live latency histogram, OHLC candlesticks and box plots.\nimport { mix, scale } from '../../color.js';\nimport { signal, glyphs, safe, vbar, hbar, put, putR, num, clamp, smooth, lift, onFill, ATTR } from './_data.js';\n\n/** Eased step between sample values: holds, then glides to the next target. */\nfunction stepped(s, t, period) {\n  const k = Math.floor(t / period), f = smooth(clamp((t / period - k) / 0.35, 0, 1));\n  return s.at(k - 1) + (s.at(k) - s.at(k - 1)) * f;\n}\n\nconst barsVertical = {\n  id: 'charts-bars-vertical',\n  title: 'Eased Column Chart',\n  category: 'Bars & Histograms',\n  description: 'Columns that glide to new values with eighth-block precision, value labels riding on top and a labeled baseline.',\n  tags: ['bar chart', 'columns', 'eighth blocks', 'animated', 'hourly'],\n  kind: 'chart',\n  size: { w: 56, h: 12 },\n  minSize: { w: 20, h: 5 },\n  loop: 12,\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const labels = ['00', '03', '06', '09', '12', '15', '18', '21'];\n    const n = Math.max(3, Math.min(labels.length, Math.floor((W + 1) / 4)));\n    const streams = labels.slice(0, n).map((_, i) => signal(ctx, { seed: 90 + i, base: 55 + 25 * Math.sin(i * 0.8), amp: 22, min: 4, max: 100, rate: 1 }));\n    const bw = Math.max(1, Math.floor((W - (n - 1)) / n) - 1);\n    const gap = Math.floor((W - n * bw) / Math.max(1, n));\n    const plotH = H - 2;\n    return (t) => {\n      const base = H - 2;\n      for (let x = 0; x < W; x++) b.set(x, base + 1, g.h, th.line);\n      streams.forEach((s, i) => {\n        const v = stepped(s, t + i * 0.12, 2);\n        const x0 = i * (bw + gap) + Math.floor(gap / 2);\n        const hgt = (v / 100) * Math.max(1, plotH - 1);\n        const c = mix(th.accent, th.crit, v / 100);\n        for (let dx = 0; dx < bw; dx++) vbar(b, x0 + dx, base, hgt, dx === 0 && bw > 2 ? lift(th, c, 0.2) : c, g);\n        const label = String(Math.round(v));\n        const ly = Math.max(0, base - Math.ceil(hgt));\n        if (bw >= label.length) b.text(x0 + Math.floor((bw - label.length) / 2), ly, label, th.ink, undefined, ATTR.BOLD);\n        put(b, x0 + Math.floor((bw - 2) / 2), H - 1, labels[i], Math.max(2, bw), th.dim);\n      });\n    };\n  },\n};\n\nconst TEAMS = ['payments', 'search', 'checkout', 'catalog', 'auth', 'notify', 'cart', 'orders'];\n\nconst leaderboard = {\n  id: 'charts-bars-leaderboard',\n  title: 'Live Leaderboard',\n  category: 'Bars & Histograms',\n  description: 'Horizontal bars ranked by throughput that re-sort as values change, with rank numbers and movement arrows.',\n  tags: ['leaderboard', 'ranking', 'horizontal bars', 'sorting', 'top n'],\n  kind: 'chart',\n  size: { w: 56, h: 10 },\n  minSize: { w: 24, h: 3 },\n  loop: 16,\n  color: '256',\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const streams = TEAMS.map((name, i) => ({ name, s: signal(ctx, { seed: 110 + i, base: 600 - i * 45, amp: 180, rate: 0.8, min: 20 }) }));\n    const rows = Math.min(TEAMS.length, H);\n    return (t) => {\n      const now = streams.map((st) => ({ ...st, v: st.s.now(t), past: st.s.now(t - 3) }));\n      const rankPast = [...now].sort((a, b2) => b2.past - a.past).map((x) => x.name);\n      now.sort((a, b2) => b2.v - a.v);\n      const max = now[0].v;\n      const nameW = W >= 36 ? 10 : 0, valW = 7;\n      const barW = Math.max(2, W - 4 - nameW - valW);\n      now.slice(0, rows).forEach((st, i) => {\n        const y = i;\n        const moved = rankPast.indexOf(st.name) - i;\n        b.text(0, y, String(i + 1).padStart(2), i === 0 ? th.warn : th.dim, undefined, i === 0 ? ATTR.BOLD : 0);\n        b.set(2, y, moved > 0 ? g.up : moved < 0 ? g.down : ' ', moved > 0 ? th.pos : th.neg);\n        if (nameW) put(b, 4, y, st.name, nameW - 1, th.ink);\n        const len = (st.v / max) * barW;\n        hbar(b, 4 + nameW, y, len, mix(th.pos, th.accent, i / rows), g);\n        putR(b, W, y, num(st.v, 0), th.muted);\n      });\n    };\n  },\n};\n\nconst stacked = {\n  id: 'charts-bars-stacked',\n  title: 'Stacked Status Codes',\n  category: 'Bars & Histograms',\n  description: 'Per-region request mix as stacked horizontal segments — 2xx, 3xx, 4xx, 5xx — with a legend and live totals.',\n  tags: ['stacked bars', 'http status', 'segments', 'regions', 'composition'],\n  kind: 'chart',\n  size: { w: 60, h: 9 },\n  minSize: { w: 24, h: 3 },\n  loop: 12,\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const regions = ['us-east-1', 'us-west-2', 'eu-west-1', 'ap-south-1', 'sa-east-1', 'ca-central'];\n    const codes = [['2xx', th.pos], ['3xx', th.info], ['4xx', th.warn], ['5xx', th.neg]];\n    const streams = regions.map((r, i) => codes.map(([, ], k) => signal(ctx, { seed: 130 + i * 4 + k, base: [80, 8, 6, 1.5][k], amp: [6, 3, 3, i === 2 ? 4 : 1][k], rate: 1.5, min: 0.2 })));\n    const legend = H >= 4 ? 1 : 0;\n    const rows = Math.min(regions.length, H - legend);\n    const nameW = W >= 40 ? 11 : 0, totW = W >= 30 ? 7 : 0;\n    const barW = Math.max(4, W - nameW - totW - 1);\n    return (t) => {\n      if (legend) {\n        let x = 0;\n        for (const [n, c] of codes) { b.set(x, 0, '■', c); x = b.text(x + 2, 0, n, th.muted) + 2; }\n      }\n      for (let r = 0; r < rows; r++) {\n        const y = r + legend;\n        const parts = streams[r].map((s) => s.now(t));\n        const total = parts.reduce((a, v) => a + v, 0);\n        if (nameW) put(b, 0, y, regions[r], nameW - 1, th.ink);\n        let x = nameW, acc = 0;\n        parts.forEach((p, k) => {\n          const from = Math.round((acc / total) * barW); acc += p;\n          const to = Math.round((acc / total) * barW);\n          for (let i = from; i < to; i++) b.set(x + i, y, g.u ? '█' : '#', codes[k][1]);\n        });\n        if (totW) putR(b, W, y, `${((parts[3] / total) * 100).toFixed(1)}%`, parts[3] / total > 0.03 ? th.neg : th.dim);\n      }\n    };\n  },\n};\n\nconst histogram = {\n  id: 'charts-histogram-live',\n  title: 'Live Latency Histogram',\n  category: 'Bars & Histograms',\n  description: 'A latency distribution that reshapes every second, with p50, p95 and p99 marker lines and a long tail you can watch grow.',\n  tags: ['histogram', 'distribution', 'percentiles', 'latency', 'tail'],\n  kind: 'chart',\n  size: { w: 64, h: 14 },\n  minSize: { w: 24, h: 6 },\n  loop: 14,\n  order: 40,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const tail = signal(ctx, { seed: 150, base: 0.4, amp: 0.4, rate: 0.5, min: 0, max: 1 });\n    const n = Math.max(8, W);\n    return (t) => {\n      const tailK = tail.now(t);\n      const bins = new Array(n).fill(0);\n      for (let i = 0; i < n; i++) {\n        const x = i / n;\n        bins[i] = Math.exp(-(((x - 0.22) / 0.09) ** 2)) + (0.12 + 0.25 * tailK) * Math.exp(-(((x - 0.6) / (0.15 + 0.1 * tailK)) ** 2)) + 0.05 * ctx.noise.noise2(i * 0.6, Math.floor(t)) ** 2;\n      }\n      const max = Math.max(...bins);\n      const plotH = H - 2;\n      const total = bins.reduce((a, v) => a + v, 0);\n      const pct = (q) => { let acc = 0; for (let i = 0; i < n; i++) { acc += bins[i]; if (acc >= q * total) return i; } return n - 1; };\n      const marks = [[0.5, 'p50', th.pos], [0.95, 'p95', th.warn], [0.99, 'p99', th.neg]].map(([q, l, c]) => ({ x: pct(q), l, c }));\n      for (let i = 0; i < n && i < W; i++) {\n        const c = i >= marks[2].x ? th.neg : i >= marks[1].x ? th.warn : th.accent;\n        vbar(b, i, H - 2, (bins[i] / max) * plotH, c, g);\n      }\n      for (const m of marks) {\n        for (let y = 1; y < H - 1; y++) if (b.get(m.x, y)?.ch === ' ') b.set(m.x, y, g.v === '|' ? ':' : '┊', m.c);\n        if (W >= 30) b.text(Math.min(W - 3, m.x), 0, m.l, m.c, undefined, ATTR.BOLD);\n      }\n      for (let x = 0; x < W; x++) b.set(x, H - 1, g.h, th.line);\n      if (W >= 40) { b.text(0, H - 1, '0 ms', th.dim); putR(b, W, H - 1, '2 s', th.dim); }\n    };\n  },\n};\n\nconst candles = {\n  id: 'charts-candles',\n  title: 'Candlesticks',\n  category: 'Bars & Histograms',\n  description: 'OHLC candlesticks at half-cell precision with wicks, green and red bodies, a volume strip and a right-hand price scale.',\n  tags: ['candlestick', 'ohlc', 'finance', 'trading', 'volume'],\n  kind: 'chart',\n  size: { w: 72, h: 16 },\n  minSize: { w: 28, h: 7 },\n  loop: 20,\n  canvas: 'field',\n  order: 50,\n  setup(ctx) {\n    const th = ctx.theme, W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const price = signal(ctx, { seed: 170, base: 142, amp: 9, rate: 4, trend: 0.004 });\n    const volH = H >= 10 ? 2 : 0, axisW = W >= 40 ? 8 : 0;\n    const cv = ctx.canvas('field', { cols: W - axisW, rows: H - volH });\n    const cw = 3; // pixels per candle (body 2 + gap 1)\n    return (t) => {\n      const per = 4; // samples per candle\n      const nC = Math.floor(cv.width / cw);\n      const k0 = Math.floor(t * 1.2) - nC + 1;\n      const cs = [];\n      for (let i = 0; i < nC; i++) {\n        const ks = [0, 1, 2, 3].map((j) => price.at((k0 + i) * per + j));\n        const body = Math.max(0.4, Math.abs(ks[3] - ks[0]));\n        const wickUp = body * (0.35 + 1.6 * Math.abs(ctx.noise.noise2((k0 + i) * 0.37, 3.3)));\n        const wickDn = body * (0.35 + 1.6 * Math.abs(ctx.noise.noise2((k0 + i) * 0.37, 7.7)));\n        cs.push({ o: ks[0], c: ks[3], h: Math.max(...ks) + wickUp, l: Math.min(...ks) - wickDn, v: 0.4 + body });\n      }\n      const hi = Math.max(...cs.map((c) => c.h)), lo = Math.min(...cs.map((c) => c.l));\n      const Y = (p) => (cv.height - 1) * (1 - (p - lo) / (hi - lo || 1));\n      cv.clear();\n      cs.forEach((c, i) => {\n        const x = i * cw, up = c.c >= c.o, col = up ? th.pos : th.neg;\n        cv.line(x + 1, Y(c.h), x + 1, Y(c.l), scale(col, 0.75));\n        const y0 = Math.min(Y(c.o), Y(c.c)), y1 = Math.max(Y(c.o), Y(c.c));\n        cv.rect(x, y0, 2, Math.max(1, y1 - y0 + 1), col, true);\n      });\n      cv.paint(b, 0, 0);\n      if (volH) {\n        const vmax = Math.max(...cs.map((c) => c.v));\n        cs.forEach((c, i) => {\n          const hgt = (c.v / vmax) * volH;\n          const col = mix(th.bg, c.c >= c.o ? th.pos : th.neg, 0.55);\n          const cx = Math.floor((i * cw) / 1);\n          for (let dx = 0; dx < 2; dx++) for (let k = 0; k < Math.ceil(hgt); k++) b.set(cx + dx, H - 1 - k, k + 1 <= hgt ? '█' : '▄', col);\n        });\n      }\n      if (axisW) {\n        for (const p of [hi, (hi + lo) / 2, lo]) b.text(W - axisW + 1, Math.round(((H - volH - 1) * (hi - p)) / (hi - lo || 1)), p.toFixed(2), th.dim);\n        const last = cs[cs.length - 1].c;\n        const row = Math.round(((H - volH - 1) * (hi - last)) / (hi - lo || 1));\n        b.text(W - axisW, row, ` ${last.toFixed(2)}`, th.bg, last >= cs[cs.length - 1].o ? th.pos : th.neg, ATTR.BOLD);\n      }\n    };\n  },\n};\n\nconst boxplot = {\n  id: 'charts-boxplot',\n  title: 'Box Plots',\n  category: 'Bars & Histograms',\n  description: 'Response-time box plots per service: whiskers, interquartile box, median tick and outlier dots on a shared scale.',\n  tags: ['box plot', 'quartiles', 'statistics', 'outliers', 'distribution'],\n  kind: 'chart',\n  size: { w: 60, h: 11 },\n  minSize: { w: 28, h: 3 },\n  loop: 14,\n  order: 60,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const svcs = ['search', 'checkout', 'catalog', 'auth', 'cart'];\n    const st = svcs.map((s, i) => ({ s, med: signal(ctx, { seed: 190 + i, base: 120 + i * 40, amp: 25, rate: 0.6, min: 30 }), iqr: signal(ctx, { seed: 200 + i, base: 60 + i * 10, amp: 15, rate: 0.6, min: 15 }) }));\n    const scaleRow = H >= 4 ? 1 : 0;\n    const rows = Math.min(svcs.length, Math.floor((H - scaleRow) / (H >= 9 ? 2 : 1)));\n    const step = H >= 9 ? 2 : 1;\n    const nameW = 10, plotW = Math.max(10, W - nameW);\n    const MAX = 600;\n    const X = (v) => nameW + Math.round(clamp(v / MAX, 0, 1) * (plotW - 1));\n    return (t) => {\n      for (let r = 0; r < rows; r++) {\n        const y = r * step;\n        const m = st[r].med.now(t), q = st[r].iqr.now(t);\n        const q1 = m - q * 0.45, q3 = m + q * 0.55, lo = Math.max(5, q1 - q * 1.2), hi = q3 + q * 1.6;\n        put(b, 0, y, svcs[r], nameW - 1, th.ink);\n        for (let x = X(lo); x <= X(hi); x++) b.set(x, y, g.h, th.muted);\n        b.set(X(lo), y, g.u ? '├' : '|', th.muted); b.set(X(hi), y, g.u ? '┤' : '|', th.muted);\n        for (let x = X(q1); x <= X(q3); x++) b.set(x, y, g.u ? '█' : '#', mix(th.bg, th.accent, 0.7));\n        b.set(X(m), y, g.u ? '┃' : '|', onFill(th, mix(th.bg, th.accent, 0.7)), mix(th.bg, th.accent, 0.7), ATTR.BOLD);\n        for (let o = 0; o < 2; o++) {\n          const ov = hi + q * (0.6 + o * 0.9 + 0.3 * Math.sin(t + r + o));\n          if (ov < MAX) b.set(X(ov), y, g.u ? '•' : 'o', th.warn);\n        }\n      }\n      if (scaleRow) {\n        for (let x = nameW; x < W; x++) b.set(x, H - 1, g.h, th.line);\n        for (const v of [0, 200, 400, 600]) { const x = X(v); b.set(x, H - 1, g.tick, th.line); if (plotW > 30) b.text(Math.min(W - 3, x + 1), H - 1, String(v), th.dim); }\n      }\n    };\n  },\n};\n\nexport default [barsVertical, leaderboard, stacked, histogram, candles, boxplot];\n",
  "prism-term/src/facets/charts/gauges.js": "// Gauges & Meters: arc and ring gauges, a stereo VU meter with peak hold,\n// bullet charts, a sweeping donut and a morphing radar plot.\nimport { mix, scale, gradient } from '../../color.js';\nimport { gauge } from '../../widgets/index.js';\nimport { signal, glyphs, safe, put, putR, num, clamp, smooth, light, ATTR } from './_data.js';\n\nconst gaugeArc = {\n  id: 'charts-gauge-arc',\n  title: 'Arc Gauges',\n  category: 'Gauges & Meters',\n  description: 'CPU, memory and disk as braille arc gauges with warning and critical zones, sweeping needles and centered readouts.',\n  tags: ['gauge', 'arc', 'needle', 'speedometer', 'zones'],\n  kind: 'chart',\n  size: { w: 72, h: 12 },\n  minSize: { w: 20, h: 6 },\n  loop: 12,\n  glyphs: 'braille',\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme, W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const defs = [['CPU', 61, 0.25], ['Memory', 62, 0.12], ['Disk', 63, 0.06]].map(([label, seed, amp]) => ({ label, s: signal(ctx, { seed, base: label === 'Disk' ? 0.82 : 0.55, amp, rate: 1.5, min: 0.02, max: 0.99 }) }));\n    const n = Math.max(1, Math.min(defs.length, Math.floor(W / 18)));\n    const slot = Math.floor(W / n);\n    // Gauges read best at dial size; past that they only cost more to draw.\n    const gw = Math.min(slot - 1, 30), gh = Math.min(H, 13);\n    const ox = Math.floor((slot - 1 - gw) / 2), oy = Math.floor((H - gh) / 2);\n    return (t) => {\n      for (let i = 0; i < n; i++) gauge(b, i * slot + ox, oy, gw, gh, defs[i].s.now(t), { label: defs[i].label, theme: th });\n    };\n  },\n};\n\nconst gaugeRing = {\n  id: 'charts-gauge-ring',\n  title: 'Ring Gauges',\n  category: 'Gauges & Meters',\n  description: 'Four full-circle ring gauges — availability, cache hits, error budget, saturation — filling and draining in real time.',\n  tags: ['gauge', 'ring', 'donut', 'percent', 'slo'],\n  kind: 'chart',\n  size: { w: 72, h: 11 },\n  minSize: { w: 18, h: 6 },\n  loop: 14,\n  glyphs: 'braille',\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme, W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const defs = [\n      ['Availability', signal(ctx, { seed: 71, base: 0.97, amp: 0.02, rate: 1, min: 0.9, max: 0.9999 }), [[0, 'neg'], [0.95, 'warn'], [0.99, 'pos']], th.pos],\n      ['Cache hits', signal(ctx, { seed: 72, base: 0.84, amp: 0.08, rate: 1, min: 0.5, max: 0.99 }), undefined, th.accent],\n      ['Error budget', signal(ctx, { seed: 73, base: 0.58, amp: 0.15, rate: 0.5, min: 0.05, max: 0.98 }), [[0, 'neg'], [0.25, 'warn'], [0.5, 'pos']], th.pos],\n      ['Saturation', signal(ctx, { seed: 74, base: 0.63, amp: 0.2, rate: 1, min: 0.05, max: 0.98 }), undefined, th.info],\n    ];\n    const n = Math.max(1, Math.min(defs.length, Math.floor(W / 17)));\n    const slot = Math.floor(W / n);\n    const gw = Math.min(slot - 1, 26), gh = Math.min(H, 12);\n    const ox = Math.floor((slot - 1 - gw) / 2), oy = Math.floor((H - gh) / 2);\n    return (t) => {\n      for (let i = 0; i < n; i++) {\n        const [label, s, zones, color] = defs[i];\n        const v = s.now(t);\n        gauge(b, i * slot + ox, oy, gw, gh, v, { label, style: 'ring', theme: th, thresholds: zones, color, valueText: v > 0.995 ? `${(v * 100).toFixed(2)}%` : `${(v * 100).toFixed(0)}%` });\n      }\n    };\n  },\n};\n\nconst vu = {\n  id: 'charts-vu-meter',\n  title: 'Stereo VU Meter',\n  category: 'Gauges & Meters',\n  description: 'Left and right LED ladders with fast attack, slow release, peak-hold markers and a dB scale — straight off a mixing desk.',\n  tags: ['vu meter', 'led', 'audio', 'peak hold', 'db'],\n  kind: 'chart',\n  size: { w: 60, h: 6 },\n  minSize: { w: 20, h: 2 },\n  loop: 8,\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const ch = [{ name: 'L', ph: 0 }, { name: 'R', ph: 1.7 }];\n    const level = (t, ph) => {\n      const beat = Math.max(0, Math.sin(t * Math.PI * 2 * 2.1 + ph)) ** 6;\n      const v = 0.45 + 0.25 * beat + 0.12 * ctx.noise.noise2(t * 6 + ph, ph) + 0.1 * Math.sin(t * 0.7 + ph);\n      return clamp(v, 0, 1);\n    };\n    const labelW = 2, meterW = Math.max(6, W - labelW - (W >= 30 ? 6 : 0));\n    const segs = Math.floor(meterW / (g.u ? 1 : 1));\n    const heat = (k) => (k > 0.88 ? th.neg : k > 0.7 ? th.warn : th.pos);\n    return (t) => {\n      const rows = Math.min(2, H);\n      for (let r = 0; r < rows; r++) {\n        const y = H >= 4 ? r * 2 + (H >= 6 ? 1 : 0) : r;\n        // Fast attack / slow release, evaluated as a peak over the recent past.\n        let v = 0;\n        for (let k = 0; k < 6; k++) v = Math.max(v, level(t - k * 0.03, ch[r].ph) * (1 - k * 0.09));\n        let peak = 0;\n        for (let k = 0; k < 40; k++) peak = Math.max(peak, level(t - k * 0.05, ch[r].ph));\n        b.text(0, y, ch[r].name, th.muted, undefined, ATTR.BOLD);\n        const lit = Math.round(v * segs), pk = Math.min(segs - 1, Math.round(peak * segs));\n        for (let i = 0; i < segs; i++) {\n          const k = i / segs, on = i < lit;\n          b.set(labelW + i, y, g.u ? (on ? '▮' : '▯') : on ? '#' : '.', on ? heat(k) : mix(th.bg, heat(k), 0.25));\n        }\n        b.set(labelW + pk, y, g.u ? '▮' : '|', light(th));\n        if (W >= 30) putR(b, W, y, `${(20 * Math.log10(Math.max(0.001, v)) + 3).toFixed(1)}`, heat(v));\n      }\n      if (H >= 5) {\n        const y = H - 1;\n        for (const [db, k] of [[-20, 0.1], [-10, 0.32], [-5, 0.56], [-3, 0.7], [0, 0.88], [3, 0.98]]) {\n          const x = labelW + Math.round(k * (segs - 1));\n          if (x < W - 2) b.text(x, y, String(db), db >= 0 ? th.neg : th.dim);\n        }\n      }\n    };\n  },\n};\n\nconst bullet = {\n  id: 'charts-bullet',\n  title: 'Bullet Charts',\n  category: 'Gauges & Meters',\n  description: 'Stephen Few-style bullet charts: qualitative bands, the actual value as a bar and a target tick, one KPI per row.',\n  tags: ['bullet chart', 'kpi', 'target', 'bands', 'goals'],\n  kind: 'chart',\n  size: { w: 60, h: 8 },\n  minSize: { w: 28, h: 2 },\n  loop: 12,\n  order: 40,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const kpis = [\n      ['Revenue', 'k$', 320, 260, signal(ctx, { seed: 81, base: 270, amp: 40, rate: 0.6, min: 50, max: 340 })],\n      ['Signups', '', 1200, 950, signal(ctx, { seed: 82, base: 880, amp: 140, rate: 0.6, min: 100, max: 1190 })],\n      ['NPS', '', 100, 60, signal(ctx, { seed: 83, base: 54, amp: 9, rate: 0.6, min: 0, max: 99 })],\n      ['Uptime', '%', 100, 99.9, signal(ctx, { seed: 84, base: 99.93, amp: 0.05, rate: 0.6, min: 99.6, max: 99.999 })],\n    ];\n    const step = H >= 8 ? 2 : 1;\n    const rows = Math.min(kpis.length, Math.floor(H / step));\n    const nameW = 9, valW = 9;\n    const barW = Math.max(8, W - nameW - valW);\n    return (t) => {\n      for (let r = 0; r < rows; r++) {\n        const [name, unit, max, target, s] = kpis[r];\n        const y = r * step;\n        const lo = name === 'Uptime' ? 99.5 : 0;\n        const X = (v) => Math.round(clamp((v - lo) / (max - lo), 0, 1) * (barW - 1));\n        put(b, 0, y, name, nameW - 1, th.ink);\n        const bands = [0.6, 0.85, 1].map((k) => lo + (max - lo) * k);\n        for (let i = 0; i < barW; i++) {\n          const v = lo + ((max - lo) * i) / (barW - 1);\n          const band = v <= bands[0] ? 0.16 : v <= bands[1] ? 0.26 : 0.36;\n          b.set(nameW + i, y, ' ', undefined, mix(th.bg, th.muted, band));\n        }\n        const v = s.now(t);\n        const len = X(v);\n        for (let i = 0; i <= len; i++) b.set(nameW + i, y, g.u ? '▬' : '=', v >= target ? th.pos : th.warn, undefined, ATTR.BOLD);\n        b.set(nameW + X(target), y, g.u ? '┃' : '|', light(th), undefined, ATTR.BOLD);\n        putR(b, W, y, `${name === 'Uptime' ? v.toFixed(2) : num(v, 0)}${unit}`, v >= target ? th.pos : th.warn, undefined, ATTR.BOLD);\n      }\n    };\n  },\n};\n\nconst pie = {\n  id: 'charts-pie',\n  title: 'Sweeping Donut',\n  category: 'Gauges & Meters',\n  description: 'A cost-by-service donut that sweeps in segment by segment, then slowly turns, with a legend of shares beside it.',\n  tags: ['pie', 'donut', 'share', 'cost', 'legend'],\n  kind: 'chart',\n  size: { w: 56, h: 14 },\n  minSize: { w: 24, h: 6 },\n  loop: 12,\n  canvas: 'hires',\n  order: 50,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const parts = [['compute', 0.38, th.accent], ['storage', 0.22, th.pos], ['network', 0.16, th.warn], ['database', 0.14, th.crit], ['other', 0.1, th.dim]];\n    const legendW = W >= 40 ? 20 : 0;\n    const cols = W - legendW;\n    const cv = ctx.canvas('hires', { cols, rows: H });\n    // The ring's pixels never move — precompute each one's angle and shade once.\n    const A = cv.aspect;\n    const cx = cv.width / 2, cy = cv.height / 2;\n    const R = Math.min(cv.width / 2, (cv.height / 2) * A, 28) * 0.95, r0 = R * 0.55;\n    const ring = [];\n    for (let y = Math.max(0, Math.floor(cy - R / A)); y < Math.min(cv.height, Math.ceil(cy + R / A)); y++) {\n      for (let x = Math.max(0, Math.floor(cx - R)); x < Math.min(cv.width, Math.ceil(cx + R)); x++) {\n        const dx = x + 0.5 - cx, dy = (y + 0.5 - cy) * A, d = Math.hypot(dx, dy);\n        if (d > R || d < r0) continue;\n        const a0 = (Math.atan2(dy, dx) + Math.PI / 2) / (Math.PI * 2);\n        const k = (d > R - 1.2 ? 1.15 : 1) * (0.75 + 0.25 * (d - r0) / (R - r0));\n        ring.push([x, y, a0, k]);\n      }\n    }\n    const shades = parts.map((p) => Array.from({ length: 12 }, (_, i) => scale(p[2], 0.75 + i * 0.04)));\n    const bounds = parts.reduce((acc, p) => { acc.push((acc.at(-1) ?? 0) + p[1]); return acc; }, []);\n    return (t) => {\n      const cycle = t % 12;\n      const sweep = smooth(clamp(cycle / 2.5, 0, 1));\n      const rot = (t * 0.15) / (Math.PI * 2);\n      cv.clear();\n      for (const [x, y, a0, k] of ring) {\n        let a = a0 - rot; a -= Math.floor(a);\n        if (a > sweep) continue;\n        let seg = 0;\n        while (seg < parts.length - 1 && a > bounds[seg]) seg++;\n        cv.px[y * cv.width + x] = shades[seg][Math.min(11, Math.round((k - 0.75) / 0.04))];\n      }\n      cv.paint(b);\n      const total = '$48.2k';\n      const tx = Math.round(cols / 2 - total.length / 2), ty = Math.floor(H / 2);\n      if (cols >= 20) { b.text(tx, ty, total, th.ink, undefined, ATTR.BOLD); if (H >= 10) b.text(Math.round(cols / 2 - 3), ty + 1, '/month', th.dim); }\n      if (legendW) {\n        parts.forEach(([name, share, c], i) => {\n          const y = Math.floor((H - parts.length * 2) / 2) + i * 2;\n          b.set(cols + 1, y, '■', c);\n          b.text(cols + 3, y, name, th.ink);\n          putR(b, W, y, `${Math.round(share * 100 * Math.min(1, sweep * 1.4))}%`, th.muted);\n        });\n      }\n    };\n  },\n};\n\nconst radar = {\n  id: 'charts-radar',\n  title: 'Radar Profile',\n  category: 'Gauges & Meters',\n  description: 'A six-axis spider chart comparing a service to its baseline: the live profile morphs as the service degrades and heals.',\n  tags: ['radar', 'spider chart', 'profile', 'comparison', 'braille'],\n  kind: 'chart',\n  size: { w: 56, h: 16 },\n  minSize: { w: 26, h: 8 },\n  loop: 16,\n  glyphs: 'braille',\n  canvas: 'line',\n  order: 60,\n  setup(ctx) {\n    const th = ctx.theme, W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const axes = ['latency', 'errors', 'saturation', 'traffic', 'cost', 'availability'];\n    const base = [0.35, 0.15, 0.45, 0.6, 0.4, 0.92];\n    const bad = [0.85, 0.7, 0.8, 0.55, 0.5, 0.6];\n    const cv = ctx.canvas('line', { cols: W, rows: H, threshold: 0 });\n    return (t) => {\n      const k = smooth(0.5 + 0.5 * Math.sin((t / 16) * Math.PI * 2));\n      const live = base.map((v, i) => v + (bad[i] - v) * k + 0.03 * Math.sin(t * 2 + i));\n      const A = cv.aspect, cx = cv.width / 2, cy = cv.height / 2;\n      const R = Math.min(cv.width / 2 - 8, (cv.height / 2) * A - 4);\n      const pt = (i, v) => { const a = -Math.PI / 2 + (i / axes.length) * Math.PI * 2; return [cx + Math.cos(a) * R * v, cy + (Math.sin(a) * R * v) / A]; };\n      cv.clear();\n      for (const ring of [0.33, 0.66, 1]) cv.path(axes.map((_, i) => pt(i, ring)), mix(th.bg, th.line, 0.9), true);\n      axes.forEach((_, i) => { const [x, y] = pt(i, 1); cv.line(cx, cy, x, y, mix(th.bg, th.line, 0.8)); });\n      cv.path(base.map((v, i) => pt(i, v)), th.dim, true);\n      const poly = live.map((v, i) => pt(i, clamp(v, 0, 1)));\n      cv.path(poly, mix(th.accent, th.neg, k), true);\n      for (const [x, y] of poly) { cv.set(x, y, light(th)); cv.set(x + 1, y, light(th)); }\n      cv.paint(b);\n      axes.forEach((name, i) => {\n        const [x, y] = pt(i, 1.12);\n        const cxl = Math.round(x / cv.sx), cyl = Math.round(y / cv.sy);\n        const label = W >= 40 ? name : name.slice(0, 3);\n        b.text(clamp(cxl - Math.floor(label.length / 2), 0, W - label.length), clamp(cyl, 0, H - 1), label, th.muted);\n      });\n    };\n  },\n};\n\nexport default [gaugeArc, gaugeRing, vu, bullet, pie, radar];\n",
  "prism-term/src/facets/charts/heatmaps.js": "// Heatmaps & Matrices: a scrolling latency heatmap, a contributions calendar\n// and a live correlation matrix.\nimport { mix, gradient } from '../../color.js';\nimport { palette as makePalette } from '../../palettes.js';\nimport { monoHint } from '../../canvas.js';\nimport { signal, glyphs, safe, put, putR, clamp, smooth, memo, lift, onFill, ATTR } from './_data.js';\n\n/**\n * The heat ramp. Magma's near-black floor reads as \"nothing\" on Night glass;\n * on Day pearl it would be a black slab, so Day climbs from the pearl itself\n * through the brand violet to the warm end. --set palette=… always wins.\n */\nconst heatRamp = (ctx) => {\n  const th = ctx.theme;\n  if (th.mode !== 'light' || ctx.params.palette) return ctx.palette('magma');\n  return gradient([th.bg, mix(th.bg, th.accent, 0.35), th.accent, th.crit, th.warn]);\n};\n\nconst heatLatency = {\n  id: 'charts-heatmap-latency',\n  title: 'Latency Heatmap',\n  category: 'Heatmaps & Matrices',\n  description: 'Request latency as a scrolling heatmap — log-spaced buckets over time in half-blocks, hot where requests pile up.',\n  tags: ['heatmap', 'latency', 'histogram over time', 'half blocks', 'magma'],\n  kind: 'chart',\n  size: { w: 72, h: 14 },\n  minSize: { w: 24, h: 5 },\n  loop: 16,\n  palette: 'magma',\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme, W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const pal = heatRamp(ctx);\n    const p50 = signal(ctx, { seed: 91, base: 0.32, amp: 0.06, rate: 2, min: 0.1, max: 0.8 });\n    const tail = signal(ctx, { seed: 92, base: 0.1, amp: 0.25, rate: 0.5, min: 0, max: 1 });\n    const axisW = W >= 32 ? 7 : 0, foot = H >= 7 ? 1 : 0;\n    const cols = W - axisW, rowsPx = (H - foot) * 2;\n    const labels = ['5s', '1s', '200ms', '50ms', '10ms'];\n    const cached = memo();\n    // Mono depth: no color for the heat, so each cell is a luminance-ramp glyph of its value.\n    const mono = monoHint(ctx.buffer), ramp = mono?.ramp, last = ramp ? ramp.length - 1 : 0;\n    return (t) => {\n      const k0 = Math.floor(t * 2) - cols + 1;\n      cached(b, 0, 0, W, H, k0, (buf) => draw(buf, k0));\n    };\n    function draw(b, k0) {\n      for (let c = 0; c < cols; c++) {\n        const k = k0 + c;\n        const m = p50.at(k), tl = tail.at(Math.floor(k / 4));\n        for (let r = 0; r < rowsPx; r += 2) {\n          const heat = (rr) => {\n            const y = 1 - rr / (rowsPx - 1); // 0 fast (bottom) .. 1 slow (top)\n            return clamp(Math.exp(-(((y - m) / 0.11) ** 2)) + tl * 0.6 * Math.exp(-(((y - (m + 0.38)) / 0.14) ** 2)) + 0.06 * Math.abs(ctx.noise.noise2(k * 0.7, rr * 0.9)), 0, 1);\n          };\n          if (mono) b.set(axisW + c, Math.floor(r / 2), ramp[Math.round(((heat(r) + heat(r + 1)) / 2) * last)], -1);\n          else b.set(axisW + c, Math.floor(r / 2), '▀', pal(heat(r)), pal(heat(r + 1)));\n        }\n      }\n      if (axisW) labels.forEach((l, i) => { const y = Math.round((i / (labels.length - 1)) * (H - foot - 1)); b.text(0, y, l.padStart(axisW - 1), th.dim); });\n      if (foot) { b.text(axisW, H - 1, '-60s', th.dim); putR(b, W, H - 1, 'now', th.dim); if (W >= 50) b.text(axisW + Math.floor(cols / 2) - 6, H - 1, 'checkout p95', th.muted); }\n    }\n  },\n};\n\nconst MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];\n\nconst calendar = {\n  id: 'charts-calendar',\n  title: 'Contribution Calendar',\n  category: 'Heatmaps & Matrices',\n  description: 'A GitHub-style contributions calendar — weeks by weekday, five intensity levels, month labels and a \"today\" cell filling in.',\n  tags: ['calendar', 'github', 'contributions', 'activity', 'heatmap'],\n  kind: 'chart',\n  size: { w: 80, h: 10 },\n  minSize: { w: 24, h: 8 },\n  loop: 12,\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const levels = [mix(th.bg, th.line, 0.55), mix(th.bg, th.pos, 0.35), mix(th.bg, th.pos, 0.55), mix(th.bg, th.pos, 0.78), th.pos];\n    const labW = 4;\n    const cw = W - labW >= 70 ? 2 : 1;\n    const weeks = Math.max(4, Math.floor((W - labW) / cw));\n    const top = H >= 9 ? 1 : 0;\n    const days = Array.from({ length: weeks * 7 }, (_, i) => {\n      const n = ctx.noise.noise2(i * 0.11, 4.2) + 0.35 * ctx.noise.noise2(i * 0.9, 9.1);\n      const weekend = i % 7 >= 5 ? -0.25 : 0;\n      return clamp(Math.floor((n + 0.25 + weekend) * 7), 0, 4);\n    });\n    return (t) => {\n      const today = weeks * 7 - 3;\n      const grow = Math.floor((t % 12) / 2.4);\n      if (top) for (let w = 0; w < weeks; w += 4) {\n        const month = MONTHS[Math.floor(((w / weeks) * 12 + 2) % 12)];\n        if (labW + w * cw + 3 < W) b.text(labW + w * cw, 0, month, th.dim);\n      }\n      ['Mon', '', 'Wed', '', 'Fri', '', ''].forEach((d, i) => { if (d) b.text(0, top + i, d, th.dim); });\n      for (let i = 0; i < weeks * 7; i++) {\n        const w = Math.floor(i / 7), d = i % 7;\n        if (i > today) continue;\n        let lv = days[i];\n        if (i === today) lv = clamp(grow, 0, 4);\n        const ch = g.u ? (cw === 2 ? '■' : '▪') : '#';\n        b.set(labW + w * cw, top + d, ch, i === today && Math.floor(t * 2) % 2 ? lift(th, levels[lv], 0.4) : levels[lv]);\n      }\n      if (H >= 9) {\n        const y = top + 7;\n        const total = days.slice(0, today).reduce((a, v) => a + v * 3, 0) + grow * 3;\n        b.text(labW, y, `${total.toLocaleString('en-US')} contributions in the last year`, th.muted);\n        if (W >= 60) {\n          let x = W - 17;\n          x = b.text(x, y, 'Less ', th.dim);\n          for (const c of levels) b.set(x++, y, g.u ? '■' : '#', c);\n          b.text(x + 1, y, 'More', th.dim);\n        }\n      }\n    };\n  },\n};\n\nconst corr = {\n  id: 'charts-corr-matrix',\n  title: 'Correlation Matrix',\n  category: 'Heatmaps & Matrices',\n  description: 'Pairwise correlations between six service metrics on a diverging scale, with coefficients printed in every cell.',\n  tags: ['correlation', 'matrix', 'diverging', 'statistics', 'metrics'],\n  kind: 'chart',\n  size: { w: 52, h: 9 },\n  minSize: { w: 30, h: 7 },\n  loop: 16,\n  color: '256',\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme, W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const names = ['cpu', 'mem', 'rps', 'p95', 'err', 'gc'];\n    const base = [\n      [1, 0.4, 0.82, 0.55, 0.3, 0.45], [0.4, 1, 0.3, 0.48, 0.2, 0.78], [0.82, 0.3, 1, 0.42, 0.15, 0.25],\n      [0.55, 0.48, 0.42, 1, 0.71, 0.62], [0.3, 0.2, 0.15, 0.71, 1, 0.35], [0.45, 0.78, 0.25, 0.62, 0.35, 1],\n    ];\n    const div = gradient([th.info, mix(th.bg, th.ink, 0.15), th.neg]);\n    const n = Math.min(names.length, H - 1);\n    const labW = 5, cw = Math.max(4, Math.min(7, Math.floor((W - labW) / n)));\n    return (t) => {\n      names.slice(0, n).forEach((nm, j) => b.text(labW + j * cw + Math.floor((cw - nm.length) / 2), 0, nm, th.muted));\n      for (let i = 0; i < n; i++) {\n        b.text(0, i + 1, names[i], th.muted);\n        for (let j = 0; j < n; j++) {\n          const wob = i === j ? 0 : 0.18 * ctx.noise.noise2((i * 7 + j * 3 + Math.min(i, j)) * 0.3, t * 0.25 + (i + j) * 0.1);\n          const v = clamp(i === j ? 1 : base[Math.min(i, j)][Math.max(i, j)] + wob - 0.12, -1, 1);\n          const bg = div((v + 1) / 2);\n          const txt = (v >= 0 ? ' ' : '') + v.toFixed(2).replace(/^(-?)0/, '$1');\n          for (let k = 0; k < cw - 1; k++) b.set(labW + j * cw + k, i + 1, ' ', undefined, bg);\n          if (cw >= 5) b.text(labW + j * cw + Math.floor((cw - 1 - txt.length) / 2), i + 1, txt, Math.abs(v) > 0.6 ? onFill(th, bg) : th.ink, bg, Math.abs(v) > 0.6 ? ATTR.BOLD : 0);\n        }\n      }\n    };\n  },\n};\n\nconst DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];\n\nconst heatWeek = {\n  id: 'charts-heatmap-week',\n  title: 'Hour × Weekday Heatmap',\n  category: 'Heatmaps & Matrices',\n  description: 'Requests by hour of day and weekday: a weekly traffic heatmap with the current hour filling in live and a cursor reading cells.',\n  tags: ['heatmap', 'weekday', 'hourly', 'traffic', 'punch card'],\n  kind: 'chart',\n  size: { w: 80, h: 10 },\n  minSize: { w: 30, h: 8 },\n  loop: 14,\n  palette: 'magma',\n  order: 15,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    const pal = heatRamp(ctx);\n    const labW = 4, cw = clamp(Math.floor((W - labW) / 24), 1, 3);\n    const top = H >= 9 ? 1 : 0;\n    // Weekday office-hours hump, an evening shoulder, quieter weekends, plus noise.\n    const val = Array.from({ length: 7 * 24 }, (_, i) => {\n      const d = Math.floor(i / 24), h = i % 24;\n      const work = Math.exp(-(((h - 13.5) / 3.6) ** 2)), eve = 0.55 * Math.exp(-(((h - 20.5) / 1.8) ** 2));\n      const wk = d >= 5 ? 0.55 : 1;\n      return clamp(0.06 + (work * wk + eve * (d >= 4 ? 1.15 : 0.9)) * 0.8 + 0.12 * ctx.noise.noise2(d * 1.3, h * 0.4), 0, 1);\n    });\n    const NOW = 6 * 24 + 15; // Sunday 15:00\n    let peak = 0;\n    for (let i = 0; i < NOW; i++) if (val[i] > val[peak]) peak = i;\n    return (t) => {\n      const lt = t % 14;\n      const fill = smooth(clamp((lt % 7) / 6, 0, 1));\n      if (top) for (let h = 0; h < 24; h += cw >= 3 ? 3 : 6) b.text(labW + h * cw, 0, String(h).padStart(2, '0'), th.dim);\n      // The cursor hops between filled cells, reading one out every two-thirds of a second.\n      const cur = (Math.floor(lt * 1.5) * 37) % (NOW + 1);\n      for (let d = 0; d < 7; d++) {\n        const y = top + d;\n        if (y >= H) break;\n        b.text(0, y, DAYS[d], d >= 5 ? th.dim : th.muted);\n        for (let h = 0; h < 24; h++) {\n          const i = d * 24 + h;\n          if (i > NOW) { for (let k = 0; k < cw - (cw > 1 ? 1 : 0); k++) b.set(labW + h * cw + k, y, g.u ? '·' : '.', mix(th.bg, th.line, 0.5)); continue; }\n          const v = i === NOW ? val[i] * fill : val[i];\n          const c = pal(0.08 + 0.9 * v);\n          const hot = i === cur;\n          for (let k = 0; k < cw - (cw > 1 ? 1 : 0); k++) b.set(labW + h * cw + k, y, g.u ? '█' : '#', hot ? lift(th, c, 0.45) : c);\n          if (i === NOW && Math.floor(t * 3) % 2 && cw > 1) b.set(labW + h * cw + cw - 1, y, g.u ? '◂' : '<', th.ink);\n        }\n      }\n      if (top + 7 < H) {\n        const d = Math.floor(cur / 24), h = cur % 24;\n        const v = cur === NOW ? val[NOW] * fill : val[cur];\n        put(b, labW, top + 7, `${DAYS[d]} ${String(h).padStart(2, '0')}:00  ${(v * 61.4).toFixed(1)}k req/h`, W - labW - 12, th.ink);\n        if (W >= 60) putR(b, W, top + 7, `peak ${DAYS[Math.floor(peak / 24)]} ${String(peak % 24).padStart(2, '0')}:00`, th.muted);\n      }\n    };\n  },\n};\n\nexport default [heatLatency, heatWeek, calendar, corr];\n",
  "prism-term/src/facets/charts/kpis.js": "// KPI Tiles: a tile row with sparklines and deltas, a big-number tile in a\n// bitmap font, and a compact NOC wallboard.\nimport { mix } from '../../color.js';\nimport { kpi, lineChart } from '../../widgets/index.js';\nimport { drawBanner, measureBanner } from '../../text/index.js';\nimport { signal, glyphs, safe, frame, put, putR, num, clamp, memo, ATTR } from './_data.js';\n\nconst kpiRow = {\n  id: 'charts-kpi-row',\n  title: 'KPI Tile Row',\n  category: 'KPI Tiles',\n  description: 'Four boxed KPI tiles — throughput, latency, errors, saturation — each with a value, a colored delta and a trend sparkline.',\n  tags: ['kpi', 'tiles', 'dashboard', 'delta', 'sparkline'],\n  kind: 'chart',\n  size: { w: 80, h: 5 },\n  minSize: { w: 22, h: 4 },\n  loop: 12,\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme, W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const defs = [\n      { label: 'throughput', unit: 'rps', s: signal(ctx, { seed: 101, base: 2400, amp: 500, rate: 3, min: 200 }), color: 'info', goodWhen: 'up', f: (v) => num(v, 0) },\n      { label: 'p95 latency', unit: 'ms', s: signal(ctx, { seed: 102, base: 230, amp: 60, spike: 160, rate: 3, min: 40 }), color: 'accent', goodWhen: 'down', f: (v) => num(v, 0) },\n      { label: 'error rate', unit: '%', s: signal(ctx, { seed: 103, base: 0.4, amp: 0.3, spike: 1.6, rate: 3, min: 0 }), color: 'neg', goodWhen: 'down', f: (v) => v.toFixed(2) },\n      { label: 'saturation', unit: '%', s: signal(ctx, { seed: 104, base: 62, amp: 14, rate: 3, min: 5, max: 99 }), color: 'warn', goodWhen: 'down', f: (v) => num(v, 0) },\n    ];\n    const n = Math.max(1, Math.min(defs.length, Math.floor(W / 20)));\n    const tw = Math.floor(W / n);\n    return (t) => {\n      for (let i = 0; i < n; i++) {\n        const d = defs[i], v = d.s.now(t), past = d.s.now(t - 10);\n        kpi(b, i * tw, 0, (i === n - 1 ? W - i * tw : tw - 1), H, { label: d.label, value: d.f(v), unit: d.unit, delta: ((v - past) / (past || 1)) * 100, goodWhen: d.goodWhen, trend: d.s.window(t, 40), color: d.color, theme: th });\n      }\n    };\n  },\n};\n\nconst bigNumber = {\n  id: 'charts-big-number',\n  title: 'Big Number Tile',\n  category: 'KPI Tiles',\n  description: 'The wallboard hero tile: one metric in a bold bitmap font, its trend arrow and delta, and a sparkline underneath.',\n  tags: ['big number', 'hero metric', 'bitmap font', 'kpi', 'wallboard'],\n  kind: 'chart',\n  size: { w: 56, h: 10 },\n  minSize: { w: 24, h: 5 },\n  loop: 12,\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const s = signal(ctx, { seed: 111, base: 12400, amp: 1600, rate: 1.2, min: 4000 });\n    const mode = ctx.caps.unicode === false ? 'ascii' : 'half';\n    return (t) => {\n      const v = s.now(t), past = s.now(t - 15);\n      const txt = Math.round(v).toLocaleString('en-US');\n      b.text(0, 0, 'ORDERS / MIN', th.muted, undefined, ATTR.BOLD);\n      const fonts = ['bold', 'standard', 'tiny'];\n      let font = fonts.find((f) => { const m = measureBanner(txt, { font: f, mode }); return m.width <= W && m.height <= H - 3; }) || null;\n      let y = 1;\n      if (font) {\n        const m = drawBanner(b, 0, 1, txt, { font, mode, fill: 'theme', theme: th, t, speed: 0.05 });\n        y = 1 + m.height;\n      } else { b.text(0, 1, txt, th.ink, undefined, ATTR.BOLD); y = 2; }\n      const d = ((v - past) / past) * 100;\n      const up = d >= 0;\n      if (y < H) b.text(0, y, `${up ? g.up : g.down} ${Math.abs(d).toFixed(1)}% vs 15 s ago`, up ? th.pos : th.neg, undefined, ATTR.BOLD);\n      if (y + 1 < H) {\n        const data = s.window(t, W);\n        const lo = Math.min(...data), hi = Math.max(...data);\n        const bl = g.u ? '▁▂▃▄▅▆▇█' : '.:-=+*#@';\n        for (let i = 0; i < W; i++) b.set(i, y + 1, bl[Math.round(((data[i] - lo) / (hi - lo || 1)) * 7)], mix(th.dim, th.accent, i / W));\n      }\n    };\n  },\n};\n\nconst wallboard = {\n  id: 'charts-noc-wallboard',\n  title: 'NOC Wallboard',\n  category: 'KPI Tiles',\n  description: 'A compact operations wallboard: headline KPIs, a latency chart and the top error sources, laid out to fill any screen.',\n  tags: ['wallboard', 'noc', 'dashboard', 'kpi', 'errors'],\n  kind: 'chart',\n  size: { w: 80, h: 24 },\n  minSize: { w: 44, h: 12 },\n  loop: 14,\n  glyphs: 'braille',\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const rps = signal(ctx, { seed: 121, base: 5200, amp: 900, rate: 3, min: 500 });\n    const p95 = signal(ctx, { seed: 122, base: 210, amp: 50, spike: 200, rate: 3, min: 40 });\n    const err = signal(ctx, { seed: 123, base: 0.35, amp: 0.25, spike: 1.4, rate: 3, min: 0 });\n    const srcs = ['POST /checkout 503', 'GET /search 504', 'auth: token expired', 'payments: TLS reset', 'cart: pool timeout', 'GET /catalog 500'];\n    const counts = srcs.map((_, i) => signal(ctx, { seed: 130 + i, base: 120 - i * 16, amp: 30, rate: 1, min: 1 }));\n    const chart = memo();\n    return (t) => {\n      const tileH = 5, n = 3, tw = Math.floor(W / n);\n      const defs = [\n        ['requests', num(rps.now(t), 1), 'rps', rps, 'info', 'up'],\n        ['p95 latency', num(p95.now(t), 0), 'ms', p95, 'accent', 'down'],\n        ['errors', err.now(t).toFixed(2), '%', err, 'neg', 'down'],\n      ];\n      defs.forEach(([label, value, unit, s, color, goodWhen], i) => kpi(b, i * tw, 0, i === n - 1 ? W - i * tw : tw - 1, tileH, { label, value, unit, delta: ((s.now(t) - s.now(t - 10)) / (s.now(t - 10) || 1)) * 100, goodWhen, trend: s.window(t, 40), color, theme: th }));\n      const restH = H - tileH;\n      const listW = W >= 70 ? Math.floor(W * 0.36) : 0;\n      const chartW = W - listW - (listW ? 1 : 0);\n      const inner = frame(b, 0, tileH, chartW, restH, th.line, g, 'p95 latency · ms', th.ink);\n      chart(b, inner.x, inner.y, inner.w, inner.h, Math.floor(t * 3), (buf) => lineChart(buf, 0, 0, buf.width, buf.height, [{ data: p95.window(t, inner.w * 2), color: th.accent, label: 'p95' }], { theme: th, min: 0, grid: true, legend: false }));\n      if (listW) {\n        const li = frame(b, chartW + 1, tileH, listW, restH, th.line, g, 'top errors · 5 min', th.ink);\n        const rows = srcs.map((s, i) => ({ s, c: counts[i].now(t) })).sort((a, b2) => b2.c - a.c);\n        const max = rows[0].c;\n        rows.slice(0, Math.floor(li.h / 2)).forEach((r, i) => {\n          const y = li.y + i * 2;\n          put(b, li.x, y, r.s, li.w - 6, th.ink);\n          putR(b, li.x + li.w, y, num(r.c, 0), th.neg, undefined, ATTR.BOLD);\n          const bw = Math.round((r.c / max) * li.w);\n          for (let k = 0; k < bw; k++) b.set(li.x + k, y + 1, g.u ? '▔' : '-', mix(th.bg, th.neg, 0.75 - i * 0.08));\n        });\n      }\n    };\n  },\n};\n\nexport default [kpiRow, bigNumber, wallboard];\n",
  "prism-term/src/facets/charts/lines.js": "// Line & Area: braille line charts, dithered areas, smooth streaming, btop-style\n// graphs, stacked areas, and x/y plots (scatter, bubble).\nimport { mix, scale, gradient } from '../../color.js';\nimport { lineChart } from '../../widgets/index.js';\nimport { signal, glyphs, safe, put, putR, num, clamp, frame, memo, lift, onFill, ATTR } from './_data.js';\n\nconst BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map((v) => (v + 0.5) / 16);\n\n/** Y-axis labels on the left of a plot area; returns the label column width. */\nfunction yAxis(b, x, y, h, lo, hi, color, fmt = (v) => num(v, 0)) {\n  const labels = [hi, (hi + lo) / 2, lo].map(fmt);\n  const w = Math.max(...labels.map((l) => l.length)) + 1;\n  if (h >= 3) {\n    b.text(x + w - 1 - labels[0].length, y, labels[0], color);\n    b.text(x + w - 1 - labels[1].length, y + Math.floor((h - 1) / 2), labels[1], color);\n    b.text(x + w - 1 - labels[2].length, y + h - 1, labels[2], color);\n  }\n  return w;\n}\n\nconst lineMulti = {\n  id: 'charts-line-multi',\n  title: 'Latency Percentiles',\n  category: 'Line & Area',\n  description: 'p50, p95 and p99 as a braille multi-series line chart with y-axis ticks, gridlines, time labels and a legend.',\n  tags: ['line chart', 'latency', 'percentiles', 'braille', 'legend'],\n  kind: 'chart',\n  size: { w: 72, h: 16 },\n  minSize: { w: 24, h: 6 },\n  loop: 12,\n  glyphs: 'braille',\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme, W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const p50 = signal(ctx, { seed: 31, base: 110, amp: 18, rate: 4, min: 40 });\n    const p95 = signal(ctx, { seed: 32, base: 260, amp: 60, spike: 120, rate: 4, min: 90 });\n    const p99 = signal(ctx, { seed: 33, base: 520, amp: 140, spike: 340, rate: 4, min: 200 });\n    const title = H >= 8;\n    const cached = memo();\n    return (t) => {\n      const n = Math.max(20, (W - 7) * 2);\n      if (title) { b.text(0, 0, 'Latency', th.ink, undefined, ATTR.BOLD); b.text(8, 0, '(ms) · last 60 s', th.dim); }\n      // The chart only changes when a new sample arrives (4 Hz).\n      cached(b, 0, title ? 1 : 0, W, H - (title ? 1 : 0), Math.floor(t * 4), (buf) => lineChart(buf, 0, 0, buf.width, buf.height, [\n        { data: p50.window(t, n), label: 'p50', color: th.pos },\n        { data: p95.window(t, n), label: 'p95', color: th.warn },\n        { data: p99.window(t, n), label: 'p99', color: th.neg },\n      ], { theme: th, min: 0, grid: H >= 10, labels: W >= 40 ? ['-60s', '-30s', 'now'] : undefined, legend: H >= 8 }));\n    };\n  },\n};\n\nconst areaDither = {\n  id: 'charts-area-dither',\n  title: 'Dithered Area',\n  category: 'Line & Area',\n  description: 'An area chart whose fill fades toward the baseline with ordered dithering — a bright crest line over a halftone body.',\n  tags: ['area chart', 'dither', 'halftone', 'braille', 'throughput'],\n  kind: 'chart',\n  size: { w: 64, h: 12 },\n  minSize: { w: 20, h: 4 },\n  loop: 12,\n  glyphs: 'braille',\n  canvas: 'line',\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme, W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const s = signal(ctx, { seed: 41, base: 620, amp: 220, rate: 5, min: 50 });\n    const head = H >= 5 ? 1 : 0;\n    const cv = ctx.canvas('line', { cols: W, rows: H - head, threshold: 0 });\n    const ramp = gradient([th.info, th.accent, th.crit]);\n    // Shade table per column (8 depth levels) so the inner loop is a lookup.\n    const shades = Array.from({ length: cv.width }, (_, x) => Array.from({ length: 8 }, (_, d) => scale(ramp(x / cv.width), 0.55 + 0.45 * (1 - d / 7))));\n    const crest = Array.from({ length: cv.width }, (_, x) => lift(th, ramp(x / cv.width), 0.35));\n    return (t) => {\n      const n = cv.width, data = s.window(t, n);\n      const lo = 0, hi = Math.max(...data) * 1.15;\n      cv.clear();\n      for (let x = 0; x < n; x++) {\n        const top = (cv.height - 1) * (1 - (data[x] - lo) / (hi - lo));\n        const span = Math.max(1, cv.height - top), sh = shades[x];\n        for (let y = Math.ceil(top); y < cv.height; y++) {\n          const depth = (y - top) / span;\n          const density = 0.85 * (1 - depth) * (1 - depth * 0.3);\n          if (BAYER[(y & 3) * 4 + (x & 3)] < density) cv.set(x, y, sh[(depth * 7) | 0]);\n        }\n        cv.set(x, top, crest[x]);\n      }\n      cv.paint(b, 0, head);\n      if (head) {\n        b.text(0, 0, 'egress', th.muted);\n        putR(b, W, 0, `${num(s.now(t), 0)} Mb/s`, th.accent, undefined, ATTR.BOLD);\n      }\n    };\n  },\n};\n\nconst streamScroll = {\n  id: 'charts-stream-scroll',\n  title: 'Smooth Streaming Line',\n  category: 'Line & Area',\n  description: 'A live line that scrolls a sub-sample at a time with dotted gridlines, axis labels and a pinned current-value tag.',\n  tags: ['streaming', 'line chart', 'realtime', 'scroll', 'grid'],\n  kind: 'chart',\n  size: { w: 70, h: 14 },\n  minSize: { w: 24, h: 5 },\n  loop: 10,\n  glyphs: 'braille',\n  canvas: 'line',\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme, W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const RATE = 6;\n    const s = signal(ctx, { seed: 51, base: 48, amp: 22, spike: 25, rate: RATE, min: 0, max: 100 });\n    const axisW = W >= 30 ? 5 : 0;\n    const cv = ctx.canvas('line', { cols: W - axisW, rows: H, threshold: 0 });\n    return (t) => {\n      const px = cv.width, py = cv.height;\n      const spp = 1.5; // samples per pixel column\n      const x0 = t * RATE;\n      const val = (i) => { const k = x0 - (px - 1 - i) / spp; const k0 = Math.floor(k); return s.at(k0) + (s.at(k0 + 1) - s.at(k0)) * (k - k0); };\n      const Y = (v) => (py - 1) * (1 - v / 100);\n      cv.clear();\n      for (const g of [25, 50, 75]) for (let x = (Math.floor(x0 * spp) % 4 + 4) % 4; x < px; x += 4) cv.set(px - 1 - x, Math.round(Y(g)), mix(th.bg, th.line, 0.9));\n      let prev = Y(val(0));\n      for (let i = 1; i < px; i++) {\n        const y = Y(val(i));\n        cv.line(i - 1, prev, i, y, mix(th.mode === 'light' ? mix(th.bg, th.accent, 0.6) : scale(th.accent, 0.75), lift(th, th.accent, 0.25), i / px));\n        prev = y;\n      }\n      cv.paint(b, axisW, 0);\n      if (axisW) for (const g of [100, 50, 0]) b.text(0, Math.round(((H - 1) * (100 - g)) / 100), String(g).padStart(3) + '%', th.dim);\n      const v = val(px - 1), tag = ` ${v.toFixed(1)}% `;\n      const row = clamp(Math.round(((H - 1) * (100 - v)) / 100), 0, H - 1);\n      b.text(W - tag.length, row, tag, th.bg, th.accent, ATTR.BOLD);\n    };\n  },\n};\n\nconst btopCpu = {\n  id: 'charts-btop-cpu',\n  title: 'btop-style CPU',\n  category: 'Line & Area',\n  description: 'A btop-inspired CPU panel: filled braille history colored by height, per-core bars with percentages, and load averages.',\n  tags: ['btop', 'cpu', 'cores', 'system monitor', 'braille'],\n  kind: 'chart',\n  size: { w: 80, h: 16 },\n  minSize: { w: 34, h: 7 },\n  loop: 12,\n  glyphs: 'braille',\n  canvas: 'line',\n  order: 40,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const total = signal(ctx, { seed: 61, base: 42, amp: 20, spike: 30, rate: 5, min: 2, max: 100 });\n    const cores = Array.from({ length: 8 }, (_, i) => signal(ctx, { seed: 70 + i, base: 30 + (i * 13) % 35, amp: 26, spike: i === 2 ? 40 : 0, rate: 5, min: 1, max: 100 }));\n    const heat = gradient([th.pos, th.warn, th.neg]);\n    const coreW = W >= 56 ? 24 : 0;\n    const inner = frame(b, 0, 0, W, H, th.line, g, 'cpu', th.accent);\n    const gw = inner.w - coreW - (coreW ? 1 : 0);\n    const cv = ctx.canvas('line', { cols: Math.max(4, gw), rows: Math.max(1, inner.h - 1), threshold: 0 });\n    return (t) => {\n      frame(b, 0, 0, W, H, th.line, g, 'cpu', th.accent);\n      const n = cv.width, data = total.window(t, n);\n      cv.clear();\n      for (let x = 0; x < n; x++) {\n        const top = Math.round((cv.height - 1) * (1 - data[x] / 100));\n        for (let y = top; y < cv.height; y++) cv.set(x, y, heat(1 - y / Math.max(1, cv.height - 1)));\n      }\n      cv.paint(b, inner.x, inner.y);\n      const v = total.now(t);\n      put(b, inner.x, inner.y + inner.h - 1, `total ${v.toFixed(0).padStart(3)}%`, gw, heat(v / 100), undefined, ATTR.BOLD);\n      const la = [1, 5, 15].map((m, i) => (total.now(t - i * 3) / 100) * 8 * (1 - i * 0.08));\n      if (gw >= 34) putR(b, inner.x + gw, inner.y + inner.h - 1, `load ${la.map((l) => l.toFixed(2)).join(' ')}`, th.muted);\n      if (coreW) {\n        const cx = inner.x + gw + 1;\n        for (let y = inner.y; y < inner.y + inner.h; y++) b.set(cx - 1, y, g.v, th.line);\n        const rows = Math.min(cores.length, inner.h);\n        for (let i = 0; i < rows; i++) {\n          const cv2 = cores[i].now(t), y = inner.y + i;\n          b.text(cx, y, `c${i}`, th.dim);\n          const bw = coreW - 9;\n          const fill = (cv2 / 100) * bw;\n          for (let k = 0; k < bw; k++) {\n            const on = k < Math.floor(fill);\n            b.set(cx + 3 + k, y, on ? (g.u ? '■' : '#') : (g.u ? '·' : '.'), on ? heat(k / bw) : th.line);\n          }\n          putR(b, cx + coreW - 1, y, `${Math.round(cv2)}%`, heat(cv2 / 100));\n        }\n      }\n    };\n  },\n};\n\nconst stackedArea = {\n  id: 'charts-stacked-area',\n  title: 'Stacked Area',\n  category: 'Line & Area',\n  description: 'Traffic by region as stacked half-block areas — each layer keeps its color, the stack keeps its total.',\n  tags: ['stacked area', 'regions', 'traffic', 'half blocks', 'composition'],\n  kind: 'chart',\n  size: { w: 64, h: 12 },\n  minSize: { w: 20, h: 4 },\n  loop: 14,\n  canvas: 'field',\n  order: 50,\n  setup(ctx) {\n    const th = ctx.theme, W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const layers = [\n      { name: 'us-east', c: th.accent, s: signal(ctx, { seed: 81, base: 40, amp: 10, rate: 3, min: 5 }) },\n      { name: 'eu-west', c: th.pos, s: signal(ctx, { seed: 82, base: 26, amp: 8, rate: 3, min: 4 }) },\n      { name: 'ap-south', c: th.crit, s: signal(ctx, { seed: 83, base: 18, amp: 9, rate: 3, min: 3 }) },\n    ];\n    const legend = H >= 5 ? 1 : 0;\n    const cv = ctx.canvas('field', { cols: W, rows: H - legend });\n    return (t) => {\n      const n = cv.width;\n      const series = layers.map((l) => l.s.window(t, n));\n      const totals = series[0].map((_, x) => series.reduce((a, s) => a + s[x], 0));\n      const hi = Math.max(...totals) * 1.1;\n      cv.clear();\n      for (let x = 0; x < n; x++) {\n        let acc = 0;\n        layers.forEach((l, k) => {\n          const y1 = cv.height * (1 - acc / hi);\n          acc += series[k][x];\n          const y0 = cv.height * (1 - acc / hi);\n          for (let y = Math.max(0, Math.floor(y0)); y < Math.min(cv.height, Math.ceil(y1)); y++) cv.set(x, y, y === Math.floor(y0) ? lift(th, l.c, 0.25) : th.mode === 'light' ? mix(th.bg, l.c, 0.7) : scale(l.c, 0.7));\n        });\n      }\n      cv.paint(b, 0, legend);\n      if (legend) {\n        let x = 0;\n        for (const l of layers) { b.set(x, 0, '■', l.c); x = b.text(x + 2, 0, l.name, th.muted) + 2; if (x > W - 10) break; }\n        putR(b, W, 0, `${num(totals[n - 1], 0)} k rps`, th.ink, undefined, ATTR.BOLD);\n      }\n    };\n  },\n};\n\nconst scatter = {\n  id: 'charts-scatter',\n  title: 'Scatter & Trend',\n  category: 'Line & Area',\n  description: 'Latency against payload size for three services as drifting braille clusters, with a least-squares trend line.',\n  tags: ['scatter', 'regression', 'clusters', 'braille', 'correlation'],\n  kind: 'chart',\n  size: { w: 60, h: 16 },\n  minSize: { w: 24, h: 6 },\n  loop: 16,\n  glyphs: 'braille',\n  canvas: 'line',\n  order: 60,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const groups = [\n      { name: 'search', c: th.accent, cx: 0.25, cy: 0.3, k: 0.18 },\n      { name: 'checkout', c: th.warn, cx: 0.6, cy: 0.62, k: 0.16 },\n      { name: 'catalog', c: th.pos, cx: 0.45, cy: 0.42, k: 0.2 },\n    ];\n    const pts = [];\n    for (const gr of groups) for (let i = 0; i < 70; i++) pts.push({ gr, u: ctx.rng.gauss() * gr.k, v: ctx.rng.gauss() * gr.k * 0.7, p: ctx.rng() * 6.28 });\n    const axisL = 2, legend = H >= 8 ? 1 : 0;\n    const cv = ctx.canvas('line', { cols: W - axisL, rows: H - 1 - legend, threshold: 0 });\n    return (t) => {\n      const X = (x) => clamp(x, 0, 1) * (cv.width - 1), Y = (y) => (1 - clamp(y, 0, 1)) * (cv.height - 1);\n      cv.clear();\n      let sx = 0, sy = 0, sxx = 0, sxy = 0;\n      for (const p of pts) {\n        const x = p.gr.cx + p.u + 0.02 * Math.sin(t * 0.7 + p.p), y = p.gr.cy + p.v + p.u * 0.6 + 0.02 * Math.cos(t * 0.9 + p.p);\n        sx += x; sy += y; sxx += x * x; sxy += x * y;\n        cv.set(X(x), Y(y), p.gr.c);\n      }\n      const n = pts.length, slope = (n * sxy - sx * sy) / (n * sxx - sx * sx), icpt = (sy - slope * sx) / n;\n      for (let i = 0; i < cv.width; i += 2) { const x = i / (cv.width - 1); cv.set(i, Y(icpt + slope * x), mix(th.bg, th.ink, 0.7)); }\n      cv.paint(b, axisL, legend);\n      for (let y = legend; y < H - 1; y++) b.set(axisL - 1, y, g.v, th.line);\n      b.text(axisL - 1, H - 1, g.cornerL + g.h.repeat(Math.max(0, W - axisL)), th.line);\n      if (W >= 30) putR(b, W, H - 1, ' payload KB →', th.dim);\n      b.text(0, legend, g.up, th.dim);\n      if (legend) {\n        let x = 0;\n        for (const gr of groups) { b.set(x, 0, g.dot, gr.c); x = b.text(x + 2, 0, gr.name, th.muted) + 2; if (x > W - 12) break; }\n        if (W >= 52) putR(b, W, 0, `r = ${(slope / 1.4).toFixed(2)}`, th.ink);\n      }\n    };\n  },\n};\n\nconst bubble = {\n  id: 'charts-bubble',\n  title: 'Bubble Chart',\n  category: 'Line & Area',\n  description: 'Services plotted by request rate and error rate, bubble area showing cost — bubbles breathe as traffic shifts.',\n  tags: ['bubble chart', 'cost', 'services', 'blocks', 'x y'],\n  kind: 'chart',\n  size: { w: 64, h: 16 },\n  minSize: { w: 24, h: 6 },\n  loop: 14,\n  canvas: 'hires',\n  order: 70,\n  setup(ctx) {\n    const th = ctx.theme, W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const items = [\n      { name: 'search', x: 0.2, y: 0.25, r: 0.09, c: th.accent },\n      { name: 'checkout', x: 0.55, y: 0.7, r: 0.13, c: th.neg },\n      { name: 'catalog', x: 0.75, y: 0.3, r: 0.11, c: th.pos },\n      { name: 'auth', x: 0.38, y: 0.45, r: 0.07, c: th.crit },\n      { name: 'notify', x: 0.85, y: 0.78, r: 0.06, c: th.warn },\n    ];\n    const cv = ctx.canvas('hires', { cols: W, rows: H });\n    return (t) => {\n      cv.clear();\n      const A = cv.aspect;\n      const pos = items.map((it, i) => {\n        const breathe = 1 + 0.12 * Math.sin(t * 0.9 + i * 1.7);\n        const x = (0.08 + it.x * 0.84 + 0.02 * Math.sin(t * 0.3 + i)) * cv.width;\n        const y = (1 - (0.1 + it.y * 0.8)) * cv.height;\n        const r = it.r * Math.min(cv.width, cv.height * A) * breathe;\n        return { it, x, y, r };\n      });\n      for (const p of pos) {\n        cv.ellipse(p.x, p.y, p.r, p.r / A, mix(th.bg, p.it.c, 0.35), true);\n        cv.ellipse(p.x, p.y, p.r, p.r / A, p.it.c);\n      }\n      cv.paint(b);\n      for (const p of pos) {\n        const cx = Math.round(p.x / cv.sx), cy = Math.round(p.y / cv.sy);\n        if (W >= 30) b.text(Math.max(0, cx - Math.floor(p.it.name.length / 2)), Math.min(H - 1, cy), p.it.name, onFill(th, mix(th.bg, p.it.c, 0.35)), undefined, ATTR.BOLD);\n      }\n      if (W >= 40) { b.text(0, H - 1, 'requests →', th.dim); b.text(0, 0, '↑ errors', th.dim); }\n    };\n  },\n};\n\nexport default [lineMulti, areaDither, streamScroll, btopCpu, stackedArea, scatter, bubble];\n",
  "prism-term/src/facets/charts/sparklines.js": "// Sparklines: word-sized trend lines — eighth-block bars, braille, dots, and a wall of them.\nimport { mix, scale } from '../../color.js';\nimport { signal, glyphs, safe, vbar, put, putR, num, clamp, light, lift, ATTR, VBLOCKS } from './_data.js';\n\nconst METRICS = [\n  { key: 'cpu', label: 'cpu', unit: '%', base: 46, amp: 22, max: 100, warn: 75, crit: 90, seed: 1 },\n  { key: 'mem', label: 'mem', unit: '%', base: 61, amp: 10, max: 100, warn: 80, crit: 92, seed: 2 },\n  { key: 'net', label: 'net', unit: 'MB/s', base: 38, amp: 26, max: 120, warn: 95, crit: 110, seed: 3, spike: 18 },\n  { key: 'err', label: 'err', unit: '%', base: 0.6, amp: 0.5, max: 8, warn: 2, crit: 4, seed: 4, spike: 1.2, min: 0 },\n];\n\nconst levelColor = (th, m, v) => (v >= m.crit ? th.neg : v >= m.warn ? th.warn : th.pos);\n\nconst sparkBars = {\n  id: 'charts-spark-bars',\n  title: 'Sparkline Rows',\n  category: 'Sparklines',\n  description: 'Four live metrics as eighth-block sparklines with labels, window min/max, current values and threshold coloring — the classic `spark` look.',\n  tags: ['sparkline', 'trend', 'metrics', 'eighth blocks', 'cpu'],\n  kind: 'chart',\n  size: { w: 66, h: 5 },\n  minSize: { w: 22, h: 2 },\n  loop: 10,\n  color: '256',\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const streams = METRICS.map((m) => ({ m, s: signal(ctx, { seed: m.seed, base: m.base, amp: m.amp, spike: m.spike || 0, min: m.min ?? 0, max: m.max, rate: 3 }) }));\n    const rows = Math.min(METRICS.length, H);\n    const labelW = W >= 30 ? 5 : 0, valueW = W >= 26 ? 9 : 0, rangeW = W >= 60 ? 14 : 0;\n    const sw = Math.max(4, W - labelW - valueW - rangeW);\n    const short = (v) => (v < 10 ? v.toFixed(1) : String(Math.round(v)));\n    return (t) => {\n      for (let r = 0; r < rows; r++) {\n        const { m, s } = streams[r];\n        const data = s.window(t, sw), v = s.now(t);\n        const y = r + Math.max(0, Math.floor((H - rows) / 2));\n        if (labelW) b.text(0, y, m.label.padEnd(labelW - 1), th.muted);\n        // Autoscale with headroom, like `spark`: the shape matters, the readout gives the value.\n        let lo = Math.min(...data), hi = Math.max(...data);\n        const pad = (hi - lo) * 0.15 || 1;\n        lo = Math.max(0, lo - pad); hi += pad;\n        for (let i = 0; i < sw; i++) {\n          const k = clamp((data[i] - lo) / (hi - lo), 0, 1);\n          const idx = Math.max(1, Math.round(k * 8));\n          const c = levelColor(th, m, data[i]);\n          b.set(labelW + i, y, g.u ? VBLOCKS[idx] : ' ._-=+*#@'[idx], i === sw - 1 ? lift(th, c, 0.35) : th.mode === 'light' ? mix(th.bg, c, 0.55 + 0.45 * (i / sw)) : scale(c, 0.55 + 0.45 * (i / sw)));\n        }\n        if (rangeW) {\n          // Window min and max, like `spark --range`.\n          const mn = Math.min(...data), mx = Math.max(...data);\n          const x = labelW + sw + 1;\n          let e = b.text(x, y, g.down, th.dim);\n          e = b.text(e, y, short(mn), th.muted);\n          e = b.text(e + 1, y, g.up, th.dim);\n          b.text(e, y, short(mx), th.muted);\n        }\n        if (valueW) {\n          const txt = `${v < 10 ? v.toFixed(1) : Math.round(v)}${m.unit === '%' ? '%' : ' ' + m.unit}`;\n          putR(b, W, y, txt, levelColor(th, m, v), undefined, ATTR.BOLD);\n        }\n      }\n    };\n  },\n};\n\nconst sparkBraille = {\n  id: 'charts-spark-braille',\n  title: 'Braille Sparklines',\n  category: 'Sparklines',\n  description: 'High-resolution braille sparklines (2×4 dots per cell) with min/max markers, a glowing last-value point and a live readout.',\n  tags: ['sparkline', 'braille', 'high resolution', 'min max', 'latency'],\n  kind: 'chart',\n  size: { w: 56, h: 9 },\n  minSize: { w: 24, h: 3 },\n  loop: 12,\n  glyphs: 'braille',\n  canvas: 'line',\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const defs = [\n      { label: 'p95 latency', unit: 'ms', s: signal(ctx, { seed: 11, base: 180, amp: 70, spike: 90, min: 20, rate: 6 }), c: th.accent },\n      { label: 'requests', unit: '/s', s: signal(ctx, { seed: 12, base: 1400, amp: 420, rate: 6 }), c: th.pos },\n      { label: 'queue depth', unit: '', s: signal(ctx, { seed: 13, base: 22, amp: 18, spike: 30, min: 0, rate: 6 }), c: th.crit },\n    ];\n    const per = H >= 9 ? 3 : H >= 6 ? 2 : 1;\n    const rows = Math.max(1, Math.min(defs.length, Math.floor(H / per)));\n    const textH = per >= 2 ? 1 : 0;\n    const cvRows = Math.max(1, per - textH);\n    const cv = ctx.canvas('line', { cols: W, rows: cvRows, threshold: 0 });\n    return (t) => {\n      for (let r = 0; r < rows; r++) {\n        const d = defs[r], y0 = r * per;\n        const n = cv.width;\n        const data = d.s.window(t, n);\n        let lo = Infinity, hi = -Infinity, iLo = 0, iHi = 0;\n        data.forEach((v, i) => { if (v < lo) { lo = v; iLo = i; } if (v > hi) { hi = v; iHi = i; } });\n        const span = hi - lo || 1;\n        const py = (v) => (cv.height - 1) - ((v - lo) / span) * (cv.height - 1);\n        cv.clear();\n        let prev = null;\n        for (let i = 0; i < n; i++) {\n          const y = py(data[i]);\n          const c = mix(scale(d.c, 0.45), d.c, i / n);\n          if (prev !== null) cv.line(i - 1, prev, i, y, c); else cv.set(i, y, c);\n          prev = y;\n        }\n        cv.set(iHi, py(hi), th.warn); cv.set(iHi, py(hi) - 1, th.warn);\n        cv.set(iLo, py(lo), th.info); cv.set(iLo, py(lo) + 1, th.info);\n        cv.set(n - 1, py(data[n - 1]), light(th)); cv.set(n - 2, py(data[n - 1]), light(th));\n        cv.paint(b, 0, y0 + textH);\n        if (textH) {\n          const v = d.s.now(t);\n          b.text(0, y0, d.label, th.muted);\n          const val = `${num(v, v < 10 ? 1 : 0)}${d.unit}`;\n          const right = W >= 40 ? `${g.down}${num(lo, 0)}  ${g.up}${num(hi, 0)}  ` : '';\n          putR(b, W - val.length, y0, right, th.dim);\n          putR(b, W, y0, val, d.c, undefined, ATTR.BOLD);\n        }\n      }\n    };\n  },\n};\n\nconst sparkDots = {\n  id: 'charts-spark-dots',\n  title: 'Dot Sparkline & Threshold',\n  category: 'Sparklines',\n  description: 'A dot-plot sparkline against a dashed SLO threshold: points that breach glow red and are counted live.',\n  tags: ['sparkline', 'dots', 'threshold', 'slo', 'breach'],\n  kind: 'chart',\n  size: { w: 60, h: 6 },\n  minSize: { w: 24, h: 3 },\n  loop: 10,\n  canvas: 'line',\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const s = signal(ctx, { seed: 21, base: 215, amp: 120, spike: 420, min: 40, rate: 5 });\n    const LIMIT = 290;\n    const rows = Math.max(1, H - 1);\n    const cv = ctx.canvas('line', { cols: W, rows, threshold: 0 });\n    return (t) => {\n      const n = Math.floor(cv.width / 2);\n      const data = s.window(t, n);\n      const lo = 0, hi = Math.max(LIMIT * 1.35, Math.max(...data) * 1.08);\n      const py = (v) => (cv.height - 1) - clamp((v - lo) / (hi - lo), 0, 1) * (cv.height - 1);\n      cv.clear();\n      const ly = Math.round(py(LIMIT));\n      for (let x = 0; x < cv.width; x += 3) cv.set(x, ly, mix(th.bg, th.warn, 0.8));\n      let breaches = 0;\n      data.forEach((v, i) => {\n        const bad = v > LIMIT;\n        if (bad) breaches++;\n        const x = i * 2 + 1, y = py(v);\n        const c = bad ? th.neg : mix(th.info, th.accent, i / n);\n        cv.set(x, y, c); cv.set(x, y + 1, scale(c, 0.7));\n        if (bad) { cv.set(x, y - 1, c); cv.set(x - 1, y, scale(c, 0.6)); cv.set(x + 1, y, scale(c, 0.6)); }\n      });\n      cv.paint(b, 0, 1);\n      put(b, 0, 0, 'checkout p95', W, th.muted);\n      const msg = `${breaches} over ${LIMIT} ms`;\n      if (W >= 30) putR(b, W, 0, msg, breaches ? th.neg : th.pos, undefined, ATTR.BOLD);\n      if (W >= 46) putR(b, W - msg.length - 2, 0, `${g.dash.repeat(2)} SLO`, th.warn);\n    };\n  },\n};\n\nconst SERVICES = ['api-gw', 'auth', 'cart', 'checkout', 'search', 'catalog', 'payments', 'orders', 'rx-orders', 'loyalty', 'notify', 'inventory'];\n\nconst sparkWall = {\n  id: 'charts-spark-wall',\n  title: 'Sparkline Wall',\n  category: 'Sparklines',\n  description: 'A NOC wall of a dozen services: name, a mini sparkline and the current rate, each tinted by health.',\n  tags: ['sparkline', 'wallboard', 'noc', 'services', 'grid'],\n  kind: 'chart',\n  size: { w: 80, h: 8 },\n  minSize: { w: 26, h: 3 },\n  loop: 12,\n  color: '256',\n  order: 40,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, glyphs(ctx));\n    const cellW = 26;\n    const cols = Math.max(1, Math.floor((W + 1) / cellW));\n    const perCol = Math.max(1, H);\n    const count = Math.min(SERVICES.length, cols * perCol);\n    const streams = SERVICES.slice(0, count).map((name, i) => {\n      const base = 300 + (i * 97) % 900;\n      return { name, base, s: signal(ctx, { seed: 40 + i, base, amp: base * 0.22, spike: i === 3 ? base * 1.4 : 0, min: 5, rate: 3 }) };\n    });\n    const w = Math.min(cellW - 1, W);\n    return (t) => {\n      streams.forEach((st, i) => {\n        const cx = Math.floor(i / perCol) * cellW, cy = i % perCol;\n        const sw = Math.max(4, w - 16);\n        const data = st.s.window(t, sw);\n        const max = Math.max(...data), min = Math.min(...data);\n        const v = st.s.now(t);\n        const health = v > st.base * 1.9 ? 0.3 : v > st.base * 1.45 ? 0.6 : 1;\n        const c = health >= 0.8 ? th.pos : health >= 0.5 ? th.warn : th.neg;\n        b.set(cx, cy, g.dot, c);\n        put(b, cx + 2, cy, st.name, 9, th.ink);\n        for (let k = 0; k < sw; k++) {\n          const idx = Math.max(1, Math.round(((data[k] - min) / (max - min || 1)) * 7) + 1);\n          b.set(cx + 11 + k, cy, g.u ? VBLOCKS[Math.min(8, idx)] : '.:-=+*#%@'[Math.min(8, idx)], scale(c, 0.5 + 0.5 * (k / sw)));\n        }\n        putR(b, cx + w, cy, num(v, 0), c, undefined, ATTR.BOLD);\n      });\n    };\n  },\n};\n\nexport default [sparkBars, sparkBraille, sparkDots, sparkWall];\n",
  "prism-term/src/facets/diagrams/_lines.js": "// Orthogonal line routing for box-drawing diagrams.\n//\n// Segments are accumulated as N/E/S/W direction bits per cell, then painted\n// with the box-drawing glyph that joins exactly those directions (─ │ ┌ ┬ ┼ …),\n// so crossing and branching edges merge into proper junctions instead of\n// overwriting each other. ASCII mode paints - | + instead.\n\nimport { uni, panel, center, stringWidth } from '../callouts/_kit.js';\n\nconst N = 1, E = 2, S = 4, W = 8;\nconst LIGHT = {\n  [N]: '│', [S]: '│', [N | S]: '│', [E]: '─', [W]: '─', [E | W]: '─',\n  [E | S]: '┌', [W | S]: '┐', [N | E]: '└', [N | W]: '┘',\n  [N | E | S]: '├', [N | W | S]: '┤', [E | W | S]: '┬', [N | E | W]: '┴', [N | E | S | W]: '┼',\n};\nconst ROUND = { ...LIGHT, [E | S]: '╭', [W | S]: '╮', [N | E]: '╰', [N | W]: '╯' };\nconst HEAVY = {\n  [N]: '┃', [S]: '┃', [N | S]: '┃', [E]: '━', [W]: '━', [E | W]: '━',\n  [E | S]: '┏', [W | S]: '┓', [N | E]: '┗', [N | W]: '┛',\n  [N | E | S]: '┣', [N | W | S]: '┫', [E | W | S]: '┳', [N | E | W]: '┻', [N | E | S | W]: '╋',\n};\nconst ASCII = (m) => ((m & (N | S)) && (m & (E | W)) ? '+' : m & (N | S) ? '|' : '-');\n\nexport const DIR = Object.freeze({ N, E, S, W });\n\n/**\n * Join a line into a box border: 'top' → ┴, 'bottom' → ┬, 'left' → ┤, 'right' → ├\n * (heavy/double borders get their own tees; ASCII gets +).\n */\nexport function tap(ctx, s, x, y, side, color, style = 'light') {\n  const u = uni(ctx);\n  const T = style === 'double'\n    ? { top: '╧', bottom: '╤', left: '╢', right: '╟' }\n    : style === 'heavy' ? { top: '┷', bottom: '┯', left: '┨', right: '┠' } : { top: '┴', bottom: '┬', left: '┤', right: '├' };\n  s.set(x, y, u ? T[side] : '+', color ?? ctx.theme.line);\n}\n\nexport const ARROWS = { E: ['▶', '>'], W: ['◀', '<'], S: ['▼', 'v'], N: ['▲', '^'] };\n\nexport class LineGrid {\n  constructor(w, h) {\n    this.w = w; this.h = h;\n    this.mask = new Uint8Array(w * h);\n    this.color = new Int32Array(w * h).fill(-1);\n    this.weight = new Uint8Array(w * h);\n    this.heads = [];\n  }\n\n  _add(x, y, bits, c, heavy) {\n    if (x < 0 || y < 0 || x >= this.w || y >= this.h) return;\n    const i = y * this.w + x;\n    this.mask[i] |= bits;\n    if (c !== undefined) this.color[i] = c;\n    if (heavy) this.weight[i] = 1;\n  }\n\n  /** OR raw direction bits (DIR.N | DIR.S …) into a cell, e.g. to stub a line into a box border. */\n  bits(x, y, dirs, c) { this._add(x, y, dirs, c); }\n\n  /** Horizontal run on row y from x0 to x1 (inclusive). */\n  hline(x0, x1, y, c, heavy) {\n    if (x1 < x0) [x0, x1] = [x1, x0];\n    for (let x = x0; x <= x1; x++) this._add(x, y, (x > x0 ? W : 0) | (x < x1 ? E : 0) || E | W, c, heavy);\n  }\n\n  /** Vertical run in column x from y0 to y1 (inclusive). */\n  vline(x, y0, y1, c, heavy) {\n    if (y1 < y0) [y0, y1] = [y1, y0];\n    for (let y = y0; y <= y1; y++) this._add(x, y, (y > y0 ? N : 0) | (y < y1 ? S : 0) || N | S, c, heavy);\n  }\n\n  /**\n   * Elbow route from (x0,y0) to (x1,y1): horizontal first then vertical\n   * ('hv'), vertical first ('vh'), or vertical-horizontal-vertical through\n   * row `midY` ('vhv', the org-chart / tree shape).\n   */\n  route(x0, y0, x1, y1, { c, heavy, shape = 'hv', midY, arrow } = {}) {\n    if (shape === 'vhv') {\n      const my = midY ?? Math.round((y0 + y1) / 2);\n      this.vline(x0, y0, my, c, heavy); this.hline(x0, x1, my, c, heavy); this.vline(x1, my, y1, c, heavy);\n    } else if (shape === 'vh') {\n      this.vline(x0, y0, y1, c, heavy); this.hline(x0, x1, y1, c, heavy);\n    } else {\n      this.hline(x0, x1, y0, c, heavy); this.vline(x1, y0, y1, c, heavy);\n    }\n    if (arrow) {\n      const dir = shape === 'hv' ? (y1 !== y0 ? (y1 > y0 ? 'S' : 'N') : x1 >= x0 ? 'E' : 'W')\n        : shape === 'vh' ? (x1 !== x0 ? (x1 > x0 ? 'E' : 'W') : y1 >= y0 ? 'S' : 'N')\n          : (y1 >= (midY ?? y1) ? 'S' : 'N');\n      this.heads.push({ x: x1, y: y1, dir, c });\n    }\n  }\n\n  /** Axis-aligned polyline through corner points [[x, y], ...]; optional arrowhead at the end. */\n  path(corners, { c, heavy, arrow } = {}) {\n    for (let k = 1; k < corners.length; k++) {\n      const [ax, ay] = corners[k - 1], [bx, by] = corners[k];\n      if (ay === by) this.hline(ax, bx, ay, c, heavy);\n      else this.vline(ax, ay, by, c, heavy);\n    }\n    if (arrow && corners.length > 1) {\n      const [ax, ay] = corners[corners.length - 2], [bx, by] = corners[corners.length - 1];\n      const dir = ay === by ? (bx >= ax ? 'E' : 'W') : by >= ay ? 'S' : 'N';\n      this.heads.push({ x: bx, y: by, dir, c });\n    }\n  }\n\n  /** Paint into a surface at offset (ox, oy). style: 'light' | 'round' | 'heavy'. */\n  paint(ctx, s, ox = 0, oy = 0, { style = 'round', fallback } = {}) {\n    const u = uni(ctx);\n    const table = style === 'heavy' ? HEAVY : style === 'light' ? LIGHT : ROUND;\n    for (let y = 0; y < this.h; y++) for (let x = 0; x < this.w; x++) {\n      const i = y * this.w + x, m = this.mask[i];\n      if (!m) continue;\n      const ch = u ? (this.weight[i] ? HEAVY[m] : table[m]) || '┼' : ASCII(m);\n      s.set(ox + x, oy + y, ch, this.color[i] >= 0 ? this.color[i] : fallback ?? ctx.theme.line);\n    }\n    for (const h of this.heads) s.set(ox + h.x, oy + h.y, ARROWS[h.dir][u ? 0 : 1], h.c ?? fallback ?? ctx.theme.line);\n  }\n}\n\n/** Points along a polyline route (for packets traveling an edge). */\nexport function routePoints(x0, y0, x1, y1, shape = 'hv', midY) {\n  const pts = [];\n  const seg = (ax, ay, bx, by) => {\n    const n = Math.max(Math.abs(bx - ax), Math.abs(by - ay));\n    for (let k = pts.length ? 1 : 0; k <= n; k++) pts.push([Math.round(ax + ((bx - ax) * k) / (n || 1)), Math.round(ay + ((by - ay) * k) / (n || 1))]);\n  };\n  if (shape === 'vhv') { const my = midY ?? Math.round((y0 + y1) / 2); seg(x0, y0, x0, my); seg(x0, my, x1, my); seg(x1, my, x1, y1); }\n  else if (shape === 'vh') { seg(x0, y0, x0, y1); seg(x0, y1, x1, y1); }\n  else { seg(x0, y0, x1, y0); seg(x1, y0, x1, y1); }\n  return pts;\n}\n\n/**\n * A labeled diagram node: a 3-row box with the label centered. Returns its\n * geometry, with anchor points just outside each side for routing edges.\n */\nexport function nodeBox(ctx, s, x, y, label, { w, color, fg, bg, style = 'round', attr = 0 } = {}) {\n  const ww = w ?? stringWidth(label) + 4;\n  const th = ctx.theme;\n  const inner = panel(ctx, s, x, y, ww, 3, { style, color: color ?? th.line, bg });\n  center(inner, 0, 0, inner.width, label, fg ?? th.ink, bg, attr, ctx);\n  return box(x, y, ww, 3);\n}\n\n/** Geometry for a box at (x, y, w, h): center and the cells just outside each side. */\nexport function box(x, y, w, h) {\n  const cx = x + Math.floor(w / 2), cy = y + Math.floor(h / 2);\n  return { x, y, w, h, cx, cy, left: [x - 1, cy], right: [x + w, cy], top: [cx, y - 1], bottom: [cx, y + h] };\n}\n\n/** Point a fraction `f` (0..1) along a polyline given as [[x, y], ...] cell points. */\nexport function along(pts, f) {\n  if (!pts.length) return null;\n  const i = Math.max(0, Math.min(pts.length - 1, Math.floor(f * pts.length)));\n  return pts[i];\n}\n\n/** Cell points along an axis-aligned polyline through the given corner points. */\nexport function polyline(corners) {\n  const pts = [];\n  for (let k = 1; k < corners.length; k++) {\n    const [ax, ay] = corners[k - 1], [bx, by] = corners[k];\n    const n = Math.max(Math.abs(bx - ax), Math.abs(by - ay));\n    for (let j = pts.length ? 1 : 0; j <= n; j++) pts.push([Math.round(ax + ((bx - ax) * j) / (n || 1)), Math.round(ay + ((by - ay) * j) / (n || 1))]);\n  }\n  return pts;\n}\n",
  "prism-term/src/facets/diagrams/architecture.js": "// A service architecture: clients → load balancer → three app servers →\n// postgres / replica / redis, with request packets flowing along the edges.\n// Mid-loop app-2 fails its health checks and traffic shifts to its peers.\nimport { ATTR, glyphs, uni, phase, fit } from '../callouts/_kit.js';\nimport { LineGrid, nodeBox, box, polyline, along } from './_lines.js';\n\nconst DOWN = [3.4, 5.8];\nconst PACKETS = 14;\n\nexport default {\n  id: 'diagrams-architecture',\n  title: 'Service Architecture',\n  category: 'Boxes & Flows',\n  description: 'Clients, load balancer, three app servers and the data tier, with request packets flowing along routed edges; app-2 fails health checks and traffic reroutes.',\n  tags: ['architecture', 'load balancer', 'traffic', 'topology', 'failover'],\n  kind: 'diagram',\n  size: { w: 76, h: 18 },\n  minSize: { w: 58, h: 14 },\n  loop: 8,\n  color: '256',\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 8);\n      const down = p >= DOWN[0] && p < DOWN[1];\n      const DH = H - 1;\n      // Columns.\n      const cw = [11, 11, 9, 12];\n      const gap = Math.max(3, Math.floor((W - cw.reduce((a, c) => a + c, 0)) / 3));\n      const xs = [0];\n      for (let i = 1; i < 4; i++) xs.push(xs[i - 1] + cw[i - 1] + gap);\n      // Rows: three tiers, centered vertically.\n      const step = Math.min(5, Math.floor((DH - 3) / 2));\n      const y0 = Math.max(0, Math.floor((DH - (2 * step + 3)) / 2));\n      const ys = [y0, y0 + step, y0 + 2 * step];\n      const clients = box(xs[0], ys[1], cw[0], 3), lb = box(xs[1], ys[1], cw[1], 3);\n      const apps = ys.map((y) => box(xs[2], y, cw[2], 3));\n      const data = ys.map((y) => box(xs[3], y, cw[3], 3));\n      const busA = lb.x + lb.w + Math.floor(gap / 2), busB = xs[2] + cw[2] + Math.floor(gap / 2);\n\n      // Edges.\n      const grid = new LineGrid(W, DH);\n      grid.path([clients.right, lb.left], { arrow: true });\n      grid.hline(lb.right[0], busA, lb.cy);\n      grid.vline(busA, apps[0].cy, apps[2].cy);\n      apps.forEach((a, k) => {\n        const c = down && k === 1 ? th.neg : undefined;\n        grid.path([[busA, a.cy], a.left], { c, arrow: true });\n        grid.path([a.right, [busB, a.cy]], { c });\n      });\n      grid.vline(busB, apps[0].cy, apps[2].cy);\n      data.forEach((d) => grid.path([[busB, d.cy], d.left], { arrow: true }));\n      grid.paint(ctx, b, 0, 0, { style: 'round' });\n\n      // Nodes.\n      nodeBox(ctx, b, clients.x, clients.y, 'clients', { w: cw[0], color: th.muted });\n      nodeBox(ctx, b, lb.x, lb.y, 'edge-lb', { w: cw[1], color: th.accent, attr: ATTR.BOLD });\n      apps.forEach((a, k) => {\n        const bad = down && k === 1;\n        nodeBox(ctx, b, a.x, a.y, bad ? `app-2 ${g.cross}` : `app-${k + 1}`, { w: cw[2], color: bad ? th.neg : th.info, fg: bad ? th.neg : th.ink, attr: bad ? ATTR.BOLD : 0 });\n        if (step >= 5) {\n          const rps = down ? (k === 1 ? '0 rps' : '620 rps') : `${[412, 405, 418][k]} rps`;\n          b.text(a.x + Math.floor((a.w - rps.length) / 2), a.y + 3, rps, bad ? th.neg : th.dim);\n        }\n      });\n      const DATA = [['postgres', th.accent], ['replica', th.info], ['redis', th.pos]];\n      data.forEach((d, k) => nodeBox(ctx, b, d.x, d.y, DATA[k][0], { w: cw[3], color: DATA[k][1] }));\n      if (step >= 5) b.text(data[0].x + 2, data[0].y + 3, u ? '↓ repl' : 'v repl', th.dim);\n\n      // Packets: client → lb → app → data store.\n      for (let i = 0; i < PACKETS; i++) {\n        const f = phase(p / 2.2 + i / PACKETS, 1);\n        let k = i % 3;\n        if (down && k === 1) k = i % 2 ? 0 : 2;\n        const tgt = i % 3 === 2 ? 2 : i % 2;\n        const a = apps[k], d = data[tgt];\n        // Hop node to node: the packet is hidden while it's \"inside\" a box.\n        const pts = [\n          ...polyline([clients.right, lb.left]),\n          ...polyline([lb.right, [busA, lb.cy], [busA, a.cy], a.left]),\n          ...polyline([a.right, [busB, a.cy], [busB, d.cy], d.left]),\n        ];\n        const at = along(pts, f);\n        if (!at) continue;\n        b.set(at[0], at[1], u ? '●' : 'o', DATA[tgt][1], undefined, ATTR.BOLD);\n      }\n\n      if (y0 >= 2) b.text(0, 0, fit(`checkout ${g.mid} prod-us-east-2`, W, undefined, ctx), th.dim);\n      // Status line.\n      const msg = down\n        ? `${g.cross} app-2 failing health checks ${g.mid} traffic on app-1, app-3`\n        : p >= DOWN[1] && p < DOWN[1] + 1.4\n          ? `${g.check} app-2 passing again ${g.mid} 3/3 healthy`\n          : `rps 1.2k ${g.mid} p99 180ms ${g.mid} 3/3 healthy`;\n      b.text(0, H - 1, fit(msg, W, undefined, ctx), down ? th.neg : p >= DOWN[1] && p < DOWN[1] + 1.4 ? th.pos : th.muted, undefined, down ? ATTR.BOLD : 0);\n    };\n  },\n};\n",
  "prism-term/src/facets/diagrams/cluster-map.js": "// A cluster map: three nodes with their pods, CPU and memory. node-b goes\n// NotReady, its pods are evicted and reschedule onto the healthy nodes one\n// by one (Pending → Running), then node-b comes back empty.\nimport { ATTR, glyphs, uni, phase, fit, hbar, panel, mix } from '../callouts/_kit.js';\n\nconst SVC = { c: ['checkout', 'accent'], p: ['payments', 'pos'], s: ['search', 'info'], i: ['inventory', 'warn'] };\nconst BASE = { a: 'ccpsic', b: 'cppsis', c: 'psicc' };\n// node-b's pods, where they land and when (Pending at t, Running at t + 0.6).\nconst MOVES = [['c', 'a', 3.5], ['p', 'c', 3.9], ['p', 'a', 4.3], ['s', 'c', 4.7], ['i', 'a', 5.1], ['s', 'c', 5.5]];\nconst DOWN = 2.8, EVICT = 3.2, BACK = 7.6;\nconst NODES = [['a', 'node-a', '10.20.3.11'], ['b', 'node-b', '10.20.3.12'], ['c', 'node-c', '10.20.3.13']];\n\nexport default {\n  id: 'diagrams-cluster-map',\n  title: 'Cluster Map',\n  category: 'Networks & Topologies',\n  description: 'Three cluster nodes with pods colored by service plus CPU and memory; node-b goes NotReady, its pods are evicted and reschedule onto healthy nodes.',\n  tags: ['kubernetes', 'cluster', 'pods', 'scheduling', 'nodes'],\n  kind: 'diagram',\n  size: { w: 72, h: 12 },\n  minSize: { w: 54, h: 10 },\n  loop: 10,\n  color: '256',\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 10);\n      const sick = p >= DOWN && p < BACK;\n      const pods = { a: [...BASE.a].map((s) => [s, 'run']), b: p < EVICT ? [...BASE.b].map((s) => [s, sick ? 'term' : 'run']) : [], c: [...BASE.c].map((s) => [s, 'run']) };\n      for (const [s, to, at] of MOVES) if (p >= at) pods[to].push([s, p < at + 0.6 ? 'pend' : 'run']);\n      const nw = Math.floor((W - 2) / 3);\n      NODES.forEach(([id, name, ip], i) => {\n        const x = i * (nw + 1);\n        const down = id === 'b' && sick;\n        const n = pods[id].filter(([, st]) => st === 'run').length;\n        const title = down ? (nw >= 22 ? `${g.cross} ${name} NotReady` : `${g.cross} ${name}`) : nw >= 20 ? `${name} ${g.mid} ${n} pods` : name;\n        const inner = panel(ctx, b, x, 0, nw, 6, { title, titleColor: down ? th.neg : th.ink, color: down ? th.neg : th.line, footer: nw >= 20 ? ip : '' });\n        const iw = inner.width;\n        const list = pods[id];\n        const perRow = Math.max(1, Math.floor((iw - 1) / 2));\n        list.forEach(([s, st], k) => {\n          const r = Math.floor(k / perRow), cx = 1 + (k % perRow) * 2;\n          if (r > 1) return;\n          const c = th[SVC[s][1]];\n          const ch = st === 'pend' ? (u ? '□' : 'o') : st === 'term' ? (u ? '▪' : 'x') : (u ? '■' : '#');\n          inner.set(cx, r, ch, st === 'term' ? mix(th.bg, c, 0.45) : c, undefined, st === 'pend' ? ATTR.BLINK : ATTR.BOLD);\n        });\n        if (!list.length) inner.text(1, 0, fit(down ? 'evicted' : 'no pods', iw - 2, undefined, ctx), down ? th.neg : th.dim, undefined, ATTR.ITALIC);\n        const running = list.filter(([, st]) => st === 'run').length;\n        const cpu = down ? 0 : Math.max(0.02, Math.min(0.97, 0.08 * running + 0.05 * Math.sin(t * 1.3 + i)));\n        const mem = down ? 0 : Math.min(0.95, 0.07 * running + 0.18);\n        const bw = Math.max(4, iw - 10);\n        inner.text(1, 2, 'cpu', th.muted);\n        hbar(ctx, inner, 5, 2, bw, cpu, cpu > 0.75 ? th.warn : th.accent);\n        inner.text(6 + bw, 2, `${Math.round(cpu * 100)}%`.padStart(3), th.dim);\n        inner.text(1, 3, 'mem', th.muted);\n        hbar(ctx, inner, 5, 3, bw, mem, mem > 0.75 ? th.warn : th.info);\n        inner.text(6 + bw, 3, `${Math.round(mem * 100)}%`.padStart(3), th.dim);\n      });\n      // Legend.\n      let lx = 0;\n      for (const [, [name, tone]] of Object.entries(SVC)) {\n        if (lx + name.length + 3 > W) break;\n        b.set(lx, 6, u ? '■' : '#', th[tone], undefined, ATTR.BOLD);\n        lx = b.text(lx + 2, 6, name, th.muted) + 2;\n      }\n      if (lx + 9 <= W) b.text(lx, 6, `${u ? '□' : 'o'} pending`, th.dim);\n      // Event log (newest last).\n      const EV = [\n        [DOWN, th.neg, `${g.cross} node-b NotReady: kubelet stopped posting status`],\n        [EVICT, th.warn, `${g.warn} evicting 6 pods from node-b`],\n        [3.5, th.info, `${g.right} rescheduling onto node-a, node-c`],\n        [6.1, th.pos, `${g.check} 6/6 pods running ${g.mid} 0 lost`],\n        [BACK, th.pos, `${g.check} node-b Ready (cordoned, no pods)`],\n      ].filter(([at]) => p >= at);\n      const rows = H - 8;\n      EV.slice(-rows).forEach(([, c, text], r) => b.text(0, 8 + r, fit(text, W, undefined, ctx), c));\n      if (!EV.length) b.text(0, 8, fit(`${g.check} 3/3 nodes Ready ${g.mid} 17 pods`, W, undefined, ctx), th.muted);\n    };\n  },\n};\n",
  "prism-term/src/facets/diagrams/dependency-tree.js": "// An `npm ls --all` dependency tree that prints line by line, then `npm audit`\n// lights the path to a vulnerable transitive package (a fictional one).\nimport { ATTR, glyphs, uni, phase, blink, clip, stringWidth } from '../callouts/_kit.js';\n\n// [name, version, children, flag] — flag: 'deduped' | 'vuln'\nconst ROOT = ['@checkout/api', '2.14.1', [\n  ['express', '4.21.2', [['body-parser', '1.20.3'], ['send', '0.19.0', [['ms', '2.1.3']]], ['qs', '6.13.0']]],\n  ['pg', '8.13.1', [['pg-pool', '3.7.0'], ['pg-protocol', '1.7.0']]],\n  ['@checkout/telemetry', '1.4.0', [['left-padder', '1.0.2', null, 'vuln'], ['ms', '2.1.3', null, 'deduped']]],\n  ['ioredis', '5.4.1'],\n  ['zod', '3.23.8'],\n]];\nconst PRINT = [0.3, 2.6], EXPLAIN = 3.4;\n\nfunction flatten() {\n  const rows = [];\n  const walk = (node, guides, last, depth, path) => {\n    const [name, ver, kids, flag] = node;\n    rows.push({ name, ver, flag, guides, last, depth, hasKids: !!(kids && kids.length), path: [...path, name] });\n    (kids || []).forEach((k, i) => walk(k, depth ? [...guides, !last] : [], i === kids.length - 1, depth + 1, [...path, name]));\n  };\n  walk(ROOT, [], true, 0, []);\n  return rows;\n}\nconst ROWS = flatten();\nconst VULN = ROWS.find((r) => r.flag === 'vuln');\n\nexport default {\n  id: 'diagrams-dependency-tree',\n  title: 'Dependency Tree',\n  category: 'Trees & Hierarchies',\n  description: 'An npm ls --all tree printing line by line with deduped markers, then npm audit lights the path from the root to a vulnerable transitive dependency.',\n  tags: ['dependencies', 'npm ls', 'tree', 'supply chain', 'vulnerability'],\n  kind: 'diagram',\n  size: { w: 60, h: 20 },\n  minSize: { w: 44, h: 10 },\n  loop: 8,\n  color: '16',\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 8);\n      const G = u ? { pipe: '│ ', gap: '  ', tee: '├─', end: '└─', kid: '┬ ', leaf: '─ ' } : { pipe: '| ', gap: '  ', tee: '+-', end: '`-', kid: '+ ', leaf: '- ' };\n      const explaining = p >= EXPLAIN;\n      const shown = Math.min(ROWS.length, Math.max(0, Math.floor(((p - PRINT[0]) / (PRINT[1] - PRINT[0])) * ROWS.length)));\n      const lines = [];\n      const prompt = (cmd) => lines.push([[`$ `, th.pos, ATTR.BOLD], [cmd, th.ink, ATTR.BOLD]]);\n      prompt('npm ls --all');\n      ROWS.slice(0, explaining ? ROWS.length : shown).forEach((r) => {\n        const onPath = explaining && VULN.path.slice(0, r.depth + 1).join('/') === r.path.join('/');\n        const dim = explaining && !onPath;\n        const lc = onPath ? th.neg : th.line;\n        const segs = [];\n        if (r.depth > 0) {\n          segs.push([r.guides.map((more) => (more ? G.pipe : G.gap)).join(''), dim ? th.dim : th.line]);\n          segs.push([(r.last ? G.end : G.tee) + (r.hasKids ? G.kid : G.leaf), lc]);\n        }\n        segs.push([r.name, dim ? th.dim : r.flag === 'vuln' ? th.neg : th.ink, r.flag === 'vuln' || onPath ? ATTR.BOLD : 0]);\n        segs.push([`@${r.ver}`, dim ? th.dim : r.flag === 'vuln' ? th.neg : th.muted]);\n        if (r.depth === 0) segs.push([' /srv/checkout', th.dim]);\n        if (r.flag === 'deduped') segs.push([' deduped', th.dim, ATTR.ITALIC]);\n        if (r.flag === 'vuln' && (explaining || shown === ROWS.length)) segs.push([` ${g.warn} high`, th.neg, ATTR.BOLD]);\n        lines.push(segs);\n      });\n      if (explaining) {\n        prompt('npm audit');\n        lines.push([[`${g.warn} 1 high severity vulnerability`, th.neg, ATTR.BOLD]]);\n        lines.push([[`  left-padder <1.0.3 ${g.mid} prototype pollution`, th.muted]]);\n        lines.push([[`  fix: npm i @checkout/telemetry@1.4.1`, th.pos]]);\n      } else if (p >= PRINT[1] + 0.1) {\n        lines.push([]);\n        lines.push([[`$ `, th.pos, ATTR.BOLD], [blink(t, 0.8) ? (u ? '▏' : '_') : ' ', th.ink]]);\n      }\n      // Keep the newest lines (or the vulnerable path) in view.\n      const first = Math.max(0, lines.length - H);\n      for (let r = 0; r < Math.min(H, lines.length - first); r++) {\n        let x = 0;\n        for (const [text, fg, attr] of lines[first + r]) {\n          const room = Math.max(0, W - x);\n          x = b.text(x, r, stringWidth(text) <= room ? text : clip(ctx, text, room), fg, undefined, attr);\n        }\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/diagrams/file-tree.js": "// A file tree explorer: tree(1) guide lines, folders that expand and collapse\n// as the cursor walks, git status markers and a running summary.\nimport { ATTR, glyphs, uni, phase, tint, fit } from '../callouts/_kit.js';\n\n// [name, children | size, git status]\nconst TREE = ['prism-term', [\n  ['src', [\n    ['facets', [\n      ['ai', [['thinking.js', '3.1K'], ['token-stream.js', '4.2K']]],\n      ['callouts', [['_kit.js', '6.8K'], ['table-grid.js', '3.9K', 'M']]],\n      ['input', [['command-palette.js', '5.2K', 'A'], ['select.js', '2.4K']]],\n    ]],\n    ['buffer.js', '7.7K'],\n    ['canvas.js', '9.4K'],\n  ]],\n  ['test', [['facets.test.js', '4.4K'], ['tui.test.js', '2.1K', '?']]],\n  ['package.json', '1.2K', 'M'],\n  ['README.md', '8.0K'],\n]];\n// Folder paths and when they open (+) / close (−); cursor path per step.\nconst OPEN = { 'prism-term': [[0, 99]], 'prism-term/src': [[0, 99]], 'prism-term/test': [[0, 99]], 'prism-term/src/facets': [[1.0, 5.6]], 'prism-term/src/facets/callouts': [[2.0, 5.6]], 'prism-term/src/facets/input': [[3.4, 5.6]] };\nconst CURSOR = [[0, 'prism-term/src'], [0.6, 'prism-term/src/facets'], [1.5, 'prism-term/src/facets/ai'], [1.7, 'prism-term/src/facets/callouts'],\n  [2.5, 'prism-term/src/facets/callouts/_kit.js'], [2.8, 'prism-term/src/facets/callouts/table-grid.js'], [3.1, 'prism-term/src/facets/input'],\n  [3.9, 'prism-term/src/facets/input/command-palette.js'], [5.3, 'prism-term/src/facets'], [6.2, 'prism-term/src']];\nconst REVEAL = 0.07;\n\nexport default {\n  id: 'diagrams-file-tree',\n  title: 'File Tree',\n  category: 'Trees & Hierarchies',\n  description: 'A tree(1)-style explorer with guide lines: folders expand and collapse as the cursor walks, with git status markers, file sizes and a live summary.',\n  tags: ['file tree', 'tree', 'explorer', 'directory', 'git status'],\n  kind: 'diagram',\n  size: { w: 56, h: 18 },\n  minSize: { w: 36, h: 10 },\n  loop: 7,\n  color: '16',\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 7);\n      const isOpen = (path) => {\n        const spans = OPEN[path];\n        if (!spans) return null;\n        const s = spans.find(([a, z]) => p >= a && p < z);\n        return s ? s[0] : null;\n      };\n      // Flatten the visible tree.\n      const rows = [];\n      let dirs = 0, files = 0;\n      const walk = (node, path, depth, guides, last) => {\n        const [name, kids, git] = node;\n        const isDir = Array.isArray(kids);\n        rows.push({ name, path, depth, guides, last, isDir, size: isDir ? '' : kids, git, open: isDir && isOpen(path) !== null });\n        if (isDir && depth > 0) dirs++; else if (!isDir) files++;\n        const openedAt = isDir ? isOpen(path) : null;\n        if (openedAt === null) return;\n        const show = openedAt === 0 ? kids.length : Math.min(kids.length, Math.floor((p - openedAt) / REVEAL) + 1);\n        kids.slice(0, show).forEach((k, i) => walk(k, `${path}/${k[0]}`, depth + 1, depth > 0 ? [...guides, !last] : [], i === show - 1 && show === kids.length));\n      };\n      walk(TREE, TREE[0], 0, [], true);\n      let cur = CURSOR[0][1];\n      for (const [at, path] of CURSOR) if (p >= at) cur = path;\n      const ci = Math.max(0, rows.findIndex((r) => r.path === cur));\n      const listH = H - 1;\n      const first = Math.max(0, Math.min(rows.length - listH, ci - Math.floor(listH / 2)));\n      const G = u ? { pipe: '│   ', gap: '    ', tee: '├── ', end: '└── ' } : { pipe: '|   ', gap: '    ', tee: '|-- ', end: '`-- ' };\n      for (let r = 0; r < Math.min(listH, rows.length - first); r++) {\n        const row = rows[first + r], y = r, on = first + r === ci;\n        if (on) b.fill(0, y, W, 1, ' ', undefined, tint(ctx, th.accent, 0.16));\n        let x = 0;\n        if (row.depth > 0) {\n          for (const more of row.guides) x = b.text(x, y, more ? G.pipe : G.gap, th.line);\n          x = b.text(x, y, row.last ? G.end : G.tee, th.line);\n        }\n        if (row.isDir) {\n          x = b.text(x, y, row.open ? g.triDown : g.tri, th.muted);\n          x = b.text(x + 1, y, `${row.name}/`, th.info, undefined, ATTR.BOLD | (on ? ATTR.UNDERLINE : 0));\n        } else {\n          const c = row.name.endsWith('.js') ? th.ink : row.name.endsWith('.json') ? th.warn : th.muted;\n          x = b.text(x, y, row.name, c, undefined, on ? ATTR.BOLD | ATTR.UNDERLINE : 0);\n        }\n        const right = `${row.git ? row.git : ' '}  ${row.size.padStart(4)}`;\n        if (row.size && W - right.length > x + 1) {\n          b.text(W - right.length, y, row.git || ' ', row.git === 'M' ? th.warn : row.git === 'A' ? th.pos : th.neg, undefined, ATTR.BOLD);\n          b.text(W - 4, y, row.size.padStart(4), th.dim);\n        }\n      }\n      const sum = `${dirs} directories, ${files} files ${g.mid} ${g.up}${g.down} move  l open  h close`;\n      b.text(0, H - 1, fit(sum, W, undefined, ctx), th.dim);\n    };\n  },\n};\n",
  "prism-term/src/facets/diagrams/flowchart.js": "// A delivery flowchart with decisions and a loop: a run token walks the graph,\n// fails tests once, goes round the fix loop, then ships through a canary.\nimport { ATTR, glyphs, uni, phase, fit } from '../callouts/_kit.js';\nimport { LineGrid, nodeBox, box, polyline, along } from './_lines.js';\n\n// Node layout on a 64-column grid; decisions are drawn double-bordered.\nconst NODES = {\n  commit: [0, 2, 11, 'commit'], build: [17, 2, 9, 'build'], tests: [32, 2, 12, 'tests?', 1], notify: [52, 2, 10, 'notify'],\n  canary: [32, 8, 13, 'canary 5%'], healthy: [14, 8, 13, 'healthy?', 1], promote: [0, 8, 9, 'ship'],\n  rollback: [14, 13, 13, 'roll back'],\n};\n// Steps the run token takes: [kind, id, seconds, result].\nconst STEPS = [\n  ['node', 'commit', 0.5], ['edge', 'commit-build', 0.3], ['node', 'build', 0.6], ['edge', 'build-tests', 0.3],\n  ['node', 'tests', 0.5, 'fail'], ['edge', 'tests-notify', 0.3], ['node', 'notify', 0.5], ['edge', 'notify-build', 0.6],\n  ['node', 'build', 0.5], ['edge', 'build-tests', 0.3], ['node', 'tests', 0.5, 'pass'], ['edge', 'tests-canary', 0.4],\n  ['node', 'canary', 0.7], ['edge', 'canary-healthy', 0.3], ['node', 'healthy', 0.5, 'pass'], ['edge', 'healthy-promote', 0.3],\n  ['node', 'promote', 1.9, 'done'],\n];\n\nexport default {\n  id: 'diagrams-flowchart',\n  title: 'Flowchart',\n  category: 'Boxes & Flows',\n  description: 'A delivery flowchart with decisions, labeled yes/no branches and a fix loop; a run token walks it, fails tests once, loops back, then ships via a canary.',\n  tags: ['flowchart', 'decision', 'process', 'ci', 'graph'],\n  kind: 'diagram',\n  size: { w: 64, h: 17 },\n  minSize: { w: 62, h: 17 },\n  loop: 9,\n  color: '16',\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme;\n    const N = Object.fromEntries(Object.entries(NODES).map(([k, [x, y, w]]) => [k, box(x, y, w, 3)]));\n    const EDGES = {\n      'commit-build': [[N.commit.right, N.build.left]],\n      'build-tests': [[N.build.right, N.tests.left]],\n      'tests-notify': [[N.tests.right, N.notify.left], 'no'],\n      'notify-build': [[N.notify.top, [N.notify.cx, 0], [N.build.cx, 0], N.build.top], 'fix'],\n      'tests-canary': [[N.tests.bottom, N.canary.top], 'yes'],\n      'canary-healthy': [[N.canary.left, N.healthy.right]],\n      'healthy-promote': [[N.healthy.left, N.promote.right], 'yes'],\n      'healthy-rollback': [[N.healthy.bottom, N.rollback.top], 'no'],\n    };\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 9);\n      // Where is the token?\n      let acc = 0, cur = STEPS.length - 1, local = 1;\n      for (let i = 0; i < STEPS.length; i++) {\n        if (p < acc + STEPS[i][2]) { cur = i; local = (p - acc) / STEPS[i][2]; break; }\n        acc += STEPS[i][2];\n      }\n      const visited = new Set(), results = {};\n      for (let i = 0; i <= cur; i++) {\n        const [kind, id, , res] = STEPS[i];\n        if (kind === 'node') { visited.add(id); if (res && (i < cur || local > 0.6)) results[id] = res; }\n      }\n      const [kind, id] = STEPS[cur];\n\n      const grid = new LineGrid(W, H - 1);\n      for (const [eid, [corners]] of Object.entries(EDGES)) {\n        const hot = kind === 'edge' && eid === id;\n        grid.path(corners, { c: hot ? th.accent : undefined, arrow: true });\n      }\n      grid.paint(ctx, b, 0, 0, { style: 'round' });\n      for (const [eid, [corners, label]] of Object.entries(EDGES)) {\n        if (!label) continue;\n        const [[ax, ay], [bx, by]] = corners;\n        const lx = ay === by ? Math.floor((ax + bx) / 2) - Math.floor(label.length / 2) : ax + 1;\n        const ly = ay === by ? ay - (eid === 'notify-build' ? 0 : 1) : Math.floor((ay + by) / 2);\n        if (eid === 'notify-build') b.text(Math.floor((N.notify.cx + N.build.cx) / 2) - 2, 0, ` ${label} `, th.muted);\n        else b.text(lx, ly, label, label === 'no' ? th.neg : th.pos);\n      }\n      for (const [nid, [x, y, w, label, decision]] of Object.entries(NODES)) {\n        const here = kind === 'node' && id === nid;\n        const res = results[nid];\n        const color = res === 'fail' && (here || !visited.has('canary')) ? th.neg : here ? th.accent : visited.has(nid) ? th.pos : th.line;\n        const mark = res === 'fail' ? `${g.cross} ` : res === 'pass' || res === 'done' ? `${g.check} ` : '';\n        nodeBox(ctx, b, x, y, here || res ? `${mark}${label}` : label, {\n          w, color, style: decision ? 'double' : 'round',\n          fg: nid === 'rollback' ? th.muted : here ? th.ink : visited.has(nid) ? th.ink : th.muted, attr: here ? ATTR.BOLD : 0,\n        });\n      }\n      // The token, traveling the current edge.\n      if (kind === 'edge') {\n        const at = along(polyline(EDGES[id][0]), local);\n        if (at) b.set(at[0], at[1], u ? '●' : '@', th.accent, undefined, ATTR.BOLD);\n      }\n      const msg = results.promote === 'done' ? `${g.check} shipped a1f9c3e ${g.mid} 2 runs, 1 fix`\n        : results.tests === 'fail' && !visited.has('canary') ? `${g.cross} unit tests failed ${g.mid} author notified` : `run #418 ${g.mid} ${kind === 'node' ? NODES[id][3].replace('?', '') : `${g.ellipsis}`}`;\n      b.text(0, H - 1, fit(msg, W, undefined, ctx), results.promote === 'done' ? th.pos : msg.startsWith(g.cross) ? th.neg : th.muted);\n    };\n  },\n};\n",
  "prism-term/src/facets/diagrams/gantt.js": "// A rollout Gantt chart: the \"now\" line sweeps the maintenance window, bars\n// fill as tasks complete, and when the schema migration overruns the\n// dependent tasks slide 15 minutes later.\nimport { ATTR, glyphs, uni, phase, ease, fit, tint } from '../callouts/_kit.js';\n\n// [label, start, end] in minutes after midnight; index 1 overruns by SLIP.\nconst TASKS = [\n  ['snapshot db', 60, 90], ['migrate schema', 90, 150], ['canary 5%', 150, 210], ['canary 25%', 210, 255],\n  ['full rollout', 255, 330], ['verify & soak', 330, 390],\n];\nconst MILESTONE = ['all-clear', 390];\nconst T0 = 60, T1 = 420, SLIP = 15;\nconst hhmm = (m) => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(Math.round(m % 60)).padStart(2, '0')}`;\n\nexport default {\n  id: 'diagrams-gantt',\n  title: 'Gantt Chart',\n  category: 'Sequence & Timelines',\n  description: 'A rollout Gantt chart for a maintenance window: the now-line sweeps, bars fill as tasks finish, and an overrunning migration slides every dependent task later.',\n  tags: ['gantt', 'schedule', 'timeline', 'plan', 'rollout'],\n  kind: 'diagram',\n  size: { w: 72, h: 12 },\n  minSize: { w: 54, h: 11 },\n  loop: 9,\n  color: '256',\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 9);\n      const now = T0 + Math.min(1, p / 7.5) * (T1 - T0);\n      // The migration overruns once \"now\" reaches its planned end.\n      const slipAt = (TASKS[1][2] - T0) / (T1 - T0) * 7.5;\n      const slip = SLIP * ease(p, slipAt, slipAt + 0.6);\n      const tasks = TASKS.map(([l, a, z], i) => (i === 1 ? [l, a, z + slip] : i > 1 ? [l, a + slip, z + slip] : [l, a, z]));\n      const mile = MILESTONE[1] + slip;\n      const lw = Math.min(16, Math.floor(W * 0.25)), x0 = lw + 1, cw = W - x0 - 1;\n      const col = (m) => x0 + Math.round(((m - T0) / (T1 - T0)) * (cw - 1));\n      // Axis.\n      for (let m = T0; m <= T1; m += 60) {\n        const x = col(m);\n        const lab = hhmm(m);\n        if (x + lab.length <= W && (m < T1 || W - x >= lab.length)) b.text(Math.min(x, W - lab.length), 0, lab, th.muted);\n        b.set(x, 1, u ? '┬' : '+', th.line);\n      }\n      for (let x = x0; x < x0 + cw; x++) if (b.get(x, 1)?.ch === ' ') b.set(x, 1, u ? '─' : '-', th.line);\n      // Bars.\n      tasks.forEach(([label, a, z], i) => {\n        const y = 2 + i;\n        if (y >= H - 2) return;\n        const state = now >= z ? 'done' : now >= a ? 'run' : 'todo';\n        const late = i === 1 && slip > 0.5;\n        b.text(0, y, fit(label, lw, undefined, ctx), state === 'todo' ? th.muted : th.ink, undefined, state === 'run' ? ATTR.BOLD : 0);\n        const xa = col(a), xz = Math.max(xa + 1, col(z));\n        for (let x = xa; x < xz; x++) {\n          const m = T0 + ((x - x0) / (cw - 1)) * (T1 - T0);\n          const filled = m < now;\n          const over = late && m >= TASKS[1][2];\n          const c = over ? th.neg : filled ? (state === 'done' ? th.pos : th.accent) : tint(ctx, th.accent, 0.55);\n          b.set(x, y, u ? (filled ? '█' : '▒') : (filled ? '#' : '='), c);\n        }\n        if (late && xz + 5 <= W) b.text(xz + 1, y, `+${Math.round(slip)}m`, th.neg, undefined, ATTR.BOLD);\n      });\n      // Milestone.\n      const my = 2 + tasks.length;\n      if (my < H - 2) {\n        b.text(0, my, fit(MILESTONE[0], lw, undefined, ctx), now >= mile ? th.pos : th.muted, undefined, ATTR.BOLD);\n        b.text(col(mile), my, u ? '◆' : '<>', now >= mile ? th.pos : th.warn, undefined, ATTR.BOLD);\n        if (col(mile) + 9 <= W) b.text(col(mile) + 2, my, hhmm(mile), th.dim);\n      }\n      // Now line.\n      const nx = col(now);\n      // Through the gaps only: on a bar, the fill edge already marks \"now\".\n      for (let y = 1; y < Math.min(H - 2, my + 1); y++) {\n        const cell = b.get(nx, y);\n        if (y === 1 || !cell || cell.ch === ' ') b.set(nx, y, u ? (y === 1 ? '╋' : '┃') : '|', th.warn, undefined, ATTR.BOLD);\n      }\n      b.text(Math.max(0, Math.min(W - 5, nx - 2)), H - 2, hhmm(now), th.warn, undefined, ATTR.BOLD);\n      const msg = now >= mile ? `${g.check} all-clear at ${hhmm(mile)} ${g.mid} 15m late, inside the window`\n        : slip > 0.5 ? `${g.warn} migrate schema +${Math.round(slip)}m ${g.mid} all-clear now ${hhmm(mile)}`\n        : `maintenance window 01:00-07:00 ${g.mid} ${tasks.filter(([, , z]) => now >= z).length}/${tasks.length} done`;\n      b.text(0, H - 1, fit(msg, W, undefined, ctx), now >= mile ? th.pos : slip > 0.5 ? th.warn : th.muted);\n    };\n  },\n};\n",
  "prism-term/src/facets/diagrams/git-branches.js": "// A branch diagram drawn left to right: main, a hotfix and a feature branch\n// fork and merge as a playhead reveals history, tags riding above main.\nimport { ATTR, uni, phase, fit } from '../callouts/_kit.js';\nimport { LineGrid } from './_lines.js';\n\n// Lanes: [name, row, color key]\nconst LANES = { main: ['main', 2, 'accent'], hotfix: ['hotfix/cart', 5, 'warn'], feat: ['feat/pool-size', 8, 'pos'] };\n// Branches: [lane, fork at, merge at]\nconst BRANCHES = [['feat', 0.2, 0.76], ['hotfix', 0.4, 0.55]];\n// Commits: [lane, at, hash, message, tag, merge?]\nconst COMMITS = [\n  ['main', 0.04, '6c4f2d9', 'Split checkout worker'], ['main', 0.13, '9d02c55', 'Release v2.14.0', 'v2.14.0'],\n  ['feat', 0.28, '3b8d0aa', 'Add pool wait alert'], ['main', 0.34, 'e41d7f2', 'Bump checkout deps'],\n  ['feat', 0.43, '7c2e9b1', 'Size DB pool to workers'], ['hotfix', 0.47, 'c0a1b3f', 'Fix cart rounding'],\n  ['main', 0.55, '5e2a9d0', 'Merge hotfix/cart', 'v2.14.1', true], ['feat', 0.63, '8f3c1e7', 'Alert on pool wait p99'],\n  ['main', 0.76, 'a1f9c3e', 'Merge pull request #418', '', true], ['main', 0.88, 'b72ffed', 'Release v2.15.0', 'v2.15.0'],\n];\nconst SWEEP = 7;\n\nexport default {\n  id: 'diagrams-git-branches',\n  title: 'Branch Diagram',\n  category: 'Git Graphs',\n  description: 'A left-to-right branch diagram: a feature branch and a hotfix fork from main, gain commits and merge back as a playhead reveals history, with release tags.',\n  tags: ['git', 'branches', 'gitflow', 'merge', 'release'],\n  kind: 'diagram',\n  size: { w: 76, h: 11 },\n  minSize: { w: 56, h: 11 },\n  loop: 9,\n  color: '16',\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, u = uni(ctx);\n      const p = phase(t, 9);\n      const head = Math.min(1, p / SWEEP);\n      const lw = W >= 70 ? 15 : 12;\n      const x0 = lw + 1, cw = W - x0 - 2;\n      const X = (f) => x0 + Math.round(f * cw);\n      const grid = new LineGrid(W, H - 1);\n      const mainY = LANES.main[1];\n      grid.hline(x0, X(head), mainY, th[LANES.main[2]]);\n      for (const [lane, fork, merge] of BRANCHES) {\n        if (head < fork) continue;\n        const [, y, ck] = LANES[lane], c = th[ck];\n        grid.vline(X(fork), mainY, y, c);\n        grid.hline(X(fork), X(Math.min(head, merge)), y, c);\n        if (head >= merge) grid.vline(X(merge), mainY, y, c);\n      }\n      grid.paint(ctx, b, 0, 0, { style: 'round' });\n      // Lane labels.\n      for (const [key, [name, y, ck]] of Object.entries(LANES)) {\n        const active = key === 'main' || BRANCHES.some(([l, f]) => l === key && head >= f);\n        b.text(0, y, fit(name, lw, undefined, ctx), active ? th[ck] : th.dim, undefined, active ? ATTR.BOLD : 0);\n      }\n      // Commits and tags.\n      let latest = null;\n      for (const c of COMMITS) {\n        const [lane, at, , , tag, merge] = c;\n        if (head < at) continue;\n        latest = c;\n        const [, y, ck] = LANES[lane];\n        const fresh = head - at < 0.06;\n        b.set(X(at), y, merge ? (u ? '◉' : '@') : (u ? '●' : 'o'), fresh ? th.ink : th[ck], undefined, ATTR.BOLD);\n        if (tag) b.text(Math.min(W - tag.length, X(at) - Math.floor(tag.length / 2)), y - 2, tag, th.warn, undefined, ATTR.BOLD);\n        if (tag) b.set(X(at), y - 1, u ? '╷' : '|', th.warn);\n      }\n      // Playhead.\n      if (p < SWEEP) for (let y = 0; y < H - 1; y++) if (b.get(X(head), y)?.ch === ' ') b.set(X(head), y, u ? '┊' : ':', th.dim);\n      if (latest) {\n        const [lane, , hash, msg] = latest;\n        let x = b.text(0, H - 1, hash, th.warn);\n        x = b.text(x + 1, H - 1, LANES[lane][0], th[LANES[lane][2]], undefined, ATTR.BOLD);\n        b.text(x + 2, H - 1, fit(msg, Math.max(0, W - x - 2), undefined, ctx), th.ink);\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/diagrams/git-graph.js": "// `git log --graph --oneline --decorate`, live: a feature branch forks,\n// main moves on, then the pull request merges. Unicode gets tig-style lanes,\n// ASCII gets git's own * | / \\ graph.\nimport { ATTR, uni, phase, tint, fit, stringWidth } from '../callouts/_kit.js';\n\n// Rows: [unicode graph, ascii graph, hash, refs, message]\nconst OLD = [\n  ['●', '*', '9d02c55', 'tag: v2.14.0', 'Release v2.14.0'],\n  ['●', '*', '5f1a8e3', '', 'Fix cart rounding'],\n  ['●', '*', '0b77e21', '', 'Add OTLP exporter'],\n  ['●', '*', '6c4f2d9', '', 'Split checkout worker'],\n];\nconst STATES = [\n  [0, [['●', '*', '9d02c55', 'HEAD -> main, tag: v2.14.0', 'Release v2.14.0'], ...OLD.slice(1)], []],\n  [1.4, [\n    ['●', '*', '3b8d0aa', 'HEAD -> feat/pool-size', 'Add pool wait alert'],\n    ['●', '*', '9d02c55', 'main, tag: v2.14.0', 'Release v2.14.0'], ...OLD.slice(1)], ['3b8d0aa']],\n  [2.4, [\n    ['●', '*', '7c2e9b1', 'HEAD -> feat/pool-size', 'Size DB pool to workers'],\n    ['●', '*', '3b8d0aa', '', 'Add pool wait alert'],\n    ['●', '*', '9d02c55', 'main, tag: v2.14.0', 'Release v2.14.0'], ...OLD.slice(1)], ['7c2e9b1']],\n  [3.6, [\n    ['●', '*', 'e41d7f2', 'HEAD -> main', 'Bump checkout to v2.14.1'],\n    ['│ ●', '| *', '7c2e9b1', 'feat/pool-size', 'Size DB pool to workers'],\n    ['│ ●', '| *', '3b8d0aa', '', 'Add pool wait alert'],\n    ['├─╯', '|/', '', '', ''],\n    ['●', '*', '9d02c55', 'tag: v2.14.0', 'Release v2.14.0'], ...OLD.slice(1)], ['e41d7f2']],\n  [5.0, [\n    ['●─╮', '*   ', 'a1f9c3e', 'HEAD -> main, origin/main', 'Merge pull request #418 from feat/pool-size'],\n    ['│ │', '|\\\\', '', '', ''],\n    ['│ ●', '| *', '7c2e9b1', 'feat/pool-size', 'Size DB pool to workers'],\n    ['│ ●', '| *', '3b8d0aa', '', 'Add pool wait alert'],\n    ['● │', '* |', 'e41d7f2', '', 'Bump checkout to v2.14.1'],\n    ['├─╯', '|/', '', '', ''],\n    ['●', '*', '9d02c55', 'tag: v2.14.0', 'Release v2.14.0'], ...OLD.slice(1)], ['a1f9c3e']],\n];\n\nexport default {\n  id: 'diagrams-git-graph',\n  title: 'Git Log Graph',\n  category: 'Git Graphs',\n  description: 'git log --graph, live: a feature branch forks off main, main moves on, then the pull request merges, with colored lanes and decorated refs.',\n  tags: ['git', 'log', 'graph', 'branches', 'merge'],\n  kind: 'diagram',\n  size: { w: 72, h: 12 },\n  minSize: { w: 48, h: 9 },\n  loop: 8,\n  color: '16',\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, u = uni(ctx);\n      const p = phase(t, 8);\n      let si = 0;\n      STATES.forEach(([at], i) => { if (p >= at) si = i; });\n      const [at, rows, fresh] = STATES[si];\n      const flash = p - at < 0.9;\n      b.text(0, 0, fit('$ git log --graph --oneline --decorate', W, undefined, ctx), th.dim);\n      const gw = Math.max(...rows.map((r) => stringWidth((u ? r[0] : r[1]).trimEnd()))) + 1;\n      rows.slice(0, H - 1).forEach(([ug, ag, hash, refs, msg], r) => {\n        const y = 1 + r;\n        const isNew = flash && fresh.includes(hash);\n        if (isNew) b.fill(0, y, W, 1, ' ', undefined, tint(ctx, th.accent, 0.18));\n        const graph = u ? ug : ag;\n        // Lane colors: main in accent, the feature lane in pos.\n        [...graph].forEach((ch, x) => b.set(x, y, ch, x < 2 ? th.accent : th.pos, undefined, ch === '●' || ch === '*' ? ATTR.BOLD : 0));\n        if (!hash) return;\n        let x = b.text(gw, y, hash, th.warn);\n        if (refs) {\n          x = b.text(x + 1, y, '(', th.warn);\n          refs.split(', ').forEach((ref, i) => {\n            if (i) x = b.text(x, y, ', ', th.warn);\n            const c = ref.startsWith('HEAD') ? th.info : ref.startsWith('tag:') ? th.warn : ref.startsWith('origin/') ? th.neg : th.pos;\n            x = b.text(x, y, ref, c, undefined, ATTR.BOLD);\n          });\n          x = b.text(x, y, ')', th.warn);\n        }\n        b.text(x + 1, y, fit(msg, Math.max(0, W - x - 1), undefined, ctx).trimEnd(), isNew ? th.ink : th.muted, undefined, isNew ? ATTR.BOLD : 0);\n      });\n    };\n  },\n};\n",
  "prism-term/src/facets/diagrams/git-rebase.js": "// A rebase, visualized: feature commits lift off their old base, replay onto\n// the tip of main with new hashes (the originals left behind as ghosts), then\n// main fast-forwards so history is a straight line.\nimport { ATTR, uni, phase, ease, fit, typed } from '../callouts/_kit.js';\nimport { LineGrid } from './_lines.js';\n\nconst MAIN = [['A', 0.06, '6c4f2d9'], ['B', 0.2, '9d02c55'], ['C', 0.34, 'e41d7f2']];\n// Feature commits: [label, x before, x after, old hash, new hash, replay start]\nconst FEAT = [['D', 0.36, 0.52, '3b8d0aa', '9a1c4e0', 1.6], ['E', 0.5, 0.66, '7c2e9b1', 'f02b7d3', 2.1]];\nconst CMDS = [[0, 'git switch feat/pool-size'], [1.3, 'git rebase main'], [4.2, 'git switch main && git merge --ff-only feat/pool-size']];\nconst MOVE = 0.9, FF = [4.9, 5.6], GC = 7.2;\nconst MAIN_Y = 2, NEW_Y = 5, OLD_Y = 8;\n\nexport default {\n  id: 'diagrams-git-rebase',\n  title: 'Rebase, Visualized',\n  category: 'Git Graphs',\n  description: 'A git rebase, visualized: feature commits lift off their old base and replay onto main with new hashes, leaving ghosts, then main fast-forwards to a line.',\n  tags: ['git', 'rebase', 'fast-forward', 'history', 'branches'],\n  kind: 'diagram',\n  size: { w: 72, h: 12 },\n  minSize: { w: 52, h: 12 },\n  loop: 9,\n  color: '16',\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, u = uni(ctx);\n      const p = phase(t, 9);\n      const X = (f) => 2 + Math.round(f * (W - 8));\n      // Short hashes when commits sit close together.\n      const hl = Math.max(4, Math.min(7, X(0.14) - X(0) - 1));\n      const hash = (x, y, h, c, attr) => b.text(x - Math.floor(hl / 2), y, h.slice(0, hl), c, undefined, attr);\n      const ff = ease(p, FF[0], FF[1]);\n      const started = p >= FEAT[0][5];\n      const rebased = FEAT.every(([, , , , , at]) => p >= at + MOVE);\n      const ghosts = started && p < GC;\n      // Where each feature commit is right now: arcing from the old lane to the new one, then up into main.\n      const pos = FEAT.map(([, a, z, , , at]) => {\n        const k = ease(p, at, at + MOVE);\n        const y = OLD_Y + (NEW_Y - OLD_Y) * k - Math.sin(Math.PI * k) * 1.5 - (NEW_Y - MAIN_Y) * ff;\n        return [X(a + (z - a) * k), Math.round(y), k];\n      });\n\n      const grid = new LineGrid(W, H - 2);\n      grid.hline(X(MAIN[0][1]), ff >= 1 ? X(FEAT[1][2]) : X(MAIN[2][1]), MAIN_Y, th.accent);\n      // The original lane hangs off B; once replayed it is unreachable (dim) until gc.\n      if (!started || ghosts) {\n        const c = started ? th.dim : th.pos;\n        grid.vline(X(MAIN[1][1]), MAIN_Y, OLD_Y, c);\n        grid.hline(X(MAIN[1][1]), X(FEAT[1][1]), OLD_Y, c);\n      }\n      if (rebased && ff < 1) {\n        const ly = Math.round(NEW_Y - (NEW_Y - MAIN_Y) * ff);\n        if (ly > MAIN_Y) grid.vline(X(MAIN[2][1]), MAIN_Y, ly, th.pos);\n        grid.hline(X(MAIN[2][1]), X(FEAT[1][2]), ly, th.pos);\n      }\n      grid.paint(ctx, b, 0, 0, { style: 'round' });\n\n      MAIN.forEach(([label, f, h]) => {\n        b.set(X(f), MAIN_Y, u ? '●' : 'o', th.accent, undefined, ATTR.BOLD);\n        b.text(X(f), MAIN_Y - 1, label, th.ink, undefined, ATTR.BOLD);\n        hash(X(f), MAIN_Y + 1, h, th.muted);\n      });\n      FEAT.forEach(([label, a, , oldHash, newHash], i) => {\n        const [x, y, k] = pos[i];\n        if (ghosts && k > 0) {\n          b.set(X(a), OLD_Y, u ? '○' : 'o', th.dim);\n          b.text(X(a), OLD_Y - 1, label, th.dim);\n          hash(X(a), OLD_Y + 1, oldHash, th.dim, ATTR.STRIKE);\n        }\n        const moved = k >= 1;\n        b.set(x, y, u ? '●' : 'o', k > 0 && !moved ? th.warn : th.pos, undefined, ATTR.BOLD);\n        b.text(x, y - 1, moved ? `${label}'` : label, th.ink, undefined, ATTR.BOLD);\n        if (k === 0 || moved) hash(x, y + 1, moved ? newHash : oldHash, moved ? th.pos : th.muted);\n      });\n      // Branch pointers.\n      const tip = pos[1];\n      if (ff >= 1) b.text(Math.min(W - 21, tip[0] + 2), MAIN_Y, ' main, feat/pool-size', th.accent, undefined, ATTR.BOLD);\n      else {\n        b.text(X(MAIN[2][1]) + 2, MAIN_Y, ' main', th.accent, undefined, ATTR.BOLD);\n        b.text(Math.min(W - 15, tip[0] + 2), tip[1], ' feat/pool-size', th.pos, undefined, ATTR.BOLD);\n      }\n      // Output and command lines.\n      let ci = 0;\n      CMDS.forEach(([at], i) => { if (p >= at) ci = i; });\n      const [at, cmd] = CMDS[ci];\n      const x = b.text(0, H - 1, '$ ', th.pos, undefined, ATTR.BOLD);\n      b.text(x, H - 1, fit(typed(cmd, p, at, 30), W - x, undefined, ctx), th.ink, undefined, ATTR.BOLD);\n      const note = ff >= 1 ? (p >= GC ? 'Fast-forward. Old commits unreachable; gc will drop them.' : 'Fast-forward: history is linear.')\n        : rebased ? 'Successfully rebased and updated refs/heads/feat/pool-size.'\n          : started ? `Applying: ${p >= FEAT[1][5] ? 'Size DB pool to workers' : 'Add pool wait alert'}` : '';\n      if (note) b.text(0, H - 2, fit(note, W, undefined, ctx), ff >= 1 ? th.pos : th.muted);\n    };\n  },\n};\n",
  "prism-term/src/facets/diagrams/network-topology.js": "// A network map learned passively from telemetry: hosts, switches and the\n// gateway appear as they are first seen in incoming data (no scanning),\n// links draw in once both ends are known.\nimport { ATTR, glyphs, uni, phase, tint, fit, stringWidth } from '../callouts/_kit.js';\nimport { LineGrid, nodeBox, box, tap, DIR } from './_lines.js';\n\n// id: [label, ip, tier, column(0..5), seen at, source]\nconst NODES = {\n  edge: ['edge-lb', '203.0.113.10', 0, 2.5, 3.3, 'access log X-Forwarded-For'],\n  core: ['core-gw', '10.20.0.1', 1, 2.5, 2.0, 'default gateway in netflow'],\n  swWeb: ['sw-web', '10.20.1.0/24', 2, 1, 1.2, 'subnet of web-01, web-02'],\n  swData: ['sw-data', '10.20.2.0/24', 2, 4, 2.4, 'subnet of db-01'],\n  web1: ['web-01', '10.20.1.11', 3, 0, 0.2, 'OTLP resource host.name'],\n  web2: ['web-02', '10.20.1.12', 3, 1, 0.8, 'OTLP resource host.name'],\n  web3: ['web-03', '10.20.1.13', 3, 2, 3.8, 'syslog hostname'],\n  db1: ['db-01', '10.20.2.41', 3, 3, 1.6, 'postgres log peer address'],\n  cache1: ['cache-01', '10.20.2.51', 3, 4, 2.8, 'redis client span peer'],\n  queue1: ['queue-01', '10.20.2.61', 3, 5, 4.3, 'consumer group metadata'],\n};\nconst LINKS = [['edge', 'core'], ['core', 'swWeb'], ['core', 'swData'], ['swWeb', 'web1'], ['swWeb', 'web2'], ['swWeb', 'web3'], ['swData', 'db1'], ['swData', 'cache1'], ['swData', 'queue1']];\nconst TIER_Y = [0, 4, 8, 12];\n\nexport default {\n  id: 'diagrams-network-topology',\n  title: 'Learned Network Map',\n  category: 'Networks & Topologies',\n  description: 'A network map that builds itself passively from telemetry: hosts, subnets and the gateway appear as they show up in incoming data, and links draw in.',\n  tags: ['network', 'topology', 'discovery', 'subnet', 'passive'],\n  kind: 'diagram',\n  size: { w: 76, h: 18 },\n  minSize: { w: 66, h: 18 },\n  loop: 9,\n  color: '256',\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 9);\n      const colW = Math.floor(W / 6);\n      const B = {};\n      for (const [id, [label, , tier, col]] of Object.entries(NODES)) {\n        const w = Math.min(colW - 1, stringWidth(label) + 4);\n        const cx = Math.round(col * colW + colW / 2);\n        B[id] = box(cx - Math.floor(w / 2), TIER_Y[tier], w, 3);\n      }\n      const known = (id) => p >= NODES[id][4];\n      const fresh = (id) => known(id) && p < NODES[id][4] + 0.7;\n\n      const grid = new LineGrid(W, H - 1), taps = [];\n      for (const [a, z] of LINKS) {\n        if (!known(a) || !known(z)) continue;\n        const A = B[a], Z = B[z], c = fresh(a) || fresh(z) ? th.accent : undefined;\n        // Bus on the row between tiers, tapped into both box borders.\n        const my = A.y + 3;\n        if (A.cx === Z.cx) grid.bits(A.cx, my, DIR.N | DIR.S, c);\n        else { grid.hline(A.cx, Z.cx, my, c); grid.bits(A.cx, my, DIR.N, c); grid.bits(Z.cx, my, DIR.S, c); }\n        taps.push([A.cx, A.y + 2, 'bottom', c], [Z.cx, Z.y, 'top', c]);\n      }\n      grid.paint(ctx, b, 0, 0, { style: 'round' });\n      for (const [id, [label, ip, tier]] of Object.entries(NODES)) {\n        if (!known(id)) continue;\n        const bx = B[id];\n        const c = fresh(id) ? th.accent : tier === 0 ? th.warn : tier < 3 ? th.info : th.line;\n        nodeBox(ctx, b, bx.x, bx.y, label, { w: bx.w, color: c, bg: fresh(id) ? tint(ctx, th.accent, 0.2) : undefined, attr: fresh(id) ? ATTR.BOLD : 0 });\n        // Address under hosts; beside the box for the upper tiers.\n        if (tier === 3) b.text(bx.cx - Math.floor(ip.length / 2), bx.y + 3, ip, th.dim);\n        else if (bx.x + bx.w + 1 + ip.length <= W) b.text(bx.x + bx.w + 1, bx.cy, ip, th.dim);\n      }\n      for (const [x, y, side, c] of taps) tap(ctx, b, x, y, side, c ?? th.line);\n      // Learning log.\n      const seen = Object.entries(NODES).filter(([id]) => known(id)).sort((a, z) => a[1][4] - z[1][4]);\n      const hosts = seen.filter(([, n]) => n[2] === 3).length;\n      const last = seen[seen.length - 1];\n      const line = last && p < 5\n        ? `${g.spark} learned ${last[1][0]} (${last[1][1]}) from ${last[1][5]}`\n        : `${hosts} hosts ${g.mid} 2 subnets ${g.mid} learned passively from 2.1k events`;\n      b.text(0, H - 1, fit(line, W, undefined, ctx), p < 5 ? th.accent : th.muted, undefined, p < 5 ? ATTR.BOLD : 0);\n      if (!seen.length) b.text(0, H - 1, fit(`${g.ellipsis} waiting for telemetry`, W, undefined, ctx), th.dim);\n    };\n  },\n};\n",
  "prism-term/src/facets/diagrams/org-chart.js": "// A top-down hierarchy (org chart / agent tree): mainbrain dispatches\n// instructions down to its agents and findings travel back up the elbows.\nimport { ATTR, glyphs, uni, phase, tint, fit, stringWidth } from '../callouts/_kit.js';\nimport { LineGrid, nodeBox, box, polyline, along, tap } from './_lines.js';\n\nconst ROOT = 'mainbrain';\nconst TEAM = [\n  ['collector', ['ingest', 'logs']],\n  ['analyst', ['detect', 'correlate']],\n  ['fixer', ['planner', 'approve']],\n];\n// Message script: [start, leaf index 0-5, direction 'down' | 'up', text]\nconst MSGS = [\n  [0.2, 0, 'down', 'sample checkout spans'], [1.0, 0, 'up', '1.2k spans/s, p99 2.4s'],\n  [1.8, 2, 'down', 'scan for anomalies'], [2.6, 3, 'up', 'pool wait ~ p99 (r=0.94)'],\n  [3.4, 4, 'down', 'plan: pool 20 -> 40'], [4.2, 5, 'up', 'fix #142 awaiting approval'],\n  [5.0, 1, 'down', 'tail checkout errors'], [5.8, 1, 'up', 'timeouts down 92%'],\n];\nconst HOP = 0.8;\n\nexport default {\n  id: 'diagrams-org-chart',\n  title: 'Agent Hierarchy',\n  category: 'Trees & Hierarchies',\n  description: 'An org-chart style tree with elbow connectors: mainbrain sends instructions down to its agents and their findings travel back up, each hop lighting the path.',\n  tags: ['org chart', 'hierarchy', 'tree', 'agents', 'elbow connectors'],\n  kind: 'diagram',\n  size: { w: 76, h: 16 },\n  minSize: { w: 70, h: 16 },\n  loop: 7,\n  color: '256',\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 7);\n      // Leaves spread across the width; parents center over their children.\n      const leaves = TEAM.flatMap(([, kids]) => kids);\n      const lw = leaves.map((l) => stringWidth(l) + 4);\n      const gap = Math.max(1, Math.floor((W - lw.reduce((a, c) => a + c, 0)) / (leaves.length - 1)));\n      let x = Math.max(0, Math.floor((W - lw.reduce((a, c) => a + c, 0) - gap * (leaves.length - 1)) / 2));\n      const L = leaves.map((name, i) => { const bx = box(x, 12, lw[i], 3); x += lw[i] + gap; return bx; });\n      const P = TEAM.map(([name], i) => {\n        const a = L[i * 2], z = L[i * 2 + 1];\n        const w = stringWidth(name) + 4;\n        return box(Math.round((a.cx + z.cx) / 2 - w / 2), 6, w, 3);\n      });\n      const rw = stringWidth(ROOT) + 6;\n      // Root sits over the middle agent so its stem drops straight through.\n      const R = box(P[1].cx - Math.floor(rw / 2), 0, rw, 3);\n\n      // Which path is active?\n      const live = MSGS.filter(([at]) => p >= at && p < at + HOP).pop();\n      const grid = new LineGrid(W, H - 1);\n      const edge = (from, to, my) => [from.bottom, [from.cx, my], [to.cx, my], to.top];\n      P.forEach((pp, i) => {\n        const hot = live && Math.floor(live[1] / 2) === i;\n        grid.path(edge(R, pp, 4), { c: hot ? (live[2] === 'down' ? th.accent : th.pos) : undefined });\n        [0, 1].forEach((k) => {\n          const leafHot = live && live[1] === i * 2 + k;\n          grid.path(edge(pp, L[i * 2 + k], 10), { c: leafHot ? (live[2] === 'down' ? th.accent : th.pos) : undefined });\n        });\n      });\n      grid.paint(ctx, b, 0, 0, { style: 'round' });\n\n      nodeBox(ctx, b, R.x, R.y, ROOT, { w: R.w, color: th.accent, bg: tint(ctx, th.accent, 0.15), attr: ATTR.BOLD });\n      TEAM.forEach(([name], i) => {\n        const hot = live && Math.floor(live[1] / 2) === i;\n        nodeBox(ctx, b, P[i].x, P[i].y, name, { w: P[i].w, color: hot ? th.info : th.line, attr: hot ? ATTR.BOLD : 0 });\n      });\n      leaves.forEach((name, i) => {\n        const hot = live && live[1] === i;\n        nodeBox(ctx, b, L[i].x, L[i].y, name, { w: L[i].w, color: hot ? (live[2] === 'down' ? th.accent : th.pos) : th.line, fg: hot ? th.ink : th.muted });\n      });\n\n      // Join the connectors into the box borders.\n      tap(ctx, b, R.cx, R.y + 2, 'bottom', th.accent);\n      P.forEach((pp, i) => { tap(ctx, b, pp.cx, pp.y, 'top'); tap(ctx, b, pp.cx, pp.y + 2, 'bottom'); tap(ctx, b, L[i * 2].cx, L[i * 2].y, 'top'); tap(ctx, b, L[i * 2 + 1].cx, L[i * 2 + 1].y, 'top'); });\n      // The message dot, root → leaf or leaf → root.\n      if (live) {\n        const [at, leaf, dir] = live;\n        const pi = Math.floor(leaf / 2);\n        const pts = [...polyline(edge(R, P[pi], 4)), ...polyline(edge(P[pi], L[leaf], 10))];\n        const f = (p - at) / HOP;\n        const pt = along(pts, dir === 'down' ? f : 1 - f);\n        if (pt) b.set(pt[0], pt[1], u ? '●' : 'o', dir === 'down' ? th.accent : th.pos, undefined, ATTR.BOLD);\n      }\n      // Message log line.\n      const last = MSGS.filter(([at]) => p >= at).pop();\n      if (last) {\n        const [, leaf, dir, text] = last;\n        const who = leaves[leaf];\n        const arrow = dir === 'down' ? `${ROOT} ${g.right} ${who}` : `${who} ${g.right} ${ROOT}`;\n        let lx = b.text(0, H - 1, arrow, dir === 'down' ? th.accent : th.pos, undefined, ATTR.BOLD);\n        b.text(lx + 2, H - 1, fit(u ? text.replace('->', '→') : text, Math.max(0, W - lx - 2), undefined, ctx), th.ink);\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/diagrams/pipeline.js": "// A CI/CD pipeline graph: build fans out to parallel test jobs, fans back in\n// to staging, waits at a human approval gate, then deploys to production.\nimport { ATTR, glyphs, uni, phase, fit, spin } from '../callouts/_kit.js';\nimport { LineGrid, nodeBox, box } from './_lines.js';\n\n// [id, label, start, end] in seconds; the approval gate waits for a human.\nconst JOBS = [\n  ['build', 'build', 0.2, 1.5],\n  ['unit', 'unit', 1.6, 2.9], ['integ', 'integ', 1.6, 3.6], ['lint', 'lint', 1.6, 2.3],\n  ['staging', 'staging', 3.7, 4.9],\n  ['approve', 'approve', 5.0, 6.6],\n  ['prod', 'prod', 6.7, 8.0],\n];\nconst MIN_PER_S = 55; // fake minutes:seconds shown per real second\n\nconst clock = (s) => `${Math.floor(s / 60)}m${String(Math.floor(s % 60)).padStart(2, '0')}s`;\n\nexport default {\n  id: 'diagrams-pipeline',\n  title: 'CI/CD Pipeline',\n  category: 'Boxes & Flows',\n  description: 'A pipeline graph: build fans out to parallel test jobs with spinners and timings, fans in to staging, waits at a human approval gate, then deploys to prod.',\n  tags: ['pipeline', 'ci', 'cd', 'dag', 'approval'],\n  kind: 'diagram',\n  size: { w: 76, h: 13 },\n  minSize: { w: 70, h: 13 },\n  loop: 10,\n  color: '256',\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 10);\n      const state = (id) => {\n        const [, , a, z] = JOBS.find((j) => j[0] === id);\n        return p >= z ? 'done' : p >= a ? (id === 'approve' ? 'wait' : 'run') : 'todo';\n      };\n      // Layout: five columns, the test column holds three stacked jobs.\n      const cw = [11, 11, 13, 13, 10];\n      const gap = Math.max(3, Math.floor((W - cw.reduce((a, c) => a + c, 0)) / 4));\n      const xs = [0];\n      for (let i = 1; i < 5; i++) xs.push(xs[i - 1] + cw[i - 1] + gap);\n      const mid = 4;\n      const B = {\n        build: box(xs[0], mid, cw[0], 3),\n        unit: box(xs[1], 0, cw[1], 3), integ: box(xs[1], 4, cw[1], 3), lint: box(xs[1], 8, cw[1], 3),\n        staging: box(xs[2], mid, cw[2], 3), approve: box(xs[3], mid, cw[3], 3), prod: box(xs[4], mid, cw[4], 3),\n      };\n      const fanOut = B.build.x + B.build.w + Math.floor(gap / 2), fanIn = xs[1] + cw[1] + Math.floor(gap / 2);\n      const grid = new LineGrid(W, H - 1);\n      const ec = (from) => (state(from) === 'done' ? th.pos : th.line);\n      grid.hline(B.build.right[0], fanOut, B.build.cy, ec('build'));\n      grid.vline(fanOut, B.unit.cy, B.lint.cy, ec('build'));\n      for (const id of ['unit', 'integ', 'lint']) {\n        grid.path([[fanOut, B[id].cy], B[id].left], { c: ec('build'), arrow: true });\n        grid.path([B[id].right, [fanIn, B[id].cy]], { c: ec(id) });\n      }\n      const allTests = ['unit', 'integ', 'lint'].every((id) => state(id) === 'done');\n      grid.vline(fanIn, B.unit.cy, B.lint.cy, allTests ? th.pos : th.line);\n      grid.path([[fanIn, B.staging.cy], B.staging.left], { c: allTests ? th.pos : th.line, arrow: true });\n      grid.path([B.staging.right, B.approve.left], { c: ec('staging'), arrow: true });\n      grid.path([B.approve.right, B.prod.left], { c: ec('approve'), arrow: true });\n      grid.paint(ctx, b, 0, 0, { style: 'round' });\n\n      for (const [id, label, a, z] of JOBS) {\n        const s = state(id), bx = B[id];\n        const mark = s === 'done' ? g.check : s === 'run' ? spin(ctx, t) : s === 'wait' ? (u ? '‖' : '=') : g.ring;\n        const color = s === 'done' ? th.pos : s === 'run' ? th.accent : s === 'wait' ? (Math.floor(p * 2) % 2 ? th.warn : th.line) : th.line;\n        nodeBox(ctx, b, bx.x, bx.y, `${mark} ${label}`, { w: bx.w, color, fg: s === 'todo' ? th.muted : th.ink, attr: s === 'run' || s === 'wait' ? ATTR.BOLD : 0 });\n        // Timing / gate note under (or beside) each box.\n        const note = s === 'done' ? (id === 'approve' ? 'by you' : clock((z - a) * MIN_PER_S)) : s === 'run' ? clock((p - a) * MIN_PER_S) : s === 'wait' ? 'needs you' : '';\n        const ny = bx.y + 3;\n        if (note && ny < H - 1) b.text(bx.x + Math.floor((bx.w - note.length) / 2), ny, note, s === 'wait' ? th.warn : th.dim, undefined, s === 'wait' ? ATTR.BOLD : 0);\n      }\n      const total = clock(Math.min(p, 8) * MIN_PER_S);\n      const msg = p >= 8 ? `${g.check} pipeline #2291 passed in ${total}`\n        : state('approve') === 'wait' ? `${u ? '‖' : '='} waiting for approval ${g.mid} deploy v2.14.1 to prod?`\n          : `${spin(ctx, t)} pipeline #2291 ${g.mid} ${total}`;\n      b.text(0, H - 1, fit(msg, W, undefined, ctx), p >= 8 ? th.pos : state('approve') === 'wait' ? th.warn : th.muted, undefined, p >= 8 ? ATTR.BOLD : 0);\n    };\n  },\n};\n",
  "prism-term/src/facets/diagrams/sequence.js": "// A sequence diagram: lifelines for four participants, call arrows that grow\n// from caller to callee, dashed returns, activation bars and a note.\nimport { ATTR, glyphs, uni, phase, ease, fit, clip, stringWidth } from '../callouts/_kit.js';\nimport { nodeBox } from './_lines.js';\n\nconst WHO = ['client', 'gateway', 'checkout', 'postgres'];\n// [from, to, label, kind, start]\nconst MSGS = [\n  [0, 1, 'POST /checkout', 'call', 0.4],\n  [1, 2, 'createOrder()', 'call', 1.0],\n  [2, 3, 'BEGIN; INSERT order', 'call', 1.6],\n  [2, 2, 'pool 20/20 busy, waited 840ms', 'note', 2.3],\n  [3, 2, 'ok 12ms', 'ret', 3.3],\n  [2, 1, '201 Created', 'ret', 3.9],\n  [1, 0, '201  1.12s', 'ret', 4.5],\n];\nconst GROW = 0.35;\n\nexport default {\n  id: 'diagrams-sequence',\n  title: 'Sequence Diagram',\n  category: 'Sequence & Timelines',\n  description: 'A sequence diagram of one checkout request: call arrows grow between lifelines, activation bars mark who is busy, a note flags pool wait, returns come back dashed.',\n  tags: ['sequence diagram', 'uml', 'lifeline', 'request', 'trace'],\n  kind: 'diagram',\n  size: { w: 72, h: 19 },\n  minSize: { w: 60, h: 13 },\n  loop: 9,\n  color: '16',\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 9);\n      const xs = WHO.map((_, i) => Math.floor((W / WHO.length) * (i + 0.5)));\n      WHO.forEach((name, i) => {\n        const w = stringWidth(name) + 4;\n        nodeBox(ctx, b, xs[i] - Math.floor(w / 2), 0, name, { w, color: th.accent, attr: ATTR.BOLD });\n      });\n      const top = 3, rowsAvail = H - top - 1;\n      const step = rowsAvail >= MSGS.length * 2 ? 2 : 1;\n      const rowOf = (k) => top + 1 + k * step;\n      // Lifelines.\n      for (let y = top; y < H - 1; y++) xs.forEach((x) => b.set(x, y, u ? '┊' : ':', th.dim));\n      // Activation bars: from a call's arrival until the matching return leaves.\n      const bar = (who, y0, y1) => { for (let y = y0; y <= y1; y++) b.set(xs[who], y, u ? '┃' : '#', th.accent, undefined, ATTR.BOLD); };\n      const shown = MSGS.filter((m) => p >= m[4]).length;\n      const lastRow = rowOf(Math.max(0, shown - 1));\n      MSGS.forEach(([from, to, , kind, at], k) => {\n        if (kind !== 'call' || p < at + GROW) return;\n        const ret = MSGS.findIndex((m, j) => j > k && m[3] === 'ret' && m[0] === to && m[1] === from);\n        const end = ret >= 0 && p >= MSGS[ret][4] ? rowOf(ret) : lastRow;\n        bar(to, rowOf(k) + (step === 2 ? 1 : 0), end + (step === 2 ? 1 : 0));\n      });\n      // Messages.\n      MSGS.forEach(([from, to, label, kind, at], k) => {\n        if (p < at) return;\n        // With room, labels ride on their own row above the arrow.\n        const y = rowOf(k) + (step === 2 && kind !== 'note' ? 1 : 0);\n        if (y >= H - 1) return;\n        if (kind === 'note') {\n          const text = ` ${label} `;\n          const nx = Math.max(0, Math.min(W - stringWidth(text), xs[from] - Math.floor(stringWidth(text) / 2)));\n          b.text(nx, y, fit(text, W - nx, undefined, ctx), th.bg, th.warn, ATTR.BOLD);\n          return;\n        }\n        const grow = ease(p, at, at + GROW);\n        const dir = to > from ? 1 : -1;\n        const x0 = xs[from] + dir, x1 = xs[to] - dir;\n        const len = Math.abs(x1 - x0);\n        const reach = Math.round(len * grow);\n        const ch = kind === 'ret' ? (u ? '╌' : '.') : (u ? '─' : '-');\n        const c = kind === 'ret' ? th.muted : th.ink;\n        for (let s = 0; s <= reach; s++) b.set(x0 + dir * s, y, ch, c);\n        if (grow >= 1) {\n          b.set(x1, y, dir > 0 ? (u ? '▶' : '>') : (u ? '◀' : '<'), c, undefined, ATTR.BOLD);\n          const lab = step === 2 ? label : ` ${label} `;\n          const room = step === 2 ? len + 6 : len - 1;\n          const shownLab = clip(ctx, lab, Math.max(0, room));\n          const lx = Math.min(x0, x1) + Math.floor((len - stringWidth(shownLab)) / 2) + 1;\n          b.text(lx, step === 2 ? y - 1 : y, shownLab, kind === 'ret' ? th.muted : th.ink, undefined, kind === 'call' ? ATTR.BOLD : 0);\n        }\n      });\n      const done = p >= MSGS[MSGS.length - 1][4] + GROW;\n      b.text(0, H - 1, fit(done ? `${g.check} 201 in 1.12s ${g.mid} 840ms of it waiting for a db connection` : `trace 4bf92f35 ${g.mid} ${shown}/${MSGS.length} spans`, W, undefined, ctx), done ? th.warn : th.muted);\n    };\n  },\n};\n",
  "prism-term/src/facets/diagrams/service-mesh.js": "// A service mesh on a braille canvas: services ring the frame, diagonal call\n// edges carry traffic comets, and one edge turns hot during a latency spike.\nimport { ATTR, glyphs, uni, phase, mix, fit, stringWidth } from '../callouts/_kit.js';\n\nconst SVCS = ['gateway', 'checkout', 'payments', 'notify', 'inventory', 'search', 'users'];\n// [from, to, relative rate]\nconst CALLS = [[0, 1, 1.2], [0, 5, 1.0], [0, 6, 0.6], [1, 2, 0.8], [1, 4, 0.9], [1, 3, 0.4], [5, 4, 0.7], [2, 3, 0.5]];\nconst HOT = [1, 2], SPIKE = [3.0, 6.0];\n\nexport default {\n  id: 'diagrams-service-mesh',\n  title: 'Service Mesh',\n  category: 'Networks & Topologies',\n  description: 'A service mesh on a braille canvas: services ring the frame, diagonal call edges carry traffic comets, and one edge runs hot red during a latency spike.',\n  tags: ['service mesh', 'graph', 'traffic', 'braille', 'latency'],\n  kind: 'diagram',\n  canvas: 'line',\n  size: { w: 64, h: 18 },\n  minSize: { w: 44, h: 12 },\n  loop: 8,\n  color: '256',\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 8);\n      const spike = p >= SPIKE[0] && p < SPIKE[1];\n      const DH = H - 1;\n      const cv = ctx.canvas('line', { cols: W, rows: DH });\n      // Services on an ellipse (cell coordinates), gateway on the left.\n      const rx = W / 2 - 9, ry = DH / 2 - 1.2;\n      const pos = SVCS.map((_, i) => {\n        const a = Math.PI + (i / SVCS.length) * Math.PI * 2;\n        return [W / 2 + rx * Math.cos(a), DH / 2 + ry * Math.sin(a)];\n      });\n      const px = ([x, y]) => [(x + 0.5) * cv.sx, (y + 0.5) * cv.sy];\n      // Edges, then traffic comets traveling them.\n      for (const [a, z, rate] of CALLS) {\n        const hot = spike && a === HOT[0] && z === HOT[1];\n        const [x0, y0] = px(pos[a]), [x1, y1] = px(pos[z]);\n        cv.line(x0, y0, x1, y1, hot ? mix(th.bg, th.neg, 0.7) : mix(th.line, th.muted, 0.45));\n        const n = hot ? 1 : 2;\n        for (let k = 0; k < n; k++) {\n          const f = phase(t * rate * (hot ? 0.35 : 0.6) + k / n + a * 0.13, 1);\n          for (let tail = 0; tail < 4; tail++) {\n            const ff = f - tail * 0.025;\n            if (ff < 0) continue;\n            const c = mix(th.bg, hot ? th.neg : th.accent, 1 - tail * 0.22);\n            cv.set(x0 + (x1 - x0) * ff, y0 + (y1 - y0) * ff, c);\n            cv.set(x0 + (x1 - x0) * ff + 1, y0 + (y1 - y0) * ff, c);\n          }\n        }\n      }\n      cv.paint(b);\n      // Hot-edge label at its midpoint.\n      if (spike) {\n        const [ax, ay] = pos[HOT[0]], [zx, zy] = pos[HOT[1]];\n        const label = ' p99 2.4s ';\n        b.text(Math.round((ax + zx) / 2 - label.length / 2), Math.round((ay + zy) / 2), label, th.bg, th.neg, ATTR.BOLD);\n      }\n      // Service labels sit on top of the lines.\n      SVCS.forEach((name, i) => {\n        const [x, y] = pos[i];\n        const bad = spike && (i === HOT[0] || i === HOT[1]);\n        const label = `${u ? '●' : '*'} ${name}`;\n        const lx = Math.max(0, Math.min(W - stringWidth(label) - 2, Math.round(x - stringWidth(label) / 2) - 1));\n        b.text(lx, Math.round(y), ` ${label} `, th.ink, th.panel, i === 0 ? ATTR.BOLD : 0);\n        b.set(lx + 1, Math.round(y), u ? '●' : '*', bad ? th.neg : th.pos, th.panel);\n      });\n      const msg = spike ? `${g.cross} checkout ${g.right} payments p99 2.4s ${g.mid} retries 3.1%` : `${CALLS.length} edges ${g.mid} 1.4k rps ${g.mid} mTLS on ${g.mid} 0 errors`;\n      b.text(0, H - 1, fit(msg, W, undefined, ctx), spike ? th.neg : th.muted, undefined, spike ? ATTR.BOLD : 0);\n    };\n  },\n};\n",
  "prism-term/src/facets/diagrams/state-machine.js": "// A job lifecycle state machine: the current state lights up and a token\n// rides each labeled transition — one failure, a retry with backoff, success.\nimport { ATTR, glyphs, uni, phase, tint, fit } from '../callouts/_kit.js';\nimport { LineGrid, nodeBox, box, polyline, along } from './_lines.js';\n\nconst S = {\n  PENDING: [4, 1, 12], RUNNING: [26, 1, 11], SUCCEEDED: [49, 1, 13],\n  RETRYING: [4, 9, 12], FAILED: [26, 9, 11], DEAD: [52, 9, 8],\n};\n// [start time, edge id] — each transition takes HOP seconds.\nconst HOP = 0.45;\nconst SCRIPT = [[0.2, 'start'], [0.9, 'schedule'], [1.9, 'exit1'], [2.9, 'retry'], [3.8, 'backoff'], [4.9, 'schedule'], [5.9, 'exit0'], [7.0, 'end']];\n\nexport default {\n  id: 'diagrams-state-machine',\n  title: 'State Machine',\n  category: 'Boxes & Flows',\n  description: 'A job lifecycle state machine with labeled transitions: the active state lights up and a token rides each edge through a failure, a backoff retry and success.',\n  tags: ['state machine', 'fsm', 'lifecycle', 'transitions', 'retry'],\n  kind: 'diagram',\n  size: { w: 70, h: 14 },\n  minSize: { w: 68, h: 14 },\n  loop: 10,\n  color: '16',\n  order: 40,\n  setup(ctx) {\n    const th = ctx.theme;\n    const B = Object.fromEntries(Object.entries(S).map(([k, [x, y, w]]) => [k, box(x, y, w, 3)]));\n    const startDot = [1, B.PENDING.cy], endDot = [B.SUCCEEDED.x + B.SUCCEEDED.w + 4, B.SUCCEEDED.cy];\n    // id: [from, to, corners, label]\n    const E = {\n      start: [null, 'PENDING', [[2, B.PENDING.cy], B.PENDING.left], ''],\n      schedule: ['PENDING', 'RUNNING', [B.PENDING.right, B.RUNNING.left], 'schedule'],\n      exit0: ['RUNNING', 'SUCCEEDED', [B.RUNNING.right, B.SUCCEEDED.left], 'exit 0'],\n      end: ['SUCCEEDED', null, [B.SUCCEEDED.right, [endDot[0] - 1, endDot[1]]], ''],\n      exit1: ['RUNNING', 'FAILED', [B.RUNNING.bottom, B.FAILED.top], 'exit 1'],\n      retry: ['FAILED', 'RETRYING', [B.FAILED.left, B.RETRYING.right], 'retry <=3'],\n      backoff: ['RETRYING', 'PENDING', [B.RETRYING.top, B.PENDING.bottom], 'backoff 4s'],\n      dead: ['FAILED', 'DEAD', [B.FAILED.right, B.DEAD.left], 'exhausted'],\n    };\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 10);\n      let state = null, moving = null, f = 0, attempt = 1, last = null;\n      for (const [at, id] of SCRIPT) {\n        if (p < at) break;\n        if (p < at + HOP) { moving = id; f = (p - at) / HOP; state = null; } else { state = E[id][1]; moving = null; }\n        if (id === 'backoff') attempt = 2;\n        last = id;\n      }\n      const grid = new LineGrid(W, H - 1);\n      for (const [id, [, , corners]] of Object.entries(E)) grid.path(corners, { c: id === moving ? th.accent : undefined, arrow: true });\n      grid.paint(ctx, b, 0, 0, { style: 'round' });\n      for (const [id, [, , corners, label]] of Object.entries(E)) {\n        if (!label) continue;\n        const [[ax, ay], [bx, by]] = corners;\n        const hot = id === moving;\n        const shown = u ? label.replace('<=', '≤') : label;\n        if (ay === by) b.text(Math.min(ax, bx) + Math.floor((Math.abs(bx - ax) - shown.length) / 2) + 1, ay - 1, shown, hot ? th.accent : th.muted, undefined, hot ? ATTR.BOLD : 0);\n        else b.text(ax + 2, Math.floor((ay + by) / 2), shown, hot ? th.accent : th.muted, undefined, hot ? ATTR.BOLD : 0);\n      }\n      b.set(startDot[0], startDot[1], u ? '●' : '*', th.ink, undefined, ATTR.BOLD);\n      b.text(endDot[0], endDot[1], u ? '◉' : '@', state === null && last === 'end' && !moving ? th.pos : th.muted, undefined, ATTR.BOLD);\n      for (const [name, bx] of Object.entries(B)) {\n        const on = name === state;\n        const tone = name === 'FAILED' || name === 'DEAD' ? th.neg : name === 'SUCCEEDED' ? th.pos : name === 'RETRYING' ? th.warn : th.accent;\n        nodeBox(ctx, b, bx.x, bx.y, name, {\n          w: bx.w, color: on ? tone : th.line, bg: on ? tint(ctx, tone, 0.22) : undefined,\n          fg: on ? th.ink : name === 'DEAD' ? th.dim : th.muted, attr: on ? ATTR.BOLD : 0,\n        });\n      }\n      if (moving) {\n        const at = along(polyline(E[moving][2]), f);\n        if (at) b.set(at[0], at[1], u ? '●' : 'o', th.accent, undefined, ATTR.BOLD);\n      }\n      // Event line: the last transition, as a log entry.\n      const [from, to] = last ? E[last] : [null, null];\n      const ev = !last ? 'job queued' : `${from || 'start'} ${g.right} ${to || 'end'}${E[last][3] ? ` (${E[last][3]})` : ''}`;\n      const ts = `12:04:${String(31 + Math.floor(p * 2)).padStart(2, '0')}`;\n      let x = b.text(0, H - 1, ts, th.dim);\n      x = b.text(x + 1, H - 1, `job/reindex-8812 attempt ${attempt}/3`, th.muted);\n      b.text(x + 2, H - 1, fit(ev, Math.max(0, W - x - 2), undefined, ctx), to === 'FAILED' ? th.neg : to === 'SUCCEEDED' || last === 'end' ? th.pos : th.ink);\n    };\n  },\n};\n",
  "prism-term/src/facets/diagrams/timeline.js": "// An incident timeline: a playhead sweeps the axis, events pop in on\n// alternating label lanes, and MTTA / MTTR spans draw in once resolved.\nimport { ATTR, glyphs, uni, phase, fit, stringWidth } from '../callouts/_kit.js';\n\n// [minute after 12:00, label, tone]\nconst EVENTS = [\n  [2, 'deploy v2.14.1', 'info'], [9, 'p99 alert fires', 'neg'], [11, 'on-call paged', 'warn'], [13, 'acknowledged', 'warn'],\n  [20, 'cause: db pool', 'accent'], [24, 'fix #142 approved', 'accent'], [27, 'mitigated', 'pos'], [41, 'resolved', 'pos'],\n];\nconst SPANS = [['MTTA', 9, 13], ['MTTR', 9, 41]];\nconst SPAN_M = 45, SWEEP = 7;\n// Label lanes: [row, side] cycling up-far, down-near, up-near, down-far.\nconst LANES = [[0, 'up'], [7, 'down'], [2, 'up'], [9, 'down']];\nconst AXIS = 5;\n\nexport default {\n  id: 'diagrams-timeline',\n  title: 'Incident Timeline',\n  category: 'Sequence & Timelines',\n  description: 'An incident timeline: a playhead sweeps from deploy to resolution, events pop in on alternating label lanes, and MTTA and MTTR spans draw in at the end.',\n  tags: ['timeline', 'incident', 'postmortem', 'mttr', 'events'],\n  kind: 'diagram',\n  size: { w: 76, h: 14 },\n  minSize: { w: 64, h: 14 },\n  loop: 9,\n  color: '16',\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 9);\n      const nowM = Math.min(1, p / SWEEP) * SPAN_M;\n      const x = (m) => 1 + Math.round((m / SPAN_M) * (W - 3));\n      // Axis, bold up to the playhead, with 15-minute ticks.\n      for (let i = 0; i < W; i++) b.set(i, AXIS, u ? (i <= x(nowM) ? '━' : '─') : (i <= x(nowM) ? '=' : '-'), i <= x(nowM) ? th.ink : th.line);\n      for (let m = 0; m <= SPAN_M; m += 15) {\n        b.set(x(m), AXIS, u ? (m <= nowM ? '╋' : '┼') : '+', m <= nowM ? th.ink : th.line);\n        b.text(Math.max(0, Math.min(W - 5, x(m) - 2)), AXIS + 1, `12:${String(m).padStart(2, '0')}`, th.dim);\n      }\n      const laneEnd = LANES.map(() => -2);\n      // Events.\n      EVENTS.forEach(([m, label, tone], k) => {\n        if (nowM < m) return;\n        const ex = x(m), c = th[tone];\n        const [ly, side] = LANES[k % LANES.length];\n        const text = `12:${String(m).padStart(2, '0')} ${label}`;\n        const tw = stringWidth(text);\n        // Centered on the marker, but never over the previous label in this lane.\n        const lane = k % LANES.length;\n        const lx = Math.max(laneEnd[lane] + 2, Math.max(0, Math.min(W - tw, ex - Math.floor(tw / 2))));\n        b.text(lx, ly, fit(text, Math.max(0, W - lx), undefined, ctx).trimEnd(), c, undefined, nowM - m < 2 ? ATTR.BOLD : 0);\n        laneEnd[lane] = lx + tw;\n        // Stub from the label to the axis.\n        const [y0, y1] = side === 'up' ? [ly + 1, AXIS - 1] : [AXIS + 1, ly - 1];\n        for (let y = y0; y <= y1; y++) if (b.get(ex, y)?.ch === ' ') b.set(ex, y, u ? '│' : '|', th.line);\n        b.set(ex, AXIS, u ? '●' : 'o', c, undefined, ATTR.BOLD);\n      });\n      // Playhead.\n      if (p < SWEEP) b.set(x(nowM), AXIS, u ? '◆' : '#', th.accent, undefined, ATTR.BOLD);\n      // Spans, once resolved.\n      if (nowM >= 41) {\n        SPANS.forEach(([name, a, z], i) => {\n          const y = 11 + i;\n          if (y >= H - 1) return;\n          const xa = x(a), xz = x(z);\n          for (let xx = xa; xx <= xz; xx++) b.set(xx, y, u ? '─' : '-', th.muted);\n          b.set(xa, y, u ? '├' : '|', th.muted); b.set(xz, y, u ? '┤' : '|', th.muted);\n          const lab = ` ${name} ${z - a}m `;\n          const lx = xz - xa > lab.length + 1 ? xa + Math.floor((xz - xa - lab.length) / 2) + 1 : xz + 2;\n          b.text(lx, y, lab, name === 'MTTR' ? th.pos : th.warn, undefined, ATTR.BOLD);\n        });\n      }\n      const live = EVENTS.filter(([m]) => nowM >= m).pop();\n      const msg = nowM >= 41 ? `${g.check} INC-3127 resolved ${g.mid} customer impact 25m ${g.mid} postmortem due Thu`\n        : live ? `INC-3127 ${g.mid} 12:${String(Math.floor(nowM)).padStart(2, '0')} ${g.mid} ${live[1]}` : `INC-3127 ${g.mid} 12:00`;\n      b.text(0, H - 1, fit(msg, W, undefined, ctx), nowM >= 41 ? th.pos : th.muted);\n    };\n  },\n};\n",
  "prism-term/src/facets/fx/aurora.js": "// Aurora borealis: three curtains of light whose folds and rays come from\n// per-column fbm noise (cheap: noise is sampled per column, not per pixel),\n// hung over a twinkling night sky and a mountain ridge with a lake reflection.\nexport default {\n  title: 'Aurora',\n  gallery: 'fx',\n  category: 'Light & Sky',\n  kind: 'scene',\n  tags: ['aurora', 'northern lights', 'sky', 'noise'],\n  order: 60,\n  description: 'Northern-lights curtains with drifting rays over a twinkling sky, mountains and a mirrored lake.',\n  canvas: 'field',\n  palette: 'aurora',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette();\n    const rand = ctx.rng;\n    const { noise2, fbm } = ctx.noise;\n    const { width: W, height: H, px } = cv;\n    const horizon = Math.floor(H * 0.78);\n    const sky = new Int32Array(W * H);\n    const top = 0x02030c, low = 0x0b1630;\n    for (let y = 0; y < H; y++) {\n      const k = Math.min(1, y / horizon);\n      const r = ((top >> 16) & 255) * (1 - k) + ((low >> 16) & 255) * k;\n      const g = ((top >> 8) & 255) * (1 - k) + ((low >> 8) & 255) * k;\n      const b = (top & 255) * (1 - k) + (low & 255) * k;\n      sky.fill((r << 16) | (g << 8) | (b | 0), y * W, (y + 1) * W);\n    }\n    const stars = Array.from({ length: Math.floor(W * horizon * 0.012) }, () => ({\n      x: Math.floor(rand() * W), y: Math.floor(rand() * horizon * 0.92), p: rand() * 6.28, s: 0.4 + rand() * 0.6, f: 0.5 + rand() * 2,\n    }));\n    // Ridged noise (1 − |n|) gives sharp peaks rather than rolling hills.\n    const ridge = new Float32Array(W);\n    for (let x = 0; x < W; x++) {\n      const u = x / W;\n      const p = 1 - Math.abs(fbm(u * 3.2, 3.1, 0, 2)) * 1.6, q = 1 - Math.abs(fbm(u * 9, 7.7, 0, 2)) * 1.8;\n      ridge[x] = horizon - H * (0.03 + 0.13 * Math.max(0, p) ** 2 + 0.035 * Math.max(0, q) ** 2);\n    }\n    const layers = [\n      { base: 0.50, amp: 0.07, len: 0.42, f: 2.2, sp: 0.05, lo: 0.62, hi: 0.9, gain: 1.5 },\n      { base: 0.42, amp: 0.06, len: 0.30, f: 3.4, sp: 0.08, lo: 0.55, hi: 0.75, gain: 1.0 },\n      { base: 0.32, amp: 0.05, len: 0.26, f: 1.6, sp: 0.035, lo: 0.78, hi: 1.0, gain: 0.7 },\n    ];\n    const lb = new Float32Array(W), li = new Float32Array(W);\n    const acc = new Float32Array(W * horizon * 3);\n\n    return (t) => {\n      acc.fill(0);\n      for (let L = 0; L < layers.length; L++) {\n        const ly = layers[L];\n        for (let x = 0; x < W; x++) {\n          const u = x / W;\n          const fold = fbm(u * ly.f + L * 7.3, t * ly.sp, L, 3);\n          lb[x] = H * (ly.base + ly.amp * Math.sin(u * 5 + t * 0.2 + L) + fold * 0.14);\n          const rays = 0.5 + 0.5 * noise2(u * 38 + t * 0.6 * (L + 1) * 0.4, t * 0.35 + L * 3);\n          li[x] = Math.max(0, fold * 0.9 + 0.55) * (0.35 + rays * rays * 1.3) * ly.gain;\n        }\n        const c0 = pal(ly.lo), c1 = pal(ly.hi); // bright lower hem → violet crown\n        const len = H * ly.len;\n        for (let x = 0; x < W; x++) {\n          const b = lb[x], inten = li[x];\n          if (inten <= 0.01) continue;\n          const y0 = Math.max(0, Math.floor(b - len)), y1 = Math.min(horizon - 1, Math.ceil(b + 3));\n          for (let y = y0; y <= y1; y++) {\n            const d = b - y; // >0 above the curtain's lower edge\n            const a = d >= 0 ? Math.exp(-d / (len * 0.35)) * Math.min(1, d / 2 + 0.6) : Math.exp(d * 1.2);\n            const k = inten * a;\n            if (k < 0.004) continue;\n            const m = Math.min(1, Math.max(0, d / len));\n            const j = (y * W + x) * 3;\n            acc[j] += (((c0 >> 16) & 255) * (1 - m) + ((c1 >> 16) & 255) * m) * k;\n            acc[j + 1] += (((c0 >> 8) & 255) * (1 - m) + ((c1 >> 8) & 255) * m) * k;\n            acc[j + 2] += ((c0 & 255) * (1 - m) + (c1 & 255) * m) * k;\n          }\n        }\n      }\n      for (let y = 0, i = 0; y < H; y++) {\n        for (let x = 0; x < W; x++, i++) {\n          if (y < horizon) {\n            const s = sky[i], j = i * 3;\n            const r = ((s >> 16) & 255) + acc[j], g = ((s >> 8) & 255) + acc[j + 1], b = (s & 255) + acc[j + 2];\n            px[i] = y >= ridge[x] ? mountain(x, y, acc, j) : ((r > 255 ? 255 : r) << 16) | ((g > 255 ? 255 : g) << 8) | (b > 255 ? 255 : b | 0);\n          } else {\n            // Lake: mirror the sky above the horizon with ripple wobble and darkening.\n            const d = y - horizon;\n            const sx = Math.min(W - 1, Math.max(0, Math.round(x + Math.sin(d * 0.9 + t * 2.1) * (1 + d * 0.08))));\n            const my = horizon - 1 - d;\n            const src = my >= 0 ? px[my * W + sx] : 0;\n            const k = 0.55 - (d / (H - horizon)) * 0.3;\n            px[i] = ((((src >> 16) & 255) * k) << 16) | ((((src >> 8) & 255) * k) << 8) | (((src & 255) * k) | 0);\n          }\n        }\n      }\n      for (const s of stars) {\n        if (s.y >= ridge[s.x]) continue;\n        const tw = s.s * (0.55 + 0.45 * Math.sin(t * s.f + s.p));\n        cv.add(s.x, s.y, ((tw * 200) << 16) | ((tw * 210) << 8) | (tw * 255 | 0));\n      }\n      cv.paint(ctx.buffer);\n    };\n\n    function mountain(x, y, acc, j) {\n      // Ridge silhouette, rim-lit by whatever aurora light falls just above it.\n      const edge = y - ridge[x];\n      const rim = edge < 1.5 ? 0.5 : 0.06;\n      const r = 6 + acc[j] * rim, g = 8 + acc[j + 1] * rim, b = 16 + acc[j + 2] * rim;\n      return ((r > 255 ? 255 : r) << 16) | ((g > 255 ? 255 : g) << 8) | (b > 255 ? 255 : b | 0);\n    }\n  },\n};\n",
  "prism-term/src/facets/fx/burning-ship.js": "// The Burning Ship fractal (z → (|Re z| + i|Im z|)² + c), flipped upright and\n// colored like its name. The camera breathes from the whole hull down into\n// the little ship riding the antenna off its bow and back, on a log-scale zoom.\nexport default {\n  title: 'Burning Ship',\n  gallery: 'fx',\n  category: 'Fractals',\n  kind: 'scene',\n  tags: ['burning ship', 'fractal', 'escape time', 'zoom', 'fire'],\n  order: 50,\n  description: 'The Burning Ship fractal in flame colors, breathing from the whole hull down to the little ship on its antenna.',\n  size: { w: 80, h: 24 },\n  minSize: { w: 20, h: 6 },\n  loop: 30,\n  canvas: 'field',\n  palette: 'fire',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette(undefined, { size: 1024 });\n    const { width: W, height: H, px, aspect } = cv;\n    const C0 = [-0.5, -0.5], W0 = 3.4;                // the whole hull\n    const C1 = [-1.755, -0.03], W1 = 0.12;            // the little ship on the bow's antenna\n    const inside = ctx.theme.bg;\n    return (t) => {\n      const s = 0.5 - 0.5 * Math.cos(((t % 30) / 30) * Math.PI * 2);\n      const w = W0 * Math.pow(W1 / W0, s);\n      const k = (w - W1) / (W0 - W1);                  // zooms toward the target point\n      const cx = C1[0] + (C0[0] - C1[0]) * k, cy = C1[1] + (C0[1] - C1[1]) * k;\n      const unit = w / Math.min(W, (H * aspect) * 1.6);\n      const maxIt = Math.round(70 + 110 * s);\n      for (let y = 0, i = 0; y < H; y++) {\n        const ci = cy + (y - H / 2 + 0.5) * aspect * unit;\n        for (let x = 0; x < W; x++, i++) {\n          const cr = cx + (x - W / 2 + 0.5) * unit;\n          let zr = 0, zi = 0, n = 0, r2 = 0, i2 = 0;\n          while (n < maxIt) {\n            r2 = zr * zr; i2 = zi * zi;\n            if (r2 + i2 > 64) break;\n            zi = Math.abs(2 * zr * zi) + ci;\n            zr = r2 - i2 + cr;\n            n++;\n          }\n          if (n >= maxIt) { px[i] = inside; continue; }\n          const mu = n + 1 - Math.log(Math.log(r2 + i2) * 0.5) / Math.LN2;\n          const v = Math.min(1, Math.pow(mu / maxIt, 0.42) * 1.15);\n          px[i] = pal(v);\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/fx/caustics.js": "// The bottom of a swimming pool on a sunny day: tiles and grout bent by the\n// moving surface, overlaid with the bright network of caustics — light focused\n// where the surface's wave crests act as lenses. Two noise layers drift\n// against each other; caustic lines live where their sum crosses zero.\nimport { lerpc, clamp01 } from '../retro/_kit.js';\n\nexport default {\n  title: 'Pool Caustics',\n  gallery: 'fx',\n  category: 'Fire & Water',\n  kind: 'scene',\n  tags: ['caustics', 'pool', 'water', 'light', 'refraction', 'summer', 'tiles'],\n  order: 100,\n  description: 'A sunlit pool floor: tiles bent by the moving surface under a dancing network of bright caustic light.',\n  size: { w: 72, h: 24 },\n  minSize: { w: 16, h: 6 },\n  loop: 16,\n  canvas: 'field',\n  palette: 'ocean',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette();\n    const { noise3 } = ctx.noise;\n    const { width: W, height: H, px, aspect } = cv;\n    const S = Math.min(W, H * aspect);\n    const tile = Math.max(4, S / 5);\n    const water = pal(0.42), deep = pal(0.28), light = pal(0.95), grout = lerpc(pal(0.22), 0x000000, 0.2);\n    // The three noise fields are smooth, so on big canvases they're evaluated on a\n    // coarse lattice and interpolated; tile edges and ridges stay per-pixel.\n    const g = W * H > 9000 ? 2 : 1;\n    const GW = Math.ceil(W / g) + 2, GH = Math.ceil(H / g) + 2;\n    const OX = new Float32Array(GW * GH), OY = new Float32Array(GW * GH), NN = new Float32Array(GW * GH);\n    const lerp2 = (A, x, y) => {\n      const gx = x / g, gy = y / g, ix = gx | 0, iy = gy | 0, fx = gx - ix, fy = gy - iy, j = iy * GW + ix;\n      return (A[j] * (1 - fx) + A[j + 1] * fx) * (1 - fy) + (A[j + GW] * (1 - fx) + A[j + GW + 1] * fx) * fy;\n    };\n    return (t) => {\n      const u = (t / 16) * Math.PI * 2;\n      const ca = Math.cos(u), sa = Math.sin(u);\n      for (let gy = 0; gy < GH; gy++) for (let gx = 0; gx < GW; gx++) {\n        const vx = (gx * g + 0.5) / S, vy = ((gy * g + 0.5) * aspect) / S, j = gy * GW + gx;\n        OX[j] = noise3(vx * 2.2 + ca * 0.4, vy * 2.2, sa * 0.4 + 1) * 0.06;\n        OY[j] = noise3(vx * 2.2, vy * 2.2 + sa * 0.4, ca * 0.4 + 5) * 0.06;\n        NN[j] = noise3(vx * 3.1 + ca * 0.7, vy * 3.1 + sa * 0.7, 0.5) + noise3(vx * 3.7 - sa * 0.6, vy * 3.7 + ca * 0.6, 7.5) * 0.8 + noise3(vx * 5.3 + sa, vy * 5.3 - ca, 3.1) * 0.35;\n      }\n      for (let y = 0, i = 0; y < H; y++) {\n        const vy = ((y + 0.5) * aspect) / S;\n        for (let x = 0; x < W; x++, i++) {\n          const vx = (x + 0.5) / S;\n          // Refraction wobble for the tiles.\n          const gx = ((vx + lerp2(OX, x, y)) * S) / tile, gy = ((vy + lerp2(OY, x, y)) * S) / tile;\n          const fx = gx - Math.floor(gx), fy = gy - Math.floor(gy);\n          const line = Math.min(fx, 1 - fx, fy, 1 - fy) * tile;\n          let c = lerpc(deep, water, 0.5 + 0.5 * Math.sin(Math.floor(gx) * 1.7 + Math.floor(gy) * 2.3) * 0.3);\n          if (line < Math.max(0.6, tile * 0.07)) c = grout;\n          // Caustic network: sharp ridges where drifting noise layers cancel.\n          const ridge = Math.pow(clamp01(1 - Math.abs(lerp2(NN, x, y)) * 3.2), 3);\n          c = lerpc(c, light, Math.min(0.85, ridge * 0.95));\n          px[i] = c;\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/fx/crt-static.js": "// Between channels on an old CRT: snow, a darker hum bar crawling up the\n// tube, scanlines and a vignette — then the set catches a station for a beat,\n// a test card rolls into lock with the channel number, and it's gone again.\nimport { lerpc, clamp01, hash2 } from '../retro/_kit.js';\n\nconst LOOP = 12;\n\nexport default {\n  title: 'CRT Static',\n  gallery: 'fx',\n  category: 'Glitch & Noise',\n  kind: 'scene',\n  tags: ['static', 'snow', 'crt', 'tv', 'noise', 'analog', 'channel'],\n  order: 40,\n  description: 'Between channels on an old CRT: snow, a crawling hum bar and scanlines, until a test card rolls into lock.',\n  size: { w: 64, h: 20 },\n  minSize: { w: 16, h: 6 },\n  loop: LOOP,\n  canvas: 'field',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const b = ctx.buffer;\n    const { theme } = ctx;\n    const { width: W, height: H, px, aspect } = cv;\n    const tint = lerpc(0xffffff, theme.info, 0.12);\n    // A simple test card: bars, a gray ramp, a circle with a cross.\n    const card = (x, y) => {\n      const u = x / W, v = y / H;\n      const dx = (u - 0.5) * W, dy = (v - 0.5) * H * aspect, r = Math.hypot(dx, dy), R = Math.min(W, H * aspect) * 0.36;\n      if (Math.abs(r - R) < 0.9 || (r < R && (Math.abs(dx) < 0.6 || Math.abs(dy) < 0.6))) return 0xffffff;\n      if (v > 0.38 && v < 0.62 && r < R) return lerpc(0x101010, 0xf0f0f0, Math.floor(u * 8) / 7);\n      if (v < 0.5) return [0xb0b0b0, 0xb0b000, 0x00b0b0, 0x00b000, 0xb000b0, 0xb00000, 0x0000b0][Math.min(6, Math.floor(u * 7))];\n      return [0x0000b0, 0x101010, 0xb000b0, 0x101010, 0x00b0b0, 0x101010, 0xb0b0b0][Math.min(6, Math.floor(u * 7))];\n    };\n    return (t) => {\n      const lt = t % LOOP, frame = Math.floor(t * 30);\n      // Station lock: fades in from 6.0 s, rolls into place, holds, drops at 8.4 s.\n      const lock = lt > 6 && lt < 8.4 ? clamp01((lt - 6) * 3) * clamp01((8.4 - lt) * 5) : 0;\n      const roll = lt > 6 ? Math.floor(Math.max(0, 1 - (lt - 6) * 1.4) * H * 1.5) : 0;\n      const hum = ((-lt / 3) % 1 + 1) % 1 * H * 1.4 - H * 0.2;\n      for (let y = 0, i = 0; y < H; y++) {\n        const bar = Math.exp(-Math.pow((y - hum) / (H * 0.12), 2));\n        const scan = y % 2 ? 0.86 : 1;\n        for (let x = 0; x < W; x++, i++) {\n          let v = hash2(x, y, frame);\n          v = v * v * 0.9 + (hash2(x >> 1, y, frame + 99) > 0.97 ? 0.4 : 0);\n          let c = lerpc(0x000000, tint, clamp01(v) * (1 - bar * 0.45) * scan);\n          if (lock > 0) c = lerpc(c, card(x, (y + roll) % H), lock * (0.9 - bar * 0.2) * scan);\n          // Vignette toward the tube's corners.\n          const vx = (x / W - 0.5) * 2, vy = (y / H - 0.5) * 2;\n          px[i] = lerpc(c, 0x000000, clamp01((vx * vx + vy * vy - 0.55) * 0.55));\n        }\n      }\n      cv.paint(b);\n      if (lock > 0.5) b.text(Math.max(1, ctx.width - 7), 1, 'CH 03', theme.pos);\n      else if (ctx.width >= 24) b.text(Math.max(1, ctx.width - 7), 1, `CH ${String(2 + (Math.floor(lt / 1.5) % 11)).padStart(2, '0')}`, lerpc(theme.pos, 0x000000, 0.3));\n    };\n  },\n};\n",
  "prism-term/src/facets/fx/fire.js": "// Old-school fire: each pixel averages the heat beneath it (drifting with a\n// gusting wind) minus a scrolling cooling map, which tears the rising heat into\n// tongues of flame; a flickering fuel bed feeds it and embers spiral off the top.\n// On light themes the flame burns in daylight: its cool edge melts into the page\n// instead of a soot-black band.\nimport { mix } from '../../color.js';\n\nexport default {\n  title: 'Fire',\n  gallery: 'fx',\n  category: 'Fire & Water',\n  kind: 'scene',\n  tags: ['fire', 'doom', 'embers', 'heat'],\n  order: 20,\n  description: 'Classic demoscene fire — heat rises, averages and cools through a scrolling noise map, throwing embers on the wind.',\n  canvas: 'field',\n  palette: 'fire',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette();\n    const day = ctx.theme.mode === 'light' && ctx.bg >= 0, paper = ctx.bg;\n    const rand = ctx.rng;\n    const { noise3 } = ctx.noise;\n    const { width: W, height: H, px, aspect } = cv;\n    const heat = new Float32Array(W * (H + 2)); // two extra rows = fuel bed\n    const rate = 32 * Math.max(1, H / 70);   // simulation steps (rows of rise) per second\n    // Cooling texture, seamlessly tiled vertically so it can scroll forever.\n    const CH = H * 2;\n    const cool = new Float32Array(W * CH);\n    const strength = ctx.param('height', 1);\n    const base = 255 / Math.max(10, H * 0.62 * strength);\n    for (let y = 0; y < CH; y++)\n      for (let x = 0; x < W; x++) {\n        const f = (yy) => {\n          const n = noise3(x * 0.19, yy * 0.075 * aspect, 0.5) * 0.5 + 0.5;\n          return n * n;\n        };\n        const k = y / CH;\n        cool[y * W + x] = base * (0.35 + 2.3 * (f(y) * (1 - k) + f(y + CH) * k));\n      }\n    const embers = [];\n    let acc = 0, scroll = 0, steps = 0;\n\n    const step = (t) => {\n      const fuel = H * W;\n      for (let x = 0; x < W; x++) {\n        const n = noise3(x * 0.05, t * 1.3, 2.7);\n        const v = 205 + n * 150 + (rand() - 0.5) * 70;\n        heat[fuel + x] = v < 0 ? 0 : v > 255 ? 255 : v;\n        heat[fuel + W + x] = heat[fuel + x];\n      }\n      scroll = (scroll + 1) % CH;\n      for (let y = 0; y < H; y++) {\n        const wind = noise3(y * 0.04, t * 0.35, 9.1) * 1.8 + Math.sin(t * 0.5) * 0.6;\n        const shift = wind > 0.5 ? 1 : wind < -0.5 ? -1 : 0;\n        const r1 = (y + 1) * W, r2 = (y + 2) * W;\n        const cr = ((y + scroll) % CH) * W;\n        for (let x = 0; x < W; x++) {\n          const xs = x - shift;\n          const a = xs > 0 ? xs - 1 : 0, b = xs < 0 ? 0 : xs >= W ? W - 1 : xs, c = xs + 1 < W ? xs + 1 : W - 1;\n          const v = (heat[r1 + a] + heat[r1 + b] + heat[r1 + c] + heat[r2 + b]) * 0.25 - cool[cr + x];\n          heat[y * W + x] = v > 0 ? v : 0;\n        }\n      }\n      if (rand() < 0.3 * Math.max(1, W / 80)) {\n        embers.push({ x: rand() * W, y: H * (0.55 + rand() * 0.3), vx: (rand() - 0.5) * 8, vy: -(12 + rand() * 20) * Math.max(1, H / 60), life: 1, p: rand() * 6 });\n      }\n      steps++;\n    };\n\n    return (t, dt) => {\n      acc += dt * rate;\n      let n = 0;\n      while (acc >= 1 && n < 4) { step(t); acc -= 1; n++; }\n      if (acc > 4) acc = 0;\n      if (steps === 0) for (let k = 0; k < H; k++) step(t); // start with flames already up\n\n      for (let i = 0; i < W * H; i++) {\n        const h = heat[i];\n        px[i] = h < 10 ? -1 : day ? mix(paper, pal(0.36 + 0.64 * (h / 255)), Math.min(1, (h - 10) / 110)) : pal(h / 255);\n      }\n      for (let i = embers.length - 1; i >= 0; i--) {\n        const e = embers[i];\n        e.x += (e.vx + Math.sin(t * 2.5 + e.p) * 6) * dt;\n        e.y += e.vy * dt;\n        e.life -= dt * 0.6;\n        if (e.life <= 0 || e.y < 0) { embers.splice(i, 1); continue; }\n        cv.set(e.x, e.y, pal(0.45 + e.life * 0.5));\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/fx/fractal-tree.js": "// A recursive blossom tree: ten generations of branches, each a little shorter\n// and thinner, swaying more the further they are from the trunk. Blossoms at\n// the tips, petals drifting down on the wind.\nimport { lerpc, hash2 } from '../retro/_kit.js';\n\nconst LOOP = 16;\n\nexport default {\n  title: 'Blossom Tree',\n  gallery: 'fx',\n  category: 'Fractals',\n  kind: 'scene',\n  tags: ['tree', 'fractal', 'recursive', 'l-system', 'blossom', 'sakura', 'wind'],\n  order: 70,\n  description: 'A recursive blossom tree swaying in the wind — ten generations of branches, blooming tips and drifting petals.',\n  size: { w: 64, h: 24 },\n  minSize: { w: 20, h: 8 },\n  loop: LOOP,\n  canvas: 'line',\n  palette: 'candy',\n  params: { depth: { default: 10, min: 5, max: 12, label: 'Generations' } },\n  setup(ctx) {\n    const cv = ctx.canvas('line');\n    const pal = ctx.palette();\n    const { theme } = ctx;\n    const { width: PW, height: PH, aspect } = cv;\n    const depth = Math.max(5, Math.min(12, Math.round(ctx.param('depth', 10))));\n    const bark = lerpc(0x6b4a2e, theme.ink, 0.15), twig = lerpc(0x9a7a5a, theme.ink, 0.2);\n    const blossom = [pal(0), pal(0.15), lerpc(pal(0), 0xffffff, 0.5)];\n    const ground = lerpc(theme.bg, theme.pos, 0.35);\n    const len0 = Math.min(PH * 0.3 * aspect, PW * 0.2);\n    const petals = Array.from({ length: Math.round(PW * 0.25) }, (_, i) => ({ x: hash2(i, 1), ph: hash2(i, 2), sp: 1 + Math.floor(hash2(i, 3) * 2), c: blossom[i % 3] }));\n    return (t) => {\n      cv.clear();\n      const u = (t / LOOP) * Math.PI * 2;\n      const wind = Math.sin(u) * 0.6 + Math.sin(u * 3 + 1) * 0.25;\n      // Ground line with a little grass.\n      const gy = PH - 1;\n      for (let x = 0; x < PW; x++) { cv.set(x, gy, ground); if (hash2(x, 5) < 0.3) cv.set(x, gy - 1, ground); }\n      const tips = [];\n      const grow = (x, y, ang, len, d, seed) => {\n        const sway = wind * 0.035 * (depth - d + 1) * (1 + 0.3 * Math.sin(u * 2 + seed));\n        const a = ang + sway;\n        const x2 = x + Math.sin(a) * len, y2 = y - (Math.cos(a) * len) / aspect;\n        const thick = Math.max(1, Math.round((d / depth) ** 2 * 3.2));\n        const c = d > depth * 0.5 ? bark : twig;\n        for (let o = 0; o < thick; o++) cv.line(x + o - (thick >> 1), y, x2 + o - (thick >> 1), y2, c);\n        if (d <= 1) { tips.push([x2, y2, seed]); return; }\n        const spread = 0.42 + 0.08 * Math.sin(seed * 3.1);\n        const shrink = 0.72 + 0.06 * hash2(seed, d);\n        grow(x2, y2, a - spread, len * shrink, d - 1, seed * 2 + 1);\n        grow(x2, y2, a + spread * 0.9, len * (shrink - 0.03), d - 1, seed * 2 + 2);\n      };\n      grow(PW / 2, gy - 1, 0, len0, depth, 1);\n      for (const [x, y, s] of tips) {\n        const c = blossom[s % 3];\n        cv.set(x, y, c); cv.set(x + 1, y, c); cv.set(x, y - 1, c);\n        if (hash2(s, 7) < 0.5) cv.set(x - 1, y + 1, c);\n      }\n      // Petals: each falls on its own loop, sliding with the wind.\n      for (const p of petals) {\n        const f = ((t / LOOP) * p.sp + p.ph) % 1;\n        const y = PH * 0.15 + f * PH * 0.85;\n        const x = (p.x * PW + f * PW * 0.25 + Math.sin(f * 12 + p.ph * 9) * PW * 0.02 + wind * PW * 0.03 * f) % PW;\n        cv.set(x, y, p.c);\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/fx/god-rays.js": "// Crepuscular rays through a forest: the low sun behind swaying canopy and\n// trunks, its light marched pixel by pixel back toward the sun through the\n// gaps, so shafts of light fan out through the haze. Dust motes drift in them.\nimport { lerpc, clamp01, hash2 } from '../retro/_kit.js';\n\nconst LOOP = 20, STEPS = 14;\n\nexport default {\n  title: 'God Rays',\n  gallery: 'fx',\n  category: 'Light & Sky',\n  kind: 'scene',\n  tags: ['god rays', 'crepuscular', 'volumetric', 'light shafts', 'forest', 'sun', 'haze'],\n  order: 90,\n  description: 'Volumetric light: the low sun behind a swaying forest canopy, shafts fanning through the gaps and dust drifting in them.',\n  size: { w: 80, h: 24 },\n  minSize: { w: 20, h: 8 },\n  loop: LOOP,\n  canvas: 'field',\n  palette: 'gold',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette();\n    const { fbm } = ctx.noise;\n    const { width: W, height: H, px, aspect } = cv;\n    const occ = new Float32Array(W * H);                      // 1 = blocked, 0 = open sky\n    const trunks = Array.from({ length: Math.max(3, Math.round(W / 9)) }, (_, i) => ({ x: (i + 0.3 + hash2(i, 1) * 0.4) * (W / Math.max(3, Math.round(W / 9))), w: 1 + hash2(i, 2) * Math.max(1, W / 40), lean: (hash2(i, 3) - 0.5) * 0.15 }));\n    const motes = Array.from({ length: Math.round(W * H * 0.008) }, (_, i) => [hash2(i, 4), hash2(i, 5), hash2(i, 6)]);\n    const haze = pal(0.45), dark = lerpc(pal(0.04), 0x000000, 0.4), sunC = pal(0.98);\n    const emit = new Float32Array(W * H);\n    // The canopy noise is smooth: on big canvases it's computed every other pixel.\n    const g = W * H > 9000 ? 2 : 1, GW = Math.ceil(W / g) + 1, GH = Math.ceil(H / g) + 1;\n    const leafG = new Float32Array(GW * GH), lightG = new Float32Array(GW * GH);\n    return (t) => {\n      const u = (t / LOOP) * Math.PI * 2;\n      const sunX = W * (0.5 + 0.08 * Math.sin(u)), sunY = H * 0.2;\n      for (let gy = 0; gy < GH; gy++) for (let gx = 0; gx < GW; gx++) {\n        const x = gx * g, y = gy * g, sway = Math.sin(u + y * 0.1) * 1.5;\n        leafG[gy * GW + gx] = fbm((x + sway) * 0.06, y * aspect * 0.06, Math.cos(u) * 0.3 + Math.sin(u) * 0.3, 3);\n      }\n      // Occluders: a canopy thickest at the top with gaps, trunks, the forest floor.\n      for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {\n        const v = y / H, gx = x / g, gy = y / g, ix = gx | 0, iy = gy | 0, fx = gx - ix, fy = gy - iy, j = iy * GW + ix;\n        const leaf = (leafG[j] * (1 - fx) + leafG[j + 1] * fx) * (1 - fy) + (leafG[j + GW] * (1 - fx) + leafG[j + GW + 1] * fx) * fy;\n        let o = clamp01((leaf + 0.2 - v * 0.6) * 5);\n        if (v > 0.86) o = 1;\n        occ[y * W + x] = o;\n      }\n      for (const tr of trunks) for (let y = Math.floor(H * 0.2); y < H; y++) {\n        const cx = tr.x + (y - H) * tr.lean;\n        for (let dx = 0; dx < tr.w; dx++) { const x = Math.round(cx + dx); if (x >= 0 && x < W) occ[y * W + x] = 1; }\n      }\n      // Only open sky near the sun emits; marching toward the sun smears it into shafts.\n      for (let y = 0, i = 0; y < H; y++) for (let x = 0; x < W; x++, i++) {\n        const d = Math.hypot((x - sunX) / W, ((y - sunY) * aspect) / W);\n        emit[i] = (1 - occ[i]) * Math.pow(clamp01(1 - d / 0.55), 2.2);\n      }\n      // The light march runs on the same lattice — shafts are smooth along their length.\n      for (let gy = 0; gy < GH; gy++) for (let gx = 0; gx < GW; gx++) {\n        const x = Math.min(W - 1, gx * g), y = Math.min(H - 1, gy * g);\n        const dx = (sunX - x) / STEPS, dy = (sunY - y) / STEPS;\n        let light = 0, sx = x, sy = y, w = 1;\n        for (let s = 0; s < STEPS; s++) {\n          const ix = sx | 0, iy = sy | 0;\n          if (ix >= 0 && iy >= 0 && ix < W && iy < H) light += emit[iy * W + ix] * w;\n          sx += dx; sy += dy; w *= 0.95;\n        }\n        lightG[gy * GW + gx] = light / 4.7;\n      }\n      for (let y = 0, i = 0; y < H; y++) for (let x = 0; x < W; x++, i++) {\n        const gx = x / g, gy = y / g, ix = gx | 0, iy = gy | 0, fx = gx - ix, fy = gy - iy, j = iy * GW + ix;\n        const light = (lightG[j] * (1 - fx) + lightG[j + 1] * fx) * (1 - fy) + (lightG[j + GW] * (1 - fx) + lightG[j + GW + 1] * fx) * fy;\n        const d = Math.hypot((x - sunX) / W, ((y - sunY) * aspect) / W);\n        const open = 1 - occ[i];\n        let c = lerpc(dark, haze, open * 0.4 * (1 - y / H));            // dim far forest air\n        c = lerpc(c, haze, clamp01(light * 1.1));                         // the shafts\n        c = lerpc(c, sunC, clamp01(light * 1.6 - 0.6));\n        c = lerpc(c, sunC, clamp01(1 - d * 11) * open);                   // the sun's disc\n        px[i] = c;\n      }\n      // Dust motes catching the light inside the shafts.\n      for (const [mx, my, k] of motes) {\n        const x = (mx * W + Math.sin(u + k * 9) * 3 + W) % W, y = ((((my + (t / LOOP) * (k > 0.5 ? 1 : -1)) % 1) + 1) % 1) * H * 0.85;\n        const i = Math.floor(y) * W + Math.floor(x);\n        cv.blend(x, y, sunC, clamp01((((px[i] >> 8) & 255) - 90) / 120) * 0.7);\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/fx/interference.js": "// Ripple-tank interference: three point sources on slow Lissajous orbits, each\n// sending out concentric waves. Where crests meet they reinforce, where crest\n// meets trough they cancel — the moiré of nodal lines sweeps as sources move.\nimport { lerpc } from '../retro/_kit.js';\n\nexport default {\n  title: 'Interference',\n  gallery: 'fx',\n  category: 'Fields',\n  kind: 'scene',\n  tags: ['interference', 'waves', 'moire', 'ripple tank', 'physics', 'rings'],\n  order: 60,\n  description: 'A ripple tank: three orbiting sources whose concentric waves reinforce and cancel into sweeping moiré nodal lines.',\n  size: { w: 80, h: 24 },\n  minSize: { w: 20, h: 6 },\n  loop: 20,\n  canvas: 'field',\n  palette: 'ocean',\n  params: {\n    sources: { default: 3, min: 2, max: 5, label: 'Sources' },\n    wavelength: { default: 1, min: 0.4, max: 3, label: 'Wavelength' },\n  },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette(undefined, { size: 512 });\n    const { width: W, height: H, px, aspect } = cv;\n    const n = Math.max(2, Math.min(5, Math.round(ctx.param('sources', 3))));\n    const S = Math.min(W, H * aspect);\n    const k = (Math.PI * 2) / (Math.max(5, S * 0.17) * ctx.param('wavelength', 1));\n    const src = Array.from({ length: n }, (_, i) => ({ a: 1 + (i % 3), b: 2 + ((i + 1) % 2), ph: i * 2.1, r: 0.22 + 0.06 * i }));\n    const pos = new Float32Array(n * 2);\n    const omega = (Math.PI * 2) / 1.25;   // a crest every 1.25 s\n    // Night: crests glow out of shadowed nodal lines. Day: the nodes are the page and\n    // the waves are drawn in the palette's ink range (no washed-out whites).\n    const day = ctx.theme.mode === 'light';\n    const node = day ? ctx.theme.bg : lerpc(ctx.theme.bg, pal(0), 0.5);\n    const spot = day ? ctx.theme.accent : 0xffffff;\n    return (t) => {\n      const u = (t / 20) * Math.PI * 2;\n      src.forEach((s, i) => {\n        pos[i * 2] = W / 2 + Math.sin(u * s.a + s.ph) * W * s.r * 1.2;\n        pos[i * 2 + 1] = (H * aspect) / 2 + Math.cos(u * s.b + s.ph * 0.7) * H * aspect * s.r;\n      });\n      const wt = omega * t;\n      for (let y = 0, i = 0; y < H; y++) {\n        const vy = (y + 0.5) * aspect;\n        for (let x = 0; x < W; x++, i++) {\n          let v = 0;\n          for (let j = 0; j < n; j++) {\n            const dx = x + 0.5 - pos[j * 2], dy = vy - pos[j * 2 + 1];\n            const d = Math.sqrt(dx * dx + dy * dy);\n            v += Math.sin(d * k - wt) / (1 + d * 0.012);\n          }\n          v /= n;\n          // Amplitude picks the color; near-zero (the nodal lines) falls into shadow.\n          const a = Math.abs(v);\n          px[i] = lerpc(node, pal(day ? 0.45 + v * 0.3 : 0.5 + v * 0.55), Math.min(1, a * 2.4));\n        }\n      }\n      // Bright dots at the sources themselves.\n      for (let j = 0; j < n; j++) cv.set(pos[j * 2], pos[j * 2 + 1] / aspect, spot);\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/fx/kaleidoscope.js": "// A kaleidoscope: a slowly flowing field of interleaved waves and noise, folded\n// through mirrored wedges around the center and turning — the classic tube,\n// with a soft vignette like the eyepiece.\nimport { lerpc } from '../retro/_kit.js';\n\nexport default {\n  title: 'Kaleidoscope',\n  gallery: 'fx',\n  category: 'Fields',\n  kind: 'scene',\n  tags: ['kaleidoscope', 'mirror', 'symmetry', 'mandala', 'psychedelic', 'polar'],\n  order: 90,\n  description: 'A turning kaleidoscope: a flowing field folded through mirrored wedges into a mandala, with an eyepiece vignette.',\n  size: { w: 72, h: 24 },\n  minSize: { w: 16, h: 6 },\n  loop: 24,\n  canvas: 'field',\n  palette: 'prism',\n  params: { segments: { default: 6, min: 3, max: 12, label: 'Mirrors' } },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette(undefined, { size: 512 });\n    const { noise3 } = ctx.noise;\n    const { width: W, height: H, px, aspect } = cv;\n    const seg = Math.max(3, Math.min(12, Math.round(ctx.param('segments', 6))));\n    const wedge = Math.PI / seg;\n    const R = Math.min(W, H * aspect) / 2;\n    const bg = ctx.theme.bg;\n    return (t) => {\n      const u = (t / 24) * Math.PI * 2;\n      const rot = u, flow = Math.sin(u) * 0.8;\n      for (let y = 0, i = 0; y < H; y++) {\n        const dy = ((y + 0.5) * aspect - (H * aspect) / 2) / R;\n        for (let x = 0; x < W; x++, i++) {\n          const dx = (x + 0.5 - W / 2) / R;\n          const r = Math.sqrt(dx * dx + dy * dy);\n          if (r > 1.11) { px[i] = bg; continue; }\n          // Fold the angle into one mirrored wedge.\n          let a = Math.atan2(dy, dx) + rot;\n          a = ((a % (2 * wedge)) + 2 * wedge) % (2 * wedge);\n          if (a > wedge) a = 2 * wedge - a;\n          const fx = Math.cos(a) * r * 2.2, fy = Math.sin(a) * r * 2.2;\n          const n = noise3(fx * 1.3 + flow, fy * 1.3, Math.cos(u) * 0.6 + 2) * 1.4;\n          const v = Math.sin(fx * 3.1 + Math.sin(fy * 4.2 + u) + n * 2) + Math.sin(fy * 5.3 - u * 2 + n);\n          const band = (v * 0.25 + r * 0.4 - t / 24 + 10) % 1;\n          const light = 0.55 + 0.45 * Math.cos(v * 2.4);\n          let c = lerpc(bg, pal(band), light);\n          if (r > 0.86) c = lerpc(c, bg, Math.min(1, (r - 0.86) * 4));\n          px[i] = c;\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/fx/lava-lamp.js": "// Lava lamps on a shelf: wax blobs warm at the bulb, rise, cool, stretch and\n// sink again — metaballs inside a tapered glass, lit from below, with the\n// metal cap and base. One lamp when narrow, a row of three when there's room.\nimport { lerpc, clamp01 } from '../retro/_kit.js';\n\nconst LOOP = 30;\n\nexport default {\n  title: 'Lava Lamp',\n  gallery: 'fx',\n  category: 'Fields',\n  kind: 'scene',\n  tags: ['lava lamp', 'metaballs', 'wax', 'blobs', 'retro', '1970s', 'groovy'],\n  order: 80,\n  description: 'Lava lamps on a shelf: metaball wax warms at the bulb, rises, stretches and sinks inside tapered glass lit from below.',\n  size: { w: 64, h: 24 },\n  minSize: { w: 16, h: 8 },\n  loop: LOOP,\n  canvas: 'field',\n  palette: 'magma',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette(undefined, { size: 512 });\n    const rand = ctx.rng;\n    const { theme } = ctx;\n    const { width: W, height: H, px, aspect } = cv;\n    const count = W / (H * aspect) > 1.05 ? 3 : 1;\n    const lamps = Array.from({ length: count }, (_, i) => {\n      const cx = W * ((i + 0.5) / count);\n      const half = Math.min((W / count) * 0.34, H * aspect * 0.15);\n      const hue = count === 1 ? 0 : [0.08, 0, 0.16][i];\n      return {\n        cx, half, hue,\n        top: H * 0.05, cap: H * 0.17, bot: H * 0.8, base: H * 0.97,\n        blobs: Array.from({ length: 5 }, (_, j) => ({ ph: rand(), sp: 1 + (j % 3), sw: rand() * 6.28, r: 0.26 + rand() * 0.2 })),\n      };\n    });\n    const wall = lerpc(theme.bg, theme.panel, 0.6);\n    const metal = [0x3a3a46, 0x8a8a9e, 0xd8d8e8];\n    // Glass radius at height v (0 = top of glass, 1 = bottom): a tall tapered rocket shape.\n    const radius = (v) => (0.5 + 0.5 * v) * Math.sqrt(Math.min(1, v * 12, (1 - v) * 14 + 0.3));\n    return (t) => {\n      // Back wall with a warm pool of light behind each lamp.\n      for (let y = 0, i = 0; y < H; y++) for (let x = 0; x < W; x++, i++) px[i] = wall;\n      for (const L of lamps) {\n        const liquid = pal(0.18 + L.hue), waxLo = pal(0.62 + L.hue), waxHi = pal(0.9 + L.hue * 0.5);\n        for (let y = 0; y < H; y++) for (let x = Math.max(0, Math.floor(L.cx - L.half * 3.2)); x < Math.min(W, L.cx + L.half * 3.2); x++) {\n          const dx = (x + 0.5 - L.cx) / (L.half * 3.2), dy = (y - L.bot) / H;\n          px[y * W + x] = lerpc(px[y * W + x], waxLo, clamp01(0.22 - (dx * dx + dy * dy) * 0.3));\n        }\n        // Blob centers: each rides a slow heat cycle, rising from the bulb and sinking back.\n        const bl = L.blobs.map((b) => {\n          const ph = ((t / LOOP) * b.sp + b.ph) % 1;\n          const rise = 0.5 - 0.5 * Math.cos(ph * Math.PI * 2);\n          return { x: Math.sin(((t * Math.PI * 2) / LOOP) * b.sp + b.sw) * 0.35, y: 0.93 - rise * 0.86, r: b.r * (0.75 + 0.35 * Math.sin(ph * Math.PI * 4 + b.sw)) };\n        });\n        const gh = L.bot - L.cap;\n        for (let y = Math.floor(L.cap); y < Math.ceil(L.bot); y++) {\n          const v = (y + 0.5 - L.cap) / gh, rw = L.half * radius(v);\n          for (let x = Math.max(0, Math.floor(L.cx - rw)); x < Math.min(W, Math.ceil(L.cx + rw)); x++) {\n            const lx = (x + 0.5 - L.cx) / L.half, ux = (x + 0.5 - L.cx) / rw;\n            let f = 0;\n            for (const b of bl) {\n              const dx = lx - b.x, dy = ((v - b.y) * gh * aspect) / L.half;\n              f += (b.r * b.r) / (dx * dx + dy * dy + 0.02);\n            }\n            f += Math.max(0, (v - 0.93) * 14) * 1.2;           // the molten pool on the bottom\n            const glow = clamp01(v * 0.6 + 0.1);               // lit from the bulb below\n            let c = lerpc(liquid, waxLo, glow * 0.25);\n            if (f > 1) {\n              const core = clamp01((f - 1) * 0.6);\n              c = lerpc(waxLo, waxHi, core * (0.55 + 0.45 * glow));\n            } else if (f > 0.8) c = lerpc(c, waxLo, (f - 0.8) / 0.2 * 0.6);\n            // Glass: a highlight down the left, darkening at the rim.\n            const rim = Math.abs(ux);\n            if (rim > 0.82) c = lerpc(c, 0x000000, (rim - 0.82) * 2.2);\n            if (ux > -0.62 && ux < -0.48) c = lerpc(c, 0xffffff, 0.22);\n            px[y * W + x] = c;\n          }\n        }\n        // Cap and base: brushed metal cones.\n        const metalAt = (u) => (u < -0.3 ? lerpc(metal[0], metal[1], (u + 1) / 0.7) : u < 0.25 ? lerpc(metal[1], metal[2], (u + 0.3) / 0.55) : lerpc(metal[2], metal[0], (u - 0.25) / 0.75));\n        for (let y = Math.floor(L.top); y < Math.ceil(L.cap); y++) {\n          const k = (y - L.top) / (L.cap - L.top), rw = L.half * (0.3 + 0.13 * k);\n          for (let x = Math.floor(L.cx - rw); x < L.cx + rw; x++) if (x >= 0 && x < W) px[y * W + x] = metalAt((x + 0.5 - L.cx) / rw);\n        }\n        for (let y = Math.floor(L.bot); y < Math.min(H, Math.ceil(L.base)); y++) {\n          const k = (y - L.bot) / (L.base - L.bot), rw = L.half * (0.5 + 0.55 * Math.sin(k * Math.PI * 0.6 + 0.35));\n          for (let x = Math.floor(L.cx - rw); x < L.cx + rw; x++) if (x >= 0 && x < W) px[y * W + x] = metalAt((x + 0.5 - L.cx) / rw);\n        }\n      }\n      // The shelf.\n      for (let y = Math.floor(H * 0.97); y < H; y++) px.fill(lerpc(theme.panel, 0x000000, 0.3), y * W, (y + 1) * W);\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/fx/lightning.js": "// A thunderstorm over the hills: rolling cloud decks, slanting rain, and every\n// few seconds a forked bolt built by midpoint displacement — it flickers two or\n// three times, lighting the clouds from inside and the land beneath.\nimport { lerpc, clamp01, hash2 } from '../retro/_kit.js';\n\nconst LOOP = 24, EVERY = 3;\n\nfunction bolt(k, W, top, bottom) {\n  // Midpoint displacement from cloud base to ground, plus a few forks.\n  let seed = 0;\n  const r = () => hash2(k, seed++, 77);\n  const x0 = W * (0.15 + r() * 0.7);\n  let pts = [[x0, top], [x0 + (r() - 0.5) * W * 0.25, bottom]];\n  for (let pass = 0; pass < 6; pass++) {\n    const out = [pts[0]];\n    for (let i = 1; i < pts.length; i++) {\n      const [ax, ay] = pts[i - 1], [bx, by] = pts[i];\n      const len = Math.hypot(bx - ax, by - ay);\n      out.push([(ax + bx) / 2 + (r() - 0.5) * len * 0.55, (ay + by) / 2 + (r() - 0.5) * len * 0.1], pts[i]);\n    }\n    pts = out;\n  }\n  const forks = [];\n  for (let f = 0; f < 3; f++) {\n    const start = pts[Math.floor((0.15 + r() * 0.5) * pts.length)];\n    let [x, y] = start;\n    const dir = r() < 0.5 ? -1 : 1, seg = [];\n    seg.push([x, y]);\n    for (let i = 0; i < 10; i++) { x += dir * (0.6 + r() * 2.2); y += 0.8 + r() * 2.4; seg.push([x, y]); }\n    forks.push(seg);\n  }\n  return { main: pts, forks, x: x0 };\n}\n\nexport default {\n  title: 'Thunderstorm',\n  gallery: 'fx',\n  category: 'Light & Sky',\n  kind: 'scene',\n  tags: ['lightning', 'storm', 'thunder', 'rain', 'clouds', 'weather', 'bolt'],\n  order: 70,\n  description: 'A thunderstorm: rolling clouds, slanting rain and forked bolts that flicker, lighting the clouds from inside.',\n  size: { w: 80, h: 24 },\n  minSize: { w: 20, h: 8 },\n  loop: LOOP,\n  canvas: 'field',\n  palette: 'ice',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette();\n    const { fbm } = ctx.noise;\n    const { width: W, height: H, px, aspect } = cv;\n    const base = Math.floor(H * 0.42), ground = H * 0.82;\n    // Cloud deck, tiled horizontally so it can roll forever.\n    const cloud = new Float32Array(W * H);\n    for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {\n      const k = x / W, f = (xx) => fbm(xx * 0.035, y * aspect * 0.06, 1.7, 5);\n      cloud[y * W + x] = (f(x) * (1 - k) + f(x - W) * k) * 0.5 + 0.5;\n    }\n    const hills = new Float32Array(W);\n    for (let x = 0; x < W; x++) hills[x] = ground + (Math.sin(x * 0.07 + 1) * 0.5 + Math.sin(x * 0.19) * 0.25) * H * 0.05;\n    const drops = Array.from({ length: Math.round(W * H * 0.012) }, (_, i) => [hash2(i, 1), hash2(i, 2), 1 + Math.floor(hash2(i, 3) * 2)]);\n    const skyDark = lerpc(pal(0.1), 0x000000, 0.2), cloudLit = pal(0.85), cloudDim = pal(0.34), boltC = 0xf4f8ff, glowC = pal(0.7);\n    const land = lerpc(0x0a0f0a, pal(0.1), 0.3);\n    let cacheK = -1, cache = null;\n    return (t) => {\n      const lt = t % LOOP;\n      const k = Math.floor(lt / EVERY), at = k * EVERY + hash2(k, 5) * 0.8, age = lt - at;\n      // Flash envelope: two or three quick pulses.\n      let flash = 0;\n      if (age >= 0 && age < 0.6) {\n        const pulses = 2 + Math.floor(hash2(k, 6) * 2);\n        for (let p = 0; p < pulses; p++) { const pt = p * 0.13, d = age - pt; if (d >= 0 && d < 0.09) flash = Math.max(flash, 1 - d / 0.09); }\n        flash = Math.max(flash, Math.max(0, 0.25 - age * 0.4));\n      }\n      if (k !== cacheK) { cacheK = k; cache = bolt(k, W, base * 0.9, ground); }\n      const roll = (lt / LOOP) * W;\n      for (let y = 0, i = 0; y < H; y++) for (let x = 0; x < W; x++, i++) {\n        const cv0 = cloud[y * W + (Math.floor(x + roll) % W)];\n        const depth = clamp01(1 - y / (base * 1.4));\n        let c = lerpc(skyDark, cloudDim, clamp01((cv0 - 0.3) * 2.4) * (0.55 + depth * 0.45));\n        if (flash > 0) {\n          const near = Math.exp(-Math.pow((x - cache.x) / (W * 0.25), 2));\n          c = lerpc(c, cloudLit, flash * clamp01(cv0 * 1.3 - 0.2) * (0.25 + 0.75 * near) * (0.4 + 0.6 * clamp01(1.6 - y / base)));\n        }\n        if (y >= hills[x]) c = lerpc(land, pal(0.5), flash * 0.25);\n        px[i] = c;\n      }\n      // Rain: short slanting streaks, brighter during a flash.\n      const rainC = lerpc(cloudDim, cloudLit, 0.25 + flash * 0.5);\n      for (const [rx, ry, sp] of drops) {\n        const y = ((ry + (lt / LOOP) * 12 * sp) % 1) * H, x = (rx * W + y * 0.35) % W;\n        for (let j = 0; j < 2; j++) cv.blend(x - j * 0.35, y - j, rainC, 0.6);\n      }\n      // The bolt.\n      if (age >= 0 && age < 0.55 && flash > 0.05) {\n        const draw = (pts, c, w) => { for (let i = 1; i < pts.length; i++) { cv.line(pts[i - 1][0], pts[i - 1][1], pts[i][0], pts[i][1], c); if (w) cv.line(pts[i - 1][0] + 1, pts[i - 1][1], pts[i][0] + 1, pts[i][1], lerpc(glowC, c, 0.4)); } };\n        for (const f of cache.forks) draw(f, lerpc(glowC, boltC, flash * 0.7), false);\n        draw(cache.main, lerpc(glowC, boltC, 0.5 + flash * 0.5), true);\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/fx/mandelbrot.js": "// Endless Mandelbrot dive into Seahorse Valley with smooth (normalized\n// iteration count) coloring. The zoom breathes in and out on a log scale, and\n// the iteration cap adapts to the work done last frame so cost stays bounded.\n// `--set set=julia` swaps in an animated Julia set.\nexport default {\n  title: 'Mandelbrot Dive',\n  gallery: 'fx',\n  category: 'Fractals',\n  kind: 'scene',\n  tags: ['mandelbrot', 'julia', 'fractal', 'zoom'],\n  order: 40,\n  description: 'Continuous zoom into Seahorse Valley with smooth iteration coloring; --set set=julia for a morphing Julia set.',\n  canvas: 'field',\n  palette: 'magma',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette(undefined, { size: 1024 });\n    const julia = ctx.param('set', 'mandelbrot') === 'julia';\n    const period = ctx.param('period', 36);\n    const { width: W, height: H, px, aspect } = cv;\n    const FX = -0.743643887037151, FY = 0.13182590420533;\n    const maxLog = Math.log(julia ? 6 : 3e4);\n    const budget = 3.6e6; // iterations per frame (~6 ms)\n    const inside = ctx.theme.bg;\n    let cap = 220;\n\n    return (t) => {\n      const phase = (t % period) / period;\n      const zoom = Math.exp(maxLog * (1 - Math.cos(phase * Math.PI * 2)) / 2);\n      const want = Math.min(900, Math.floor(90 + 110 * Math.log10(zoom)));\n      const maxIt = Math.max(24, Math.min(want, cap));\n      const unit = (julia ? 3.1 : 3.0) / (Math.min(W, H * aspect) * zoom);\n      const rot = julia ? 0 : phase * Math.PI * 2;\n      const cr = Math.cos(rot), sr = Math.sin(rot);\n      // Julia c walks the main cardioid's boundary (c = e^iθ/2 − e^2iθ/4), where the\n      // sets are connected and most intricate.\n      const jt = t * 0.12 + 1.2;\n      const jr = 0.5 * Math.cos(jt) - 0.25 * Math.cos(2 * jt), ji = 0.5 * Math.sin(jt) - 0.25 * Math.sin(2 * jt);\n      const cx0 = julia ? 0 : FX, cy0 = julia ? 0 : FY;\n      const shift = t * 0.015;\n      let work = 0;\n\n      for (let y = 0, i = 0; y < H; y++) {\n        const vy = (y - H / 2 + 0.5) * aspect * unit;\n        for (let x = 0; x < W; x++, i++) {\n          const vx = (x - W / 2 + 0.5) * unit;\n          const ox = cx0 + vx * cr - vy * sr, oy = cy0 + vx * sr + vy * cr;\n          let zr, zi, ar, ai;\n          if (julia) { zr = ox; zi = oy; ar = jr; ai = ji; }\n          else {\n            zr = 0; zi = 0; ar = ox; ai = oy;\n            // Skip the main cardioid and period-2 bulb outright.\n            const q = (ox - 0.25) * (ox - 0.25) + oy * oy;\n            if (q * (q + (ox - 0.25)) <= 0.25 * oy * oy || (ox + 1) * (ox + 1) + oy * oy <= 0.0625) { px[i] = inside; continue; }\n          }\n          let n = 0, r2 = 0, i2 = 0;\n          while (n < maxIt) {\n            r2 = zr * zr; i2 = zi * zi;\n            if (r2 + i2 > 256) break;\n            zi = 2 * zr * zi + ai;\n            zr = r2 - i2 + ar;\n            n++;\n          }\n          work += n;\n          if (n >= maxIt) { px[i] = inside; continue; }\n          const mu = n + 1 - Math.log(Math.log(r2 + i2) * 0.5) / Math.LN2;\n          const s = Math.log2(Math.max(1, mu) + 1) * 0.3 + shift;\n          px[i] = pal(1 - Math.abs(((s % 2) + 2) % 2 - 1));\n        }\n      }\n      // Steer next frame's cap toward the budget (deterministic: depends only on content).\n      if (work > budget) cap = Math.max(24, Math.floor(cap * 0.85));\n      else if (work < budget * 0.6) cap = Math.min(900, Math.ceil(cap * 1.08) + 1);\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/fx/matrix.js": "// Digital rain with real glyphs: halfwidth katakana, digits and symbols fall\n// in per-column streams. Every lit cell decays at its stream's own rate, so\n// trail lengths vary; heads burn white, glyphs mutate as they fade, and dimmer\n// slow columns read as a second, more distant layer.\nconst KATAKANA = Array.from({ length: 0xff9d - 0xff66 + 1 }, (_, i) => String.fromCodePoint(0xff66 + i));\nconst UNICODE_GLYPHS = [...KATAKANA, ...'0123456789', ...'=+-*<>:.\"|¦_'];\nconst ASCII_GLYPHS = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@#$%&*+=-<>?'];\n\nexport default {\n  title: 'Digital Rain',\n  gallery: 'fx',\n  category: 'Glitch & Noise',\n  kind: 'scene',\n  tags: ['matrix', 'digital rain', 'katakana', 'hacker'],\n  order: 20,\n  description: 'Matrix digital rain in real halfwidth katakana — white-hot heads, per-column speeds and trail lengths, mutating glyphs.',\n  canvas: 'ascii',\n  palette: 'matrix',\n  setup(ctx) {\n    const W = ctx.width, H = ctx.height;\n    const rand = ctx.rng;\n    const pal = ctx.palette();\n    const speedK = ctx.param('speed', 1);\n    const glyphs = ctx.caps.unicode === false || ctx.mode === 'ascii' ? ASCII_GLYPHS : UNICODE_GLYPHS;\n    const pick = () => glyphs[Math.floor(rand() * glyphs.length)];\n    const N = W * H;\n    const I = new Float32Array(N); // intensity 0..1\n    const R = new Float32Array(N); // decay rate (1/s) of the stream that last lit the cell\n    const G = new Array(N);\n    for (let i = 0; i < N; i++) G[i] = pick();\n    const head = new Uint8Array(N);\n    // Column depth: a dim, slow back layer and a bright, fast front layer.\n    const depth = new Float32Array(W);\n    for (let x = 0; x < W; x++) depth[x] = rand() < 0.35 ? 0.35 + rand() * 0.25 : 0.75 + rand() * 0.25;\n\n    const D = W + Math.ceil(W * 0.4 * Math.min(1, H / 30));\n    const dc = new Int32Array(D), dy = new Float32Array(D), dv = new Float32Array(D), dl = new Float32Array(D);\n    const respawn = (d, initial) => {\n      dc[d] = d < W ? d : Math.floor(rand() * W);\n      const z = depth[dc[d]];\n      dv[d] = (5 + rand() * 18) * (0.45 + z * 0.75);\n      dl[d] = 5 + rand() * Math.max(6, H * 0.9);\n      dy[d] = initial ? rand() * H * 1.4 - H * 0.4 : -rand() * H * 0.6;\n    };\n    for (let d = 0; d < D; d++) respawn(d, true);\n\n    let mutateAcc = 0;\n    return (t, dt) => {\n      // Decay every lit cell at its stream's rate.\n      for (let i = 0; i < N; i++) {\n        head[i] = 0;\n        if (I[i] > 0) { I[i] *= Math.exp(-R[i] * dt); if (I[i] < 0.02) I[i] = 0; }\n      }\n      // Advance drops, lighting every cell the head passed this frame.\n      for (let d = 0; d < D; d++) {\n        const x = dc[d], v = dv[d] * speedK;\n        const y0 = Math.floor(dy[d]);\n        dy[d] += v * dt;\n        const y1 = Math.floor(dy[d]);\n        const rate = (Math.log(40) * v) / dl[d];\n        for (let y = Math.max(0, y0); y <= Math.min(H - 1, y1); y++) {\n          const i = y * W + x;\n          I[i] = 1; R[i] = rate;\n          if (y !== y0) G[i] = pick();\n        }\n        if (y1 >= 0 && y1 < H) head[y1 * W + x] = 1;\n        if (dy[d] - dl[d] > H) respawn(d, false);\n      }\n      // Glyph mutation: a few percent of lit cells flicker to new symbols per second.\n      mutateAcc += N * 0.05 * dt;\n      while (mutateAcc >= 1) { mutateAcc--; const i = Math.floor(rand() * N); if (I[i] > 0) G[i] = pick(); }\n\n      const buf = ctx.buffer;\n      for (let y = 0, i = 0; y < H; y++) {\n        for (let x = 0; x < W; x++, i++) {\n          const v = I[i];\n          if (v <= 0) continue;\n          if (head[i]) buf.set(x, y, G[i], pal(1), undefined, depth[x] > 0.7 ? 1 : 0);\n          else buf.set(x, y, G[i], pal(0.12 + 0.82 * Math.pow(v, 0.65) * depth[x]));\n        }\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/fx/metaballs.js": "// Metaballs: an inverse-square field from orbiting blobs. Color is the\n// field-weighted blend of each ball's palette hue, so merging blobs mix\n// smoothly; the iso-surface gets a hot rim and the outside a soft halo.\nexport default {\n  title: 'Metaballs',\n  gallery: 'fx',\n  category: 'Fields',\n  kind: 'scene',\n  tags: ['metaballs', 'blobs', 'isosurface', 'glow'],\n  order: 50,\n  description: 'Orbiting metaballs that merge with blended hues, a glowing iso-contour rim and soft halo.',\n  canvas: 'field',\n  palette: 'candy',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette();\n    const rand = ctx.rng;\n    const count = Math.max(2, Math.min(12, ctx.param('count', 6)));\n    const { width: W, height: H, px, aspect } = cv;\n    const S = Math.min(W, H * aspect);\n    const balls = Array.from({ length: count }, (_, i) => {\n      const c = pal(i / count);\n      return {\n        r: S * (0.1 + rand() * 0.06),\n        ax: 0.25 + rand() * 0.2, ay: 0.22 + rand() * 0.18,\n        fx: 0.3 + rand() * 0.5, fy: 0.25 + rand() * 0.5, p: rand() * 6.28,\n        cr: (c >> 16) & 255, cg: (c >> 8) & 255, cb: c & 255,\n        x: 0, y: 0, r2: 0,\n      };\n    });\n    const bg = ctx.theme.bg;\n    const bR = (bg >> 16) & 255, bG = (bg >> 8) & 255, bB = bg & 255;\n\n    return (t) => {\n      for (const b of balls) {\n        b.x = W / 2 + Math.sin(t * b.fx + b.p) * W * b.ax * 0.95;\n        b.y = (H * aspect) / 2 + Math.cos(t * b.fy + b.p * 1.3) * H * aspect * b.ay * 0.95;\n        const pulse = 1 + 0.12 * Math.sin(t * 1.7 + b.p);\n        b.r2 = b.r * b.r * pulse * pulse;\n      }\n      for (let y = 0, i = 0; y < H; y++) {\n        const vy = (y + 0.5) * aspect;\n        for (let x = 0; x < W; x++, i++) {\n          let f = 0, r = 0, g = 0, bl = 0;\n          for (let k = 0; k < count; k++) {\n            const b = balls[k];\n            const dx = x + 0.5 - b.x, dy = vy - b.y;\n            const w = b.r2 / (dx * dx + dy * dy + 1);\n            f += w; r += b.cr * w; g += b.cg * w; bl += b.cb * w;\n          }\n          r /= f; g /= f; bl /= f;\n          let k, wh;\n          if (f >= 1) {\n            const e = Math.min(1, (f - 1) * 3);       // 0 at the rim → 1 deep inside\n            const rim = Math.exp(-(f - 1) * (f - 1) * 90);\n            k = 0.55 + 0.45 * e;\n            wh = rim * 0.7;\n          } else {\n            const halo = f * f * f;\n            k = halo * 0.55;\n            wh = Math.exp(-(f - 1) * (f - 1) * 260) * 0.35;\n          }\n          const rr = bR * (1 - Math.min(1, k)) + r * k + (255 - r) * wh;\n          const gg = bG * (1 - Math.min(1, k)) + g * k + (255 - g) * wh;\n          const bb = bB * (1 - Math.min(1, k)) + bl * k + (255 - bl) * wh;\n          px[i] = ((rr > 255 ? 255 : rr) << 16) | ((gg > 255 ? 255 : gg) << 8) | (bb > 255 ? 255 : bb | 0);\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/fx/nebula.js": "// Deep field: a domain-warped emission nebula with dark dust lanes, painted\n// once into a texture larger than the screen, then slowly turned and breathed\n// through. Twinkling stars in front, a few bright ones with diffraction spikes.\nimport { lerpc, clamp01, hash2 } from '../retro/_kit.js';\n\nconst LOOP = 40;\n\nexport default {\n  title: 'Nebula',\n  gallery: 'fx',\n  category: 'Light & Sky',\n  kind: 'scene',\n  tags: ['nebula', 'space', 'stars', 'cosmos', 'galaxy', 'deep field', 'ambient'],\n  order: 80,\n  description: 'A domain-warped emission nebula with dust lanes, slowly turning, with twinkling stars and diffraction spikes.',\n  size: { w: 80, h: 24 },\n  minSize: { w: 20, h: 6 },\n  loop: LOOP,\n  canvas: 'field',\n  palette: 'magma',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette(undefined, { size: 512 });\n    const { fbm } = ctx.noise;\n    const { width: W, height: H, px, aspect } = cv;\n    const VH = H * aspect;\n    // Texture in visual units: a square that covers the screen at any rotation.\n    // It's smooth, so it's painted at most 200 texels across and sampled bilinearly.\n    const T = Math.ceil(Math.hypot(W, VH) * 1.15), S = Math.min(W, VH);\n    const N = Math.min(T, 200), q = T / N;\n    const R = new Float32Array(N * N), G = new Float32Array(N * N), B = new Float32Array(N * N);\n    for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {\n      const u = ((x * q) / S) * 1.6, v = ((y * q) / S) * 1.6;\n      const wx = fbm(u + 3.1, v + 1.7, 0.5, 4), wy = fbm(u - 2.3, v + 4.4, 1.5, 4);\n      const gas = fbm(u + wx * 1.4, v + wy * 1.4, 2.5, 5) * 0.5 + 0.5;\n      const dust = clamp01(fbm(u * 2.2 + wy, v * 2.2 - wx, 7.5, 4) * 1.8 + 0.1);\n      const glow = Math.pow(clamp01(gas * 1.25 - 0.18), 1.6);\n      const c = pal(clamp01(glow * 0.95 + 0.04));\n      const o = lerpc(c, 0x000000, clamp01(dust * 0.85) * clamp01(glow * 2)), j = y * N + x;\n      R[j] = (o >> 16) & 255; G[j] = (o >> 8) & 255; B[j] = o & 255;\n    }\n    const sample = (tx, ty) => {\n      const fx = Math.min(N - 1.001, Math.max(0, tx / q)), fy = Math.min(N - 1.001, Math.max(0, ty / q));\n      const ix = fx | 0, iy = fy | 0, ax = fx - ix, ay = fy - iy, j = iy * N + ix;\n      const w00 = (1 - ax) * (1 - ay), w10 = ax * (1 - ay), w01 = (1 - ax) * ay, w11 = ax * ay;\n      const r = R[j] * w00 + R[j + 1] * w10 + R[j + N] * w01 + R[j + N + 1] * w11;\n      const g = G[j] * w00 + G[j + 1] * w10 + G[j + N] * w01 + G[j + N + 1] * w11;\n      const b = B[j] * w00 + B[j + 1] * w10 + B[j + N] * w01 + B[j + N + 1] * w11;\n      return (r << 16) | (g << 8) | b;\n    };\n    const stars = Array.from({ length: Math.round(W * H * 0.02) }, (_, i) => [hash2(i, 1), hash2(i, 2), hash2(i, 3)]);\n    const bright = Array.from({ length: Math.max(2, Math.round((W * H) / 900)) }, (_, i) => [hash2(i, 11) * W, hash2(i, 12) * H, hash2(i, 13)]);\n    return (t) => {\n      const u = (t / LOOP) * Math.PI * 2;\n      const rot = Math.sin(u) * 0.12, zoom = 1 + 0.06 * Math.sin(u * 2 + 1);\n      const cr = Math.cos(rot) / zoom, sr = Math.sin(rot) / zoom;\n      const ox = T / 2 + Math.cos(u) * S * 0.04, oy = T / 2 + Math.sin(u) * S * 0.03;\n      for (let y = 0, i = 0; y < H; y++) {\n        const vy = (y + 0.5) * aspect - VH / 2;\n        for (let x = 0; x < W; x++, i++) {\n          const vx = x + 0.5 - W / 2;\n          px[i] = sample(ox + vx * cr - vy * sr, oy + vx * sr + vy * cr);\n        }\n      }\n      for (const [sx, sy, k] of stars) {\n        const tw = 0.55 + 0.45 * Math.sin((t * Math.PI * 2 * (1 + Math.floor(k * 4))) / 8 + k * 50);\n        cv.add(sx * W, sy * H, lerpc(0x000000, k > 0.7 ? 0xbfd8ff : 0xfff0d8, tw * (0.35 + k * 0.6)));\n      }\n      for (const [bx, by, k] of bright) {\n        const tw = 0.75 + 0.25 * Math.sin((t * Math.PI * 2) / 4 + k * 20);\n        const c = lerpc(0x000000, 0xffffff, tw), arm = Math.max(2, Math.round(Math.min(W, H) * 0.05));\n        cv.set(bx, by, 0xffffff);\n        for (let d = 1; d <= arm; d++) {\n          const a = tw * (1 - d / (arm + 1));\n          cv.blend(bx + d, by, c, a); cv.blend(bx - d, by, c, a);\n          cv.blend(bx, by + d / aspect, c, a); cv.blend(bx, by - d / aspect, c, a);\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/fx/newton-fractal.js": "// Newton's method as art: every point is a guess that slides toward one of a\n// polynomial's roots. Color says which root it reaches, shade says how fast.\n// The roots orbit and breathe, so the fractal basin boundaries writhe.\nimport { lerpc } from '../retro/_kit.js';\n\nexport default {\n  title: 'Newton Basins',\n  gallery: 'fx',\n  category: 'Fractals',\n  kind: 'scene',\n  tags: ['newton', 'fractal', 'basins', 'roots', 'complex', 'polynomial'],\n  order: 60,\n  description: \"Newton's method basins for a polynomial whose roots orbit and breathe — color by root, shade by speed.\",\n  size: { w: 80, h: 24 },\n  minSize: { w: 20, h: 6 },\n  loop: 24,\n  canvas: 'field',\n  palette: 'prism',\n  params: { roots: { default: 3, min: 3, max: 6, label: 'Roots' } },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette();\n    const { width: W, height: H, px, aspect } = cv;\n    const n = Math.max(3, Math.min(6, Math.round(ctx.param('roots', 3))));\n    const colors = Array.from({ length: n }, (_, k) => pal(k / n));\n    const dark = lerpc(ctx.theme.bg, 0x000000, 0.4);\n    const rr = new Float64Array(n), ri = new Float64Array(n);\n    const MAX = 28;\n    return (t) => {\n      const u = (t / 24) * Math.PI * 2;\n      for (let k = 0; k < n; k++) {\n        const a = (k / n) * Math.PI * 2 + u * 0.5, m = 1 + 0.28 * Math.sin(u * (k % 2 ? 1 : 2) + k);\n        rr[k] = Math.cos(a) * m; ri[k] = Math.sin(a) * m;\n      }\n      const unit = 3.4 / Math.min(W, H * aspect * 1.4);\n      for (let y = 0, i = 0; y < H; y++) {\n        const y0 = (y - H / 2 + 0.5) * aspect * unit;\n        for (let x = 0; x < W; x++, i++) {\n          let zr = (x - W / 2 + 0.5) * unit, zi = y0, it = 0, hit = -1, near = 1;\n          for (; it < MAX; it++) {\n            // z ← z − 1 / Σ 1/(z − r_k)\n            let sr = 0, si = 0;\n            for (let k = 0; k < n; k++) {\n              const dr = zr - rr[k], di = zi - ri[k], d = dr * dr + di * di;\n              if (d < 1e-6) { hit = k; near = d; break; }\n              sr += dr / d; si -= di / d;\n            }\n            if (hit >= 0) break;\n            const s2 = sr * sr + si * si;\n            if (s2 < 1e-12) break;\n            zr -= sr / s2; zi += si / s2;\n          }\n          if (hit < 0) { px[i] = dark; continue; }\n          // Smooth the iteration count with the last step's distance.\n          const f = (it + Math.log(-Math.log(near + 1e-12) + 1) * 0.1) / MAX;\n          px[i] = lerpc(colors[hit], dark, Math.min(0.92, Math.pow(f, 0.55) * 1.05));\n        }\n      }\n      // Root markers.\n      for (let k = 0; k < n; k++) cv.set(W / 2 + rr[k] / unit, H / 2 + ri[k] / unit / aspect, 0xffffff);\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/fx/ocean-waves.js": "// Open sea at sunset, looking toward the horizon. Every water pixel is a\n// point on a sum-of-waves height field seen in perspective: its normal\n// reflects the sky (more of it at grazing angles), and where it tilts toward\n// the low sun the glitter path flickers. Distant waves fade so nothing aliases.\nimport { lerpc, clamp01 } from '../retro/_kit.js';\n\nconst WAVES = [\n  [0.9, 0.42, 0.55, 1.0], [-0.6, 0.8, 0.9, 0.55], [0.2, 1, 1.7, 0.3], [-0.95, 0.3, 2.9, 0.16], [0.7, -0.7, 4.3, 0.09],\n].map(([dx, dz, k, a]) => {\n  // Deep-water dispersion (ω = √gk), rounded so every wave repeats over the 20 s loop.\n  const l = Math.hypot(dx, dz), w = Math.max(1, Math.round((Math.sqrt(9.8 * k) * 1.2 * 20) / (2 * Math.PI))) * ((2 * Math.PI) / 20);\n  return { dx: dx / l, dz: dz / l, k, a, w };\n});\n\nexport default {\n  title: 'Ocean at Sunset',\n  gallery: 'fx',\n  category: 'Fire & Water',\n  kind: 'scene',\n  tags: ['ocean', 'sea', 'waves', 'sunset', 'reflection', 'glitter', 'water'],\n  order: 80,\n  description: 'Open sea at sunset in perspective: a height field of waves reflecting the sky, with a flickering sun-glitter path.',\n  size: { w: 80, h: 24 },\n  minSize: { w: 20, h: 6 },\n  loop: 20,\n  canvas: 'field',\n  palette: 'sunset',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette(undefined, { size: 512 });\n    const { fbm } = ctx.noise;\n    const { width: W, height: H, px, aspect } = cv;\n    const hz = Math.floor(H * 0.42);\n    const sunX = W * 0.5, sunR = Math.min(W * 0.09, hz * aspect * 0.5);\n    const sky = (e) => pal(clamp01(0.86 - e * 1.9));                 // elevation 0 = horizon\n    const deep = lerpc(pal(0.04), 0x000814, 0.4);\n    const clouds = new Float32Array(W * hz);\n    // Cloud bands, tiled horizontally (two fbm copies cross-faded) so they can drift forever.\n    for (let y = 0; y < hz; y++) for (let x = 0; x < W; x++) {\n      const k = x / W, f = (xx) => fbm(xx * 0.03, y * aspect * 0.22, 3.3, 4);\n      clouds[y * W + x] = clamp01((f(x) * (1 - k) + f(x - W) * k) * 1.6 - 0.05) * clamp01((y / hz) * 1.6 - 0.15);\n    }\n    const focal = W * 0.6, camH = 2.2;\n    return (t) => {\n      const drift = (t / 20) * W;\n      // Sky: gradient, banks of cloud lit from below, and a sun sitting on the horizon.\n      for (let y = 0; y < hz; y++) {\n        const e = (hz - y) / (H * 0.9);\n        for (let x = 0; x < W; x++) {\n          let c = sky(e);\n          const dx = x + 0.5 - sunX, dy = (y + 0.5 - hz + sunR * 0.35 / aspect) * aspect, d = Math.hypot(dx, dy);\n          if (d < sunR) c = lerpc(pal(0.97), pal(0.85), clamp01(dy / sunR + 0.5));\n          else c = lerpc(c, pal(0.9), clamp01(1 - (d - sunR) / (sunR * 2.5)) * 0.35);\n          const cl = clouds[y * W + (Math.floor(x + drift) % W)];\n          if (cl > 0) c = lerpc(c, lerpc(pal(0.25), pal(0.75), clamp01(1 - e * 2.5)), Math.min(0.5, cl * 0.7));\n          px[y * W + x] = c;\n        }\n      }\n      // Sea.\n      for (let y = hz; y < H; y++) {\n        const r = y - hz + 0.5, z = (camH * focal) / (r * aspect);\n        const fade = clamp01(1 - z / 140);                            // far waves average out to flat\n        for (let x = 0; x < W; x++) {\n          const wx = ((x + 0.5 - W / 2) * z) / focal;\n          let sx = 0, sz = 0, h = 0;\n          for (const wv of WAVES) {\n            const lod = clamp01(1.4 - (z * wv.k) / 60);\n            if (lod <= 0) continue;\n            const ph = (wx * wv.dx + z * wv.dz) * wv.k - wv.w * t;\n            const a = wv.a * lod * fade;\n            h += Math.sin(ph) * a;\n            const g = Math.cos(ph) * a * wv.k;\n            sx += g * wv.dx; sz += g * wv.dz;\n          }\n          // Normal, view ray, reflection.\n          let nx = -sx * 0.22, ny = 1, nz = -sz * 0.22;\n          const nl = Math.hypot(nx, ny, nz); nx /= nl; ny /= nl; nz /= nl;\n          let vx = wx, vy = -camH, vz = z;\n          const vl = Math.hypot(vx, vy, vz); vx /= vl; vy /= vl; vz /= vl;\n          const dn = vx * nx + vy * ny + vz * nz;\n          const ry = vy - 2 * dn * ny, rx = vx - 2 * dn * nx;\n          const fres = 0.25 + 0.75 * Math.pow(1 - Math.max(0, -dn), 4);\n          let c = lerpc(deep, sky(Math.min(0.42, Math.max(0, ry) * 0.7)), fres);\n          // Sun glitter: reflections that point back at the low sun.\n          const toSun = Math.max(0, 1 - Math.hypot((rx - (sunX - W / 2) / focal) * 1.6, ry - 0.04) * 6);\n          if (toSun > 0) c = lerpc(c, pal(0.98), Math.min(1, Math.pow(toSun, 2.5) * 2.6));\n                    px[y * W + x] = c;\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/fx/pixel-sort.js": "// Pixel sorting, the glitch-art staple: a sunset scene whose rows are sorted\n// by brightness wherever they fall inside a moving threshold window — but only\n// within drifting bands, so the clean image and the smeared streaks alternate.\nimport { lerpc, clamp01 } from '../retro/_kit.js';\n\nconst LOOP = 16;\nconst lum = (c) => ((c >> 16) & 255) * 0.3 + ((c >> 8) & 255) * 0.59 + (c & 255) * 0.11;\n\nexport default {\n  title: 'Pixel Sort',\n  gallery: 'fx',\n  category: 'Glitch & Noise',\n  kind: 'scene',\n  tags: ['pixel sort', 'glitch', 'databend', 'melt', 'sort', 'glitch art'],\n  order: 50,\n  description: 'Glitch-art pixel sorting: a sunset whose rows sort by brightness inside a moving threshold, in drifting smeared bands.',\n  size: { w: 80, h: 24 },\n  minSize: { w: 16, h: 6 },\n  loop: LOOP,\n  canvas: 'field',\n  palette: 'sunset',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette();\n    const { fbm } = ctx.noise;\n    const { width: W, height: H, px, aspect } = cv;\n    // The source: sky, sun, ridge and a lake — painted once.\n    const src = new Int32Array(W * H), L = new Float32Array(W * H);\n    const hz = Math.floor(H * 0.62), sunR = Math.min(W * 0.14, hz * aspect * 0.55);\n    for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {\n      const ridge = hz - H * (0.06 + 0.12 * Math.abs(fbm(x * 0.03, 0.5, 0, 3)) * 2.2);\n      let c;\n      if (y < hz) {\n        c = pal(0.1 + (y / hz) * 0.75);\n        const cl = clamp01(fbm(x * 0.045, y * aspect * 0.16, 4.2, 4) * 2.2 + 0.1);   // banks of cloud\n        c = lerpc(c, cl > 0.5 ? pal(0.92) : pal(0.12), Math.abs(cl - 0.5) * 0.9);\n        const d = Math.hypot(x + 0.5 - W * 0.62, (y + 0.5 - hz * 0.62) * aspect);\n        if (d < sunR) c = lerpc(pal(0.98), pal(0.85), d / sunR);\n        if (y > ridge) c = lerpc(pal(0.06), 0x000000, 0.3);\n      } else {\n        const my = 2 * hz - y, ripple = Math.sin(y * 1.7) * 0.5 + 0.5;\n        c = lerpc(pal(0.1 + (my / hz) * 0.75), pal(0.04), 0.25 + ((y - hz) / (H - hz)) * 0.3 + ripple * 0.2);\n        if (Math.abs(x + 0.5 - W * 0.62) < sunR * (0.9 - (y - hz) / (H - hz) * 0.4) * (0.6 + 0.4 * ripple)) c = lerpc(c, pal(0.95), 0.6);\n      }\n      src[y * W + x] = c; L[y * W + x] = lum(c);\n    }\n    const idx = new Int32Array(W), keys = new Float32Array(W);\n    return (t) => {\n      px.set(src);\n      const u = (t / LOOP) * Math.PI * 2;\n      const lo = 55 + 40 * Math.sin(u), hi = 235 + 20 * Math.sin(u * 2 + 1);\n      for (let y = 0; y < H; y++) {\n        // Only rows inside the drifting glitch bands get sorted; the rest stay clean for contrast.\n        const band = Math.sin(y * 0.19 + u * 2) * Math.cos(y * 0.061 - u);\n        if (band < 0.12) continue;\n        const reach = clamp01(0.3 + band);\n        let x = 0;\n        while (x < W) {\n          while (x < W && !(L[y * W + x] >= lo && L[y * W + x] <= hi)) x++;\n          const x0 = x;\n          while (x < W && L[y * W + x] >= lo && L[y * W + x] <= hi) x++;\n          const span = x - x0, n = Math.max(1, Math.floor(span * reach));\n          if (span < 3) continue;\n          for (let k = 0; k < n; k++) { idx[k] = src[y * W + x0 + k]; keys[k] = L[y * W + x0 + k]; }\n          // Insertion sort, darkest first: light streams off to the right.\n          for (let a = 1; a < n; a++) {\n            const kv = keys[a], iv = idx[a];\n            let j = a - 1;\n            while (j >= 0 && keys[j] > kv) { keys[j + 1] = keys[j]; idx[j + 1] = idx[j]; j--; }\n            keys[j + 1] = kv; idx[j + 1] = iv;\n          }\n          // Stretch the sorted run across the whole span: the smear.\n          for (let k = 0; k < span; k++) px[y * W + x0 + k] = idx[Math.min(n - 1, Math.floor((k / span) * n))];\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/fx/plasma.js": "// Classic demoscene plasma: four interfering sine fields, palette-cycled.\nexport default {\n  title: 'Plasma',\n  gallery: 'fx',\n  category: 'Fields',\n  kind: 'scene',\n  tags: ['plasma', 'demoscene', 'sine', 'palette'],\n  order: 10,\n  description: 'Four interfering sine fields cycling through an Oklab palette — the demoscene classic.',\n  canvas: 'field',\n  palette: 'prism',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette();\n    const speed = ctx.param('speed', 1);\n    const { width: W, height: H, px, aspect } = cv;\n    const k = 16 / Math.max(W, H * aspect);\n    return (t) => {\n      const tt = t * speed;\n      const cx = Math.sin(tt * 0.3) * W * 0.3, cy = Math.cos(tt * 0.23) * H * 0.3;\n      for (let y = 0, i = 0; y < H; y++) {\n        const fy = y * aspect * k;\n        for (let x = 0; x < W; x++, i++) {\n          const fx = x * k;\n          const dx = (x - W / 2 - cx) * k, dy = (y - H / 2 - cy) * aspect * k;\n          const v = Math.sin(fx + tt) + Math.sin(fy * 0.8 + tt * 1.3)\n            + Math.sin((fx + fy) * 0.6 + tt * 0.7) + Math.sin(Math.sqrt(dx * dx + dy * dy) * 1.6 - tt * 1.9);\n          px[i] = pal(v * 0.125 + 0.5 + tt * 0.05);\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/fx/ripple.js": "// Rain on a pond: a damped 2D wave equation (two height buffers) disturbed by\n// random drops. Each pixel refracts a pebbled riverbed through the surface\n// slope and adds a specular glint, so ripples read as real water.\nexport default {\n  title: 'Ripple Pond',\n  gallery: 'fx',\n  category: 'Fire & Water',\n  kind: 'scene',\n  tags: ['water', 'ripple', 'rain', 'wave equation'],\n  order: 70,\n  description: 'Rain on water — damped wave-equation ripples refracting a pebbled bed, with specular glints.',\n  canvas: 'field',\n  palette: 'ocean',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette();\n    const rand = ctx.rng;\n    const { fbm } = ctx.noise;\n    const { width: W, height: H, px, aspect } = cv;\n    let cur = new Float32Array(W * H), prev = new Float32Array(W * H);\n    const rate = ctx.param('rain', 1);\n    const damp = 0.986;\n    // Riverbed texture: water-tinted pebbles from two noise octaves.\n    const bed = new Int32Array(W * H);\n    for (let y = 0, i = 0; y < H; y++)\n      for (let x = 0; x < W; x++, i++) {\n        const u = x / 14, v = (y * aspect) / 14;\n        const n = fbm(u, v, 0, 3) * 0.5 + 0.5;\n        const cell = Math.abs(Math.sin(u * 3.1 + Math.sin(v * 2.3) * 1.7) * Math.cos(v * 2.9 + Math.sin(u * 1.9)));\n        bed[i] = pal(0.22 + n * 0.16 + cell * 0.07);\n      }\n    let acc = 0, rain = 1;\n\n    const drop = (x, y, r, s) => {\n      for (let dy = -r; dy <= r; dy++)\n        for (let dx = -r; dx <= r; dx++) {\n          const xx = x + dx, yy = y + Math.round(dy / aspect);\n          if (xx < 1 || yy < 1 || xx >= W - 1 || yy >= H - 1) continue;\n          const d = Math.hypot(dx, dy) / (r + 0.5);\n          if (d < 1) cur[yy * W + xx] -= s * (Math.cos(d * Math.PI) * 0.5 + 0.5);\n        }\n    };\n\n    const step = () => {\n      for (let y = 1; y < H - 1; y++) {\n        const row = y * W;\n        for (let x = 1; x < W - 1; x++) {\n          const i = row + x;\n          prev[i] = ((cur[i - 1] + cur[i + 1] + cur[i - W] + cur[i + W]) * 0.5 - prev[i]) * damp;\n        }\n      }\n      const tmp = cur; cur = prev; prev = tmp;\n    };\n\n    return (t, dt) => {\n      rain += dt * rate * (1.5 + (W * H) / 3000);\n      while (rain >= 1) {\n        drop(1 + Math.floor(rand() * (W - 2)), 1 + Math.floor(rand() * (H - 2)), 1 + Math.floor(rand() * 2.5), 90 + rand() * 120);\n        rain -= 1;\n      }\n      acc += dt * 40;\n      let n = 0;\n      while (acc >= 1 && n < 3) { step(); acc -= 1; n++; }\n      if (acc > 3) acc = 0;\n\n      for (let y = 0, i = 0; y < H; y++) {\n        for (let x = 0; x < W; x++, i++) {\n          const l = x > 0 ? cur[i - 1] : cur[i], r = x < W - 1 ? cur[i + 1] : cur[i];\n          const u = y > 0 ? cur[i - W] : cur[i], d = y < H - 1 ? cur[i + W] : cur[i];\n          const gx = r - l, gy = d - u;\n          let sx = x + Math.round(gx * 0.1), sy = y + Math.round(gy * 0.1 / aspect);\n          sx = sx < 0 ? 0 : sx >= W ? W - 1 : sx;\n          sy = sy < 0 ? 0 : sy >= H ? H - 1 : sy;\n          const c = bed[sy * W + sx];\n          // Light from the upper-left: brighten slopes facing it, plus a tight glint.\n          const shade = 1 + (-gx - gy) * 0.011;\n          const spec = Math.max(0, (-gx - gy) * 0.02 - 0.3);\n          const g2 = spec * spec * 255;\n          const rr = ((c >> 16) & 255) * shade + g2, gg = ((c >> 8) & 255) * shade + g2, bb = (c & 255) * shade + g2;\n          px[i] = ((rr < 0 ? 0 : rr > 255 ? 255 : rr) << 16) | ((gg < 0 ? 0 : gg > 255 ? 255 : gg) << 8) | (bb < 0 ? 0 : bb > 255 ? 255 : bb | 0);\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/fx/smoke.js": "// Incense smoke: a thin laminar ribbon leaves the glowing tip, wavers, then\n// breaks into curling turbulence as it climbs — the column is advected through\n// a noise flow that strengthens with height, widening and thinning to nothing.\nimport { lerpc, clamp01 } from '../retro/_kit.js';\n\nexport default {\n  title: 'Incense Smoke',\n  gallery: 'fx',\n  category: 'Fire & Water',\n  kind: 'scene',\n  tags: ['smoke', 'incense', 'turbulence', 'wisps', 'calm', 'ambient'],\n  order: 90,\n  description: 'Incense smoke: a laminar ribbon leaves the glowing tip, wavers and breaks into curling turbulence as it rises.',\n  size: { w: 48, h: 24 },\n  minSize: { w: 12, h: 8 },\n  loop: 24,\n  canvas: 'field',\n  palette: 'ice',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette();\n    const { noise3 } = ctx.noise;\n    const { theme } = ctx;\n    const { width: W, height: H, px, aspect } = cv;\n    const baseY = H * 0.86, tipX = W / 2;\n    const smokeC = lerpc(pal(0.75), theme.ink, 0.3);\n    const S = Math.min(W, H * aspect);\n    const strands = [{ a: 1, b: 0, k: 1, ph: 0 }, { a: -0.8, b: 0.15, k: 0.7, ph: 1.4 }, { a: 0.6, b: -0.22, k: 0.5, ph: 2.9 }];\n    const T = 8, D = 1.6;                                           // flow cycle (s) and rise per cycle (noise units)\n    const flow = (x, yy, off) => noise3(x * 0.05, yy + off, 0.5) + noise3(x * 0.11, yy * 2.1 + off * 2.1 + 3.3, 2.5) * 0.5;\n    return (t) => {\n      const u = (t / 24) * Math.PI * 2;\n      const f = (t % T) / T;\n      for (let y = 0, i = 0; y < H; y++) {\n        const hgt = clamp01((baseY - y) / baseY);                  // 0 at the tip, 1 at the top\n        const turb = Math.pow(hgt, 1.6) * 2.6;\n        const width = (0.45 + hgt * 3.4) * (S / 48);\n        const reach = hgt * S * 0.12 + turb * S * 0.26 + width * 3;\n        const x0 = Math.max(0, Math.floor(tipX - reach)), x1 = Math.min(W, Math.ceil(tipX + reach));\n        px.fill(theme.bg, y * W, (y + 1) * W);\n        if (y > baseY) continue;\n        const yy = y * aspect * 0.06;\n        for (let x = x0; x < x1; x++) {\n          i = y * W + x;\n          let d = 0;\n          // Two copies of the flow, scrolled upward and cross-faded, so the smoke climbs and still loops.\n          const n0 = flow(x, yy, f * D) * (1 - f) + flow(x, yy, (f - 1) * D) * f;\n          for (const s of strands) {\n            const n = n0 * s.a + s.b * hgt;\n            const centre = tipX + Math.sin(hgt * 6 - u * 3 + s.ph) * hgt * S * 0.1 + n * turb * S * 0.16;\n            const q = (x + 0.5 - centre) / width;\n            d += Math.exp(-q * q) * s.k * (1 - hgt * 0.8) * (s.a === 1 ? 1 : clamp01(hgt * 4));\n          }\n          if (d > 0.02) px[i] = lerpc(theme.bg, smokeC, Math.min(0.92, d * 1.1));\n        }\n      }\n      // The stick, its holder, and the glowing tip.\n      for (let y = Math.floor(baseY); y < H; y++) cv.set(tipX, y, 0x6a4a30);\n      for (let x = Math.floor(tipX - S * 0.18); x <= tipX + S * 0.18; x++) cv.set(x, H - 1, lerpc(theme.panel, 0x8a6a40, 0.5));\n      const glow = 0.7 + 0.3 * Math.sin(t * 5) * Math.sin(t * 3.1);\n      cv.set(tipX, baseY, lerpc(0xff5a1a, 0xffd080, glow));\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/fx/tunnel.js": "// Demoscene tunnel: an angle/depth lookup table over a 2× oversized window, so\n// the camera can wobble by sliding the viewport. Checkered walls stream toward\n// the viewer through a cycling palette; neon ribs are anti-aliased to a constant\n// pixel width (their world-space width is derived per pixel from the LUT).\nexport default {\n  title: 'Tunnel',\n  gallery: 'fx',\n  category: 'Fields',\n  kind: 'scene',\n  tags: ['tunnel', 'demoscene', 'texture', 'lut'],\n  order: 30,\n  description: 'Textured demoscene tunnel — precomputed angle/depth LUT, neon ribs, palette cycling and camera wobble.',\n  canvas: 'field',\n  palette: 'prism',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette();\n    const speed = ctx.param('speed', 1);\n    const segs = 8;\n    const { width: W, height: H, px, aspect } = cv;\n    const LW = W * 2, LH = H * 2;\n    const ang = new Float32Array(LW * LH), dep = new Float32Array(LW * LH);\n    const fog = new Float32Array(LW * LH), wu = new Float32Array(LW * LH), wv = new Float32Array(LW * LH);\n    const R = Math.min(W, H * aspect) * 0.5;\n    const lineW = Math.max(0.7, R / 40); // rib half-width in pixels\n    for (let y = 0, i = 0; y < LH; y++) {\n      const dy = (y - LH / 2 + 0.5) * aspect;\n      for (let x = 0; x < LW; x++, i++) {\n        const dx = x - LW / 2 + 0.5;\n        const r = Math.sqrt(dx * dx + dy * dy) + 0.0001;\n        ang[i] = Math.atan2(dy, dx) / (Math.PI * 2) + 0.5;\n        dep[i] = (R * 0.8) / r;\n        // Pixels per texture unit: depth changes as R·0.8/r², angle as 2πr/segs.\n        wv[i] = lineW / Math.max(1e-4, (r * r) / (R * 0.8));\n        wu[i] = lineW / ((2 * Math.PI * r) / segs);\n        const f = Math.min(1, r / (R * 1.1));\n        fog[i] = f * f * (3 - 2 * f);\n      }\n    }\n    const bg = ctx.theme.bg;\n    const br = (bg >> 16) & 255, bgc = (bg >> 8) & 255, bb = bg & 255;\n\n    return (t) => {\n      const tt = t * speed;\n      const ox = Math.floor(W / 2 + Math.sin(tt * 0.61) * W * 0.16);\n      const oy = Math.floor(H / 2 + Math.sin(tt * 0.47 + 1) * H * 0.14);\n      const twist = tt * 0.06, move = tt * 1.6;\n      for (let y = 0, i = 0; y < H; y++) {\n        let j = (y + oy) * LW + ox;\n        for (let x = 0; x < W; x++, i++, j++) {\n          const d = dep[j];\n          const u = ang[j] * segs + twist + d * 0.12;\n          const v = d + move;\n          const fu = u - Math.floor(u), fv = v - Math.floor(v);\n          const check = ((Math.floor(u * 2) + Math.floor(v * 2)) & 1) ? 0.16 : 0.34;\n          const eu = Math.min(fu, 1 - fu) / wu[j], ev = Math.min(fv, 1 - fv) / wv[j];\n          const rib = Math.max(0, 1 - Math.min(eu, ev));\n          const c = pal(v * 0.11 - tt * 0.04);\n          const f = fog[j];\n          const k = f * (check + rib * 0.95);\n          const hot = rib * rib * f * 120; // white-hot core on the ribs\n          const r = ((c >> 16) & 255) * k + br * (1 - f) + hot;\n          const g = ((c >> 8) & 255) * k + bgc * (1 - f) + hot;\n          const b = (c & 255) * k + bb * (1 - f) + hot;\n          px[i] = ((r > 255 ? 255 : r) << 16) | ((g > 255 ? 255 : g) << 8) | (b > 255 ? 255 : b | 0);\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/fx/vhs-glitch.js": "// A worn VHS tape of color bars: per-line jitter, chroma bleeding off the\n// luma, a tracking band rolling down the frame, head-switching noise along the\n// bottom, dropout specks, and the deck's on-screen display over it all.\nimport { lerpc, clamp01, hash2 } from '../retro/_kit.js';\n\nconst LOOP = 12;\nconst BARS = [0xc0c0c0, 0xc0c000, 0x00c0c0, 0x00c000, 0xc000c0, 0xc00000, 0x0000c0];\nconst REV = [0x0000c0, 0x131313, 0xc000c0, 0x131313, 0x00c0c0, 0x131313, 0xc0c0c0];\nconst PLUGE = [[0.25, 0x00214c], [0.25, 0xffffff], [0.25, 0x32006a], [0.25, 0x131313]];\n\nexport default {\n  title: 'VHS Tracking',\n  gallery: 'fx',\n  category: 'Glitch & Noise',\n  kind: 'scene',\n  tags: ['vhs', 'glitch', 'tracking', 'analog', 'color bars', 'vcr', 'retro'],\n  order: 30,\n  description: 'Worn VHS color bars: line jitter, chroma bleed, a rolling tracking band, head-switch noise and the deck OSD.',\n  size: { w: 80, h: 24 },\n  minSize: { w: 24, h: 8 },\n  loop: LOOP,\n  canvas: 'field',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const b = ctx.buffer;\n    const { width: W, height: H, px } = cv;\n    const bars = (x, y) => {\n      const u = x / W, v = y / H;\n      if (v < 0.67) return BARS[Math.min(6, Math.floor(u * 7))];\n      if (v < 0.75) return REV[Math.min(6, Math.floor(u * 7))];\n      if (u < 5 / 7) { let a = 0; for (const [w, c] of PLUGE) { a += w * (5 / 7); if (u < a) return c; } }\n      return 0x131313;\n    };\n    const R = new Float32Array(W), G = new Float32Array(W), B = new Float32Array(W);\n    return (t) => {\n      const lt = t % LOOP, frame = Math.floor(t * 30);\n      const band = ((lt / LOOP) * 1.6 - 0.3) * H, bandH = Math.max(2, H * 0.08);\n      const burst = lt > 7.2 && lt < 7.6;                 // a hard tracking slip once a loop\n      const roll = burst ? Math.floor((lt - 7.2) * H * 2.5) : 0;\n      for (let y = 0; y < H; y++) {\n        const sy = (y + roll) % H;\n        const inBand = Math.abs(y - band) < bandH;\n        const head = y >= H - Math.max(2, Math.round(H * 0.05));\n        let off = (hash2(y, frame, 1) - 0.5) * 1.2 + Math.sin(y * 0.3 + t * 2) * 0.6;\n        if (inBand) off += (hash2(y, frame, 2) - 0.2) * W * 0.06;\n        if (head) off += (y - (H - H * 0.05)) * W * 0.03;\n        for (let x = 0; x < W; x++) {\n          const c = bars(Math.min(W - 1, Math.max(0, Math.round(x - off))), sy);\n          R[x] = (c >> 16) & 255; G[x] = (c >> 8) & 255; B[x] = c & 255;\n        }\n        for (let x = 0; x < W; x++) {\n          // Chroma bleeds right, smeared; luma stays sharper.\n          const xr = Math.max(0, x - 2), xb = Math.min(W - 1, x + 1);\n          let r = (R[xr] * 0.6 + R[Math.max(0, xr - 1)] * 0.4), g = G[x], bl = (B[xb] * 0.6 + B[Math.max(0, x)] * 0.4);\n          let n = (hash2(x, y, frame) - 0.5) * 26;\n          if (inBand) n += hash2(x >> 1, y, frame + 7) > 0.82 ? 150 : 0;\n          if (head) n += (hash2(x, y, frame + 3) - 0.5) * 160;\n          if (hash2(x, y * 7, frame) > 0.9985) n += 220;      // dropouts\n          const k = inBand ? 0.82 : 1;\n          px[y * W + x] = (clamp01((r * k + n) / 255) * 255) << 16 | (clamp01((g * k + n) / 255) * 255) << 8 | (clamp01((bl * k + n) / 255) * 255);\n        }\n      }\n      cv.paint(b);\n      // Deck OSD, drawn straight over the picture the way VCRs did.\n      const CW = ctx.width, CH = ctx.height;\n      const osd = (x, y, s) => b.text(x, y, s, 0xf4f4f4);\n      if (Math.floor(t * 2) % 2 === 0 || lt > 2) osd(2, 1, 'PLAY ▶');\n      const secs = 754 + Math.floor(lt);\n      const tc = `SP  ${Math.floor(secs / 3600)}:${String(Math.floor(secs / 60) % 60).padStart(2, '0')}:${String(secs % 60).padStart(2, '0')}`;\n      if (CW >= 30) osd(CW - tc.length - 3, CH - 2, tc);\n      if (burst && CW >= 30) osd(Math.floor(CW / 2) - 4, Math.floor(CH / 2), 'TRACKING');\n    };\n  },\n};\n",
  "prism-term/src/facets/fx/voronoi.js": "// Living Voronoi: drifting seeds carve the plane into cells. Each cell is shaded\n// like a bevelled tile (bright at its seed, darker toward its edge) and the\n// borders — where the nearest and second-nearest seeds tie — glow.\nimport { lerpc } from '../retro/_kit.js';\n\nexport default {\n  title: 'Voronoi Cells',\n  gallery: 'fx',\n  category: 'Fields',\n  kind: 'scene',\n  tags: ['voronoi', 'worley', 'cells', 'tiles', 'stained glass', 'procedural'],\n  order: 70,\n  description: 'Drifting seeds carve living Voronoi cells — bevelled stained-glass tiles with glowing borders where neighbors meet.',\n  size: { w: 80, h: 24 },\n  minSize: { w: 20, h: 6 },\n  loop: 24,\n  canvas: 'field',\n  palette: 'prism',\n  params: { cells: { default: 1, min: 0.3, max: 3, label: 'Cell density' } },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette(undefined, { size: 512 });\n    const rand = ctx.rng;\n    const { width: W, height: H, px, aspect } = cv;\n    const VH = H * aspect;\n    const n = Math.max(6, Math.min(48, Math.round(((W * VH) / 260) * ctx.param('cells', 1) * (80 / Math.max(80, W)) ** 0.5)));\n    const seeds = Array.from({ length: n }, () => ({ x: rand(), y: rand(), ax: 0.05 + rand() * 0.07, ay: 0.05 + rand() * 0.07, fa: 1 + Math.floor(rand() * 2), fb: 1 + Math.floor(rand() * 2), ph: rand() * 6.28, hue: rand() }));\n    const sx = new Float32Array(n), sy = new Float32Array(n);\n    const S = Math.sqrt((W * VH) / n);               // typical cell size\n    // The lead between the panes is dark in daylight too: stained glass, not frosted.\n    const edge = lerpc(0xffffff, ctx.theme.ink, 0.3);\n    const lead = ctx.theme.mode === 'light' ? lerpc(ctx.theme.ink, ctx.theme.bg, 0.2) : lerpc(ctx.theme.bg, 0x000000, 0.4);\n    return (t) => {\n      const u = (t / 24) * Math.PI * 2;\n      for (let j = 0; j < n; j++) {\n        const s = seeds[j];\n        sx[j] = (s.x + Math.sin(u * s.fa + s.ph) * s.ax) * W;\n        sy[j] = (s.y + Math.cos(u * s.fb + s.ph * 1.3) * s.ay) * VH;\n      }\n      for (let y = 0, i = 0; y < H; y++) {\n        const vy = (y + 0.5) * aspect;\n        for (let x = 0; x < W; x++, i++) {\n          let d1 = 1e9, d2 = 1e9, best = 0;\n          for (let j = 0; j < n; j++) {\n            const dx = x + 0.5 - sx[j], dy = vy - sy[j], d = dx * dx + dy * dy;\n            if (d < d1) { d2 = d1; d1 = d; best = j; } else if (d < d2) d2 = d;\n          }\n          const f1 = Math.sqrt(d1), f2 = Math.sqrt(d2), gap = (f2 - f1) / S;\n          const base = pal((seeds[best].hue + t / 48) % 1);\n          let c = lerpc(base, lead, Math.min(0.75, (f1 / S) * 0.9));       // bevel: darker away from the seed\n          if (gap < 0.08) c = lead;                                               // the leading between panes\n          else if (gap < 0.2) c = lerpc(c, edge, (1 - (gap - 0.08) / 0.12) * 0.35); // light catching the pane's edge\n          px[i] = c;\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/input/_input.js": "// Shared bits for the input gallery: a scripted \"user\", a dimmed backdrop for\n// dialogs, and a prompt-line renderer.\nexport * from '../callouts/_kit.js';\nimport { mix, uni, ATTR, panel, clip, stringWidth } from '../callouts/_kit.js';\n\nconst BACK = [\n  ['INFO', 'checkout-api', 'GET /cart 200 41ms'],\n  ['INFO', 'payments-worker', 'charge ok order=88412 312ms'],\n  ['WARN', 'checkout-api', 'db pool wait 840ms (20/20 in use)'],\n  ['INFO', 'search-api', 'GET /search?q=tent 200 22ms'],\n  ['INFO', 'gateway', 'POST /checkout 201 212ms'],\n  ['ERROR', 'checkout-api', 'timeout acquiring connection after 5000ms'],\n  ['INFO', 'inventory', 'reserve sku=TNT-2P qty=1 ok'],\n  ['INFO', 'gateway', 'GET /account 200 34ms'],\n  ['WARN', 'payments-worker', 'retrying charge order=88415 attempt=2'],\n  ['INFO', 'search-api', 'reindex shard 3/8 done'],\n];\n\n/** Dimmed app content (a log view) behind modals so they read as overlays. */\nexport function backdrop(ctx, s, t = 0) {\n  const th = ctx.theme;\n  const ink = mix(th.bg, th.muted, 0.3);\n  const lvl = { INFO: mix(th.bg, th.info, 0.3), WARN: mix(th.bg, th.warn, 0.35), ERROR: mix(th.bg, th.neg, 0.4) };\n  const base = Math.floor(t / 1.5);\n  for (let y = 0; y < (s.height ?? ctx.height); y++) {\n    const [level, svc, msg] = BACK[(y + base) % BACK.length];\n    const sec = 31 + y + base;\n    const ts = `12:04:${String(sec % 60).padStart(2, '0')}`;\n    let x = s.text(0, y, ts, ink);\n    x = s.text(x + 1, y, level.padEnd(5), lvl[level]);\n    x = s.text(x + 1, y, svc.padEnd(15), ink);\n    s.text(x + 1, y, msg, ink);\n  }\n}\n\n/** Fill a dialog rectangle with the panel color (clears what's behind). */\nexport function clearRect(ctx, s, x, y, w, h, bg) {\n  s.fill(x, y, w, h, ' ', undefined, bg ?? ctx.theme.panel);\n}\n\n/** \"? Question  answer\" prompt line, in the style of inquirer/gum/clack. */\nexport function promptLine(ctx, s, x, y, question, { state = 'active', answer = '' } = {}) {\n  const th = ctx.theme, u = uni(ctx);\n  const mark = state === 'done' ? (u ? '✔' : '+') : state === 'error' ? (u ? '✖' : 'x') : '?';\n  const c = state === 'done' ? th.pos : state === 'error' ? th.neg : th.accent;\n  let cx = s.text(x, y, mark, c, undefined, ATTR.BOLD);\n  cx = s.text(cx + 1, y, question, th.ink, undefined, ATTR.BOLD);\n  if (answer) s.text(cx + 1, y, clip(ctx, answer, Math.max(0, (s.width ?? ctx.width) - cx - 1)), state === 'done' ? th.accent : th.muted);\n  return cx;\n}\n\n/** Case-insensitive subsequence match: returns matched indices or null, plus a score (contiguous + early is better). */\nexport function fuzzyMatch(q, s) {\n  q = q.toLowerCase();\n  if (!q) return { idx: [], score: 0 };\n  const idx = [];\n  let j = 0;\n  for (let i = 0; i < s.length && j < q.length; i++) if (s[i].toLowerCase() === q[j]) { idx.push(i); j++; }\n  if (j < q.length) return null;\n  let score = 0;\n  for (let k = 1; k < idx.length; k++) score += idx[k] === idx[k - 1] + 1 ? 3 : 0;\n  score -= idx[0] * 0.05 + s.length * 0.01;\n  return { idx, score };\n}\n\n/**\n * A modal dialog: a soft drop shadow over whatever is behind it, then a filled\n * panel. `enter` (0→1) slides it down into place. Returns the interior View.\n */\nexport function modal(ctx, s, x, y, w, h, opts = {}) {\n  const th = ctx.theme;\n  const { enter = 1, ...rest } = opts;\n  const yy = y - Math.round((1 - enter) * 2);\n  // Depth from light: a soft scrim behind dialogs, barely there by Day.\n  const shade = mix(th.bg, 0x000000, th.mode === 'light' ? 0.06 : 0.35);\n  for (let r = 1; r <= h; r++) {\n    for (let c = 2; c <= w + 1; c++) {\n      if (r < h && c < w) continue;\n      const cell = s.get(x + c, yy + r);\n      if (cell) s.set(x + c, yy + r, undefined, mix(shade, cell.fg < 0 ? th.muted : cell.fg, 0.35), shade);\n    }\n  }\n  return panel(ctx, s, x, yy, w, h, { bg: th.panel, ...rest });\n}\n\n/** Join picked items for a collapsed answer, ending \"+N\" when they don't all fit in `w`. */\nexport function listAnswer(items, w) {\n  for (let n = items.length; n > 0; n--) {\n    const s = items.slice(0, n).join(', ') + (n < items.length ? ` +${items.length - n}` : '');\n    if (stringWidth(s) <= w) return s;\n  }\n  return `${items.length} selected`;\n}\n",
  "prism-term/src/facets/input/autocomplete.js": "// Shell completion: a fish-style ghost suggestion from history, then a\n// zsh-style completion menu that Tab cycles through, then the command runs.\nimport { ATTR, glyphs, uni, phase, typed, blink, tint, mix, fit } from './_input.js';\n\nconst PS1 = '~/infra $ ';\nconst HISTORY = [\n  ['kubectl get deploy -n checkout', 0],\n  ['NAME           READY   UP-TO-DATE   AVAILABLE', 1],\n  ['checkout-api   2/3     3            2', 1],\n];\nconst SUGGEST = 'kubectl rollout status deploy/checkout-api';\nconst SUBS = [\n  ['history', 'view rollout history'],\n  ['pause', 'mark as paused'],\n  ['restart', 'restart a resource'],\n  ['resume', 'resume a paused resource'],\n  ['status', 'show rollout status'],\n  ['undo', 'undo a previous rollout'],\n];\n// Script (seconds into the loop).\nconst T = { type1: 0.3, tab1: 1.8, cycle: [2.4, 2.9, 3.4], type2: 3.8, accept: 5.2, enter: 5.8 };\n\nexport default {\n  id: 'input-autocomplete',\n  title: 'Shell Autocomplete',\n  category: 'Command Palettes',\n  description: 'A shell prompt with a fish-style ghost suggestion from history, a zsh-style completion menu that Tab cycles, then → accepts and the command runs.',\n  tags: ['autocomplete', 'completion', 'shell', 'zsh', 'fish'],\n  kind: 'tui',\n  size: { w: 64, h: 12 },\n  minSize: { w: 40, h: 8 },\n  loop: 8,\n  color: '16',\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 8);\n      // Line, ghost text and menu state for this moment (menu: -2 hidden, -1 open, n highlighted).\n      let line = typed('kubectl ro', p, T.type1, 7), ghost = '', menu = -2, hint = '';\n      if (line && SUGGEST.startsWith(line) && p < T.tab1) {\n        ghost = SUGGEST.slice(line.length);\n        hint = `${g.right} accept suggestion ${g.mid} tab complete`;\n      }\n      if (p >= T.tab1) {\n        line = 'kubectl rollout ';\n        menu = -1;\n        T.cycle.forEach((at, i) => { if (p >= at) menu = i; });\n        if (menu >= 0) line += SUBS[menu][0];\n        hint = menu < 0 ? `tab cycle ${g.mid} ${SUBS.length} completions` : `tab next ${g.mid} enter accept ${g.mid} esc cancel`;\n      }\n      if (p >= T.type2) {\n        menu = -2;\n        const arg = typed('deploy/ch', p, T.type2, 9);\n        line = 'kubectl rollout restart ' + arg;\n        hint = '';\n        if (arg.length >= 7) { ghost = 'deploy/checkout-api'.slice(arg.length); hint = `${g.right} accept completion`; }\n      }\n      if (p >= T.accept) { line = 'kubectl rollout restart deploy/checkout-api'; ghost = ''; hint = `enter run`; }\n      const ran = p >= T.enter;\n\n      // History above the prompt, as much as fits with the menu open.\n      const room = H - (menu > -2 ? Math.min(SUBS.length, H - 3) + 2 : 3);\n      const keep = Math.max(0, Math.min(HISTORY.length, room - 1));\n      const hist = keep ? HISTORY.slice(-keep) : [];\n      if (keep === HISTORY.length) hist[0] = [PS1 + HISTORY[0][0], 0];\n      let y = 0;\n      for (const [txt, out] of hist) b.text(0, y++, fit(txt, W, undefined, ctx), out ? th.muted : th.dim);\n      if (keep) y++;\n      const py = y;\n\n      // The command line scrolls left when it no longer fits.\n      const room2 = W - PS1.length - 1;\n      const off = Math.max(0, line.length - room2);\n      const lx = PS1.length - off;\n      const [cmd, ...rest] = line.split(' ');\n      let x = b.text(lx, py, cmd, th.pos, undefined, ATTR.BOLD);\n      if (rest.length) x = b.text(x, py, ' ' + rest.join(' '), th.ink);\n      if (ghost) b.text(x, py, fit(ghost, Math.max(0, W - x), undefined, ctx).trimEnd(), mix(th.bg, th.muted, 0.6));\n      // Block cursor over the ghost text (inverse, so it survives NO_COLOR); a bar otherwise.\n      if (!ran && blink(t, 0.9)) {\n        if (ghost) b.set(x, py, undefined, undefined, undefined, ATTR.INVERSE);\n        else b.set(x, py, u ? '▏' : '_', th.ink);\n      }\n      b.fill(0, py, PS1.length, 1, ' ');\n      let px = b.text(0, py, '~/infra', th.info, undefined, ATTR.BOLD);\n      b.text(px + 1, py, '$', th.pos, undefined, ATTR.BOLD);\n      if (off) b.text(PS1.length - 1, py, u ? '…' : '<', th.dim);\n\n      if (menu > -2) {\n        // Menu aligned under the word being completed, as far as the width allows.\n        const mw = Math.min(W, 40);\n        const mx = Math.max(0, Math.min(W - mw, PS1.length + 'kubectl rollout '.length - 1));\n        const rows = Math.min(SUBS.length, H - py - 2);\n        const first = Math.max(0, Math.min(SUBS.length - rows, menu - rows + 1));\n        for (let r = 0; r < rows; r++) {\n          const i = first + r, [name, desc] = SUBS[i];\n          const on = i === menu;\n          const bg = on ? th.accent : tint(ctx, th.panel2, 0.9);\n          b.fill(mx, py + 1 + r, mw, 1, ' ', undefined, bg);\n          b.text(mx + 1, py + 1 + r, name.padEnd(9), on ? th.bg : th.ink, bg, ATTR.BOLD);\n          b.text(mx + 10, py + 1 + r, fit(`-- ${desc}`, mw - 11, undefined, ctx), on ? th.bg : th.dim, bg);\n        }\n      } else if (ran) {\n        b.text(0, py + 1, fit('deployment.apps/checkout-api restarted', W, undefined, ctx), th.ink);\n        if (py + 2 < H) {\n          px = b.text(0, py + 2, '~/infra', th.info, undefined, ATTR.BOLD);\n          px = b.text(px + 1, py + 2, '$', th.pos, undefined, ATTR.BOLD) + 1;\n          if (blink(t, 0.9)) b.set(px, py + 2, u ? '▏' : '_', th.ink);\n        }\n      }\n      if (hint && !ran) b.text(0, H - 1, fit(hint, W, undefined, ctx), th.dim);\n    };\n  },\n};\n",
  "prism-term/src/facets/input/command-palette.js": "// A ctrl+shift+p style command palette over the app: the query filters\n// commands live, the cursor moves, Enter runs one and a status line reports it.\nimport { ATTR, glyphs, uni, phase, typed, blink, tint, mix, fit, spin, stringWidth, backdrop, panel, fuzzyMatch } from './_input.js';\n\nconst COMMANDS = [\n  ['Service', 'Restart checkout-api', 'ctrl+r'],\n  ['Logs', 'Tail logs: checkout-api', 'ctrl+l'],\n  ['Service', 'Restart payments-worker', ''],\n  ['Deploy', 'Rollback checkout-api to v2.14.0', ''],\n  ['Incident', 'Open runbook: DB pool exhaustion', ''],\n  ['Deploy', 'Promote canary to 100%', ''],\n  ['Service', 'Scale search-api', ''],\n  ['View', 'Toggle compact log view', 'ctrl+k c'],\n  ['Incident', 'Page on-call (sev2)', ''],\n  ['Traces', 'Search traces by request id', 'ctrl+t'],\n  ['Settings', 'Change theme', 'ctrl+k t'],\n  ['Service', 'Restart search-api', ''],\n];\nconst QUERY = 'restart';\n// Pointer moves [seconds into the loop, rows] once the query has filtered the list.\nconst MOVES = [[2.3, 1], [2.8, 1], [3.3, -1]];\nconst ENTER = 3.9, CLOSE = 4.6, DONE = 5.6;\n\nexport default {\n  id: 'input-command-palette',\n  title: 'Command Palette',\n  category: 'Command Palettes',\n  description: 'A ctrl+shift+p palette over a dimmed app: the query fuzzy-filters commands with highlighted matches and key hints, Enter runs one and the status line confirms.',\n  tags: ['command palette', 'fuzzy', 'launcher', 'overlay', 'keyboard'],\n  kind: 'tui',\n  size: { w: 66, h: 16 },\n  minSize: { w: 40, h: 10 },\n  loop: 8,\n  color: '256',\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 8);\n      backdrop(ctx, b, t);\n      // Status line along the bottom of the \"app\".\n      b.fill(0, H - 1, W, 1, ' ', undefined, th.panel2);\n      if (p >= ENTER && p < DONE) {\n        b.text(1, H - 1, fit(`${spin(ctx, t)} Restarting checkout-api ${g.mid} ${Math.min(2, Math.floor((p - ENTER) / 0.5))}/3 pods ready`, W - 2, undefined, ctx), th.warn, th.panel2);\n      } else if (p >= DONE) {\n        b.text(1, H - 1, fit(`${g.check} Restarted checkout-api ${g.mid} 3/3 pods ready in 9.4s`, W - 2, undefined, ctx), th.pos, th.panel2, ATTR.BOLD);\n      } else b.text(1, H - 1, fit(`ctrl+shift+p commands ${g.mid} prod-us-east-2`, W - 2, undefined, ctx), th.muted, th.panel2);\n      if (p >= CLOSE) return;\n\n      const query = typed(QUERY, p, 0.7, 9);\n      const hits = COMMANDS.map((c, i) => ({ c, i, m: fuzzyMatch(query, c[1]) }))\n        .filter((h) => h.m)\n        .sort((a, z) => (query ? z.m.score - a.m.score : 0) || a.i - z.i);\n      let cur = 0;\n      for (const [at, d] of MOVES) if (p >= at) cur = Math.max(0, Math.min(hits.length - 1, cur + d));\n\n      const pw = Math.min(W - 4, 58), px = Math.floor((W - pw) / 2);\n      // The palette shrinks to its results, like the real thing.\n      const ph = Math.min(H - 2, 4 + Math.max(1, Math.min(hits.length, 9)));\n      const inner = panel(ctx, b, px, 0, pw, ph, { color: th.accent, bg: th.panel });\n      const iw = inner.width, ih = inner.height;\n      // Query row.\n      let x = inner.text(1, 0, '>', th.accent, th.panel, ATTR.BOLD) + 1;\n      if (!query) inner.text(x + 1, 0, fit('Type a command', iw - x - 2, undefined, ctx), mix(th.panel, th.muted, 0.7), th.panel, ATTR.ITALIC);\n      x = inner.text(x, 0, query, th.ink, th.panel, ATTR.BOLD);\n      if (blink(t, 0.8)) inner.set(x, 0, u ? '▏' : '_', th.ink, th.panel);\n      const count = `${hits.length} of ${COMMANDS.length}`;\n      if (iw > 24) inner.text(iw - count.length - 1, 0, count, th.dim, th.panel);\n      inner.fill(0, 1, iw, 1, u ? '─' : '-', th.line, th.panel);\n\n      const rows = Math.min(hits.length, ih - 2);\n      const first = Math.max(0, Math.min(hits.length - rows, cur - rows + 1));\n      for (let r = 0; r < rows; r++) {\n        const { c: [cat, label, key], m } = hits[first + r];\n        const y = 2 + r, on = first + r === cur;\n        const pressed = on && p >= ENTER;\n        const bg = on ? (pressed ? th.accent : tint(ctx, th.accent, 0.22)) : th.panel;\n        inner.fill(0, y, iw, 1, ' ', undefined, bg);\n        if (on) inner.set(0, y, u ? '▌' : '>', pressed ? th.bg : th.accent, bg, ATTR.BOLD);\n        let cx = inner.text(2, y, `${cat}: `, pressed ? th.bg : th.dim, bg);\n        const keyW = key && iw > 34 ? stringWidth(key) + 2 : 0;\n        const shown = fit(label, Math.max(0, iw - cx - keyW - 1), undefined, ctx).trimEnd();\n        [...shown].forEach((ch, i) => {\n          const hit = m.idx.includes(i);\n          inner.text(cx + i, y, ch, pressed ? th.bg : hit ? th.accent : on ? th.ink : th.muted, bg, hit || on ? ATTR.BOLD : 0);\n        });\n        if (keyW) inner.text(iw - keyW, y, ` ${key} `, pressed ? th.bg : th.muted, pressed ? th.accent : th.panel2);\n      }\n      if (!hits.length) inner.text(2, 2, 'No matching commands', th.dim, th.panel, ATTR.ITALIC);\n    };\n  },\n};\n",
  "prism-term/src/facets/input/confirm-dialog.js": "// A destructive-action confirm dialog: Cancel is the default, Delete stays\n// disabled until the resource name is typed, then it runs and closes.\nimport { ATTR, glyphs, uni, phase, typed, blink, ease, mix, fit, spin, button, backdrop, modal } from './_input.js';\n\nconst NAME = 'checkout';\nconst T = { open: 0.1, type: 0.9, tab: 2.6, enter: 3.2, close: 4.6 };\n\nexport default {\n  id: 'input-confirm-dialog',\n  title: 'Confirm Dialog',\n  category: 'Dialogs & Prompts',\n  description: 'A destructive-action modal with a shadow: Cancel is focused by default and Delete unlocks only after the namespace is typed, then it runs and closes.',\n  tags: ['dialog', 'modal', 'confirm', 'destructive', 'buttons'],\n  kind: 'tui',\n  size: { w: 64, h: 15 },\n  minSize: { w: 42, h: 10 },\n  loop: 7,\n  color: '256',\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 7);\n      backdrop(ctx, b, t);\n      b.fill(0, H - 1, W, 1, ' ', undefined, th.panel2);\n      if (p >= T.close) {\n        b.text(1, H - 1, fit(`${g.check} Deleted 3 pods in ${NAME} ${g.mid} rescheduling`, W - 2, undefined, ctx), th.pos, th.panel2, ATTR.BOLD);\n        return;\n      }\n      b.text(1, H - 1, fit(`k9s ${g.mid} ${NAME} ${g.mid} pods`, W - 2, undefined, ctx), th.muted, th.panel2);\n\n      const mw = Math.min(W - 4, 50), mh = Math.min(H - 2, 9);\n      const mx = Math.floor((W - mw) / 2), my = Math.max(0, Math.floor((H - 1 - mh) / 2));\n      const inner = modal(ctx, b, mx, my, mw, mh, {\n        enter: ease(p, T.open, T.open + 0.35), color: th.neg,\n        title: `${u ? '▲' : '!'} Delete pods`, titleColor: th.neg,\n      });\n      const iw = inner.width, ih = inner.height;\n      const typedName = typed(NAME, p, T.type, 7);\n      const ok = typedName === NAME;\n      const running = p >= T.enter;\n      let y = 0;\n      inner.text(1, y++, fit('Terminate 3 pods in checkout?', iw - 2, undefined, ctx), th.ink, th.panel, ATTR.BOLD);\n      if (ih >= 7) inner.text(1, y++, fit(\"They'll be rescheduled; local logs are lost.\", iw - 2, undefined, ctx), th.muted, th.panel);\n      if (ih >= 7) y++;\n      const tag = `Type ${NAME} to confirm`;\n      inner.text(1, y, fit(tag, iw - 2, undefined, ctx), th.muted, th.panel);\n      inner.text(6, y, NAME, th.ink, th.panel, ATTR.BOLD);\n      y++;\n      // Confirmation field.\n      const fw = Math.min(iw - 2, 30);\n      const fbg = mix(th.panel, th.bg, 0.6);\n      inner.fill(1, y, fw, 1, ' ', undefined, fbg);\n      let fx = inner.text(2, y, typedName, ok ? th.pos : th.ink, fbg);\n      if (!typedName && p > T.open + 0.3) inner.text(2, y, NAME, mix(fbg, th.muted, 0.45), fbg, ATTR.ITALIC);\n      if (p < T.tab && blink(t, 0.9)) inner.set(fx, y, undefined, undefined, undefined, ATTR.INVERSE);\n      if (ok) inner.text(fw - 1, y, g.check, th.pos, fbg, ATTR.BOLD);\n      y += 2;\n\n      // Buttons, right-aligned. Cancel holds focus until Tab.\n      const by = Math.min(ih - 1, y);\n      if (running) {\n        const n = Math.min(3, 1 + Math.floor((p - T.enter) / 0.45));\n        inner.text(1, by, fit(`${spin(ctx, t)} Deleting ${n}/3 ${g.ellipsis}`, iw - 2, undefined, ctx), th.warn, th.panel, ATTR.BOLD);\n        return;\n      }\n      const focusDelete = p >= T.tab;\n      const del = 'Delete 3 pods', cancel = 'Cancel';\n      const dw = u ? del.length + 2 : del.length + 4, cw = u ? cancel.length + 2 : cancel.length + 4;\n      const dx = iw - dw - 1, cx = dx - cw - 2;\n      button(ctx, inner, cx, by, cancel, { focused: !focusDelete, color: th.muted });\n      if (ok) button(ctx, inner, dx, by, del, { focused: focusDelete, color: th.neg, pressed: p > T.enter - 0.15 });\n      else inner.text(dx, by, u ? ` ${del} ` : `[ ${del} ]`, mix(th.panel, th.muted, 0.5), u ? mix(th.panel, th.panel2, 0.5) : undefined, ATTR.DIM);\n    };\n  },\n};\n",
  "prism-term/src/facets/input/date-picker.js": "// A calendar date-range picker: arrow keys walk the grid, Enter sets the start,\n// the range previews as the cursor moves, Enter again sets the end.\nimport { ATTR, glyphs, uni, phase, tint, fit, panel } from './_input.js';\n\nconst DOW = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];\nconst MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];\nconst TODAY = 13;\n// Script: [time, action]. Days are in October 2026.\nconst SCRIPT = [[0.6, 7], [0.9, -1], [1.3, 'start'], [1.7, 1], [2.0, 1], [2.3, 1], [2.6, 1], [3.0, 'end'], [4.6, 'next'], [5.5, 'prev']];\n\nfunction month(y, m) {\n  const first = (new Date(Date.UTC(y, m, 1)).getUTCDay() + 6) % 7;\n  const days = new Date(Date.UTC(y, m + 1, 0)).getUTCDate();\n  return { first, days };\n}\nconst DAY3 = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];\nconst dayName = (d) => DAY3[(month(2026, 9).first + d - 1) % 7];\n\nexport default {\n  id: 'input-date-picker',\n  title: 'Date Range Picker',\n  category: 'Dialogs & Prompts',\n  description: 'A calendar range picker for a change freeze: arrows walk the month grid, Enter sets the start, the range previews as the cursor moves, Enter sets the end.',\n  tags: ['date picker', 'calendar', 'range', 'schedule', 'form'],\n  kind: 'tui',\n  size: { w: 52, h: 11 },\n  minSize: { w: 27, h: 11 },\n  loop: 7,\n  color: '256',\n  order: 40,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 7);\n      let cur = TODAY, start = 0, end = 0, mon = 9;\n      for (const [at, a] of SCRIPT) {\n        if (p < at) break;\n        if (typeof a === 'number') cur += a;\n        else if (a === 'start') start = cur;\n        else if (a === 'end') end = cur;\n        else if (a === 'next') mon = 10;\n        else if (a === 'prev') mon = 9;\n      }\n      const wide = W >= 46;\n      const cw = wide ? 25 : W;\n      const inner = panel(ctx, b, 0, 0, cw, Math.min(H, 11), { title: 'Change freeze', color: th.accent });\n      // Month header with prev/next arrows.\n      const head = `${MONTHS[mon]} 2026`;\n      const hx = Math.floor((inner.width - head.length) / 2);\n      inner.text(0, 0, u ? '‹' : '<', th.muted);\n      inner.text(inner.width - 1, 0, u ? '›' : '>', th.muted);\n      inner.text(hx, 0, head, th.ink, undefined, ATTR.BOLD);\n      const gx = Math.max(0, Math.floor((inner.width - 20) / 2));\n      DOW.forEach((d, i) => inner.text(gx + i * 3, 1, d, i >= 5 ? th.dim : th.muted));\n      const { first, days } = month(2026, mon);\n      const lo = start && (end || (mon === 9 ? cur : start)), a = Math.min(start, lo), z = Math.max(start, lo);\n      for (let d = 1; d <= days; d++) {\n        const k = first + d - 1;\n        const x = gx + (k % 7) * 3 - 1, y = 2 + Math.floor(k / 7);\n        if (y >= inner.height - 1) continue;\n        const inOct = mon === 9;\n        const inRange = inOct && start && d >= a && d <= z;\n        const isEnd = inOct && (d === start || d === end);\n        const isCur = inOct && d === cur && !end;\n        let fg = k % 7 >= 5 ? th.dim : th.ink, bg, attr = 0;\n        if (inRange) { bg = tint(ctx, th.accent, end ? 0.3 : 0.18); fg = th.ink; }\n        if (isEnd) { bg = th.accent; fg = th.bg; attr = ATTR.BOLD; }\n        if (isCur) attr |= ATTR.INVERSE | ATTR.BOLD;\n        const cell = String(d).padStart(2);\n        // Range cells are joined by tinting the gap column too.\n        if (inRange && d < z) inner.set(x + 3, y, ' ', undefined, bg);\n        inner.text(x + 1, y, cell, fg, bg, attr);\n        if (inOct && d === TODAY && !isEnd && !isCur) inner.text(x + 1, y, cell, th.warn, bg, ATTR.UNDERLINE | ATTR.BOLD);\n      }\n      // Summary: to the right when wide, else on the last interior row.\n      const fmt = (d) => (d ? `${dayName(d)} ${d} Oct` : `${g.ellipsis}`);\n      const sum = start && end ? `${g.check} ${start}${u ? '–' : '-'}${end} Oct ${g.mid} ${end - start + 1} days` : start ? `From ${fmt(start)} ${g.right} ${g.ellipsis}` : 'Pick the first day';\n      if (wide) {\n        const sx = cw + 2, sw = W - sx;\n        let y = 1;\n        b.text(sx, y++, 'From', th.muted);\n        b.text(sx, y++, fit(start ? `${fmt(start)} 2026` : `${g.mid}`, sw, undefined, ctx), start ? th.ink : th.dim, undefined, ATTR.BOLD);\n        y++;\n        b.text(sx, y++, 'To', th.muted);\n        const toD = end || (start && mon === 9 && cur > start ? cur : 0);\n        b.text(sx, y++, fit(toD ? `${fmt(toD)} 2026` : `${g.mid}`, sw, undefined, ctx), end ? th.ink : th.dim, undefined, end ? ATTR.BOLD : ATTR.ITALIC);\n        y++;\n        if (start && toD) b.text(sx, y++, fit(`${z - a + 1} days ${g.mid} no deploys`, sw, undefined, ctx), end ? th.pos : th.dim);\n        b.text(sx, Math.min(H, 11) - 1, fit(`${g.left}${g.up}${g.down}${g.right} move ${g.mid} enter pick`, sw, undefined, ctx), th.dim);\n        if (mon !== 9) b.text(sx, Math.min(H, 11) - 2, fit('pgup back to October', sw, undefined, ctx), th.dim, undefined, ATTR.ITALIC);\n      } else {\n        inner.text(0, inner.height - 1, fit(sum, inner.width, undefined, ctx), start && end ? th.pos : th.muted);\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/input/fuzzy-finder.js": "// An fzf-style fuzzy finder: the query types, the list filters live with\n// matched characters highlighted and the match count updating.\nimport { ATTR, glyphs, phase, typed, blink, uni, tint, fit, fuzzyMatch as match } from './_input.js';\n\nconst FILES = [\n  'src/facets/input/fuzzy-finder.js', 'src/facets/charts/sparkline.js', 'src/screen.js', 'src/canvas.js',\n  'src/facets/fx/plasma.js', 'src/widgets/table.js', 'src/text/banner.js', 'src/facets/ops/htop.js',\n  'docs/authoring-facets.md', 'README.md', 'src/facets/progress/odometer.js', 'src/player.js',\n  'src/facets/ai/thinking.js', 'scripts/shoot.mjs', 'src/facets/diagrams/git-graph.js', 'test/facets.test.js',\n  'src/palettes.js', 'src/facets/callouts/table-grid.js', 'src/facets/sim/life.js', 'package.json',\n];\n\nexport default {\n  id: 'input-fuzzy-finder',\n  title: 'Fuzzy Finder',\n  category: 'Selects & Lists',\n  description: 'An fzf-style finder: the query types itself, results re-rank live with matched characters highlighted and the match counter updating, best match nearest the prompt.',\n  tags: ['fzf', 'fuzzy', 'finder', 'search', 'filter'],\n  kind: 'tui',\n  size: { w: 60, h: 14 },\n  minSize: { w: 34, h: 6 },\n  loop: 7,\n  color: '16',\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx);\n      const p = phase(t, 7);\n      const query = typed('facetsjs', p, 0.4, 3.2).slice(0, p > 5.5 ? 6 : 99);\n      const hits = FILES.map((f) => ({ f, m: match(query, f) })).filter((x) => x.m).sort((a, c) => c.m.score - a.m.score);\n      const listH = H - 2;\n      const cur = Math.min(hits.length - 1, Math.max(0, Math.floor(Math.max(0, p - 3.2) / 0.5) % 3));\n      // fzf draws bottom-up: best match just above the prompt.\n      for (let r = 0; r < Math.min(listH, hits.length); r++) {\n        const y = listH - 1 - r;\n        const { f, m } = hits[r];\n        const on = r === cur;\n        if (on) b.fill(0, y, W, 1, ' ', undefined, tint(ctx, th.accent, 0.14));\n        b.text(0, y, on ? (uni(ctx) ? '▌' : '>') : ' ', th.accent, undefined, ATTR.BOLD);\n        const shown = fit(f, W - 3, undefined, ctx);\n        [...shown].forEach((ch, i) => b.text(2 + i, y, ch, m.idx.includes(i) ? th.pos : on ? th.ink : th.muted, undefined, m.idx.includes(i) || on ? ATTR.BOLD : 0));\n      }\n      const info = `  ${hits.length}/${FILES.length}`;\n      b.text(0, H - 2, info, th.warn);\n      b.fill(info.length + 1, H - 2, Math.max(0, W - info.length - 1), 1, uni(ctx) ? '─' : '-', th.line);\n      let x = b.text(0, H - 1, `${g.pointer} `, th.accent, undefined, ATTR.BOLD);\n      x = b.text(x, H - 1, query, th.ink);\n      if (blink(t, 0.8)) b.set(x, H - 1, uni(ctx) ? '▏' : '_', th.ink);\n    };\n  },\n};\n",
  "prism-term/src/facets/input/menu-bar.js": "// A classic TUI menu bar (Turbo Vision / mc lineage): F10 opens a dropdown,\n// the highlight walks items, a submenu flies out, Esc closes it again.\nimport { ATTR, glyphs, uni, phase, stepAt, mix, fit, backdrop, modal } from './_input.js';\n\nconst MENUS = [\n  ['File', []],\n  ['View', [['Logs', 'L'], ['Traces', 'T'], ['Topology', 'O']]],\n  ['Service', [['Restart...', 'ctrl+r'], ['Scale...', ''], ['Rollback...', ''], null, ['Logs', '>'], null, ['Delete', 'no access']]],\n  ['Incident', []],\n  ['Help', []],\n];\nconst SUB = [['Tail', 'F3'], ['Search...', '/'], ['Export...', '']];\n// [time, menuIndex, itemIndex, subIndex]; -1 closed.\nconst SCRIPT = [\n  [0.0, -1, -1, -1], [0.7, 1, 0, -1], [1.3, 2, 0, -1], [1.8, 2, 1, -1], [2.2, 2, 2, -1],\n  [2.6, 2, 4, -1], [3.2, 2, 4, 0], [3.6, 2, 4, 1], [4.6, 2, 4, -1], [5.0, -1, -1, -1],\n];\n\nexport default {\n  id: 'input-menu-bar',\n  title: 'Menu Bar',\n  category: 'Tabs & Bars',\n  description: 'A Turbo Vision-style menu bar with hotkey letters: F10 drops a shadowed menu, the highlight walks items past a disabled one, a submenu flies out, Esc closes.',\n  tags: ['menu bar', 'dropdown', 'turbo vision', 'submenu', 'hotkeys'],\n  kind: 'tui',\n  size: { w: 64, h: 14 },\n  minSize: { w: 40, h: 11 },\n  loop: 6,\n  color: '16',\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 6);\n      const [, mi, ii, si] = SCRIPT[Math.max(0, stepAt(p, SCRIPT.map((s) => s[0])))];\n      b.fill(0, 1, W, H - 2, ' ');\n      const back = b.view(0, 1, W, H - 2);\n      backdrop(ctx, back, t);\n      // Bar.\n      const barBg = th.panel2;\n      b.fill(0, 0, W, 1, ' ', undefined, barBg);\n      const xs = [];\n      let x = 1;\n      MENUS.forEach(([name], i) => {\n        xs.push(x);\n        const on = i === mi;\n        const bg = on ? th.accent : barBg;\n        b.text(x, 0, ` ${name} `, on ? th.bg : th.ink, bg, on ? ATTR.BOLD : 0);\n        // Hotkey letter: first letter, underlined and tinted.\n        b.text(x + 1, 0, name[0], on ? th.bg : th.warn, bg, ATTR.BOLD | ATTR.UNDERLINE);\n        x += name.length + 2;\n      });\n      const clock = '12:05';\n      if (x + clock.length + 1 < W) b.text(W - clock.length - 1, 0, clock, th.muted, barBg);\n      // Status line.\n      b.fill(0, H - 1, W, 1, ' ', undefined, barBg);\n      const help = mi < 0 ? 'F10 menu  alt+letter open' : si >= 0 ? `${g.left} back  enter run  esc close` : `${g.up}${g.down} move  ${g.right} submenu  esc close`;\n      let hx = 1;\n      for (const part of help.split('  ')) {\n        const [k, ...rest] = part.split(' ');\n        hx = b.text(hx, H - 1, k, th.bg, th.muted, ATTR.BOLD);\n        hx = b.text(hx, H - 1, ` ${rest.join(' ')}  `, th.ink, barBg);\n        if (hx >= W) break;\n      }\n      if (mi < 0) return;\n\n      const drawItem = (v, r, label, key, on, disabled) => {\n        const bg = on ? th.accent : th.panel;\n        v.fill(0, r, v.width, 1, ' ', undefined, bg);\n        v.text(1, r, label, on ? th.bg : th.ink, bg, on ? ATTR.BOLD : 0);\n        if (!disabled) v.text(1, r, label[0], on ? th.bg : th.warn, bg, ATTR.BOLD | ATTR.UNDERLINE);\n        if (key) v.text(v.width - key.length - 1, r, key, on ? th.bg : th.dim, bg);\n      };\n      // Dropdown under the open menu, with a shadow.\n      const items = MENUS[mi][1];\n      const mw = Math.min(22, W - 2);\n      const mx = Math.min(xs[mi], W - mw - 2);\n      const sw = 18;\n      // Narrow terminals drill down into the submenu instead of cascading it.\n      const drill = si >= 0 && mx + mw + sw > W;\n      if (drill) {\n        const sub = modal(ctx, b, mx, 1, mw, SUB.length + 4, { style: 'single', color: th.line });\n        sub.text(1, 0, `${g.triLeft} ${items[ii][0]}`, th.muted, th.panel, ATTR.BOLD);\n        b.text(mx, 3, u ? '├' : '+', th.line, th.panel);\n        b.fill(mx + 1, 3, mw - 2, 1, u ? '─' : '-', th.line, th.panel);\n        b.text(mx + mw - 1, 3, u ? '┤' : '+', th.line, th.panel);\n        SUB.forEach(([label, key], r) => drawItem(sub, r + 2, label, key, r === si, false));\n        return;\n      }\n      const inner = modal(ctx, b, mx, 1, mw, items.length + 2, { style: 'single', color: th.line });\n      items.forEach((it, r) => {\n        if (!it) {\n          // Separators join the frame: ├────┤\n          b.text(mx, 2 + r, u ? '├' : '+', th.line, th.panel);\n          b.fill(mx + 1, 2 + r, mw - 2, 1, u ? '─' : '-', th.line, th.panel);\n          b.text(mx + mw - 1, 2 + r, u ? '┤' : '+', th.line, th.panel);\n          return;\n        }\n        const [label, key] = it;\n        const disabled = key === 'no access';\n        const on = r === ii;\n        const bg = on ? th.accent : th.panel;\n        inner.fill(0, r, inner.width, 1, ' ', undefined, bg);\n        inner.text(1, r, fit(label, inner.width - 2, undefined, ctx).trimEnd(), disabled ? mix(th.panel, th.muted, 0.5) : on ? th.bg : th.ink, bg, on ? ATTR.BOLD : disabled ? ATTR.DIM : 0);\n        if (!disabled) inner.text(1, r, label[0], on ? th.bg : th.warn, bg, ATTR.BOLD | ATTR.UNDERLINE);\n        const k = key === '>' ? g.tri : key;\n        if (k) inner.text(inner.width - k.length - 1, r, k, disabled ? mix(th.panel, th.muted, 0.45) : on ? th.bg : th.dim, bg, disabled ? ATTR.ITALIC | ATTR.DIM : 0);\n      });\n      // Submenu.\n      if (si >= 0) {\n        const sub = modal(ctx, b, mx + mw, 1 + ii, sw, SUB.length + 2, { style: 'single', color: th.line });\n        SUB.forEach(([label, key], r) => drawItem(sub, r, label, key, r === si, false));\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/input/modeline.js": "// A vim-style statusline that changes color with the mode: NORMAL motions,\n// an INSERT edit, a VISUAL LINE indent and a :w COMMAND, with showcmd echo.\nimport { ATTR, uni, phase, mix, fit } from './_input.js';\n\nconst SRC = [\n  \"import { createPool } from './db.js';\",\n  '',\n  'const POOL_SIZE = 20;',\n  'const TIMEOUT_MS = 5000;',\n  '',\n  'export function pool() {',\n  'return createPool({',\n  '  max: POOL_SIZE,',\n  '  acquireTimeoutMillis: TIMEOUT_MS,',\n  '});',\n  '}',\n];\n// [time, mode, row, col, keys-echo, event]\nconst SCRIPT = [\n  [0.0, 'NORMAL', 0, 0, '', ''],\n  [0.4, 'NORMAL', 1, 0, 'j', ''],\n  [0.7, 'NORMAL', 2, 0, 'j', ''],\n  [0.9, 'NORMAL', 2, 6, 'w', ''],\n  [1.05, 'NORMAL', 2, 16, 'w', ''],\n  [1.2, 'NORMAL', 2, 18, 'w', ''],\n  [1.5, 'INSERT', 2, 18, 'ciw', 'cut'],\n  [1.75, 'INSERT', 2, 19, '', 'type4'],\n  [1.95, 'INSERT', 2, 20, '', 'type0'],\n  [2.4, 'NORMAL', 2, 19, '', ''],\n  [2.8, 'NORMAL', 6, 0, '4j', ''],\n  [3.1, 'V-LINE', 6, 0, 'V', ''],\n  [3.4, 'V-LINE', 7, 0, 'j', ''],\n  [3.7, 'V-LINE', 8, 0, 'j', ''],\n  [4.0, 'V-LINE', 9, 0, 'j', ''],\n  [4.4, 'NORMAL', 6, 2, '>', 'indent'],\n  [5.0, 'COMMAND', 6, 2, '', ':'],\n  [5.25, 'COMMAND', 6, 2, '', ':w'],\n  [5.6, 'NORMAL', 6, 2, '', 'written'],\n];\n\nexport default {\n  id: 'input-modeline',\n  title: 'Modal Statusline',\n  category: 'Tabs & Bars',\n  description: 'A vim-style editor whose statusline recolors per mode: NORMAL motions, an INSERT edit, a VISUAL LINE indent and :w, with showcmd key echo and relative numbers.',\n  tags: ['statusline', 'vim', 'modal', 'editor', 'modeline'],\n  kind: 'tui',\n  size: { w: 66, h: 13 },\n  minSize: { w: 40, h: 8 },\n  loop: 9,\n  color: '256',\n  order: 40,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, u = uni(ctx);\n      const p = phase(t, 9);\n      // Replay the script up to now.\n      let mode = 'NORMAL', row = 0, col = 0, echo = '', echoAt = -9, cmd = '', written = false, modified = false;\n      let lines = SRC.slice(), vStart = -1;\n      for (const [at, m, r, c, keys, ev] of SCRIPT) {\n        if (p < at) break;\n        if (m === 'V-LINE' && mode !== 'V-LINE') vStart = r;\n        else if (m !== 'V-LINE') vStart = -1;\n        mode = m; row = r; col = c;\n        if (keys) { echo = keys; echoAt = at; }\n        if (ev === 'cut') { lines[2] = 'const POOL_SIZE = ;'; modified = true; }\n        if (ev === 'type4') lines[2] = 'const POOL_SIZE = 4;';\n        if (ev === 'type0') lines[2] = 'const POOL_SIZE = 40;';\n        if (ev === 'indent') for (let i = 6; i <= 9; i++) lines[i] = '  ' + lines[i];\n        if (ev === ':' || ev === ':w') cmd = ev;\n        if (ev === 'written') { cmd = ''; written = true; modified = false; }\n      }\n      const MODE_C = { NORMAL: th.accent, INSERT: th.pos, 'V-LINE': th.warn, COMMAND: th.crit };\n      const mc = MODE_C[mode];\n\n      // Buffer with relative line numbers.\n      const bodyH = H - 2;\n      const top = Math.max(0, Math.min(lines.length - bodyH, row - Math.floor(bodyH / 2)));\n      for (let r = 0; r < bodyH; r++) {\n        const i = top + r;\n        if (i >= lines.length) { b.text(0, r, '~', mix(th.bg, th.accent, 0.5)); continue; }\n        const cur = i === row;\n        const num = cur ? String(i + 1).padEnd(3) : String(Math.abs(i - row)).padStart(3);\n        b.text(0, r, num, cur ? th.warn : th.dim, undefined, cur ? ATTR.BOLD : 0);\n        const sel = vStart >= 0 && i >= Math.min(vStart, row) && i <= Math.max(vStart, row);\n        const bg = sel ? mix(th.bg, th.warn, 0.28) : cur ? mix(th.bg, th.panel2, 0.7) : undefined;\n        if (bg !== undefined) b.fill(4, r, W - 4, 1, ' ', undefined, bg);\n        const line = lines[i];\n        const m = /^(\\s*)(import|export|function|const|return|from)?(.*)$/.exec(line);\n        let x = b.text(4, r, m[1], th.ink, bg);\n        if (m[2]) x = b.text(x, r, m[2], th.crit, bg, ATTR.BOLD);\n        const rest = m[3];\n        [...fit(rest, Math.max(0, W - x), undefined, ctx).trimEnd()].forEach((ch, k) => {\n          const isNum = /\\d/.test(ch);\n          const inStr = (rest.slice(0, k).match(/'/g) || []).length % 2 === 1 || ch === \"'\";\n          b.set(x + k, r, ch, inStr ? th.pos : isNum ? th.warn : th.ink, bg);\n        });\n        if (cur) {\n          const cx = 4 + col;\n          if (mode === 'INSERT') b.set(cx, r, undefined, undefined, undefined, ATTR.UNDERLINE | ATTR.BOLD);\n          else if (mode !== 'COMMAND') b.set(cx, r, undefined, undefined, undefined, ATTR.INVERSE);\n          if (mode === 'INSERT' && u) b.set(cx, r, b.get(cx, r)?.ch === ' ' ? '▏' : undefined, th.pos);\n        }\n      }\n\n      // Statusline.\n      const sy = H - 2;\n      const sbg = mix(th.bg, th.panel2, 0.9);\n      b.fill(0, sy, W, 1, ' ', undefined, sbg);\n      let x = b.text(0, sy, ` ${mode} `, th.bg, mc, ATTR.BOLD);\n      x = b.text(x, sy, ' main ', th.ink, mix(th.bg, mc, 0.3));\n      const pct = top === 0 ? 'Top' : top + bodyH >= lines.length ? 'Bot' : `${Math.round((top / (lines.length - bodyH)) * 100)}%`;\n      const pos = ` ${row + 1}:${col + 1} `;\n      const right = [[W >= 56 ? 'utf-8  javascript ' : '', th.muted, sbg, 0], [` ${pct} `, th.ink, mix(th.bg, mc, 0.3), 0], [pos, th.bg, mc, ATTR.BOLD]];\n      const rightW = right.reduce((n, [s]) => n + s.length, 0);\n      const room = Math.max(0, W - x - 1 - rightW - 1 - (modified ? 4 : 0));\n      x = b.text(x + 1, sy, fit(room >= 14 ? 'src/db/pool.js' : 'pool.js', room, undefined, ctx).trimEnd(), th.ink, sbg);\n      if (modified) x = b.text(x, sy, ' [+]', th.warn, sbg, ATTR.BOLD);\n      let rx = W;\n      for (let i = right.length - 1; i >= 0; i--) {\n        const [s, fg, bg, at] = right[i];\n        rx -= s.length;\n        if (rx > x + 1) b.text(rx, sy, s, fg, bg, at);\n      }\n\n      // Command line: :w prompt, write message, or -- MODE --; showcmd echo on the right.\n      const cy = H - 1;\n      if (mode === 'COMMAND') {\n        const e = b.text(0, cy, cmd, th.ink);\n        b.set(e, cy, ' ', undefined, undefined, ATTR.INVERSE);\n      } else if (written && p < 8.4) b.text(0, cy, fit(`\"src/db/pool.js\" ${lines.length}L, 236B written`, W - 8, undefined, ctx), th.ink);\n      else if (mode === 'INSERT') b.text(0, cy, '-- INSERT --', th.pos, undefined, ATTR.BOLD);\n      else if (mode === 'V-LINE') b.text(0, cy, '-- VISUAL LINE --', th.warn, undefined, ATTR.BOLD);\n      if (echo && p - echoAt < 0.5) b.text(W - 8, cy, echo.padStart(6), th.muted);\n    };\n  },\n};\n",
  "prism-term/src/facets/input/multiselect.js": "// Multi-select checkboxes: items get ticked one by one, the counter follows.\nimport { ATTR, glyphs, phase, promptLine, tint, fit, listAnswer } from './_input.js';\n\nconst ITEMS = ['checkout', 'payments', 'inventory', 'catalog', 'search', 'rx-orders', 'loyalty'];\nconst SCRIPT = [ // [time, cursor, toggle?]\n  [0.5, 0, true], [1.0, 1, true], [1.5, 2, false], [1.8, 3, true], [2.4, 4, false], [2.7, 5, true], [3.4, 5, false],\n];\n\nexport default {\n  id: 'input-multiselect',\n  title: 'Multi-select Checkboxes',\n  category: 'Toggles & Checkboxes',\n  description: 'A checkbox list where a scripted user moves and toggles items with space, with a live \"4 of 7 selected\" counter and select-all hint.',\n  tags: ['checkbox', 'multiselect', 'list', 'toggle', 'prompt'],\n  kind: 'tui',\n  size: { w: 50, h: 12 },\n  minSize: { w: 32, h: 7 },\n  loop: 6,\n  color: '16',\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx);\n      const p = phase(t, 6);\n      const on = new Set();\n      let cur = 0;\n      for (const [at, c, tog] of SCRIPT) if (p >= at) { cur = c; if (tog) on.has(c) ? on.delete(c) : on.add(c); }\n      const done = p > 4.6;\n      promptLine(ctx, b, 0, 0, 'Services to restart', done ? { state: 'done', answer: listAnswer([...on].map((i) => ITEMS[i]), W - 22) } : {});\n      if (done) return;\n      const rows = Math.min(ITEMS.length, H - 3);\n      const first = Math.max(0, Math.min(ITEMS.length - rows, cur - rows + 2));\n      for (let r = 0; r < rows; r++) {\n        const i = first + r, y = 1 + r;\n        const focused = i === cur, checked = on.has(i);\n        if (focused) b.fill(0, y, W, 1, ' ', undefined, tint(ctx, th.accent, 0.1));\n        b.text(1, y, focused ? g.pointer : ' ', th.accent);\n        b.text(3, y, checked ? g.boxOn : g.boxOff, checked ? th.pos : th.dim, undefined, checked ? ATTR.BOLD : 0);\n        b.text(7, y, fit(ITEMS[i], W - 8, undefined, ctx), focused ? th.ink : checked ? th.ink : th.muted, undefined, focused ? ATTR.BOLD : 0);\n      }\n      b.text(0, H - 1, `${on.size} of ${ITEMS.length} selected ${g.mid} space toggle ${g.mid} a all ${g.mid} enter`, th.dim);\n    };\n  },\n};\n",
  "prism-term/src/facets/input/number-stepper.js": "// Number steppers: − value + with arrow presses, clamping at the limits.\nimport { ATTR, glyphs, uni, phase, tint } from './_input.js';\n\nconst FIELDS = [\n  { label: 'Workers', min: 1, max: 32, start: 8, presses: [[0.5, 1], [0.8, 1], [1.1, 1], [3.6, -1], [3.9, -1], [4.2, -1]] },\n  { label: 'Pool size', min: 10, max: 40, start: 36, step: 2, presses: [[1.6, 1], [1.9, 1], [2.2, 1], [2.5, 1], [4.6, -1], [4.9, -1]] },\n  { label: 'Retries', min: 0, max: 5, start: 3, presses: [[2.8, -1], [3.1, -1], [3.4, -1], [3.7, -1], [5.2, 1], [5.4, 1]] },\n];\n\nexport default {\n  id: 'input-number-stepper',\n  title: 'Number Steppers',\n  category: 'Text Fields',\n  description: 'Numeric steppers with − and + controls: presses flash, values step, and the field refuses to go past its min/max with a clamped hint.',\n  tags: ['number', 'stepper', 'spinner', 'increment', 'form'],\n  kind: 'tui',\n  size: { w: 48, h: 9 },\n  minSize: { w: 32, h: 5 },\n  loop: 6,\n  color: '16',\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 6);\n      const step = H >= 8 ? 3 : 2;\n      FIELDS.forEach((f, i) => {\n        const y = i * step;\n        if (y >= H) return;\n        let v = f.start, last = null, clamped = false;\n        f.presses.forEach(([at, d], k) => {\n          if (p < at) return;\n          const n = v + d * (f.step || 1);\n          clamped = n < f.min || n > f.max;\n          v = Math.max(f.min, Math.min(f.max, n));\n          if (p - at < 0.18) last = { d, k };\n        });\n        const active = last !== null;\n        b.text(0, y, f.label, active ? th.accent : th.muted, undefined, ATTR.BOLD);\n        const x0 = Math.max(14, W - 18);\n        const minus = u ? ' − ' : ' - ', plus = ' + ';\n        b.text(x0, y, minus, last?.d < 0 ? th.bg : th.ink, last?.d < 0 ? th.accent : th.panel2, ATTR.BOLD);\n        const val = String(v).padStart(4);\n        b.text(x0 + 3, y, ` ${val} `, th.ink, tint(ctx, th.accent, 0.1), ATTR.BOLD);\n        b.text(x0 + 9, y, plus, last?.d > 0 ? th.bg : th.ink, last?.d > 0 ? th.accent : th.panel2, ATTR.BOLD);\n        if (step === 3 && y + 1 < H) {\n          const atLimit = v === f.max ? `max ${f.max}` : v === f.min ? `min ${f.min}` : `${f.min}${u ? '–' : '-'}${f.max}`;\n          b.text(x0, y + 1, (clamped && v === f.max) || (clamped && v === f.min) ? `${g.bang} ${atLimit}` : atLimit, clamped ? th.warn : th.dim);\n        }\n      });\n    };\n  },\n};\n",
  "prism-term/src/facets/input/password.js": "// A masked password field with a strength meter and a reveal toggle.\nimport { ATTR, glyphs, uni, phase, typed, blink, tint, hbar } from './_input.js';\n\nconst PW = 'c0rrect-h0rse-battery!';\nconst RULES = [\n  ['12+ characters', (s) => s.length >= 12],\n  ['a digit', (s) => /\\d/.test(s)],\n  ['a symbol', (s) => /[^\\w]/.test(s)],\n  ['not in breach lists', (s) => s.length >= 16],\n];\n\nexport default {\n  id: 'input-password',\n  title: 'Password Field',\n  category: 'Text Fields',\n  description: 'A masked password input whose strength meter climbs from weak to strong as rules tick off, with a brief reveal toggle at the end.',\n  tags: ['password', 'masked', 'strength', 'security', 'input'],\n  kind: 'tui',\n  size: { w: 52, h: 9 },\n  minSize: { w: 34, h: 5 },\n  loop: 6,\n  color: '256',\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 6);\n      const v = typed(PW, p, 0.3, 7);\n      const reveal = p > 4.4 && p < 5.4;\n      const passed = RULES.filter(([, f]) => f(v)).length;\n      const fw = Math.min(W - 2, 40);\n      b.text(0, 0, 'Password', th.ink, undefined, ATTR.BOLD);\n      const bg = tint(ctx, th.accent, 0.08);\n      b.fill(0, 1, fw, 1, ' ', undefined, bg);\n      const shown = reveal ? v : (u ? '•' : '*').repeat(v.length);\n      b.text(1, 1, shown.slice(-(fw - 6)), th.ink, bg);\n      if (!reveal && blink(t, 0.9)) b.set(1 + Math.min(v.length, fw - 6), 1, u ? '▏' : '_', th.ink, bg);\n      b.text(fw - 4, 1, reveal ? (u ? '◉' : 'o') : (u ? '◎' : '-'), th.muted, bg);\n      const levels = [['weak', th.neg], ['fair', th.warn], ['good', th.info], ['strong', th.pos]];\n      const [label, c] = levels[Math.max(0, passed - 1)] || levels[0];\n      hbar(ctx, b, 0, 2, fw, passed / RULES.length, v ? c : th.line);\n      b.text(fw + 1 > W - 6 ? 0 : fw + 1, fw + 1 > W - 6 ? 3 : 2, v ? label : '', c, undefined, ATTR.BOLD);\n      const ry = fw + 1 > W - 6 ? 4 : 3;\n      RULES.forEach(([text, f], i) => {\n        const y = ry + i;\n        if (y >= H) return;\n        const ok = f(v);\n        b.text(0, y, ok ? g.check : g.mid, ok ? th.pos : th.dim);\n        b.text(2, y, text, ok ? th.ink : th.muted);\n      });\n    };\n  },\n};\n",
  "prism-term/src/facets/input/progress-dialog.js": "// A modal progress dialog for an approved fix: steps tick off, a bar and ETA\n// advance, Cancel becomes Close, and the result is verified before it closes.\nimport { ATTR, glyphs, uni, phase, span, ease, fit, spin, hbar, button, backdrop, modal } from './_input.js';\n\n// [label, start, end] in seconds into the loop.\nconst STEPS = [\n  ['Snapshot current config', 0.3, 1.0],\n  ['Write config (pool=40)', 1.0, 1.7],\n  ['Rolling restart', 1.7, 4.5],\n  ['Verify p99 < 500ms', 4.5, 5.7],\n];\nconst CLOSE = 8.2;\n\nexport default {\n  id: 'input-progress-dialog',\n  title: 'Progress Dialog',\n  category: 'Dialogs & Prompts',\n  description: 'A modal that applies a human-approved fix: steps tick off with timings, the bar and ETA advance, Cancel turns into Close, and the result is verified.',\n  tags: ['progress', 'dialog', 'modal', 'steps', 'remediation'],\n  kind: 'tui',\n  size: { w: 60, h: 15 },\n  minSize: { w: 42, h: 11 },\n  loop: 9,\n  color: '256',\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 9);\n      backdrop(ctx, b, t);\n      b.fill(0, H - 1, W, 1, ' ', undefined, th.panel2);\n      if (p >= CLOSE) {\n        b.text(1, H - 1, fit(`${g.check} Fix #142 applied ${g.mid} checkout-api p99 180ms`, W - 2, undefined, ctx), th.pos, th.panel2, ATTR.BOLD);\n        return;\n      }\n      const end = STEPS[STEPS.length - 1][2];\n      const finished = p >= end;\n      b.text(1, H - 1, fit(`PROTEUS ${g.mid} fixes ${g.mid} ${finished ? 'verifying' : '1 running'}`, W - 2, undefined, ctx), th.muted, th.panel2);\n      const mw = Math.min(W - 4, 52), mh = Math.min(H - 2, 12);\n      const mx = Math.floor((W - mw) / 2), my = Math.max(0, Math.floor((H - 1 - mh) / 2));\n      const inner = modal(ctx, b, mx, my, mw, mh, {\n        enter: ease(p, 0, 0.3), color: finished ? th.pos : th.accent,\n        title: finished ? `${g.check} Fix #142 applied` : 'Applying fix #142', titleColor: finished ? th.pos : th.ink,\n      });\n      const iw = inner.width, ih = inner.height, bg = th.panel;\n      // Compact layouts drop the spacer rows.\n      const by = ih >= 10 ? ih - 3 : ih - 2;\n      let y = 0;\n      inner.text(1, y++, fit(`Raise checkout DB pool 20 ${g.right} 40`, iw - 2, undefined, ctx), th.ink, bg, ATTR.BOLD);\n      if (ih >= 10) inner.text(1, y++, fit(`Approved by you ${g.mid} 12:05 ${g.mid} rollback ready`, iw - 2, undefined, ctx), th.dim, bg);\n      if (ih >= 10) y++;\n      for (const [label, a, z] of STEPS) {\n        if (y >= by) break;\n        const state = p >= z ? 'done' : p >= a ? 'run' : 'todo';\n        const mark = state === 'done' ? g.check : state === 'run' ? spin(ctx, t) : g.ring;\n        inner.text(1, y, mark, state === 'done' ? th.pos : state === 'run' ? th.warn : th.dim, bg, ATTR.BOLD);\n        let text = label;\n        if (label === 'Rolling restart' && state !== 'todo') text += ` ${Math.min(3, Math.floor(span(p, a, z) * 3 + (state === 'done' ? 0 : 1)))}/3 pods`;\n        const secs = state === 'done' ? `${(z - a).toFixed(1)}s` : state === 'run' ? `${(p - a).toFixed(1)}s` : '';\n        inner.text(3, y, fit(text, iw - 11, undefined, ctx), state === 'todo' ? th.muted : th.ink, bg);\n        if (secs) inner.text(iw - 1 - secs.length, y, secs, th.dim, bg);\n        y++;\n      }\n      // Overall bar + ETA.\n      const frac = span(p, STEPS[0][1], end);\n      const pct = `${Math.round(frac * 100)}%`.padStart(4);\n      const eta = finished ? 'done' : `ETA 0:${String(Math.ceil((end - p) * 3)).padStart(2, '0')}`;\n      const bw = Math.max(4, iw - 2 - pct.length - eta.length - 2);\n      hbar(ctx, inner, 1, by, bw, frac, finished ? th.pos : th.accent);\n      inner.text(2 + bw, by, pct, th.ink, bg, ATTR.BOLD);\n      inner.text(iw - 1 - eta.length, by, eta, th.dim, bg);\n      // Buttons.\n      const label = finished ? 'Close' : 'Cancel';\n      const bwid = u ? label.length + 2 : label.length + 4;\n      button(ctx, inner, iw - bwid - 1, ih - 1, label, { focused: true, color: finished ? th.pos : th.muted, pressed: p > CLOSE - 0.2 });\n      if (finished && iw > 30) inner.text(1, ih - 1, fit(`${g.check} p99 2.4s ${g.right} 180ms`, iw - bwid - 4, undefined, ctx), th.pos, bg);\n    };\n  },\n};\n",
  "prism-term/src/facets/input/prompt-flow.js": "// A clack-style prompt flow: text, select, confirm and multiselect prompts\n// chained down one gutter, each collapsing to its answer, then a spinner.\nimport { ATTR, glyphs, uni, phase, typed, blink, spin, clip, stringWidth } from './_input.js';\n\nconst T = { tag: 0.3, tagDone: 1.5, envDown: 2.1, envDone: 2.7, migNo: 3.4, migDone: 3.9, n1: 4.5, n2: 4.9, nDone: 5.5, deployed: 7.6 };\nconst ENVS = ['staging', 'production', 'canary'];\nconst NOTIFY = ['#checkout-oncall', '#releases', 'release-notes email'];\n\nexport default {\n  id: 'input-prompt-flow',\n  title: 'Prompt Flow',\n  category: 'Dialogs & Prompts',\n  description: 'A clack-style CLI wizard: text, select, yes/no and multiselect prompts chain down one gutter, each collapsing to its answer before a deploy spinner.',\n  tags: ['prompts', 'clack', 'cli wizard', 'confirm', 'yes no'],\n  kind: 'tui',\n  size: { w: 56, h: 16 },\n  minSize: { w: 36, h: 8 },\n  loop: 10,\n  color: '16',\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 10);\n      const G = u\n        ? { top: '┌', bar: '│', end: '└', done: '◇', act: '◆', on: '●', off: '○', mOn: '◼', mOff: '◻' }\n        : { top: '+', bar: '|', end: '+', done: 'o', act: '*', on: '(*)', off: '( )', mOn: '[x]', mOff: '[ ]' };\n      const lines = [];\n      const L = (...segs) => lines.push(segs);\n      const gut = (c = th.dim) => [`${G.bar}  `, c];\n      const done = (q, a) => { L([`${G.done}  `, th.pos], [q, th.ink]); L(gut(), [a, th.dim]); L(gut()); };\n      const active = (q) => L([`${G.act}  `, th.accent, ATTR.BOLD], [q, th.ink, ATTR.BOLD]);\n\n      L([`${G.top}  `, th.dim], [' deploy ', th.bg, ATTR.BOLD, th.accent], ['  checkout-api', th.muted]);\n      L(gut());\n      // 1. Release tag (text).\n      if (p < T.tagDone) {\n        active('Release tag');\n        const v = typed('v2.14.1', p, T.tag, 8);\n        const caret = blink(t, 0.9) ? (u ? '▏' : '_') : ' ';\n        L(gut(th.accent), v ? [v, th.ink] : ['v1.2.3', th.dim, ATTR.ITALIC], [caret, th.ink]);\n        L([G.end, th.accent]);\n      } else done('Release tag', 'v2.14.1');\n      // 2. Environment (select).\n      if (p >= T.tagDone && p < T.envDone) {\n        active('Environment');\n        const cur = p >= T.envDown ? 1 : 0;\n        ENVS.forEach((e, i) => L(gut(th.accent), [i === cur ? G.on : G.off, i === cur ? th.pos : th.dim], [` ${e}`, i === cur ? th.ink : th.muted], i === 1 ? ['  (12 hosts)', th.dim] : ['']));\n        L([G.end, th.accent]);\n      } else if (p >= T.envDone) done('Environment', 'production');\n      // 3. Migrations (confirm).\n      if (p >= T.envDone && p < T.migDone) {\n        active('Run database migrations?');\n        const yes = p < T.migNo;\n        L(gut(th.accent), [yes ? G.on : G.off, yes ? th.pos : th.dim], [' Yes', yes ? th.ink : th.muted], [' / ', th.dim], [yes ? G.off : G.on, yes ? th.dim : th.pos], [' No', yes ? th.muted : th.ink]);\n        L([G.end, th.accent]);\n      } else if (p >= T.migDone) done('Run database migrations?', 'No');\n      // 4. Notify (multiselect).\n      if (p >= T.migDone && p < T.nDone) {\n        active('Notify');\n        const sel = [p >= T.n1, false, p >= T.n2];\n        const cur = p >= T.n2 - 0.12 ? 2 : p >= T.n1 + 0.15 ? 1 : 0;\n        NOTIFY.forEach((n, i) => L(gut(th.accent), [i === cur ? g.pointer : ' ', th.accent], [' '], [sel[i] ? G.mOn : G.mOff, sel[i] ? th.pos : th.dim], [` ${n}`, i === cur ? th.ink : th.muted]));\n        L([G.end, th.accent], [`  space select ${g.mid} enter confirm`, th.dim]);\n      } else if (p >= T.nDone) done('Notify', '#checkout-oncall, release-notes email');\n      // 5. Deploy.\n      if (p >= T.nDone && p < T.deployed) {\n        const k = Math.min(12, Math.floor((p - T.nDone) / (T.deployed - T.nDone) * 12));\n        L([`${spin(ctx, t)}  `, th.warn], [`Deploying v2.14.1 to production ${g.mid} ${k}/12 hosts`, th.ink]);\n      } else if (p >= T.deployed) {\n        L([`${G.done}  `, th.pos], ['Deployed v2.14.1 to 12 hosts in 41s', th.ink]);\n        L(gut());\n        L([`${G.end}  `, th.dim], ['Done. ', th.ink, ATTR.BOLD], ['https://deploy.example/r/8841', th.info, ATTR.UNDERLINE]);\n      }\n\n      // The flow scrolls like a real terminal: show the newest lines.\n      const first = Math.max(0, lines.length - H);\n      for (let r = 0; r < Math.min(H, lines.length); r++) {\n        let x = 0;\n        for (const [text, fg, attr, bg] of lines[first + r]) {\n          if (!text) continue;\n          const room = Math.max(0, W - x);\n          x = b.text(x, r, stringWidth(text) <= room ? text : clip(ctx, text, room), fg, bg, attr);\n        }\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/input/radio-group.js": "// Radio groups: one choice per group, selection hopping between options.\nimport { ATTR, glyphs, phase, stepAt, tint } from './_input.js';\n\nconst GROUPS = [\n  ['Environment', ['dev', 'staging', 'production']],\n  ['Strategy', ['rolling', 'blue/green', 'canary']],\n  ['Notify', ['nobody', 'team', 'on-call']],\n];\nconst SCRIPT = [[0.4, 0, 1], [1.0, 0, 2], [1.7, 1, 2], [2.5, 2, 1], [3.3, 1, 1]];\n\nexport default {\n  id: 'input-radio-group',\n  title: 'Radio Groups',\n  category: 'Toggles & Checkboxes',\n  description: 'Three labeled radio groups — environment, rollout strategy and notification — with the selected dot hopping as choices change.',\n  tags: ['radio', 'options', 'choice', 'form', 'select'],\n  kind: 'tui',\n  size: { w: 60, h: 9 },\n  minSize: { w: 40, h: 6 },\n  loop: 5,\n  color: '16',\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, H = ctx.height, g = glyphs(ctx);\n      const p = phase(t, 5);\n      const sel = [0, 0, 1];\n      const k = stepAt(p, SCRIPT.map((s) => s[0]));\n      let focusG = 0;\n      for (let i = 0; i <= k; i++) { sel[SCRIPT[i][1]] = SCRIPT[i][2]; focusG = SCRIPT[i][1]; }\n      const step = H >= GROUPS.length * 3 ? 3 : 2;\n      GROUPS.forEach(([name, opts], gi) => {\n        const y = gi * step;\n        if (y + 1 >= H) return;\n        const focused = gi === focusG;\n        b.text(0, y, name, focused ? th.accent : th.muted, undefined, ATTR.BOLD);\n        let x = 2;\n        opts.forEach((o, oi) => {\n          const on = sel[gi] === oi;\n          if (focused && on) b.fill(x - 1, y + 1, o.length + g.radioOn.length + 3, 1, ' ', undefined, tint(ctx, th.accent, 0.14));\n          x = b.text(x, y + 1, on ? g.radioOn : g.radioOff, on ? th.accent : th.dim, undefined, ATTR.BOLD);\n          x = b.text(x + 1, y + 1, o, on ? th.ink : th.muted, undefined, on ? ATTR.BOLD : 0) + 3;\n        });\n      });\n    };\n  },\n};\n",
  "prism-term/src/facets/input/scroll-list.js": "// A long list in a short window: the cursor runs down, the view scrolls, the\n// scrollbar thumb tracks it.\nimport { ATTR, glyphs, phase, panel, scrollbar, tint, fit } from './_input.js';\n\nconst PODS = Array.from({ length: 26 }, (_, i) => {\n  const svc = ['checkout', 'payments', 'catalog', 'search', 'cart', 'auth', 'orders', 'notify'][i % 8];\n  return [`${svc}-7d9f${(i * 7919 % 9000 + 1000).toString(36)}`, ['Running', 'Running', 'Running', 'Pending', 'Running'][i % 5], `${(i * 13) % 59 + 1}m`];\n});\n\nexport default {\n  id: 'input-scroll-list',\n  title: 'Scrolling List',\n  category: 'Selects & Lists',\n  description: 'A long pod list in a short framed window: the cursor runs down and back, the view scrolls with it and the scrollbar thumb tracks position.',\n  tags: ['list', 'scroll', 'scrollbar', 'pods', 'cursor'],\n  kind: 'tui',\n  size: { w: 52, h: 12 },\n  minSize: { w: 34, h: 6 },\n  loop: 10,\n  color: '16',\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx);\n      const p = phase(t, 10);\n      const pos = p < 5 ? p / 5 : 1 - (p - 5) / 5;\n      const cur = Math.round(pos * (PODS.length - 1));\n      const inner = panel(ctx, b, 0, 0, W, H, { title: 'pods', footer: `${cur + 1}/${PODS.length}`, color: th.line });\n      const vis = inner.height;\n      const first = Math.max(0, Math.min(PODS.length - vis, cur - Math.floor(vis / 2)));\n      for (let r = 0; r < vis; r++) {\n        const i = first + r;\n        if (i >= PODS.length) break;\n        const [name, status, age] = PODS[i];\n        const on = i === cur;\n        if (on) inner.fill(0, r, inner.width - 1, 1, ' ', undefined, tint(ctx, th.accent, 0.16));\n        inner.text(0, r, on ? g.pointer : ' ', th.accent, undefined, ATTR.BOLD);\n        inner.text(2, r, fit(name, Math.max(4, inner.width - 18), undefined, ctx), on ? th.ink : th.muted, undefined, on ? ATTR.BOLD : 0);\n        inner.text(inner.width - 15, r, status.padEnd(8), status === 'Running' ? th.pos : th.warn);\n        inner.text(inner.width - 5, r, age.padStart(3), th.dim);\n      }\n      scrollbar(ctx, inner, inner.width - 1, 0, vis, PODS.length, first, vis);\n    };\n  },\n};\n",
  "prism-term/src/facets/input/select.js": "// A single-select prompt: the cursor walks the options, Enter confirms.\nimport { ATTR, glyphs, phase, stepAt, promptLine, tint } from './_input.js';\n\nconst OPTIONS = [\n  ['us-east-1', 'N. Virginia'],\n  ['us-east-2', 'Ohio'],\n  ['us-west-2', 'Oregon'],\n  ['eu-west-1', 'Ireland'],\n  ['ap-southeast-2', 'Sydney'],\n];\n\nexport default {\n  id: 'input-select',\n  title: 'Select Prompt',\n  category: 'Selects & Lists',\n  description: 'An inquirer/gum-style single select: the pointer moves through regions with a hint line, Enter confirms and the answer collapses inline.',\n  tags: ['select', 'prompt', 'list', 'choose', 'inquirer'],\n  kind: 'tui',\n  size: { w: 56, h: 10 },\n  minSize: { w: 32, h: 7 },\n  loop: 6,\n  color: '16',\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx);\n      const p = phase(t, 6);\n      const steps = [0.8, 1.4, 2.0, 2.6];\n      const k = stepAt(p, steps);\n      const cur = [0, 1, 2, 3, 2][k + 1];\n      const done = p > 3.4;\n      if (done) {\n        promptLine(ctx, b, 0, 0, 'Deploy region', { state: 'done', answer: OPTIONS[2][0] });\n        b.text(2, 2, `Deploying checkout to ${OPTIONS[2][0]} ${g.ellipsis}`, th.muted);\n        return;\n      }\n      promptLine(ctx, b, 0, 0, 'Deploy region', { answer: '' });\n      const rows = Math.min(OPTIONS.length, H - 3);\n      for (let i = 0; i < rows; i++) {\n        const on = i === cur;\n        const y = 1 + i;\n        if (on) b.fill(0, y, W, 1, ' ', undefined, tint(ctx, th.accent, 0.12));\n        b.text(1, y, on ? g.pointer : ' ', th.accent, undefined, ATTR.BOLD);\n        b.text(3, y, OPTIONS[i][0], on ? th.accent : th.ink, undefined, on ? ATTR.BOLD : 0);\n        b.text(3 + 16, y, OPTIONS[i][1], on ? th.muted : th.dim);\n      }\n      b.text(0, 1 + rows + (H > rows + 2 ? 1 : 0), `${g.up}/${g.down} move ${g.mid} enter select ${g.mid} esc cancel`, th.dim);\n    };\n  },\n};\n",
  "prism-term/src/facets/input/shortcut-bar.js": "// A nano-style editor: the two-row shortcut bar flashes as keys are pressed,\n// switches to prompt-specific keys during Where Is / Write Out, and the\n// status line reports each action.\nimport { ATTR, glyphs, phase, typed, fit, stringWidth } from './_input.js';\n\nconst FILE = [\n  'apiVersion: apps/v1', 'kind: Deployment', 'metadata:', '  name: checkout-api', '  namespace: checkout',\n  'spec:', '  replicas: 3', '  selector:', '    matchLabels:', '      app: checkout-api', '  template:',\n  '    spec:', '      containers:', '        - name: api', '          image: registry.example/checkout-api:2.14.1',\n];\nconst MAIN = [\n  [['^G', 'Help'], ['^O', 'Write Out'], ['^W', 'Where Is'], ['^K', 'Cut'], ['^C', 'Location']],\n  [['^X', 'Exit'], ['^R', 'Read File'], ['^\\\\', 'Replace'], ['^U', 'Paste'], ['^_', 'Go To Line']],\n];\nconst SEARCH = [\n  [['^G', 'Help'], ['M-C', 'Case Sens'], ['M-B', 'Backwards'], ['^P', 'Older']],\n  [['^C', 'Cancel'], ['M-R', 'Reg.exp.'], ['^R', 'Replace'], ['^N', 'Newer']],\n];\nconst WRITE = [\n  [['^G', 'Help'], ['M-D', 'DOS Format'], ['M-A', 'Append'], ['M-B', 'Backup File']],\n  [['^C', 'Cancel'], ['M-M', 'Mac Format'], ['M-P', 'Prepend'], ['^T', 'Browse']],\n];\nconst T = { find: 1.0, query: 1.3, found: 2.4, end: 2.9, edit: 3.3, write: 4.3, wrote: 5.2 };\nconst ROW = 6;\n\nexport default {\n  id: 'input-shortcut-bar',\n  title: 'Shortcut Bar',\n  category: 'Tabs & Bars',\n  description: 'A nano-style editor whose two-row shortcut bar flashes on each keypress and swaps to prompt keys for Where Is and Write Out while a deploy.yaml is edited.',\n  tags: ['shortcuts', 'key bar', 'nano', 'editor', 'hotkeys'],\n  kind: 'tui',\n  size: { w: 72, h: 14 },\n  minSize: { w: 40, h: 9 },\n  loop: 9,\n  color: '16',\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx);\n      const p = phase(t, 9);\n      const lines = FILE.slice();\n      const edited = p >= T.edit;\n      if (edited) lines[ROW] = '  replicas: 4';\n      const modified = edited && p < T.wrote;\n      // Title bar.\n      b.fill(0, 0, W, 1, ' ', th.bg, th.ink);\n      b.text(1, 0, 'nano', th.bg, th.ink, ATTR.BOLD);\n      const name = 'deploy.yaml';\n      b.text(Math.floor((W - name.length) / 2), 0, name, th.bg, th.ink, ATTR.BOLD);\n      if (modified) b.text(W - 9, 0, 'Modified', th.bg, th.ink);\n\n      // Cursor position for this moment.\n      let cy = 0, cx = 0;\n      if (p >= T.found) { cy = ROW; cx = 2; }\n      if (p >= T.end) cx = lines[ROW].length;\n      // Body, scrolled to keep the cursor in view.\n      const bodyH = Math.max(1, H - 4);\n      const top = Math.max(0, Math.min(FILE.length - bodyH, cy - Math.floor(bodyH / 2)));\n      for (let r = 0; r < bodyH && top + r < lines.length; r++) {\n        const i = top + r, line = lines[i];\n        const m = /^(\\s*-?\\s*)([\\w.]+)(:)(.*)$/.exec(line);\n        if (m) {\n          let x = b.text(0, 1 + r, m[1], th.dim);\n          x = b.text(x, 1 + r, m[2], th.accent);\n          x = b.text(x, 1 + r, m[3], th.dim);\n          b.text(x, 1 + r, fit(m[4], Math.max(0, W - x), undefined, ctx).trimEnd(), /\\d+$/.test(m[4]) ? th.warn : th.ink);\n        } else b.text(0, 1 + r, fit(line, W, undefined, ctx), th.ink);\n        // Search match highlight, briefly.\n        if (i === ROW && p >= T.found && p < T.end) b.text(2, 1 + r, 'replicas', th.bg, th.warn, ATTR.BOLD);\n        if (i === cy && p < T.write) b.set(cx, 1 + r, undefined, undefined, undefined, ATTR.INVERSE);\n      }\n\n      // Status / prompt line.\n      const sy = H - 3;\n      let keys = MAIN, pressed = '';\n      if (p >= T.find - 0.15 && p < T.find) pressed = '^W';\n      if (p >= T.write - 0.15 && p < T.write) pressed = '^O';\n      const msg = (s, c = th.ink) => {\n        const w = stringWidth(s);\n        b.text(Math.max(0, Math.floor((W - w) / 2)), sy, fit(s, W, undefined, ctx).trimEnd(), c, th.panel2, ATTR.BOLD);\n      };\n      if (p < T.find) msg(`[ Read ${FILE.length} lines ]`);\n      else if (p < T.found) {\n        keys = SEARCH;\n        b.fill(0, sy, W, 1, ' ', undefined, th.panel2);\n        const x = b.text(0, sy, 'Search: ', th.ink, th.panel2, ATTR.BOLD);\n        const q = typed('replicas', p, T.query, 9);\n        const e = b.text(x, sy, q, th.ink, th.panel2);\n        b.set(e, sy, ' ', undefined, undefined, ATTR.INVERSE);\n      } else if (p >= T.write && p < T.wrote) {\n        keys = WRITE;\n        b.fill(0, sy, W, 1, ' ', undefined, th.panel2);\n        const x = b.text(0, sy, 'File Name to Write: ', th.ink, th.panel2, ATTR.BOLD);\n        const e = b.text(x, sy, name, th.ink, th.panel2);\n        b.set(e, sy, ' ', undefined, undefined, ATTR.INVERSE);\n      } else if (p >= T.wrote) msg(`[ Wrote ${FILE.length} lines ]`, th.pos);\n      else if (p >= T.edit) {\n        b.text(0, sy, fit(`  line ${ROW + 1}/${FILE.length} ${g.mid} replicas 3 ${g.right} 4`, W, undefined, ctx), th.dim);\n      }\n\n      // Two-row shortcut bar: as many columns as fit, like nano does.\n      const need = Math.min(13, Math.max(...keys.flat().map(([k, l]) => k.length + l.length + 2)));\n      const cols = Math.max(2, Math.min(keys[0].length, Math.floor(W / need)));\n      const cw = Math.floor(W / cols);\n      for (let r = 0; r < 2; r++) {\n        keys[r].slice(0, cols).forEach(([k, label], i) => {\n          const x = i * cw;\n          const on = k === pressed;\n          const kx = b.text(x, H - 2 + r, k, th.bg, on ? th.accent : th.ink, ATTR.BOLD);\n          b.text(kx + 1, H - 2 + r, fit(label, Math.max(0, cw - k.length - 2), undefined, ctx).trimEnd(), on ? th.accent : th.ink, undefined, on ? ATTR.BOLD : 0);\n        });\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/input/slash-commands.js": "// A chat composer with a \"/\" command menu: the list filters as you type,\n// Tab completes, an argument hint appears, Enter sends a pending-approval card.\nimport { ATTR, glyphs, uni, phase, typed, blink, tint, mix, fit, panel } from './_input.js';\n\nconst CMDS = [\n  ['/status', 'service health and open incidents'],\n  ['/logs', 'tail logs for a service'],\n  ['/restart', 'restart a deployment'],\n  ['/rollback', 'roll back to a previous revision'],\n  ['/runbook', 'open the runbook for an alert'],\n  ['/page', 'page the on-call engineer'],\n];\nconst ARG_HINT = '<service> [revision]';\nconst T = { slash: 0.5, filter: 0.9, tab: 2.0, arg: 2.5, send: 4.6 };\n\nexport default {\n  id: 'input-slash-commands',\n  title: 'Slash Command Menu',\n  category: 'Command Palettes',\n  description: 'A chat composer where typing \"/\" opens a command menu that filters as you type; Tab completes, an argument hint appears, and Enter posts it for approval.',\n  tags: ['slash command', 'chat', 'composer', 'menu', 'chatops'],\n  kind: 'tui',\n  size: { w: 64, h: 14 },\n  minSize: { w: 40, h: 9 },\n  loop: 8,\n  color: '256',\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 8);\n      // Transcript above the composer.\n      let ty = 0;\n      const say = (who, c, msg) => {\n        if (ty >= H - 4) return;\n        const x = b.text(0, ty, who, c, undefined, ATTR.BOLD);\n        b.text(x + 1, ty++, fit(msg, Math.max(0, W - x - 1), undefined, ctx), th.muted);\n      };\n      say('mainbrain', th.accent, 'checkout-api p99 is 2.4s since v2.14.1 (11 min).');\n      say('mainbrain', th.accent, 'Likely cause: DB pool 20/20 in use. Suggest rollback.');\n\n      let input = '';\n      if (p >= T.slash) input = '/';\n      if (p >= T.filter) input = '/' + typed('ro', p, T.filter, 3);\n      if (p >= T.tab) input = '/rollback ';\n      if (p >= T.arg) input = '/rollback ' + typed('checkout-api', p, T.arg, 9);\n      const sent = p >= T.send;\n\n      if (sent) {\n        // The sent command renders as a card awaiting a human.\n        const cy = ty + (H - 3 - ty >= 5 ? 1 : 0);\n        if (cy + 4 <= H - 3) {\n          const cw = Math.min(W, 46);\n          const inner = panel(ctx, b, 0, cy, cw, 4, { title: 'you', color: th.line });\n          inner.text(1, 0, fit('/rollback checkout-api', inner.width - 2, undefined, ctx), th.ink, undefined, ATTR.BOLD);\n          const pill = ` ${g.ring} awaiting approval `;\n          const blinkOn = phase(p - T.send, 1.2) < 0.9;\n          inner.text(1, 1, pill, blinkOn ? th.bg : th.warn, blinkOn ? th.warn : undefined, ATTR.BOLD);\n          // mainbrain answers with the plan; nothing runs until a human approves.\n          if (p > T.send + 0.9 && cy + 4 < H - 3) {\n            const x0 = b.text(0, cy + 4, 'mainbrain', th.accent, undefined, ATTR.BOLD);\n            b.text(x0 + 1, cy + 4, fit(`Plan: v2.14.1 ${g.right} v2.14.0 on 3 pods. Approve in Fixes.`, Math.max(0, W - x0 - 1), undefined, ctx), th.muted);\n          }\n        }\n        input = '';\n      }\n\n      // Composer.\n      const ch = 3, cy = H - ch;\n      const inner = panel(ctx, b, 0, cy, W, ch, { color: sent ? th.line : th.accent, footer: sent ? '' : 'enter send', footerAlign: 'right' });\n      const iw = inner.width;\n      if (!input) inner.text(1, 0, fit('Message mainbrain, or / for commands', iw - 2, undefined, ctx), mix(th.bg, th.muted, 0.6), undefined, ATTR.ITALIC);\n      const [cmd, ...rest] = input.split(' ');\n      let x = inner.text(1, 0, cmd, th.accent, undefined, ATTR.BOLD);\n      if (rest.length) x = inner.text(x, 0, ' ' + rest.join(' '), th.ink);\n      // Argument hint after the completed command, until the argument is typed.\n      if (input === '/rollback ') inner.text(x, 0, ARG_HINT, mix(th.bg, th.muted, 0.6));\n      if (blink(t, 0.9)) inner.set(input ? x : 1, 0, undefined, undefined, undefined, ATTR.INVERSE);\n\n      // Command menu, opening upwards from the composer.\n      if (input.startsWith('/') && !input.includes(' ')) {\n        const q = input.slice(1);\n        const hits = CMDS.filter(([c]) => c.slice(1).startsWith(q));\n        const rows = Math.min(hits.length, cy - 1);\n        const mw = Math.min(W, 46);\n        hits.slice(0, rows).forEach(([c, d], i) => {\n          const y = cy - rows + i;\n          const on = i === 0;\n          const bg = on ? tint(ctx, th.accent, 0.25) : th.panel2;\n          b.fill(0, y, mw, 1, ' ', undefined, bg);\n          if (on) b.set(0, y, u ? '▌' : '>', th.accent, bg, ATTR.BOLD);\n          b.text(2, y, c.slice(0, q.length + 1), th.accent, bg, ATTR.BOLD);\n          b.text(2 + q.length + 1, y, c.slice(q.length + 1), on ? th.ink : th.muted, bg, on ? ATTR.BOLD : 0);\n          const dx = 2 + 10;\n          b.text(dx, y, fit(d, Math.max(0, mw - dx - 1), undefined, ctx), on ? th.muted : th.dim, bg);\n        });\n        if (rows && cy - rows - 1 >= ty) b.text(0, cy - rows - 1, fit(`tab complete ${g.mid} ${g.up}/${g.down} choose`, mw, undefined, ctx), th.dim);\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/input/slider.js": "// Sliders: a handle dragged along a ticked track, value label riding along.\nimport { ATTR, uni, phase, ease, mix } from './_input.js';\n\nconst SLIDERS = [\n  ['Replicas', 1, 12, 0, (p) => 2 + ease(p, 0.5, 2.0) * 7 - ease(p, 4.2, 5.0) * 7, (v) => `${Math.round(v)}`],\n  ['CPU limit', 0.25, 4, 2, (p) => 1 + ease(p, 1.2, 2.6) * 2.2 - ease(p, 4.4, 5.2) * 2.2, (v) => `${v.toFixed(2)} cores`],\n  ['Timeout', 100, 5000, 0, (p) => 3000 - ease(p, 2.2, 3.6) * 2400 + ease(p, 4.6, 5.6) * 2400, (v) => `${Math.round(v / 50) * 50} ms`],\n];\n\nexport default {\n  id: 'input-slider',\n  title: 'Sliders',\n  category: 'Toggles & Checkboxes',\n  description: 'Range sliders for replicas, CPU and timeout: handles glide along ticked tracks, filled to the value, with the reading following the handle.',\n  tags: ['slider', 'range', 'control', 'drag', 'form'],\n  kind: 'tui',\n  size: { w: 60, h: 10 },\n  minSize: { w: 34, h: 7 },\n  loop: 6,\n  color: '256',\n  order: 40,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, u = uni(ctx);\n      const p = phase(t, 6);\n      const step = H >= 9 ? 3 : 2;\n      SLIDERS.forEach(([label, lo, hi, , f, fmt], i) => {\n        const y = i * step;\n        if (y + 1 >= H) return;\n        const v = Math.max(lo, Math.min(hi, f(p)));\n        const k = (v - lo) / (hi - lo);\n        const tw = Math.max(8, W - 4);\n        const hx = 1 + Math.round(k * (tw - 1));\n        const active = Math.abs(f(p + 0.05) - f(p)) > 1e-6;\n        b.text(0, y, label, active ? th.accent : th.muted, undefined, ATTR.BOLD);\n        const val = fmt(v);\n        b.text(W - val.length, y, val, active ? th.ink : th.muted, undefined, ATTR.BOLD);\n        for (let x = 0; x < tw; x++) {\n          const filled = 1 + x <= hx;\n          b.set(1 + x, y + 1, u ? (filled ? '━' : '─') : (filled ? '=' : '-'), filled ? mix(th.accent, th.info, x / tw) : th.line);\n          if (step === 3 && y + 2 < H && x % Math.max(1, Math.floor(tw / 10)) === 0) b.set(1 + x, y + 2, u ? '╵' : \"'\", th.dim);\n        }\n        b.set(hx, y + 1, u ? '●' : 'O', active ? th.ink : th.accent, undefined, ATTR.BOLD);\n      });\n    };\n  },\n};\n",
  "prism-term/src/facets/input/tabs.js": "// A tab strip whose underline glides between tabs as the user switches,\n// each tab showing its own content.\nimport { ATTR, glyphs, uni, phase, ease, mix, fit, hbar } from './_input.js';\n\nconst TABS = [['Overview', ''], ['Logs', '12'], ['Traces', ''], ['Metrics', ''], ['Config', '']];\n// [time, tab] switches within the loop.\nconst SWITCHES = [[0, 0], [1.3, 1], [2.7, 2], [4.1, 3], [5.5, 0]];\nconst LOGS = [\n  ['WARN', 'db pool wait 840ms (20/20 in use)'],\n  ['ERROR', 'timeout acquiring connection after 5000ms'],\n  ['INFO', 'GET /cart 200 41ms'],\n  ['WARN', 'db pool wait 1210ms (20/20 in use)'],\n  ['INFO', 'POST /checkout 201 212ms'],\n];\nconst SPANS = [['GET /checkout', 0, 1], ['auth.verify', 0.02, 0.06], ['db.acquire', 0.08, 0.78], ['db.query', 0.8, 0.9], ['render', 0.92, 0.99]];\nconst SPARK = [3, 3, 4, 3, 4, 5, 4, 6, 7, 9, 12, 15, 18, 22, 24, 23, 24, 22, 23, 24];\n\nexport default {\n  id: 'input-tabs',\n  title: 'Tabs',\n  category: 'Tabs & Bars',\n  description: 'A tab strip with a count badge whose underline glides between tabs as the user switches; each tab swaps in its own overview, logs, trace or metrics view.',\n  tags: ['tabs', 'tab bar', 'navigation', 'switcher', 'underline'],\n  kind: 'tui',\n  size: { w: 60, h: 10 },\n  minSize: { w: 40, h: 7 },\n  loop: 7,\n  color: '256',\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 7);\n      let k = 0, prev = 0, at = 0;\n      SWITCHES.forEach(([s, i]) => { if (p >= s) { prev = k; k = i; at = s; } });\n      // Tab geometry.\n      const pos = [];\n      let x = 1;\n      TABS.forEach(([name, badge]) => {\n        const w = name.length + 2 + (badge ? badge.length + 1 : 0);\n        pos.push([x, w]);\n        x += w + 1;\n      });\n      TABS.forEach(([name, badge], i) => {\n        const [tx] = pos[i];\n        const on = i === k;\n        if (tx + name.length + 2 > W) return;\n        const nx = b.text(tx + 1, 0, name, on ? th.ink : th.muted, undefined, on ? ATTR.BOLD : 0);\n        if (badge && nx + badge.length <= W) b.text(nx + 1, 0, badge, on ? th.bg : th.warn, on ? th.warn : mix(th.bg, th.warn, 0.25), ATTR.BOLD);\n      });\n      // Underline glides from the previous tab to the current one.\n      const e = ease(p, at, at + 0.3);\n      const [ax, aw] = pos[prev], [bx, bw] = pos[k];\n      const lx = Math.round(ax + (bx - ax) * e), lw = Math.round(aw + (bw - aw) * e);\n      b.fill(0, 1, W, 1, u ? '─' : '-', th.line);\n      b.fill(lx, 1, Math.min(lw, W - lx), 1, u ? '━' : '=', th.accent, undefined, ATTR.BOLD);\n      const ch = H - 2;\n      const name = TABS[k][0];\n      if (name === 'Overview') {\n        const rows = [['p99 latency', '2.4s', th.neg, 0.92], ['error rate', '3.1%', th.warn, 0.31], ['pods ready', '2/3', th.warn, 0.66], ['cpu', '41%', th.pos, 0.41]];\n        rows.slice(0, ch).forEach(([label, v, c, f], r) => {\n          b.text(1, 2 + r, label.padEnd(13), th.muted);\n          b.text(14, 2 + r, v.padStart(5), c, undefined, ATTR.BOLD);\n          hbar(ctx, b, 21, 2 + r, Math.max(4, W - 23), f, c);\n        });\n      } else if (name === 'Logs') {\n        LOGS.slice(0, ch).forEach(([lvl, msg], r) => {\n          const c = lvl === 'ERROR' ? th.neg : lvl === 'WARN' ? th.warn : th.info;\n          b.text(1, 2 + r, lvl.padEnd(6), c, undefined, ATTR.BOLD);\n          b.text(7, 2 + r, fit(msg, W - 8, undefined, ctx), th.ink);\n        });\n      } else if (name === 'Traces') {\n        const lw2 = 14, tw = Math.max(6, W - lw2 - 9);\n        SPANS.slice(0, ch).forEach(([n, a, z], r) => {\n          b.text(1 + (r ? 1 : 0), 2 + r, fit(n, lw2 - 1, undefined, ctx), r ? th.muted : th.ink);\n          const x0 = lw2 + 1 + Math.round(a * tw), x1 = lw2 + 1 + Math.max(Math.round(a * tw) + 1, Math.round(z * tw));\n          b.fill(x0, 2 + r, x1 - x0, 1, u ? '█' : '#', n === 'db.acquire' ? th.neg : r ? th.info : th.accent);\n          b.text(W - 6, 2 + r, `${((z - a) * 2.4).toFixed(2)}s`.padStart(5), th.dim);\n        });\n      } else if (name === 'Metrics') {\n        const LV = u ? ' ▁▂▃▄▅▆▇█' : ' .:-=+*#%';\n        const top = 3, bottom = H >= 9 ? H - 2 : H - 1, rows = Math.max(1, bottom - top + 1);\n        const sw = Math.max(4, W - 12);\n        b.text(1, 2, 'p99 ms, last 20 min', th.muted);\n        for (let i = 0; i < sw; i++) {\n          // Resample the series across the available width.\n          const f = (i / (sw - 1)) * (SPARK.length - 1), j = Math.floor(f), fr = f - j;\n          const raw = SPARK[j] + ((SPARK[Math.min(SPARK.length - 1, j + 1)] - SPARK[j]) * fr);\n          const v = (raw / 24) * rows;\n          for (let r = 0; r < rows; r++) {\n            const cell = Math.max(0, Math.min(8, Math.round((v - r) * 8)));\n            if (cell) b.set(2 + i, bottom - r, LV[cell], raw > 14 ? th.neg : th.accent);\n          }\n        }\n        b.text(W - 9, top, '2400ms', th.neg, undefined, ATTR.BOLD);\n        b.text(W - 9, bottom, ' 120ms', th.dim);\n      }\n      // Hint, bottom right.\n      const hint = `${g.left}/${g.right} or 1-5`;\n      if (H >= 9) b.text(W - hint.length - 1, H - 1, hint, th.dim);\n    };\n  },\n};\n",
  "prism-term/src/facets/input/text-field.js": "// A labeled text field: placeholder, typing, a validation error, the fix, ✔.\nimport { ATTR, glyphs, uni, phase, typed, blink, tint, mix, fit } from './_input.js';\n\nexport default {\n  id: 'input-text-field',\n  title: 'Text Field with Validation',\n  category: 'Text Fields',\n  description: 'A labeled input with placeholder, blinking caret and inline validation: the user types an invalid hostname, sees the error, fixes it and gets a ✔.',\n  tags: ['input', 'text field', 'validation', 'form', 'caret'],\n  kind: 'tui',\n  size: { w: 56, h: 8 },\n  minSize: { w: 34, h: 5 },\n  loop: 9,\n  color: '16',\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 9);\n      const bad = 'payments_01.internal';\n      const good = 'payments-01.prod.example';\n      // Script: type a hostname with an underscore (live validation flags it),\n      // backspace to 'payments', retype it properly, get the green check.\n      let value;\n      if (p < 3.8) value = typed(bad, p, 0.6, 12);\n      else if (p < 4.3) value = bad.slice(0, Math.max(8, bad.length - Math.floor((p - 3.8) * 24)));\n      else value = 'payments' + typed(good.slice(8), p, 4.3, 12);\n      const state = value.includes('_') ? 'error' : value === good ? 'valid' : value ? 'typing' : 'empty';\n      const fw = Math.min(W - 2, 44);\n      b.text(0, 0, 'Hostname', th.ink, undefined, ATTR.BOLD);\n      b.text(9, 0, '*', th.neg);\n      const border = state === 'error' ? th.neg : state === 'valid' ? th.pos : th.accent;\n      const bg = tint(ctx, border, 0.08);\n      // Field: a tinted well underlined in the state color.\n      b.fill(0, 1, fw, 1, ' ', undefined, bg);\n      if (!value) b.text(2, 1, 'e.g. api-01.prod.example', mix(th.bg, th.muted, 0.6), bg, ATTR.ITALIC);\n      else b.text(1, 1, value.slice(-(fw - 3)), th.ink, bg);\n      const cx = 1 + Math.min(value.length, fw - 3);\n      if (state !== 'valid' && blink(t, 0.9)) b.set(cx, 1, u ? '▏' : '_', th.ink, bg);\n      if (state === 'valid') b.text(fw - 2, 1, g.check, th.pos, bg, ATTR.BOLD);\n      if (state === 'error') b.text(fw - 2, 1, g.cross, th.neg, bg, ATTR.BOLD);\n      b.fill(0, 2, fw, 1, u ? '▔' : '-', border);\n      if (H > 3) {\n        const msg = state === 'error' ? `${g.cross} Underscores aren't allowed in hostnames (RFC 1123)`\n          : state === 'valid' ? `${g.check} Resolves to 10.20.2.41`\n            : 'Lowercase letters, digits, dots and hyphens';\n        b.text(0, 3, fit(msg, W, undefined, ctx), state === 'error' ? th.neg : state === 'valid' ? th.pos : th.dim);\n      }\n      if (H > 5) b.text(0, H - 1, `enter submit ${g.mid} esc cancel ${g.mid} ctrl-u clear`, th.dim);\n    };\n  },\n};\n",
  "prism-term/src/facets/input/textarea.js": "// A multi-line editor: a commit message gets typed with soft line numbers,\n// a 50/72 ruler and a character count.\nimport { ATTR, uni, phase, typed, blink, panel, mix } from './_input.js';\n\nconst MSG = 'Raise checkout DB pool to 40\\n\\nv2.14.1 raised worker concurrency to 32 but left\\nthe pool at 20, so requests queued for a connection.\\nSize the pool to the workers and alert on pool\\nwait time.';\n\nexport default {\n  id: 'input-textarea',\n  title: 'Commit Message Editor',\n  category: 'Text Fields',\n  description: 'A multi-line textarea where a commit message is typed: line numbers, a 50/72 column guide, subject-length warning and a live character count.',\n  tags: ['textarea', 'editor', 'multiline', 'commit', 'git'],\n  kind: 'tui',\n  size: { w: 64, h: 12 },\n  minSize: { w: 40, h: 7 },\n  loop: 12,\n  color: '256',\n  order: 40,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, u = uni(ctx);\n      const p = phase(t, 12);\n      const text = typed(MSG, p, 0.2, 26);\n      const lines = text.split('\\n');\n      const inner = panel(ctx, b, 0, 0, W, H, { title: 'COMMIT_EDITMSG', footer: `${text.replace(/\\n/g, '').length} chars`, color: th.line });\n      const iw = inner.width, ih = inner.height;\n      const guide = (col, c) => { if (5 + col < iw) for (let y = 0; y < ih; y++) inner.set(5 + col, y, u ? '┊' : ':', c); };\n      guide(50, mix(th.bg, th.warn, 0.35));\n      guide(72, mix(th.bg, th.neg, 0.35));\n      const first = Math.max(0, lines.length - ih);\n      for (let r = 0; r < ih; r++) {\n        const i = first + r;\n        inner.text(0, r, String(i + 1).padStart(3), i < lines.length ? th.dim : mix(th.bg, th.dim, 0.4));\n        if (i >= lines.length) { inner.text(4, r, '~', mix(th.bg, th.accent, 0.5)); continue; }\n        const line = lines[i];\n        [...line].forEach((ch, x) => { if (5 + x < iw) inner.text(5 + x, r, ch, i === 0 && x >= 50 ? th.warn : i === 0 ? th.ink : th.muted, undefined, i === 0 ? ATTR.BOLD : 0); });\n        if (i === lines.length - 1 && blink(t, 0.8)) inner.set(5 + Math.min(line.length, iw - 6), r, u ? '▏' : '_', th.accent);\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/input/toggles.js": "// A settings pane of switches that flip with a sliding knob.\nimport { ATTR, uni, glyphs, phase, ease, panel, tint, mix, fit } from './_input.js';\n\nconst SETTINGS = [\n  ['Auto-approve diagnostics', true, 1.0, 'Read-only checks run unasked; fixes still ask.'],\n  ['Stream reasoning summaries', false, 1.8, 'Show a short summary while the model works.'],\n  ['Page on-call for sev1', true, 0, 'Locked by your org policy.'],\n  ['Compact log view', false, 2.6, 'One line per event; expand with enter.'],\n  ['Telemetry to OTLP', true, 3.4, 'Export spans to 127.0.0.1:4318.'],\n];\n\nexport default {\n  id: 'input-toggles',\n  title: 'Toggle Switches',\n  category: 'Toggles & Checkboxes',\n  description: 'A settings pane of on/off switches whose knobs slide across with color easing as a scripted user flips them, labels dimming when off.',\n  tags: ['toggle', 'switch', 'settings', 'preferences', 'on off'],\n  kind: 'tui',\n  size: { w: 52, h: 13 },\n  minSize: { w: 34, h: 6 },\n  loop: 6,\n  color: '256',\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, u = uni(ctx), g = glyphs(ctx);\n      const p = phase(t, 6);\n      const inner = panel(ctx, b, 0, 0, W, H, { title: 'Settings', footer: `space toggle ${g.mid} ${g.up}/${g.down} move`, color: th.line });\n      const rows = Math.min(SETTINGS.length, Math.floor(inner.height / (inner.height >= SETTINGS.length * 2 ? 2 : 1)));\n      const step = inner.height >= SETTINGS.length * 2 ? 2 : 1;\n      let focus = 0;\n      SETTINGS.forEach(([, , at], i) => { if (at && p >= at - 0.3) focus = i; });\n      for (let i = 0; i < rows; i++) {\n        const [label, init, at] = SETTINGS[i];\n        const k = at ? ease(p, at, at + 0.25) * (1 - ease(p, 5.4, 5.7)) : 0;\n        const val = init ? 1 - k : k; // 0 off → 1 on\n        const y = i * step;\n        const on = i === focus;\n        const sw = u ? 6 : 5;\n        const sx = inner.width - sw - 1;\n        if (on) inner.fill(0, y, inner.width, 1, ' ', undefined, tint(ctx, th.accent, 0.1));\n        inner.text(1, y, fit(label, Math.max(0, sx - 2), undefined, ctx), val > 0.5 ? th.ink : th.muted, undefined, on ? ATTR.BOLD : 0);\n        const track = mix(th.line, th.pos, val);\n        if (u) {\n          for (let x = 0; x < sw; x++) inner.set(sx + x, y, '━', track);\n          inner.set(sx + Math.round(val * (sw - 1)), y, '●', val > 0.5 ? th.ink : th.muted, undefined, ATTR.BOLD);\n        } else inner.text(sx, y, val > 0.5 ? '[ON ]' : '[OFF]', val > 0.5 ? th.pos : th.dim, undefined, ATTR.BOLD);\n        if (!at && label.length + 3 < sx - 7) inner.text(sx - 7, y, 'locked', th.dim, undefined, ATTR.ITALIC);\n      }\n      // Help for the focused row, when there's room under the list.\n      if (inner.height - 1 > (rows - 1) * step + 1) inner.text(1, inner.height - 1, fit(SETTINGS[focus][3], inner.width - 2, undefined, ctx), th.dim, undefined, ATTR.ITALIC);\n    };\n  },\n};\n",
  "prism-term/src/facets/input/wizard.js": "// A multi-step wizard: a stepper header fills in as the user moves through\n// Source → Target → Schedule → Review, content sliding in on each step.\nimport { ATTR, glyphs, uni, phase, typed, blink, ease, tint, mix, fit, button, panel, spin } from './_input.js';\n\nconst STEPS = ['Source', 'Target', 'Schedule', 'Review'];\nconst STARTS = [0, 2.2, 4.4, 6.4];\nconst SNAPS = [['snap-0413-0000', 'today 00:00', '48 GB'], ['snap-0413-0600', 'today 06:00', '48 GB'], ['snap-0412-1800', 'yesterday 18:00', '47 GB']];\nconst T = { snapDown: 1.0, next1: 1.8, type: 2.6, next2: 4.0, winDown: 5.2, next3: 6.0, restore: 8.2 };\n\nexport default {\n  id: 'input-wizard',\n  title: 'Setup Wizard',\n  category: 'Dialogs & Prompts',\n  description: 'A four-step restore wizard: the stepper header fills as the user picks a snapshot, names the target, schedules it and reviews before pressing Restore.',\n  tags: ['wizard', 'stepper', 'multi-step', 'form', 'onboarding'],\n  kind: 'tui',\n  size: { w: 64, h: 12 },\n  minSize: { w: 44, h: 11 },\n  loop: 10,\n  color: '256',\n  order: 50,\n  setup(ctx) {\n    const th = ctx.theme;\n    return (t) => {\n      const b = ctx.buffer, W = ctx.width, H = ctx.height, g = glyphs(ctx), u = uni(ctx);\n      const p = phase(t, 10);\n      let step = 0;\n      STARTS.forEach((s, i) => { if (p >= s) step = i; });\n      const inner = panel(ctx, b, 0, 0, W, H, { title: 'Restore database', color: th.line });\n      const iw = inner.width, ih = inner.height;\n\n      // Stepper: nodes joined by a track that fills as steps complete.\n      const seg = Math.floor((iw - 8) / (STEPS.length - 1));\n      const nx = (i) => 4 + i * seg;\n      for (let i = 0; i < STEPS.length - 1; i++) {\n        const fill = ease(p, STARTS[i + 1] - 0.3, STARTS[i + 1]);\n        for (let x = nx(i) + 1; x < nx(i + 1); x++) {\n          const on = (x - nx(i)) / seg <= fill;\n          inner.set(x, 0, u ? (on ? '━' : '─') : (on ? '=' : '-'), on ? th.pos : th.line);\n        }\n      }\n      STEPS.forEach((name, i) => {\n        const done = i < step, cur = i === step;\n        const mark = done ? g.check : cur ? (u ? '●' : '@') : g.ring;\n        inner.set(nx(i), 0, mark, done ? th.pos : cur ? th.accent : th.dim, undefined, ATTR.BOLD);\n        const lx = Math.max(0, Math.min(iw - name.length, nx(i) - Math.floor(name.length / 2)));\n        inner.text(lx, 1, name, done ? th.pos : cur ? th.ink : th.dim, undefined, cur ? ATTR.BOLD : 0);\n      });\n\n      // Step content slides in from the right.\n      const slide = Math.round((1 - ease(p, STARTS[step], STARTS[step] + 0.3)) * 6);\n      const cx = 2 + slide, cy = 3;\n      const body = inner.view(0, cy, iw, Math.max(0, ih - cy - 2));\n      const bh = body.height;\n      const row = (r, ...segs) => { if (r >= bh) return; let x = cx; for (const [s, c, a, bg] of segs) x = body.text(x, r, s, c, bg, a); };\n      if (step === 0) {\n        row(0, ['Which snapshot?', th.ink, ATTR.BOLD]);\n        const sel = p >= T.snapDown ? 1 : 0;\n        SNAPS.forEach(([id, when, size], i) => {\n          if (1 + i >= bh) return;\n          const on = i === sel;\n          if (on) body.fill(cx - 1, 1 + i, Math.max(0, iw - cx), 1, ' ', undefined, tint(ctx, th.accent, 0.12));\n          row(1 + i, [on ? g.radioOn : g.radioOff, on ? th.accent : th.dim, ATTR.BOLD], [` ${id}`, on ? th.ink : th.muted, on ? ATTR.BOLD : 0], iw > 52 ? [`  ${when.padEnd(16)}${size}`, th.dim] : [`  ${size}`, th.dim]);\n        });\n      } else if (step === 1) {\n        row(0, ['Restore into a new instance named', th.ink, ATTR.BOLD]);\n        const v = typed('checkout-db-restore', p, T.type, 14);\n        const fw = Math.min(iw - cx - 2, 32), fbg = mix(th.bg, th.panel2, 0.8);\n        body.fill(cx, 1, fw, 1, ' ', undefined, fbg);\n        const end = body.text(cx + 1, 1, v, th.ink, fbg);\n        if (blink(t, 0.9) && p < T.next2) body.set(end, 1, undefined, undefined, undefined, ATTR.INVERSE);\n        if (bh > 2) row(2, [v.length >= 19 ? `${g.check} name is available` : 'lowercase, digits and hyphens', v.length >= 19 ? th.pos : th.dim]);\n      } else if (step === 2) {\n        row(0, ['When should it run?', th.ink, ATTR.BOLD]);\n        const win = p >= T.winDown;\n        row(1, [win ? g.radioOff : g.radioOn, win ? th.dim : th.accent, ATTR.BOLD], [' Now', win ? th.muted : th.ink]);\n        row(2, [win ? g.radioOn : g.radioOff, win ? th.accent : th.dim, ATTR.BOLD], [' Next maintenance window', win ? th.ink : th.muted, win ? ATTR.BOLD : 0]);\n        if (bh > 3) row(3, [`   Sat 17 Oct 02:00${iw > 52 ? ' (in 3 days)' : ''}`, th.dim]);\n      } else {\n        const kv = [['Snapshot', 'snap-0413-0600'], ['Target', 'checkout-db-restore'], ['When', 'Sat 17 Oct 02:00'], ['Est. time', '~18 min']];\n        kv.forEach(([k, v], i) => row(i, [g.check, th.pos], [` ${k.padEnd(10)}`, th.muted], [v, th.ink, ATTR.BOLD]));\n      }\n\n      // Footer buttons.\n      const by = ih - 1;\n      const last = step === STEPS.length - 1;\n      if (p >= T.restore + 0.2) {\n        inner.text(2, by, fit(`${p < T.restore + 1.2 ? spin(ctx, t) : g.check} Restore queued ${g.mid} job #5521`, iw - 4, undefined, ctx), p < T.restore + 1.2 ? th.warn : th.pos, undefined, ATTR.BOLD);\n        return;\n      }\n      inner.text(2, by, `Step ${step + 1} of ${STEPS.length}`, th.dim);\n      const nextLabel = last ? 'Restore' : 'Next';\n      const nw = u ? nextLabel.length + 2 : nextLabel.length + 4;\n      const bw = u ? 6 : 8;\n      const nextX = iw - nw - 1;\n      const pressAt = [T.next1, T.next2, T.next3, T.restore][step];\n      button(ctx, inner, nextX - bw - 2, by, 'Back', { focused: false });\n      button(ctx, inner, nextX, by, nextLabel, { focused: true, color: last ? th.pos : th.accent, pressed: p > pressAt - 0.18 && p < pressAt + 0.2 });\n    };\n  },\n};\n",
  "prism-term/src/facets/lab/_lab.js": "// Shared pieces for the Animation Lab: a time-cycler, clipped labels, panel\n// grids in cells, a canvas-pixel ↔ cell mapper, and the two scenes every\n// transition study cuts between (a daybreak landscape and a night skyline),\n// rendered per pixel into plain Int32Arrays so transitions can mask them.\nimport { mix, NONE } from '../../color.js';\nimport { easing } from '../../easing.js';\n\n/** Which step of an n-step cycle of `dur` seconds t falls in, and how far through it (0…1). */\nexport function cycle(t, n, dur) {\n  const T = n * dur, tl = ((t % T) + T) % T, i = Math.min(n - 1, Math.floor(tl / dur));\n  return { i, f: (tl - i * dur) / dur };\n}\n\n/** Text clipped to the surface (and to maxW), never wrapping. */\nexport function put(buf, x, y, s, fg, bg, attr, maxW = Infinity) {\n  if (y < 0 || y >= buf.height || x >= buf.width) return;\n  let str = String(s);\n  if (x < 0) { str = str.slice(-x); x = 0; }\n  str = str.slice(0, Math.max(0, Math.min(maxW, buf.width - x)));\n  if (str) buf.text(x, y, str, fg, bg, attr);\n}\n\n/** Split a w×h cell area into cols×rows panels separated by `gap` cells. */\nexport function grid(w, h, cols, rows, gapX = 1, gapY = 1, x0 = 0, y0 = 0) {\n  const pw = Math.floor((w - gapX * (cols - 1)) / cols), ph = Math.floor((h - gapY * (rows - 1)) / rows);\n  const out = [];\n  for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) out.push({ x: x0 + c * (pw + gapX), y: y0 + r * (ph + gapY), w: pw, h: ph });\n  return out;\n}\n\n/** Cell rect → canvas pixel rect for a canvas `cv` (works in any canvas mode). */\nexport const pxRect = (cv, r) => ({ x: r.x * cv.sx, y: r.y * cv.sy, w: r.w * cv.sx, h: r.h * cv.sy });\n\n/** A short eased A→B→A schedule: hold, go, hold, come back. Returns progress 0…1 toward B. */\nexport function pingPongProgress(f, ease, hold = 0.18) {\n  const g = (1 - 2 * hold) / 2; // each move takes this share of the cycle\n  if (f < hold / 2) return 0;\n  if (f < hold / 2 + g) return ease((f - hold / 2) / g);\n  if (f < hold * 1.5 + g) return 1;\n  if (f < hold * 1.5 + 2 * g) return 1 - ease((f - hold * 1.5 - g) / g);\n  return 0;\n}\n\nconst hash = (x, y) => ((((x * 374761393) ^ (y * 668265263)) >>> 0) % 10007) / 10007;\n\n/**\n * The two transition scenes. Each is a function (out, t) that fills `out`\n * (Int32Array, W×H canvas pixels) with an opaque picture.\n */\nexport function scenes(W, H, asp, rng) {\n  // A: daybreak — graded sky, a haloed sun, drifting clouds, two hill ridges.\n  const clouds = Array.from({ length: 4 }, () => ({ x: rng(), y: 0.12 + rng() * 0.3, w: 0.12 + rng() * 0.12, s: 0.01 + rng() * 0.015 }));\n  const hill = (x, k) => H * (0.62 + k * 0.12) + Math.sin(x * (0.06 + k * 0.03) + k * 2) * H * 0.06 + Math.sin(x * 0.17 + k) * H * 0.02;\n  const day = (out, t) => {\n    const sx = W * 0.7, sy = H * 0.34, sr = Math.min(W, H * asp) * 0.11;\n    for (let y = 0; y < H; y++) {\n      const sky = mix(0x2f6fd0, 0xffcf8f, Math.pow(y / H, 1.4));\n      for (let x = 0; x < W; x++) {\n        let c = sky;\n        const d = Math.hypot(x - sx, (y - sy) * asp) / sr;\n        if (d < 1) c = mix(0xfff1b8, 0xffe07a, d);\n        else if (d < 3) c = mix(c, 0xfff1b8, 0.5 * Math.pow(1 - (d - 1) / 2, 2));\n        for (const cl of clouds) {\n          const cx = (((cl.x + t * cl.s) % 1.3) - 0.15) * W, cy = cl.y * H, cw = cl.w * W;\n          const q = ((x - cx) / cw) ** 2 + ((y - cy) / (cw * 0.28 / asp)) ** 2;\n          if (q < 1) c = mix(c, 0xffffff, 0.85 * (1 - q * q));\n        }\n        if (y > hill(x, 0)) c = mix(0x3f8f4a, 0x2a6b36, (y - hill(x, 0)) / (H * 0.4));\n        if (y > hill(x, 1)) c = mix(0x2f7a3a, 0x1d4d26, (y - hill(x, 1)) / (H * 0.3));\n        out[y * W + x] = c;\n      }\n    }\n  };\n  // B: night — deep sky, twinkling stars, a crescent moon, a skyline with lit windows.\n  const stars = Array.from({ length: Math.round((W * H) / 60) }, () => ({ x: Math.floor(rng() * W), y: Math.floor(rng() * H * 0.6), p: rng() * 6.28 }));\n  const towers = [];\n  for (let x = 0; x < W;) {\n    const w = Math.max(3, Math.round(W * (0.05 + rng() * 0.06))), h = H * (0.22 + rng() * 0.38);\n    towers.push({ x, w, top: H - h, seed: Math.floor(rng() * 1e6) });\n    x += w + (rng() < 0.3 ? 1 : 0);\n  }\n  const sky = new Int32Array(H);\n  for (let y = 0; y < H; y++) sky[y] = mix(0x070b1f, 0x2a2c66, Math.pow(y / H, 1.6));\n  const night = (out, t) => {\n    for (let y = 0; y < H; y++) out.fill(sky[y], y * W, (y + 1) * W);\n    for (const s of stars) out[s.y * W + s.x] = mix(sky[s.y], 0xe8ecff, 0.4 + 0.6 * (0.5 + 0.5 * Math.sin(t * 2 + s.p)));\n    const mx = W * 0.22, my = H * 0.26, mr = Math.min(W, H * asp) * 0.09;\n    for (let y = Math.floor(my - mr / asp - 1); y <= my + mr / asp + 1; y++) for (let x = Math.floor(mx - mr - 1); x <= mx + mr + 1; x++) {\n      if (x < 0 || y < 0 || x >= W || y >= H) continue;\n      const d = Math.hypot(x - mx, (y - my) * asp), d2 = Math.hypot(x - mx - mr * 0.45, (y - my + mr * 0.15 / asp) * asp);\n      if (d < mr && d2 > mr * 0.85) out[y * W + x] = 0xf4f1d0;\n    }\n    for (const tw of towers) {\n      for (let y = Math.max(0, Math.floor(tw.top)); y < H; y++) for (let x = tw.x; x < Math.min(W, tw.x + tw.w); x++) {\n        const lx = x - tw.x, ly = y - Math.floor(tw.top);\n        const win = lx > 0 && lx < tw.w - 1 && ly > 0 && lx % 2 === 1 && ly % 2 === 1;\n        const on = win && hash(x + tw.seed, y) < 0.45 + 0.1 * Math.sin(t * 0.7 + tw.seed);\n        out[y * W + x] = on ? mix(0xffc861, 0xfff0b0, hash(x, y + tw.seed)) : mix(0x10142a, 0x1c2140, lx / tw.w);\n      }\n    }\n  };\n  return { day, night };\n}\n\n/** Composite two scene buffers through a mask (0 = A, 1 = B) into the canvas pixels. */\nexport function composite(px, A, B, n, mask) {\n  for (let i = 0; i < n; i++) {\n    const m = mask(i);\n    px[i] = m <= 0 ? A[i] : m >= 1 ? B[i] : mix(A[i], B[i], m);\n  }\n}\n\nexport { NONE };\n\n/**\n * Shared engine for the transition studies: cycles through `variants`, each\n * going day → night → day with an eased hold-move-hold schedule, and labels\n * the one on screen. A variant is { name, mask(x, y, p, W, H, asp) → 0…1 }\n * or { name, draw(px, A, B, W, H, asp, p) } for moves that resample.\n */\nexport function transitionSetup(ctx, variants, { dur = 4, ease = easing.inOutCubic } = {}) {\n  const label = ctx.height >= 8;\n  const cv = ctx.canvas('field', { rows: label ? ctx.height - 1 : ctx.height });\n  const W = cv.width, H = cv.height, asp = cv.aspect, th = ctx.theme;\n  const { day, night } = scenes(W, H, asp, ctx.rng);\n  const A = new Int32Array(W * H), B = new Int32Array(W * H);\n  return (t) => {\n    const { i, f } = cycle(t, variants.length, dur);\n    const v = variants[i];\n    const p = pingPongProgress(f, v.ease || ease);\n    day(A, t); night(B, t);\n    if (v.draw) v.draw(cv.px, A, B, W, H, asp, p);\n    else for (let y = 0, k = 0; y < H; y++) for (let x = 0; x < W; x++, k++) {\n      const m = p <= 0 ? 0 : p >= 1 ? 1 : v.mask(x + 0.5, y + 0.5, p, W, H, asp);\n      cv.px[k] = m <= 0 ? A[k] : m >= 1 ? B[k] : mix(A[k], B[k], m);\n    }\n    cv.paint(ctx.buffer);\n    if (label) {\n      const name = `${i + 1}/${variants.length}  ${v.name}`;\n      put(ctx.buffer, 1, ctx.height - 1, name, th.ink, undefined, undefined, ctx.width - 2);\n      const pct = `${Math.round(p * 100)}%`.padStart(4);\n      if (ctx.width > name.length + 8) put(ctx.buffer, ctx.width - 5, ctx.height - 1, pct, th.muted);\n    }\n  };\n}\n",
  "prism-term/src/facets/lab/bezier.js": "// A cubic-bézier timing editor, the kind in every browser's devtools. The two\n// handles glide between the CSS presets (and a couple of custom curves); the\n// curve redraws, a dot runs it by *time* (x solved for the curve parameter by\n// Newton's method, as browsers do), and a swatch slides along a track with\n// exactly that timing beside the live cubic-bezier(…) readout.\nimport { easing } from '../../easing.js';\nimport { mix } from '../../color.js';\nimport { put } from './_lab.js';\n\nconst PRESETS = [\n  ['ease', [0.25, 0.1, 0.25, 1]],\n  ['ease-in', [0.42, 0, 1, 1]],\n  ['ease-out', [0, 0, 0.58, 1]],\n  ['ease-in-out', [0.42, 0, 0.58, 1]],\n  ['back-out', [0.34, 1.56, 0.64, 1]],\n  ['anticipate', [0.68, -0.55, 0.27, 1.55]],\n];\nconst HOLD = 2.4, MORPH = 0.6;\n\nconst bez = (a, b, s) => 3 * a * s * (1 - s) * (1 - s) + 3 * b * s * s * (1 - s) + s * s * s;\nconst dbez = (a, b, s) => 3 * a * (1 - s) * (1 - s) + 6 * (b - a) * s * (1 - s) + 3 * (1 - b) * s * s;\nfunction solve(x1, x2, x) {\n  let s = x;\n  for (let i = 0; i < 6; i++) { const d = dbez(x1, x2, s); if (Math.abs(d) < 1e-6) break; s -= (bez(x1, x2, s) - x) / d; }\n  if (s < 0 || s > 1 || Math.abs(bez(x1, x2, s) - x) > 1e-4) { // fall back to bisection\n    let lo = 0, hi = 1;\n    for (let i = 0; i < 30; i++) { s = (lo + hi) / 2; if (bez(x1, x2, s) < x) lo = s; else hi = s; }\n  }\n  return s;\n}\n\nexport default {\n  id: 'lab-bezier',\n  title: 'Cubic-Bézier Timing',\n  gallery: 'lab',\n  category: 'Easing & Timing',\n  kind: 'chart',\n  tags: ['bezier', 'cubic-bezier', 'css', 'timing function', 'easing', 'devtools'],\n  order: 30,\n  description: 'A devtools-style cubic-bézier editor: handles glide between CSS presets while a dot and a swatch run the timing.',\n  size: { w: 64, h: 20 },\n  minSize: { w: 24, h: 8 },\n  loop: PRESETS.length * (HOLD + MORPH),\n  canvas: 'line',\n  palette: 'theme',\n  params: {},\n  setup(ctx) {\n    const cv = ctx.canvas('line');\n    const th = ctx.theme;\n    // Square-ish plot on the left (in pixels), readout and swatch on the right.\n    const plotCells = Math.min(ctx.width - 2, Math.max(10, Math.round((ctx.height - 1) * 2.1)));\n    const P = { x: cv.sx * 2, y: cv.sy * 1, w: (plotCells - 3) * cv.sx, h: (ctx.height - 3) * cv.sy };\n    const VMIN = -0.6, VMAX = 1.6;\n    const X = (u) => P.x + u * P.w, Y = (v) => P.y + (1 - (v - VMIN) / (VMAX - VMIN)) * P.h;\n    const guide = mix(th.bg, th.muted, 0.45), handleC = th.info;\n    const right = plotCells + 1;\n    return (t) => {\n      cv.clear();\n      const per = HOLD + MORPH, n = Math.floor(t / per), ph = t - n * per;\n      const A = PRESETS[((n % PRESETS.length) + PRESETS.length) % PRESETS.length], B = PRESETS[(((n + 1) % PRESETS.length) + PRESETS.length) % PRESETS.length];\n      const m = ph < HOLD ? 0 : easing.inOutCubic((ph - HOLD) / MORPH);\n      const c = A[1].map((v, i) => v + (B[1][i] - v) * m);\n      const [x1, y1, x2, y2] = c;\n      // Frame: dotted unit square.\n      for (let x = 0; x <= P.w; x += 2) { cv.set(X(0) + x, Y(0), guide); cv.set(X(0) + x, Y(1), guide); }\n      for (let y = Y(1); y <= Y(0); y += 2) { cv.set(X(0), y, guide); cv.set(X(1), y, guide); }\n      // Handles.\n      cv.line(X(0), Y(0), X(x1), Y(y1), handleC); cv.line(X(1), Y(1), X(x2), Y(y2), handleC);\n      cv.circle(X(x1), Y(y1), 1.5, handleC, true); cv.circle(X(x2), Y(y2), 1.5, handleC, true);\n      // Curve.\n      let px = X(0), py = Y(0);\n      for (let i = 1; i <= 80; i++) {\n        const s = i / 80, nx = X(bez(x1, x2, s)), ny = Y(bez(y1, y2, s));\n        cv.line(px, py, nx, ny, th.ink); px = nx; py = ny;\n      }\n      // Run it by time.\n      const run = Math.min(1, Math.max(0, (ph - 0.3) / (HOLD - 0.6)));\n      const s = solve(x1, x2, run), v = bez(y1, y2, s);\n      cv.circle(X(run), Y(v), 1.5, th.accent, true);\n      for (let y = Y(Math.max(0, Math.min(1, v))); y <= Y(0); y += 3) cv.set(X(run), y, guide);\n      // Swatch on a track, on the right.\n      const tx0 = (right + 1) * cv.sx, tx1 = (ctx.width - 2) * cv.sx, ty = Math.round(ctx.height * 0.62) * cv.sy;\n      if (tx1 - tx0 > 8) {\n        for (let x = tx0; x <= tx1; x += 2) cv.set(x, ty + cv.sy, guide);\n        const sx = tx0 + Math.max(-0.6, Math.min(1.6, v)) * (tx1 - tx0 - 4);\n        for (let yy = 0; yy < cv.sy; yy++) for (let xx = 0; xx < 4; xx++) cv.set(sx + xx, ty + yy, th.accent);\n      }\n      cv.paint(ctx.buffer);\n      const name = m < 0.5 ? A[0] : B[0];\n      put(ctx.buffer, right, 1, name, th.ink, undefined, undefined, ctx.width - right);\n      const vals = c.map((v) => v.toFixed(2)), rw = ctx.width - right;\n      if (rw >= 34) put(ctx.buffer, right, 2, `cubic-bezier(${vals.join(', ')})`, th.muted, undefined, undefined, rw);\n      else { put(ctx.buffer, right, 2, 'cubic-bezier(', th.muted, undefined, undefined, rw); put(ctx.buffer, right + 1, 3, `${vals[0]}, ${vals[1]},`, th.muted, undefined, undefined, rw - 1); put(ctx.buffer, right + 1, 4, `${vals[2]}, ${vals[3]})`, th.muted, undefined, undefined, rw - 1); }\n      put(ctx.buffer, right, 6, `t ${run.toFixed(2)} → ${v.toFixed(2)}`, th.info, undefined, undefined, rw);\n      put(ctx.buffer, 0, ctx.height - 1, ' time →', th.muted, undefined, undefined, plotCells);\n    };\n  },\n};\n",
  "prism-term/src/facets/lab/blinds.js": "// Blinds and tiles: venetian blinds (every slat opening at once), vertical\n// blinds swept left to right, a checkerboard in two passes, and square tiles\n// that close in a diagonal stagger — the \"many small transitions\" family.\nimport { transitionSetup } from './_lab.js';\n\nconst clamp = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);\nconst VARIANTS = [\n  {\n    name: 'blinds · venetian',\n    mask: (x, y, p, W, H) => { const slat = Math.max(3, Math.round(H / 6)); return clamp((p * slat - (y % slat)) / 0.8); },\n  },\n  {\n    name: 'blinds · vertical, swept',\n    mask: (x, y, p, W) => {\n      const slat = Math.max(4, Math.round(W / 10)), k = Math.floor(x / slat) / Math.ceil(W / slat);\n      const q = clamp(p * 1.6 - k * 0.6); // later slats start later\n      return clamp((q * slat - (x % slat)) / 0.8);\n    },\n  },\n  {\n    name: 'tiles · checkerboard, two passes',\n    mask: (x, y, p, W, H, asp) => {\n      const s = Math.max(3, Math.round(W / 12)), odd = (Math.floor(x / s) + Math.floor((y * asp) / s)) & 1;\n      return clamp(p * 2 - odd);\n    },\n  },\n  {\n    name: 'tiles · diagonal stagger',\n    mask: (x, y, p, W, H, asp) => {\n      const s = Math.max(3, Math.round(W / 12)), cx = Math.floor(x / s), cy = Math.floor((y * asp) / s);\n      const n = Math.ceil(W / s) + Math.ceil((H * asp) / s), d = (cx + cy) / n;\n      const q = clamp((p - d * 0.6) / 0.4); // each tile's own progress\n      // Each tile shrinks its A-colored square toward its center.\n      const lx = Math.abs((x % s) / s - 0.5), ly = Math.abs((((y * asp) % s) / s) - 0.5);\n      return Math.max(lx, ly) > 0.5 * (1 - q) ? 1 : 0;\n    },\n  },\n];\n\nexport default {\n  id: 'lab-blinds',\n  title: 'Blinds & Tiles',\n  gallery: 'lab',\n  category: 'Transitions',\n  kind: 'scene',\n  tags: ['transition', 'blinds', 'venetian', 'checkerboard', 'tiles', 'stagger'],\n  order: 50,\n  description: 'Many small transitions at once: venetian blinds, swept vertical blinds, a checkerboard and diagonally staggered tiles.',\n  size: { w: 60, h: 20 },\n  minSize: { w: 20, h: 7 },\n  loop: VARIANTS.length * 4,\n  canvas: 'field',\n  palette: 'theme',\n  params: {},\n  setup: (ctx) => transitionSetup(ctx, VARIANTS),\n};\n",
  "prism-term/src/facets/lab/bounce.js": "// The animator's bouncing-ball exercise: a ball thrown across the stage,\n// losing a little height each bounce (restitution 0.7), stretching along its\n// speed in the air and squashing on every impact — with an onion skin of where\n// it was every tenth of a second, so the arcs and the spacing read at a glance.\nimport { mix } from '../../color.js';\nimport { put } from './_lab.js';\n\nconst RUN = 4.2, HZ = 240, E = 0.7;\n\nexport default {\n  id: 'lab-bounce',\n  title: 'Bounce, Squash & Stretch',\n  gallery: 'lab',\n  category: 'Springs & Physics',\n  kind: 'scene',\n  tags: ['bounce', 'squash and stretch', 'onion skin', 'arcs', 'spacing', 'animation principles'],\n  order: 20,\n  description: 'The bouncing-ball exercise: decaying bounces, stretch in flight, squash on impact and an onion skin of its spacing.',\n  size: { w: 64, h: 20 },\n  minSize: { w: 24, h: 8 },\n  loop: RUN,\n  canvas: 'field',\n  palette: 'prism',\n  params: {},\n  setup(ctx) {\n    const label = ctx.height >= 10;\n    const cv = ctx.canvas('field', { rows: label ? ctx.height - 1 : ctx.height });\n    const W = cv.width, H = cv.height, asp = cv.aspect, th = ctx.theme, pal = ctx.palette();\n    const HP = H * asp, R = Math.max(1.5, Math.min(W, HP) * 0.075), floor = HP - 2;\n    const G = HP * 3.2, ball = pal(0.02);\n    let s, run = -1, acc = 0, rec = 0, squash = 0;\n    const ghosts = [];\n    const reset = () => { s = { x: R * 1.5, y: floor - HP * 0.82, vx: (W - R * 3) / (RUN * 0.92), vy: 0 }; ghosts.length = 0; squash = 0; };\n    return (t, dt) => {\n      const r = Math.floor(t / RUN);\n      if (r !== run) { run = r; reset(); acc = 0; }\n      acc += Math.min(0.25, Math.max(0, dt)) * HZ;\n      for (; acc >= 1; acc--) {\n        s.vy += G / HZ; s.x += s.vx / HZ; s.y += s.vy / HZ;\n        if (s.y > floor - R && s.vy > 0) {\n          squash = Math.min(0.5, s.vy / (HP * 6)); // harder hits squash more\n          s.y = floor - R; s.vy = -s.vy * E;\n          if (Math.abs(s.vy) < HP * 0.25) s.vy = 0;\n        }\n        squash *= Math.pow(0.0005, 1 / HZ);\n        if (s.x > W - R) { s.x = W - R; s.vx = 0; }\n        if (++rec % (HZ / 10) === 0) { ghosts.push([s.x, s.y]); if (ghosts.length > 14) ghosts.shift(); }\n      }\n      cv.clear();\n      for (let x = 0; x < W; x++) cv.set(x, Math.floor(floor / asp) + 1, mix(th.bg, th.line, 0.9));\n      ghosts.forEach(([gx, gy], i) => {\n        const k = (i + 1) / ghosts.length;\n        cv.ellipse(gx, gy / asp, Math.max(0.6, R * 0.22), Math.max(0.6, R * 0.22) / asp, mix(th.bg, th.muted, 0.35 + 0.5 * k), true);\n      });\n      // Shadow, tighter as the ball nears the floor.\n      const hgt = Math.max(0, (floor - R - s.y) / HP), sw = R * (1 + hgt);\n      for (let x = Math.floor(s.x - sw); x <= s.x + sw; x++) {\n        const f = 1 - Math.abs(x + 0.5 - s.x) / sw;\n        if (f > 0) cv.set(x, Math.floor(floor / asp), mix(th.bg, 0x000000, 0.45 * f * (1 - Math.min(0.8, hgt))));\n      }\n      // Stretch with vertical speed in the air, squash after an impact (volume kept).\n      const speed = Math.abs(s.vy) / (HP * 4);\n      const sy = squash > 0.02 ? 1 - squash : 1 + Math.min(0.35, speed * 0.6), sx = 1 / sy;\n      const rx = R * sx, ry = R * sy, cy = squash > 0.02 ? floor - ry : s.y;\n      for (let y = Math.floor((cy - ry) / asp) - 1; y <= (cy + ry) / asp + 1; y++) for (let x = Math.floor(s.x - rx) - 1; x <= s.x + rx + 1; x++) {\n        const nx = (x + 0.5 - s.x) / rx, ny = ((y + 0.5) * asp - cy) / ry, q = nx * nx + ny * ny;\n        if (q > 1) continue;\n        const lit = Math.max(0, 0.55 - nx * 0.35 - ny * 0.45 + 0.3 * Math.sqrt(1 - q));\n        cv.set(x, y, mix(mix(0x000000, ball, 0.35 + 0.65 * Math.min(1, lit)), 0xffffff, Math.max(0, lit - 0.85)));\n      }\n      cv.paint(ctx.buffer);\n      if (label) put(ctx.buffer, 1, ctx.height - 1, 'squash & stretch · restitution 0.7 · onion skin every 0.1 s', th.muted, undefined, undefined, ctx.width - 2);\n    };\n  },\n};\n",
  "prism-term/src/facets/lab/dissolve.js": "// Dissolves, four ways: a straight cross-fade, an ordered (Bayer 8×8) dither\n// dissolve, a random-pixel dissolve, and a noise \"burn\" whose leading edge\n// glows like smouldering paper.\nimport { transitionSetup } from './_lab.js';\nimport { mix } from '../../color.js';\n\nconst BAYER = [0, 32, 8, 40, 2, 34, 10, 42, 48, 16, 56, 24, 50, 18, 58, 26, 12, 44, 4, 36, 14, 46, 6, 38, 60, 28, 52, 20, 62, 30, 54, 22,\n  3, 35, 11, 43, 1, 33, 9, 41, 51, 19, 59, 27, 49, 17, 57, 25, 15, 47, 7, 39, 13, 45, 5, 37, 63, 31, 55, 23, 61, 29, 53, 21].map((v) => (v + 0.5) / 64);\n// Integer hash (well mixed, so the random dissolve has no streaks).\nconst hash = (x, y) => {\n  let h = Math.imul(x, 374761393) + Math.imul(y, 668265263);\n  h = Math.imul(h ^ (h >>> 13), 1274126177);\n  return ((h ^ (h >>> 16)) >>> 0) / 4294967296;\n};\n\nexport default {\n  id: 'lab-dissolve',\n  title: 'Dissolves',\n  gallery: 'lab',\n  category: 'Transitions',\n  kind: 'scene',\n  tags: ['transition', 'dissolve', 'cross-fade', 'dither', 'bayer', 'burn'],\n  order: 20,\n  description: 'Four dissolves between two scenes: cross-fade, ordered Bayer dither, random pixels and a glowing noise burn.',\n  size: { w: 60, h: 20 },\n  minSize: { w: 20, h: 7 },\n  loop: 16,\n  canvas: 'field',\n  palette: 'theme',\n  params: {},\n  setup(ctx) {\n    const noise = ctx.noise;\n    const VARIANTS = [\n      { name: 'dissolve · cross-fade', mask: (x, y, p) => p },\n      { name: 'dissolve · ordered dither (Bayer 8×8)', mask: (x, y, p) => (p > BAYER[((y | 0) & 7) * 8 + ((x | 0) & 7)] ? 1 : 0) },\n      { name: 'dissolve · random pixels', mask: (x, y, p) => (p > hash(x | 0, y | 0) ? 1 : 0) },\n      {\n        name: 'dissolve · noise burn',\n        draw(px, A, B, W, H, asp, p) {\n          for (let y = 0, k = 0; y < H; y++) for (let x = 0; x < W; x++, k++) {\n            if (p <= 0) { px[k] = A[k]; continue; }\n            if (p >= 1) { px[k] = B[k]; continue; }\n            const n = 0.5 + 0.5 * noise.fbm(x * 0.07, y * 0.07 * asp, 0.3, 4);\n            const d = p * 1.3 - 0.15 - n;\n            if (d > 0.04) px[k] = B[k];\n            else if (d > -0.04) px[k] = mix(0xff5a1f, 0xffe08a, 1 - Math.abs(d) / 0.04); // the burning edge\n            else if (d > -0.09) px[k] = mix(A[k], 0x2a1206, ((d + 0.09) / 0.05) * 0.8); // scorch\n            else px[k] = A[k];\n          }\n        },\n      },\n    ];\n    return transitionSetup(ctx, VARIANTS);\n  },\n};\n",
  "prism-term/src/facets/lab/easing-gallery.js": "// The easing-curve wall chart, animated: a grid of the standard easings, each\n// plotted in braille against dotted 0 and 1 guides (with room for back and\n// elastic overshoot), a dot running the curve in lock-step across every panel,\n// and a value bar on the right of each that shows what the eased value does.\nimport { easing } from '../../easing.js';\nimport { mix } from '../../color.js';\nimport { grid, put, pxRect } from './_lab.js';\n\nconst CURVES = ['linear', 'inQuad', 'outQuad', 'inOutCubic', 'inOutSine', 'inExpo', 'outExpo', 'outCirc', 'outBack', 'inOutBack', 'outElastic', 'outBounce'];\nconst RUN = 1.6, HOLD = 0.7, VMIN = -0.35, VMAX = 1.35;\n\nexport default {\n  id: 'lab-easing-gallery',\n  title: 'Easing Curves',\n  gallery: 'lab',\n  category: 'Easing & Timing',\n  kind: 'chart',\n  tags: ['easing', 'curves', 'tween', 'timing', 'ease in out', 'overshoot', 'braille'],\n  order: 10,\n  description: 'Twelve standard easings plotted in braille, each with a dot running its curve and a bar showing the eased value.',\n  size: { w: 80, h: 24 },\n  minSize: { w: 20, h: 6 },\n  loop: RUN + HOLD,\n  canvas: 'line',\n  palette: 'theme',\n  params: {},\n  setup(ctx) {\n    const cv = ctx.canvas('line');\n    const th = ctx.theme;\n    const cols = Math.max(1, Math.min(4, Math.floor(ctx.width / 18))), rows = Math.max(1, Math.min(3, Math.floor(ctx.height / 7)));\n    const panels = grid(ctx.width, ctx.height, cols, rows, 2, 1).slice(0, CURVES.length);\n    const guide = mix(th.bg, th.muted, 0.45), curveC = mix(th.muted, th.ink, 0.25);\n    return (t) => {\n      cv.clear();\n      const ph = ((t % (RUN + HOLD)) + RUN + HOLD) % (RUN + HOLD);\n      const u = Math.min(1, ph / RUN);\n      const labels = [];\n      panels.forEach((r, k) => {\n        const name = CURVES[k], fn = easing[name];\n        const P = pxRect(cv, { x: r.x, y: r.y + 1, w: r.w - 2, h: r.h - 1 });\n        const X = (s) => P.x + s * (P.w - 1), Y = (v) => P.y + (1 - (v - VMIN) / (VMAX - VMIN)) * (P.h - 1);\n        // Dotted guides at 0 and 1.\n        for (let x = 0; x < P.w; x += 2) { cv.set(P.x + x, Y(0), guide); cv.set(P.x + x, Y(1), guide); }\n        // The curve, then the part already traveled in the accent.\n        const n = Math.max(8, P.w * 2);\n        let px = X(0), py = Y(fn(0));\n        for (let i = 1; i <= n; i++) {\n          const s = i / n, nx = X(s), ny = Y(fn(s));\n          cv.line(px, py, nx, ny, s <= u ? th.accent : curveC);\n          px = nx; py = ny;\n        }\n        const v = fn(u);\n        cv.circle(X(u), Y(v), Math.max(1, cv.sx * 0.75), th.ink, true);\n        // Value bar: a vertical track to the right of the plot.\n        const bx = (r.x + r.w - 1) * cv.sx + Math.floor(cv.sx / 2);\n        for (let y = Y(1); y <= Y(0); y++) cv.set(bx, y, guide);\n        for (let y = Math.min(Y(0), Y(v)); y <= Math.max(Y(0), Y(v)); y++) cv.set(bx, y, th.info);\n        labels.push([r, name]);\n      });\n      cv.paint(ctx.buffer);\n      for (const [r, name] of labels) put(ctx.buffer, r.x, r.y, name, th.ink, undefined, undefined, r.w);\n    };\n  },\n};\n",
  "prism-term/src/facets/lab/easing-race.js": "// An easing race with an animator's spacing chart. Every lane covers the same\n// distance in the same time, but with a different easing; the tick marks are\n// where the ball is at equal time steps, so you can read ease-in (ticks\n// bunched at the start), ease-out (bunched at the end) and overshoot at a glance.\nimport { easing } from '../../easing.js';\nimport { mix } from '../../color.js';\nimport { put } from './_lab.js';\n\nconst LANES = ['linear', 'inQuad', 'outQuad', 'inOutCubic', 'outBack', 'outBounce', 'outElastic', 'inOutExpo'];\nconst RUN = 1.8, HOLD = 0.8, TICKS = 12;\n\nexport default {\n  id: 'lab-easing-race',\n  title: 'Easing Race',\n  gallery: 'lab',\n  category: 'Easing & Timing',\n  kind: 'chart',\n  tags: ['easing', 'spacing chart', 'timing', 'race', 'ease in', 'ease out', 'animation principles'],\n  order: 20,\n  description: 'Balls racing the same distance in the same time with different easings, over an animator-style spacing chart.',\n  size: { w: 64, h: 18 },\n  minSize: { w: 24, h: 6 },\n  loop: (RUN + HOLD) * 2,\n  canvas: 'hires',\n  palette: 'prism',\n  params: {},\n  setup(ctx) {\n    const lanes = LANES.slice(0, Math.max(1, Math.floor(ctx.height / 2)));\n    const labelW = Math.min(11, Math.max(0, ctx.width - 14));\n    const cv = ctx.canvas('hires', { cols: ctx.width - labelW });\n    const th = ctx.theme, pal = ctx.palette();\n    const W = cv.width, laneH = (ctx.height / lanes.length) * cv.sy;\n    const x0 = cv.sx * 1, x1 = W - cv.sx * 1.5;\n    const track = mix(th.bg, th.line, 0.9), tick = mix(th.bg, th.muted, 0.8);\n    return (t) => {\n      cv.clear();\n      const period = RUN + HOLD, n = Math.floor(t / period), ph = t - n * period;\n      const forward = n % 2 === 0, u = Math.min(1, ph / RUN);\n      lanes.forEach((name, k) => {\n        const fn = easing[name], cy = laneH * (k + 0.5), col = pal(k / lanes.length);\n        for (let x = x0; x <= x1; x += 2) cv.set(x, cy + laneH * 0.3, track);\n        // Spacing chart: positions at equal time steps.\n        for (let i = 0; i <= TICKS; i++) {\n          const v = fn(i / TICKS), x = x0 + (forward ? v : 1 - v) * (x1 - x0);\n          for (let d = 0; d < Math.max(1, laneH * 0.2); d++) cv.set(x, cy + laneH * 0.3 - d, tick);\n        }\n        const v = fn(u), x = x0 + (forward ? v : 1 - v) * (x1 - x0);\n        cv.circle(x, cy, Math.max(1, laneH * 0.22 * cv.aspect), col, true);\n      });\n      cv.paint(ctx.buffer, labelW, 0);\n      if (labelW) lanes.forEach((name, k) => put(ctx.buffer, 0, Math.floor((ctx.height / lanes.length) * (k + 0.5)), name, th.muted, undefined, undefined, labelW - 1));\n    };\n  },\n};\n",
  "prism-term/src/facets/lab/iris.js": "// Irises: the old cinema in-camera trick. A circle opening on the scene\n// beneath, a diamond, a five-pointed star, and a circle that opens on — and\n// then closes back to — a point of interest instead of the center.\nimport { transitionSetup } from './_lab.js';\n\nconst clamp = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);\n// Signed distance to a five-pointed star of radius r (iq), y up.\nfunction star(x, y, r) {\n  const k1x = 0.809016994375, k1y = -0.587785252292, k2x = -k1x, k2y = k1y;\n  x = Math.abs(x);\n  let d = 2 * Math.max(k1x * x + k1y * y, 0); x -= d * k1x; y -= d * k1y;\n  d = 2 * Math.max(k2x * x + k2y * y, 0); x -= d * k2x; y -= d * k2y;\n  x = Math.abs(x); y -= r;\n  const bax = 0.5 * -k1y, bay = 0.5 * k1x - 1;\n  const h = Math.min(Math.max((x * bax + y * bay) / (bax * bax + bay * bay), 0), r);\n  return Math.hypot(x - bax * h, y - bay * h) * Math.sign(y * bax - x * bay);\n}\nconst VARIANTS = [\n  { name: 'iris · circle', mask: (x, y, p, W, H, asp) => { const R = Math.hypot(W / 2, (H / 2) * asp) * 1.05 * p; return clamp((R - Math.hypot(x - W / 2, (y - H / 2) * asp)) / 1.5 + 0.5); } },\n  { name: 'iris · diamond', mask: (x, y, p, W, H, asp) => { const R = (W / 2 + (H / 2) * asp) * 1.02 * p; return clamp((R - Math.abs(x - W / 2) - Math.abs((y - H / 2) * asp)) / 1.5 + 0.5); } },\n  { name: 'iris · star', mask: (x, y, p, W, H, asp) => { const R = Math.hypot(W, H * asp) * 1.6 * p; return clamp(-star(x - W / 2, -(y - H / 2) * asp, R) / 1.5 + 0.5); } },\n  { name: 'iris · on a point of interest', mask: (x, y, p, W, H, asp) => { const cx = W * 0.22, cy = H * 0.26, R = Math.hypot(W, H * asp) * p; return clamp((R - Math.hypot(x - cx, (y - cy) * asp)) / 1.5 + 0.5); } },\n];\n\nexport default {\n  id: 'lab-iris',\n  title: 'Irises',\n  gallery: 'lab',\n  category: 'Transitions',\n  kind: 'scene',\n  tags: ['transition', 'iris', 'iris in', 'iris out', 'cinema', 'mask'],\n  order: 30,\n  description: 'Cinema irises between two scenes: circle, diamond, five-pointed star, and one that opens on a point of interest.',\n  size: { w: 60, h: 20 },\n  minSize: { w: 20, h: 7 },\n  loop: VARIANTS.length * 4,\n  canvas: 'field',\n  palette: 'theme',\n  params: {},\n  setup: (ctx) => transitionSetup(ctx, VARIANTS),\n};\n",
  "prism-term/src/facets/lab/keyframes.js": "// A keyframe timeline, After-Effects style. Up top a stage where a sprite\n// moves, grows and changes color; below it one track per property with its\n// keyframes (◆ eased, ◇ linear), the value curve between them, and a playhead\n// sweeping across with a running timecode. Every property is interpolated\n// from its own keys, so you can watch the timeline drive the picture.\nimport { easing } from '../../easing.js';\nimport { mix } from '../../color.js';\nimport { put } from './_lab.js';\n\nconst LEN = 4; // seconds\n// [time, value, easing-into-this-key]\nconst TRACKS = [\n  { name: 'x', keys: [[0, 0.08, 'linear'], [1.2, 0.5, 'inOutCubic'], [2.4, 0.92, 'outBack'], [3.4, 0.5, 'inOutCubic'], [4, 0.08, 'inOutCubic']] },\n  { name: 'y', keys: [[0, 0.75, 'linear'], [0.8, 0.2, 'outQuad'], [1.6, 0.75, 'outBounce'], [2.8, 0.35, 'inOutSine'], [4, 0.75, 'inOutSine']] },\n  { name: 'scale', keys: [[0, 0.5, 'linear'], [1.2, 1, 'outElastic'], [2.6, 0.6, 'inOutCubic'], [4, 0.5, 'linear']] },\n  { name: 'hue', keys: [[0, 0, 'linear'], [2, 0.5, 'linear'], [4, 1, 'linear']] },\n];\nfunction valueAt(keys, t) {\n  if (t <= keys[0][0]) return keys[0][1];\n  for (let i = 1; i < keys.length; i++) {\n    const [t1, v1, e] = keys[i], [t0, v0] = keys[i - 1];\n    if (t <= t1) return v0 + (v1 - v0) * easing[e]((t - t0) / (t1 - t0));\n  }\n  return keys[keys.length - 1][1];\n}\n\nexport default {\n  id: 'lab-keyframes',\n  title: 'Keyframe Timeline',\n  gallery: 'lab',\n  category: 'Easing & Timing',\n  kind: 'tui',\n  tags: ['keyframes', 'timeline', 'playhead', 'interpolation', 'tracks', 'motion design'],\n  order: 40,\n  description: 'A keyframe timeline driving a sprite: per-property tracks with eased keys, value curves, a playhead and timecode.',\n  size: { w: 72, h: 22 },\n  minSize: { w: 30, h: 10 },\n  loop: LEN,\n  canvas: 'line',\n  palette: 'prism',\n  params: {},\n  setup(ctx) {\n    const th = ctx.theme, pal = ctx.palette();\n    const W = ctx.width, H = ctx.height;\n    const trackH = Math.max(1, Math.min(3, Math.floor((H * 0.5) / TRACKS.length)));\n    const tlTop = H - TRACKS.length * trackH - 1, stageH = tlTop - 1;\n    const gutter = 7;\n    const stage = ctx.canvas('line', { cols: W, rows: Math.max(1, stageH) });\n    const curves = ctx.canvas('line', { cols: W - gutter, rows: TRACKS.length * trackH });\n    const guide = mix(th.bg, th.line, 0.9), curveC = mix(th.bg, th.muted, 0.7);\n    const T = (s) => gutter + Math.round((s / LEN) * (W - gutter - 1));\n    return (t) => {\n      const tl = ((t % LEN) + LEN) % LEN;\n      const [x, y, sc, hue] = TRACKS.map((tr) => valueAt(tr.keys, tl));\n      // Stage: floor line, the sprite, and a faint onion-skin of its last positions.\n      stage.clear();\n      for (let i = 0; i < stage.width; i += 3) stage.set(i, stage.height - 1, guide);\n      for (let g = 4; g >= 1; g--) {\n        const tg = (((tl - g * 0.08) % LEN) + LEN) % LEN, [gx, gy] = TRACKS.map((tr) => valueAt(tr.keys, tg));\n        stage.circle(gx * stage.width, gy * (stage.height - 1), 1, mix(th.bg, th.muted, 0.5 - g * 0.08), true);\n      }\n      const r = Math.max(1.5, Math.min(stage.width, stage.height * stage.aspect) * 0.24 * sc);\n      stage.circle(x * stage.width, y * (stage.height - 1 - r / stage.aspect) + r / stage.aspect, r, pal(hue), true);\n      stage.paint(ctx.buffer, 0, 0);\n      // Timeline: per-track value curve, then keys and the playhead as text.\n      curves.clear();\n      TRACKS.forEach((tr, k) => {\n        const top = k * trackH * curves.sy, h = trackH * curves.sy - 1;\n        let px = 0, py = top + h * (1 - tr.keys[0][1]);\n        for (let i = 1; i <= curves.width; i++) {\n          const v = valueAt(tr.keys, (i / curves.width) * LEN), ny = top + h * (1 - Math.max(0, Math.min(1.2, v)) / 1.2);\n          curves.line(px, py, i, ny, curveC); px = i; py = ny;\n        }\n      });\n      curves.paint(ctx.buffer, gutter, tlTop + 1);\n      const b = ctx.buffer;\n      // Ruler with seconds.\n      for (let x2 = gutter; x2 < W; x2++) b.set(x2, tlTop, '─', guide);\n      for (let s = 0; s <= LEN; s++) { const cx = Math.min(W - 2, T(s)); b.set(cx, tlTop, '┬', th.muted); put(b, cx + 1, tlTop, `${s}s`, th.muted); }\n      TRACKS.forEach((tr, k) => {\n        const row = tlTop + 1 + k * trackH + Math.floor(trackH / 2);\n        put(b, 0, row, tr.name, th.ink, undefined, undefined, gutter - 1);\n        for (const [kt, , e] of tr.keys) b.set(Math.min(W - 1, T(kt)), row, e === 'linear' ? '◇' : '◆', Math.abs(kt - tl) < 0.12 ? th.accent : th.info);\n      });\n      const ph = T(tl);\n      for (let row = tlTop; row < H; row++) {\n        const ch = b.get(ph, row)?.ch;\n        b.set(ph, row, ch === '◆' || ch === '◇' ? ch : '│', th.accent); // keys under the playhead stay visible\n      }\n      put(b, 0, tlTop, `${tl.toFixed(2)}s`, th.accent, undefined, undefined, gutter - 1);\n    };\n  },\n};\n",
  "prism-term/src/facets/lab/lfo-scope.js": "// A four-channel LFO oscilloscope: sine, triangle, saw and square at musical\n// LFO rates, drawn by a sweeping beam over a dotted graticule. Ahead of the\n// beam the previous sweep lingers dimmer, like phosphor persistence; each\n// channel is labeled with its shape and rate.\nimport { mix } from '../../color.js';\nimport { fadeMark } from '../../palettes.js';\nimport { put } from './_lab.js';\n\nconst SWEEP = 2;\nconst CHANNELS = [\n  ['SIN', 0.5, (p) => Math.sin(p * Math.PI * 2)],\n  ['TRI', 1, (p) => 1 - 4 * Math.abs((((p + 0.25) % 1) + 1) % 1 - 0.5)],\n  ['SAW', 1.5, (p) => 2 * ((((p % 1) + 1) % 1)) - 1],\n  ['SQR', 2, (p) => ((((p % 1) + 1) % 1) < 0.5 ? 1 : -1)],\n];\n\nexport default {\n  id: 'lab-lfo-scope',\n  title: 'LFO Scope',\n  gallery: 'lab',\n  category: 'Loops & Cycles',\n  kind: 'chart',\n  tags: ['lfo', 'oscilloscope', 'sine', 'saw', 'square', 'triangle', 'waveform', 'phosphor'],\n  order: 10,\n  description: 'A four-channel oscilloscope sweeping sine, triangle, saw and square LFOs over a graticule with phosphor persistence.',\n  size: { w: 64, h: 20 },\n  minSize: { w: 24, h: 8 },\n  loop: SWEEP * 2,\n  canvas: 'line',\n  palette: 'prism',\n  params: {},\n  setup(ctx) {\n    const cv = ctx.canvas('line');\n    const W = cv.width, H = cv.height, th = ctx.theme, pal = ctx.palette();\n    const chans = CHANNELS.slice(0, Math.max(1, Math.floor(ctx.height / 4)));\n    const band = H / chans.length;\n    const grat = mix(th.bg, th.line, 0.85);\n    const cols = chans.map((_, k) => mix(pal(0.3 + k * 0.17), th.ink, 0.1));\n    return (t) => {\n      cv.clear();\n      // Graticule: dotted divisions every eighth, and each channel's zero line.\n      for (let d = 1; d < 8; d++) { const x = Math.round((W * d) / 8); for (let y = 0; y < H; y += 3) cv.set(x, y, grat); }\n      const sweepStart = Math.floor(t / SWEEP) * SWEEP, beam = ((t - sweepStart) / SWEEP) * W;\n      chans.forEach(([, hz, fn], k) => {\n        const cy = band * (k + 0.5), amp = band * 0.38;\n        for (let x = 0; x < W; x += 4) cv.set(x, cy, grat);\n        let px = null, py = null;\n        for (let x = 0; x < W; x++) {\n          const live = x <= beam, tt = (live ? sweepStart : sweepStart - SWEEP) + (x / W) * SWEEP;\n          const y = cy - fn(tt * hz) * amp;\n          const c = live ? cols[k] : fadeMark(th, cols[k], 0.3);\n          if (px !== null && Math.abs(x - beam) > 1) cv.line(px, py, x, y, c);\n          px = x; py = y;\n        }\n        const hy = cy - fn(t * hz) * amp;\n        cv.circle(beam, hy, 1.4, th.ink, true);\n      });\n      cv.paint(ctx.buffer);\n      chans.forEach(([name, hz], k) => put(ctx.buffer, 1, Math.floor((ctx.height / chans.length) * k), `${name} ${hz.toFixed(2)} Hz`, cols[k], undefined, undefined, ctx.width - 2));\n    };\n  },\n};\n",
  "prism-term/src/facets/lab/lissajous.js": "// A Lissajous table: columns are the x frequency, rows the y frequency, and\n// each cell traces x = sin(a·s + δ), y = sin(b·s) in braille. The phase δ\n// drifts slowly, so every figure turns in 3D-looking rotation, and a bright\n// dot runs each curve at its own pace.\nimport { fadeMark } from '../../palettes.js';\nimport { grid, put, pxRect } from './_lab.js';\n\nexport default {\n  id: 'lab-lissajous',\n  title: 'Lissajous Table',\n  gallery: 'lab',\n  category: 'Loops & Cycles',\n  kind: 'chart',\n  tags: ['lissajous', 'harmonics', 'phase', 'frequency ratio', 'parametric', 'braille'],\n  order: 30,\n  description: 'A Lissajous table — x frequency across, y frequency down — with drifting phase and a dot running every figure.',\n  size: { w: 64, h: 22 },\n  minSize: { w: 20, h: 7 },\n  loop: 20,\n  canvas: 'line',\n  palette: 'prism',\n  params: {},\n  setup(ctx) {\n    const cv = ctx.canvas('line');\n    const th = ctx.theme, pal = ctx.palette();\n    const head = ctx.height >= 10 ? 1 : 0, side = head ? 4 : 0;\n    const cols = Math.max(1, Math.min(5, Math.floor((ctx.width - side) / 11))), rows = Math.max(1, Math.min(3, Math.floor((ctx.height - head) / 6)));\n    const cells = grid(ctx.width - side, ctx.height - head, cols, rows, 1, 0, side, head);\n    return (t) => {\n      cv.clear();\n      const delta = (t / 20) * Math.PI * 2;\n      cells.forEach((r, k) => {\n        const a = (k % cols) + 1, b = Math.floor(k / cols) + 1;\n        const P = pxRect(cv, r), cx = P.x + P.w / 2, cy = P.y + P.h / 2;\n        const rx = P.w / 2 - 1, ry = Math.min(P.h / 2 - 1, rx / cv.aspect);\n        const col = pal((k / cells.length) * 0.9), n = 90 * Math.max(a, b);\n        let px = null, py = null;\n        for (let i = 0; i <= n; i++) {\n          const s = (i / n) * Math.PI * 2, x = cx + Math.sin(a * s + delta) * rx, y = cy - Math.sin(b * s) * ry;\n          if (px !== null) cv.line(px, py, x, y, fadeMark(th, col, 0.75));\n          px = x; py = y;\n        }\n        const s = t * 1.3;\n        cv.circle(cx + Math.sin(a * s + delta) * rx, cy - Math.sin(b * s) * ry, 1.2, th.ink, true);\n      });\n      cv.paint(ctx.buffer);\n      if (head) {\n        for (let c = 0; c < cols; c++) put(ctx.buffer, cells[c].x + Math.floor(cells[c].w / 2) - 1, 0, `a${c + 1}`, th.muted);\n        for (let r = 0; r < rows; r++) put(ctx.buffer, 0, cells[r * cols].y + Math.floor(cells[r * cols].h / 2), `b${r + 1}`, th.muted);\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/lab/loading-choreo.js": "// Loading choreography for a terminal card: skeleton bars with a shimmer\n// sweep, then a spinner while a progress bar eases to full, then the real\n// content staggering in line by line, then a success mark that pops — and a\n// soft fade back to the skeleton. Each stage hands off to the next on cue.\nimport { easing } from '../../easing.js';\nimport { mix } from '../../color.js';\nimport { box } from '../../widgets/index.js';\nimport { put } from './_lab.js';\n\nconst LOOP = 8;\nconst SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';\nconst LINES = [\n  ['api-gateway-03.example', 'ink'],\n  ['region  lab-west-2   pool  10.20.4.0/24', 'muted'],\n  ['p95 latency  182 ms      errors  0.02%', 'muted'],\n  ['last deploy  14 min ago  build  4f1c2e7', 'muted'],\n];\n\nexport default {\n  id: 'lab-loading-choreo',\n  title: 'Loading Choreography',\n  gallery: 'lab',\n  category: 'Loops & Cycles',\n  kind: 'tui',\n  tags: ['loading', 'skeleton', 'shimmer', 'spinner', 'progress', 'stagger', 'choreography'],\n  order: 20,\n  description: 'A card loading in stages: shimmering skeleton, spinner and eased progress, staggered content, then a popping success mark.',\n  size: { w: 56, h: 14 },\n  minSize: { w: 30, h: 9 },\n  loop: LOOP,\n  canvas: 'none',\n  palette: 'theme',\n  params: {},\n  setup(ctx) {\n    const th = ctx.theme, b = ctx.buffer;\n    return (t) => {\n      const tl = ((t % LOOP) + LOOP) % LOOP;\n      const W = Math.min(ctx.width, 56), H = Math.min(ctx.height, 14);\n      const x0 = Math.floor((ctx.width - W) / 2), y0 = Math.floor((ctx.height - H) / 2);\n      box(b, x0, y0, W, H, { theme: th, fill: true, color: th.line });\n      const ix = x0 + 2, iw = W - 4, rows = Math.min(LINES.length, H - 5);\n      const fadeOut = tl > 7.3 ? (tl - 7.3) / 0.7 : 0;\n      const rowY = (r) => y0 + 1 + (r ? r + 1 : 0); // title, a gap, then detail lines\n      // Stage 1 (0–1.8 s) and the tail of the loop: skeleton bars with a shimmer.\n      if (tl < 3.2 || fadeOut > 0) {\n        const sweep = ((tl % 1.4) / 1.4) * (iw + 16) - 8;\n        for (let r = 0; r < rows; r++) {\n          const len = Math.max(4, Math.round(iw * [0.55, 0.9, 0.8, 0.7][r]));\n          for (let x = 0; x < len; x++) {\n            const sh = Math.exp(-(((x - sweep + r * 2) / 4) ** 2)) * 0.35;\n            b.set(ix + x, rowY(r), ' ', undefined, mix(th.panel2, th.ink, sh * (1 - fadeOut * 0.5)));\n          }\n        }\n      }\n      // Stage 2 (1.8–3.2 s): spinner, status line and an eased progress bar.\n      if (tl >= 1.8 && tl < 3.4) {\n        const p = easing.inOutCubic(Math.min(1, (tl - 1.8) / 1.3));\n        const sp = SPIN[Math.floor(tl * 12) % SPIN.length];\n        put(b, ix, y0 + H - 3, `${sp} fetching service health…`, th.info, undefined, undefined, iw);\n        const bw = iw - 5, fill = p * bw;\n        for (let x = 0; x < bw; x++) {\n          const part = Math.max(0, Math.min(1, fill - x));\n          b.set(ix + x, y0 + H - 2, part >= 1 ? '█' : part > 0 ? '▌' : '─', part > 0 ? th.accent : th.line);\n        }\n        put(b, ix + bw + 1, y0 + H - 2, `${Math.round(p * 100)}%`.padStart(4), th.muted);\n      }\n      // Stage 3 (3.2–4.6 s): the real content staggers in, sliding two cells.\n      if (tl >= 3.2 && fadeOut < 1) {\n        for (let r = 0; r < rows; r++) {\n          const q = Math.min(1, Math.max(0, (tl - 3.2 - r * 0.22) / 0.4));\n          if (q <= 0) continue;\n          const e = easing.outCubic(q), off = Math.round((1 - e) * 2);\n          const [text, tone] = LINES[r];\n          const fg = mix(th.panel, th[tone], e * (1 - fadeOut));\n          put(b, ix + off, rowY(r), text, fg, undefined, r === 0 ? 1 : 0, iw - off);\n        }\n      }\n      // Stage 4 (4.6–5.6 s): the success mark pops (·, •, ✓ with an overshoot in color).\n      if (tl >= 4.6 && fadeOut < 1) {\n        const q = Math.min(1, (tl - 4.6) / 0.5), mark = q < 0.33 ? '·' : q < 0.66 ? '•' : '✓';\n        const glow = q < 1 ? mix(th.pos, 0xffffff, 0.4 * Math.sin(q * Math.PI)) : th.pos;\n        put(b, ix, y0 + H - 2, `${mark} healthy`, mix(th.panel, glow, 1 - fadeOut), undefined, 1, iw);\n        put(b, ix + 11, y0 + H - 2, 'all checks passed', mix(th.panel, th.muted, Math.min(1, (tl - 4.9) / 0.4) * (1 - fadeOut)), undefined, undefined, iw - 11);\n      }\n      const stage = tl < 1.8 ? 'skeleton' : tl < 3.2 ? 'fetch' : tl < 4.6 ? 'reveal' : tl < 7.3 ? 'done' : 'reset';\n      put(b, x0 + W - stage.length - 3, y0, ` ${stage} `, th.muted);\n    };\n  },\n};\n",
  "prism-term/src/facets/lab/motion-blur.js": "// Frame rate versus motion blur, side by side. The same scene — a three-blade\n// propeller and a ball swinging past — sampled four ways: 60 fps sharp, 12 fps\n// sharp (it strobes), 12 fps with a 180° shutter (smooth again, at the same\n// frame rate) and 6 fps with a 360° shutter. Blur is real temporal\n// supersampling: each panel averages sub-frame samples across its shutter.\nimport { mix } from '../../color.js';\nimport { grid, put } from './_lab.js';\n\nconst PANELS = [['60 fps', 60, 0], ['12 fps', 12, 0], ['12 fps · 180° shutter', 12, 0.5], ['6 fps · 360° shutter', 6, 1]];\nconst SUBS = 10, SPIN = 1.15; // revolutions per second\n\nexport default {\n  id: 'lab-motion-blur',\n  title: 'Frame Rate vs Motion Blur',\n  gallery: 'lab',\n  category: 'Easing & Timing',\n  kind: 'scene',\n  tags: ['motion blur', 'frame rate', 'shutter angle', 'strobing', 'temporal aliasing', 'fps'],\n  order: 60,\n  description: 'One scene sampled four ways — 60 fps, 12 fps, 12 fps with a 180° shutter and 6 fps blurred — to compare strobe and blur.',\n  size: { w: 72, h: 22 },\n  minSize: { w: 30, h: 10 },\n  loop: 4,\n  canvas: 'field',\n  palette: 'prism',\n  params: {},\n  setup(ctx) {\n    const th = ctx.theme, pal = ctx.palette();\n    const two = ctx.width >= 28;\n    const rects = grid(ctx.width, ctx.height, two ? 2 : 1, two ? 2 : 4, 2, 1).slice(0, PANELS.length);\n    const cv = ctx.canvas('field');\n    const asp = cv.aspect, blade = pal(0.08), ballC = pal(0.55);\n    const covers = (cx, cy, R, x, y, tt) => {\n      // Propeller: three tapered blades plus a hub; ball: a disc on a pendulum swing.\n      const dx = x - cx, dy = (y - cy) * asp, r = Math.hypot(dx, dy);\n      let hit = 0;\n      if (r < R * 0.9) {\n        const a = Math.atan2(dy, dx) - tt * SPIN * Math.PI * 2;\n        for (let k = 0; k < 3; k++) {\n          let d = (a + (k * Math.PI * 2) / 3) % (Math.PI * 2); if (d < -Math.PI) d += Math.PI * 2; if (d > Math.PI) d -= Math.PI * 2;\n          if (Math.abs(d) < 0.32 * (1 - (r / R) * 0.6) || r < R * 0.14) { hit = 1; break; }\n        }\n      }\n      const bx = cx + Math.sin(tt * Math.PI * 1.5) * R * 1.9, by = cy + R * 1.05;\n      if (Math.hypot(x - bx, (y - by) * asp) < R * 0.28) hit = 2;\n      return hit;\n    };\n    return (t) => {\n      cv.clear();\n      PANELS.forEach(([, fps, shutter], k) => {\n        const r = rects[k];\n        if (!r || r.h < 3) return;\n        const x0 = r.x * cv.sx, y0 = r.y * cv.sy, w = r.w * cv.sx, h = (r.h - 1) * cv.sy;\n        const R = Math.min(w / 4.4, (h * asp) / 2.9), cx = x0 + w / 2, cy = y0 + h * 0.4;\n        const tf = Math.floor(t * fps) / fps, n = shutter ? SUBS : 1;\n        for (let y = y0; y < y0 + h; y++) for (let x = x0; x < x0 + w; x++) {\n          let a = 0, b = 0;\n          for (let s = 0; s < n; s++) {\n            const hit = covers(cx, cy, R, x + 0.5, y + 0.5, tf + (shutter * (s + 0.5)) / (n * fps));\n            if (hit === 1) a++; else if (hit === 2) b++;\n          }\n          if (!a && !b) continue;\n          let c = mix(th.bg, blade, a / n);\n          c = mix(c, ballC, b / n);\n          cv.set(x, y, c);\n        }\n      });\n      cv.paint(ctx.buffer);\n      PANELS.forEach(([name], k) => { const r = rects[k]; if (r && r.h >= 3) put(ctx.buffer, r.x, r.y + r.h - 1, name, th.muted, undefined, undefined, r.w); });\n    };\n  },\n};\n",
  "prism-term/src/facets/lab/pendulum-wave.js": "// A pendulum wave seen from above: fifteen pendulums whose lengths are tuned\n// so pendulum k swings 20 + k times every 30 seconds. They start in a line,\n// drift into traveling snakes, split into two and three interleaved rows,\n// dissolve into apparent chaos — and all line up again at the end of the loop.\nimport { mix } from '../../color.js';\nimport { put } from './_lab.js';\n\nconst N = 15, T = 30, BASE = 20;\n\nexport default {\n  id: 'lab-pendulum-wave',\n  title: 'Pendulum Wave',\n  gallery: 'lab',\n  category: 'Loops & Cycles',\n  kind: 'scene',\n  tags: ['pendulum wave', 'harmonics', 'phase', 'beats', 'realignment', 'loop'],\n  order: 40,\n  description: 'Fifteen pendulums tuned to 20–34 swings per 30 s: snakes, interleaved rows, chaos, then perfect realignment.',\n  size: { w: 60, h: 20 },\n  minSize: { w: 20, h: 7 },\n  loop: T,\n  canvas: 'field',\n  palette: 'prism',\n  params: {},\n  setup(ctx) {\n    const label = ctx.height >= 8;\n    const cv = ctx.canvas('field', { rows: label ? ctx.height - 1 : ctx.height });\n    const W = cv.width, H = cv.height, asp = cv.aspect, th = ctx.theme, pal = ctx.palette();\n    const gap = W / N, R = Math.max(1, Math.min(gap * 0.38, H * asp * 0.08)), amp = H * 0.5 - R / asp - 1;\n    const rail = mix(th.bg, th.line, 0.8), snake = mix(th.bg, th.muted, 0.5);\n    return (t) => {\n      cv.clear();\n      const tl = ((t % T) + T) % T, cy = H / 2;\n      for (let x = 0; x < W; x += 2) cv.set(x, cy, rail);\n      const pos = Array.from({ length: N }, (_, k) => [gap * (k + 0.5), cy + Math.cos((Math.PI * 2 * (BASE + k) * tl) / T) * amp]);\n      for (let k = 1; k < N; k++) cv.line(pos[k - 1][0], pos[k - 1][1], pos[k][0], pos[k][1], snake);\n      pos.forEach(([x, y], k) => {\n        const col = pal(k / N);\n        for (let yy = Math.floor(y - R / asp) - 1; yy <= y + R / asp + 1; yy++) for (let xx = Math.floor(x - R) - 1; xx <= x + R + 1; xx++) {\n          const nx = (xx + 0.5 - x) / R, ny = ((yy + 0.5 - y) * asp) / R, q = nx * nx + ny * ny;\n          if (q <= 1) cv.set(xx, yy, mix(mix(0x000000, col, 0.4 + 0.6 * Math.max(0, 0.6 - nx * 0.4 - ny * 0.5 + 0.3 * Math.sqrt(1 - q))), 0xffffff, Math.max(0, -nx - ny - 1.1) * 0.6));\n        }\n      });\n      cv.paint(ctx.buffer);\n      if (label) put(ctx.buffer, 1, ctx.height - 1, `pendulum k swings ${BASE}+k times per ${T} s · t = ${tl.toFixed(1).padStart(4)} s`, th.muted, undefined, undefined, ctx.width - 2);\n    };\n  },\n};\n",
  "prism-term/src/facets/lab/slide.js": "// Slides: the moves every UI uses. Push (the new scene shoves the old one\n// out), cover (it slides over the top), reveal (the old one slides away), and\n// a vertical push — each eased out so it lands softly.\nimport { transitionSetup } from './_lab.js';\nimport { easing } from '../../easing.js';\n\nconst VARIANTS = [\n  {\n    name: 'slide · push left',\n    draw(px, A, B, W, H, asp, p) {\n      const o = Math.round(p * W);\n      for (let y = 0, k = 0; y < H; y++) for (let x = 0; x < W; x++, k++) px[k] = x + o < W ? A[y * W + x + o] : B[y * W + x + o - W];\n    },\n  },\n  {\n    name: 'slide · cover from the right',\n    draw(px, A, B, W, H, asp, p) {\n      const e = Math.round((1 - p) * W);\n      for (let y = 0, k = 0; y < H; y++) for (let x = 0; x < W; x++, k++) px[k] = x >= e ? B[y * W + x - e] : A[k];\n    },\n  },\n  {\n    name: 'slide · reveal (old scene slides away)',\n    draw(px, A, B, W, H, asp, p) {\n      const o = Math.round(p * W);\n      for (let y = 0, k = 0; y < H; y++) for (let x = 0; x < W; x++, k++) px[k] = x + o < W ? A[y * W + x + o] : B[k];\n    },\n  },\n  {\n    name: 'slide · push up',\n    draw(px, A, B, W, H, asp, p) {\n      const o = Math.round(p * H);\n      for (let y = 0, k = 0; y < H; y++) for (let x = 0; x < W; x++, k++) px[k] = y + o < H ? A[(y + o) * W + x] : B[(y + o - H) * W + x];\n    },\n  },\n];\n\nexport default {\n  id: 'lab-slide',\n  title: 'Slides & Pushes',\n  gallery: 'lab',\n  category: 'Transitions',\n  kind: 'scene',\n  tags: ['transition', 'slide', 'push', 'cover', 'reveal', 'ui motion'],\n  order: 40,\n  description: 'Slide transitions between two scenes: push, cover, reveal and a vertical push, each eased out to land softly.',\n  size: { w: 60, h: 20 },\n  minSize: { w: 20, h: 7 },\n  loop: VARIANTS.length * 4,\n  canvas: 'field',\n  palette: 'theme',\n  params: {},\n  setup: (ctx) => transitionSetup(ctx, VARIANTS, { ease: easing.outCubic }),\n};\n",
  "prism-term/src/facets/lab/spring.js": "// Spring and damper tuning: four masses on identical springs, differing only\n// in damping ratio ζ — underdamped (rings), lightly damped, critically damped\n// (fastest with no overshoot) and overdamped (sluggish). The target steps back\n// and forth; each lane draws the coil and mass, and plots x(t) against the target.\nimport { mix } from '../../color.js';\nimport { put } from './_lab.js';\n\nconst LANES = [[0.15, 'ringing'], [0.4, 'light'], [1, 'critical'], [2, 'overdamped']];\nconst STEP = 2.5, HZ = 240, W0 = Math.PI * 2 * 1.1, HIST = 180; // history samples (60 Hz → 3 s)\n\nexport default {\n  id: 'lab-spring',\n  title: 'Spring & Damper',\n  gallery: 'lab',\n  category: 'Springs & Physics',\n  kind: 'chart',\n  tags: ['spring', 'damping', 'damping ratio', 'critical damping', 'overshoot', 'physics animation'],\n  order: 10,\n  description: 'Four springs differing only in damping ratio chase a stepping target, each plotting its response against it.',\n  size: { w: 72, h: 20 },\n  minSize: { w: 30, h: 8 },\n  loop: STEP * 2,\n  canvas: 'line',\n  palette: 'prism',\n  params: {},\n  setup(ctx) {\n    const cv = ctx.canvas('line');\n    const th = ctx.theme, pal = ctx.palette();\n    const lanes = LANES.slice(0, Math.max(1, Math.floor(ctx.height / 4)));\n    const labelW = 11, split = labelW + Math.floor((ctx.width - labelW) * 0.48);\n    const laneH = (ctx.height / lanes.length) * cv.sy;\n    const sx0 = labelW * cv.sx, sx1 = split * cv.sx - cv.sx, px0 = (split + 1) * cv.sx, px1 = cv.width - cv.sx;\n    const S = lanes.map(() => ({ x: 0.2, v: 0, hist: [] }));\n    const guide = mix(th.bg, th.muted, 0.45);\n    let acc = 0, rec = 0, simT = 0;\n    const target = (tt) => (Math.floor(tt / STEP) % 2 === 0 ? 0.8 : 0.2);\n    return (t, dt) => {\n      acc += Math.min(0.25, Math.max(0, dt)) * HZ;\n      for (; acc >= 1; acc--) {\n        simT += 1 / HZ;\n        const g = target(simT);\n        lanes.forEach(([z], k) => { const s = S[k]; s.v += (-W0 * W0 * (s.x - g) - 2 * z * W0 * s.v) / HZ; s.x += s.v / HZ; });\n        if (++rec % 4 === 0) S.forEach((s) => { s.hist.push([s.x, g]); if (s.hist.length > HIST) s.hist.shift(); });\n      }\n      cv.clear();\n      lanes.forEach(([z], k) => {\n        const s = S[k], col = pal(k / lanes.length), top = k * laneH, cy = top + laneH * 0.5;\n        const X = (v) => sx0 + cv.sx + v * (sx1 - sx0 - cv.sx * 3);\n        // Wall, coil, mass.\n        for (let y = top + laneH * 0.2; y < top + laneH * 0.8; y++) cv.set(sx0, y, th.muted);\n        const mx = X(s.x), coils = 9, amp = laneH * 0.18;\n        let lx = sx0, ly = cy;\n        for (let i = 1; i <= coils * 2; i++) {\n          const nx = sx0 + ((mx - sx0) * i) / (coils * 2), ny = cy + (i === coils * 2 ? 0 : (i % 2 ? -amp : amp));\n          cv.line(lx, ly, nx, ny, mix(th.muted, th.ink, 0.3)); lx = nx; ly = ny;\n        }\n        cv.circle(mx + cv.sx, cy, Math.max(1.5, laneH * 0.22), col, true);\n        const gx = X(target(simT));\n        for (let y = top + laneH * 0.15; y < top + laneH * 0.85; y += 2) cv.set(gx + cv.sx, y, guide);\n        // Response plot: target (dotted) and x(t), newest on the right.\n        const h = s.hist, ph = laneH * 0.7, py = (v) => top + laneH * 0.15 + (1 - (v + 0.1) / 1.2) * ph;\n        for (let i = 1; i < h.length; i++) {\n          const xa = px1 - ((h.length - i) / HIST) * (px1 - px0), xb = px1 - ((h.length - i - 1) / HIST) * (px1 - px0);\n          if (i % 2) cv.set(xb, py(h[i][1]), guide);\n          cv.line(xa, py(h[i - 1][0]), xb, py(h[i][0]), col);\n        }\n      });\n      cv.paint(ctx.buffer);\n      lanes.forEach(([z, word], k) => {\n        const row = Math.floor((ctx.height / lanes.length) * k + (ctx.height / lanes.length) / 2) - (ctx.height >= 12 ? 1 : 0);\n        put(ctx.buffer, 0, row, `ζ ${z.toFixed(2)}`, th.ink, undefined, undefined, labelW - 1);\n        if (ctx.height >= 12) put(ctx.buffer, 0, row + 1, word, th.muted, undefined, undefined, labelW - 1);\n      });\n    };\n  },\n};\n",
  "prism-term/src/facets/lab/stagger.js": "// Stagger studies: a grid of tiles entering one after another, each popping in\n// with an overshoot (outBack) and leaving on the same order reversed. Four\n// orderings cycle — from the left, from the center out, a diagonal wave and a\n// seeded shuffle — to show how much the order alone changes the feel.\nimport { easing } from '../../easing.js';\nimport { mix } from '../../color.js';\nimport { cycle, put } from './_lab.js';\n\nconst MODE = 3.4, SPREAD = 1.1, POP = 0.45;\n\nexport default {\n  id: 'lab-stagger',\n  title: 'Stagger',\n  gallery: 'lab',\n  category: 'Easing & Timing',\n  kind: 'scene',\n  tags: ['stagger', 'cascade', 'delay', 'outback', 'choreography', 'ui motion'],\n  order: 50,\n  description: 'A grid of tiles staggering in and out four ways — from the left, from the center, as a wave, and shuffled.',\n  size: { w: 60, h: 20 },\n  minSize: { w: 20, h: 7 },\n  loop: MODE * 4,\n  canvas: 'field',\n  palette: 'prism',\n  params: {},\n  setup(ctx) {\n    const label = ctx.height >= 8;\n    const cv = ctx.canvas('field', { rows: label ? ctx.height - 1 : ctx.height });\n    const W = cv.width, H = cv.height, th = ctx.theme, pal = ctx.palette(), rand = ctx.rng;\n    const tw = Math.max(3, Math.round(W / 12)), thH = Math.max(3, Math.round(tw / cv.aspect * 0.9));\n    const cols = Math.max(1, Math.floor((W + 1) / (tw + 1))), rows = Math.max(1, Math.floor((H + 1) / (thH + 1)));\n    const ox = Math.floor((W - (cols * (tw + 1) - 1)) / 2), oy = Math.floor((H - (rows * (thH + 1) - 1)) / 2);\n    const tiles = [];\n    for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) tiles.push({ c, r, rnd: rand() });\n    const maxD = Math.hypot((cols - 1) / 2, (rows - 1) / 2) || 1;\n    const MODES = [\n      ['from the left', (p) => p.c / Math.max(1, cols - 1)],\n      ['from the centre', (p) => Math.hypot(p.c - (cols - 1) / 2, p.r - (rows - 1) / 2) / maxD],\n      ['diagonal wave', (p) => (p.c + p.r) / Math.max(1, cols + rows - 2)],\n      ['shuffled', (p) => p.rnd],\n    ];\n    return (t) => {\n      const { i, f } = cycle(t, MODES.length, MODE);\n      const [name, order] = MODES[i], lt = f * MODE;\n      cv.clear();\n      for (const p of tiles) {\n        const d = order(p) * SPREAD;\n        const qIn = Math.min(1, Math.max(0, (lt - d) / POP));\n        const qOut = Math.min(1, Math.max(0, (lt - 2.1 - d * 0.6) / (POP * 0.7)));\n        const k = qOut > 0 ? 1 - easing.inQuad(qOut) : easing.outBack(qIn);\n        if (k <= 0.01) continue;\n        const cx = ox + p.c * (tw + 1) + tw / 2, cy = oy + p.r * (thH + 1) + thH / 2;\n        const hw = (tw / 2) * k, hh = (thH / 2) * k, col = mix(th.bg, pal(order(p) * 0.85), Math.min(1, qIn * 1.5) * (1 - qOut * 0.5));\n        for (let y = Math.round(cy - hh); y < Math.round(cy + hh); y++) for (let x = Math.round(cx - hw); x < Math.round(cx + hw); x++) cv.set(x, y, col);\n      }\n      cv.paint(ctx.buffer);\n      if (label) put(ctx.buffer, 1, ctx.height - 1, `stagger · ${name} · ${Math.round((SPREAD / Math.max(1, tiles.length)) * 1000)} ms apart · outBack in, inQuad out`, th.muted, undefined, undefined, ctx.width - 2);\n    };\n  },\n};\n",
  "prism-term/src/facets/lab/wipe.js": "// Wipes, four ways, between a daybreak and a night skyline: a soft-edged\n// linear wipe, a diagonal, a clock (radial sweep) and barn doors. Each runs\n// day → night and back on an eased hold-move-hold beat.\nimport { transitionSetup } from './_lab.js';\n\nconst clamp = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);\nconst VARIANTS = [\n  { name: 'wipe · linear, soft edge', mask: (x, y, p, W) => { const soft = W * 0.08, e = p * (W + soft * 2) - soft; return clamp((e - x) / soft + 0.5); } },\n  { name: 'wipe · diagonal', mask: (x, y, p, W, H, asp) => { const u = x + y * asp, span = W + H * asp, soft = span * 0.05, e = p * (span + soft * 2) - soft; return clamp((e - u) / soft + 0.5); } },\n  { name: 'wipe · clock', mask: (x, y, p, W, H, asp) => { const a = (Math.atan2(x - W / 2, -(y - H / 2) * asp) / (Math.PI * 2) + 1) % 1; return clamp((p * 1.04 - a) / 0.04); } },\n  { name: 'wipe · barn doors', mask: (x, y, p, W) => { const u = Math.abs(x - W / 2) / (W / 2), soft = 0.06; return clamp((p * (1 + soft * 2) - soft - u) / soft + 0.5); } },\n];\n\nexport default {\n  id: 'lab-wipe',\n  title: 'Wipes',\n  gallery: 'lab',\n  category: 'Transitions',\n  kind: 'scene',\n  tags: ['transition', 'wipe', 'clock wipe', 'barn doors', 'soft edge', 'scene change'],\n  order: 10,\n  description: 'Four wipes between a daybreak and a night skyline: soft linear, diagonal, clock sweep and barn doors.',\n  size: { w: 60, h: 20 },\n  minSize: { w: 20, h: 7 },\n  loop: VARIANTS.length * 4,\n  canvas: 'field',\n  palette: 'theme',\n  params: {},\n  setup: (ctx) => transitionSetup(ctx, VARIANTS),\n};\n",
  "prism-term/src/facets/loaders/_kit.js": "// Shared presentation for spinner facets.\n//\n// A spinner tile shows the spinner the way you meet it — inline, in front of a\n// task label with an elapsed timer — and then the whole cycle as a filmstrip\n// (current frame lit) with its frame count and interval, so you can judge the\n// rhythm at a glance. Tiles degrade by dropping rows: at two rows you get the\n// live line and the strip, at one row just the live line.\n//\n// Without Unicode (TERM=linux, LANG=C) a non-ASCII spinner falls back to the\n// classic `-\\|/` and every label uses plain ASCII.\n\nimport { getSpinner } from '../../spinners.js';\nimport { palette as makePalette, PALETTES } from '../../palettes.js';\nimport { stringWidth, truncate } from '../../width.js';\nimport { mix } from '../../color.js';\n\nconst lum = (c) => (0.2126 * ((c >> 16) & 255) + 0.7152 * ((c >> 8) & 255) + 0.0722 * (c & 255)) / 255;\n\nexport const ASCII_LINE = { frames: ['-', '\\\\', '|', '/'], interval: 130 };\nconst isAscii = (s) => /^[\\x20-\\x7e]*$/.test(s);\nexport const asciiSpinner = (spin) => spin.frames.every(isAscii);\n\nconst BITS = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]];\n/** Dots ([[x,y], …] with x in 0..2*cells-1, y in 0..3) → a braille string `cells` wide. */\nexport function braille(cells, dots) {\n  const m = new Array(cells).fill(0);\n  for (const [x, y] of dots) {\n    const xi = Math.round(x), yi = Math.round(y);\n    if (xi < 0 || yi < 0 || yi > 3 || xi >= cells * 2) continue;\n    m[xi >> 1] |= BITS[yi][xi & 1];\n  }\n  return m.map((v) => String.fromCharCode(0x2800 + v)).join('');\n}\nexport const range = (n, f) => Array.from({ length: n }, (_, i) => f(i));\n\n/** Index of the frame showing at time t. */\nexport const frameAt = (spin, t) => Math.floor((Math.max(0, t) * 1000) / spin.interval) % spin.frames.length;\n\n/**\n * Draw one spinner frame. Color precedence: spin.colorAt(t, i, k, theme) per\n * character, then a sweeping spin.palette, then `color`.\n */\nexport function drawFrame(surface, x, y, spin, k, t, theme, color, cache) {\n  const chars = [...spin.frames[k]];\n  let cx = x;\n  let g = null;\n  if (spin.palette && !spin.colorAt) {\n    g = cache.get(spin.palette);\n    if (!g) { g = makePalette(spin.palette, theme); cache.set(spin.palette, g); }\n  }\n  chars.forEach((ch, i) => {\n    let c = color;\n    if (spin.colorAt) c = spin.colorAt(t, i, k, theme);\n    else if (g) {\n      const u = ((t * 0.45 + (i / Math.max(1, chars.length)) * 0.5) % 1 + 1) % 1;\n      c = PALETTES[spin.palette]?.cyclic ? g(u) : g(0.45 + 0.55 * (u < 0.5 ? u * 2 : 2 - u * 2));\n    }\n    // Spinner art colors (neon greens, embers, white sparks) are tuned for dark\n    // terminals; by Day, deepen any that would wash out against the pearl.\n    if (theme.mode === 'light' && (spin.colorAt || g) && c >= 0 && lum(c) > 0.3) c = mix(c, theme.ink, 0.45);\n    cx = surface.text(cx, y, ch, c);\n  });\n  return cx;\n}\n\nconst fmtElapsed = (s) => (s < 60 ? `${s.toFixed(1)}s` : `${Math.floor(s / 60)}m${String(Math.floor(s % 60)).padStart(2, '0')}s`);\n\n/**\n * Build a spinner facet.\n * @param {object} o\n *   id, title, category, description, tags     catalog metadata\n *   spin      a name from src/spinners.js, or { frames, interval, palette?, colorAt? }\n *   labels    task messages the live line cycles through (Unicode allowed; ASCII twins via asciiLabels)\n *   name      label for the meta line (defaults to the spinner name)\n *   color     theme token for monochrome spinners (default 'accent')\n */\nexport function spinnerFacet(o) {\n  const spin0 = typeof o.spin === 'string' ? getSpinner(o.spin) : o.spin;\n  const ascii = asciiSpinner(spin0);\n  const width = Math.max(...spin0.frames.map(stringWidth));\n  const labels = o.labels || ['Working…'];\n  const asciiLabels = o.asciiLabels || labels.map((l) => l.replace(/…/g, '...').replace(/[^\\x20-\\x7e]/g, ''));\n  return {\n    id: o.id,\n    title: o.title,\n    category: o.category,\n    description: o.description,\n    tags: ['spinner', 'loader', ...(o.tags || [])],\n    kind: 'spinner',\n    size: { w: 44, h: 5 },\n    minSize: { w: 24, h: 2 },\n    loop: o.loop || 8,\n    glyphs: ascii ? 'ascii' : o.glyphs || (spin0.frames.some((f) => /[⠀-⣿]/.test(f)) ? 'braille' : 'unicode'),\n    color: spin0.palette || spin0.colorAt ? '256' : '16',\n    order: o.order ?? 50,\n    params: { speed: { default: 1, min: 0.25, max: 4, label: 'Speed' } },\n    setup(ctx) {\n      const th = ctx.theme;\n      const unicode = ctx.caps.unicode !== false;\n      const spin = unicode || ascii ? spin0 : ASCII_LINE;\n      const lines = unicode ? labels : asciiLabels;\n      const base = th[o.color || 'accent'] ?? th.accent;\n      const cache = new Map();\n      const speed = ctx.param('speed', 1);\n      const W = ctx.width, H = ctx.height;\n      const fw = unicode || ascii ? width : 1;\n      return (t) => {\n        const b = ctx.buffer;\n        const tt = t * speed;\n        const k = frameAt(spin, tt);\n        // Live line: spinner, task label, elapsed (right).\n        const row = 0;\n        let x = drawFrame(b, 1, row, spin, k, tt, th, base, cache);\n        const label = lines[Math.floor(t / 2.6) % lines.length];\n        const elapsed = fmtElapsed(t);\n        const room = W - x - 2 - (W >= 32 ? elapsed.length + 1 : 0);\n        b.text(x + 1, row, truncate(label, Math.max(0, room), unicode ? '…' : '.'), th.ink);\n        if (W >= 32) b.text(W - 1 - elapsed.length, row, elapsed, th.dim);\n        if (H < 2) return;\n\n        // Filmstrip of the whole cycle, windowed around the current frame.\n        // Rows: live line · strip · meta, spread over what the tile has.\n        const stripY = H >= 4 ? 2 : 1;\n        const metaY = H >= 5 ? 4 : H >= 4 ? 3 : 2;\n        const cellW = fw + 1;\n        const fit = Math.max(1, Math.floor((W - 2) / cellW));\n        const n = spin.frames.length;\n        const start = n <= fit ? 0 : Math.min(n - fit, Math.max(0, k - Math.floor(fit / 2)));\n        for (let i = start, sx = 1; i < Math.min(n, start + fit); i++, sx += cellW) {\n          if (i === k) {\n            b.fill(sx, stripY, fw, 1, undefined, undefined, mix(th.bg, base, 0.18));\n            drawFrame(b, sx, stripY, spin, i, tt, th, base, cache);\n          } else b.text(sx, stripY, spin.frames[i], th.dim);\n        }\n        if (n > fit && W > 12) b.text(W - 3, stripY, unicode ? ' …' : '..', th.dim);\n        if (H < 3) return;\n\n        // Meta line.\n        const fallback = unicode || ascii ? '' : ' (ASCII fallback)';\n        const meta = `${o.name || (typeof o.spin === 'string' ? o.spin : o.id.replace(/^loaders-/, ''))}${fallback} ${unicode ? '·' : '-'} ${n} frames ${unicode ? '·' : '-'} ${spin.interval} ms`;\n        b.text(1, metaY, truncate(meta, W - 2, unicode ? '…' : '.'), th.muted);\n      };\n    },\n  };\n}\n",
  "prism-term/src/facets/loaders/bars.js": "// Bars & pulses — block-element loaders that breathe, grow and bounce.\nimport { spinnerFacet } from './_kit.js';\n\nconst C = 'Bars & Pulses';\n\nexport default [\n  spinnerFacet({ id: 'loaders-pulse', spin: 'pulse', title: 'Pulse', category: C, order: 10,\n    description: 'A dot swelling to a disc and back — a gentle pulse for \"still alive, nothing to report\".',\n    tags: ['pulse', 'dot', 'breathe'], labels: ['Holding connection open…', 'Keepalive…'] }),\n  spinnerFacet({ id: 'loaders-pulse-ring', spin: 'pulseRing', name: 'pulseRing', title: 'Pulse Ring', category: C, order: 20,\n    description: 'A ring growing from a point to a bullseye and collapsing again — a sonar ping in one glyph.',\n    tags: ['pulse', 'ring', 'sonar'], labels: ['Discovering peers…', 'Broadcasting presence…'] }),\n  spinnerFacet({ id: 'loaders-grow', spin: 'grow', title: 'Grow', category: C, order: 30,\n    description: 'A lower block rising to full height and falling back — the simplest level meter there is.',\n    tags: ['blocks', 'grow', 'vertical'], labels: ['Allocating memory…', 'Growing heap…'] }),\n  spinnerFacet({ id: 'loaders-grow-horizontal', spin: 'growH', name: 'growH', title: 'Grow Sideways', category: C, order: 40,\n    description: 'A left-anchored block widening to full and shrinking back in eighths — smooth at any font size.',\n    tags: ['blocks', 'grow', 'horizontal'], labels: ['Expanding volume…', 'Resizing filesystem…'] }),\n  spinnerFacet({ id: 'loaders-bars', spin: 'bars', title: 'Shrinking Bar', category: C, order: 50,\n    description: 'A full block shrinking to a sliver and filling back up — the reverse of Grow Sideways.',\n    tags: ['blocks', 'shrink'], labels: ['Compacting database…', 'Vacuuming tables…'] }),\n  spinnerFacet({ id: 'loaders-shade', spin: 'noise', name: 'noise', title: 'Shade', category: C, order: 60,\n    description: 'Dark, medium and light shade cycling — a flickering block like an old CRT warming up.',\n    tags: ['shade', 'crt', 'flicker'], labels: ['Warming up display…', 'Calibrating…'] }),\n  spinnerFacet({ id: 'loaders-layer', spin: 'layer', title: 'Layers', category: C, order: 70,\n    description: 'One, two, then three horizontal strokes stacking up — layers being built.',\n    tags: ['layers', 'stack'], labels: ['Building image layers…', 'Squashing layers…'] }),\n  spinnerFacet({ id: 'loaders-quadrant-walk', spin: 'boxBounce', name: 'boxBounce', title: 'Quadrant Walk', category: C, order: 80,\n    description: 'A quadrant block stepping round the four corners of its cell.',\n    tags: ['quadrant', 'block', 'walk'], labels: ['Rotating credentials…', 'Cycling keys…'] }),\n  spinnerFacet({ id: 'loaders-half-walk', spin: 'boxBounce2', name: 'boxBounce2', title: 'Half-Block Walk', category: C, order: 90,\n    description: 'Half blocks turning left, top, right, bottom — a chunky square spinner that reads from across the room.',\n    tags: ['half block', 'chunky'], labels: ['Restarting service…', 'Draining connections…'] }),\n  spinnerFacet({ id: 'loaders-bouncing-bar', spin: 'bouncingBar', name: 'bouncingBar', title: 'Bouncing Bar', category: C, order: 100,\n    description: 'Three equals signs bouncing inside brackets — the indeterminate bar of countless installers, pure ASCII.',\n    tags: ['ascii', 'indeterminate', 'brackets'], labels: ['Unpacking archive...', 'Configuring packages...'] }),\n  spinnerFacet({ id: 'loaders-aesthetic', spin: 'aesthetic', title: 'Aesthetic', category: C, order: 110,\n    description: 'Parallelogram segments filling a seven-cell track one by one — a loader that looks like a progress bar.',\n    tags: ['segments', 'fill', 'track'], labels: ['Rendering thumbnails…', 'Encoding preview…'] }),\n  spinnerFacet({ id: 'loaders-simple-dots', spin: 'simpleDots', name: 'simpleDots', title: 'Ellipsis', category: C, order: 120,\n    description: 'Dots appearing one, two, three, then clearing — the typing ellipsis, pure ASCII.',\n    tags: ['ascii', 'ellipsis', 'typing'], labels: ['Loading', 'Thinking', 'Please wait'] }),\n  spinnerFacet({ id: 'loaders-point', spin: 'point', title: 'Point', category: C, order: 130,\n    description: 'A bullet traveling along a row of small dots — a three-cell carousel.',\n    tags: ['dots', 'carousel'], labels: ['Paging results…', 'Scrolling cursor…'] }),\n];\n",
  "prism-term/src/facets/loaders/braille.js": "// Braille spinners — eight dots per cell, the npm/ora generation of loaders.\nimport { spinnerFacet } from './_kit.js';\n\nconst C = 'Braille Spinners';\n\nexport default [\n  spinnerFacet({ id: 'loaders-dots', spin: 'dots', title: 'Dots', category: C, order: 10,\n    description: 'The braille dot rotation npm, ora and half of all modern CLIs use — the default for a reason.',\n    tags: ['braille', 'npm', 'ora', 'default'], labels: ['Resolving dependencies…', 'Fetching 214 packages…', 'Linking binaries…'] }),\n  spinnerFacet({ id: 'loaders-dots-fill', spin: 'dots2', name: 'dots2', title: 'Dots Filled', category: C, order: 20,\n    description: 'A full braille cell with one dot missing, the gap orbiting round — bold enough for dark themes.',\n    tags: ['braille', 'bold'], labels: ['Installing toolchain…', 'Downloading rustc 1.82.0…'] }),\n  spinnerFacet({ id: 'loaders-dot-orbit', spin: 'dots3', name: 'dots3', title: 'Single Dot', category: C, order: 30,\n    description: 'One braille dot tracing the edge of its cell — the quietest loader in the catalog.',\n    tags: ['braille', 'minimal', 'quiet'], labels: ['Waiting for input…', 'Idle — polling every 5s…'] }),\n  spinnerFacet({ id: 'loaders-dot-bounce', spin: 'bounce', name: 'bounce', title: 'Dot Bounce', category: C, order: 40,\n    description: 'A single dot bouncing up and down the left column of a braille cell.',\n    tags: ['braille', 'bounce'], labels: ['Pinging 10.20.2.31…', 'Waiting for ack…'] }),\n  spinnerFacet({ id: 'loaders-dot-stack', spin: 'loadingBlocks', name: 'loadingBlocks', title: 'Dot Stack', category: C, order: 50,\n    description: 'Braille rows filling the cell from the bottom and draining again — a tiny level meter.',\n    tags: ['braille', 'fill', 'level'], labels: ['Buffering stream…', 'Filling cache…'] }),\n  spinnerFacet({ id: 'loaders-snake', spin: 'snake', title: 'Snake', category: C, order: 60,\n    description: 'A four-dot snake chasing its tail round a two-cell perimeter — busy without being loud.',\n    tags: ['braille', 'snake', 'perimeter'], labels: ['Crawling sitemap…', 'Walking the tree…'] }),\n  spinnerFacet({ id: 'loaders-dot-fill', spin: 'fill', title: 'Cell Fill', category: C, order: 70,\n    description: 'One braille cell filling dot by dot from the bottom, then draining — progress without a number.',\n    tags: ['braille', 'fill'], labels: ['Charging…', 'Warming up the JIT…'] }),\n];\n",
  "prism-term/src/facets/loaders/classic.js": "// Classic spinners — the single-glyph loops every CLI has shipped since the 80s.\nimport { spinnerFacet } from './_kit.js';\n\nconst C = 'Classic Spinners';\n\nexport default [\n  spinnerFacet({ id: 'loaders-line', spin: 'line', title: 'Line', category: C, order: 10,\n    description: 'The original |/-\\\\ twirl. Pure ASCII, so it works on a serial console, in LANG=C, and on every terminal ever made.',\n    tags: ['ascii', 'classic', 'twirl'], labels: ['Compiling kernel modules…', 'Linking vmlinux…', 'Generating initramfs…'] }),\n  spinnerFacet({ id: 'loaders-pipe', spin: 'pipe', title: 'Pipe', category: C, order: 20,\n    description: 'A box-drawing tee rotating through all eight orientations — sturdy, compact and readable at a glance.',\n    tags: ['box drawing', 'rotate'], labels: ['Reading package lists…', 'Building dependency tree…', 'Reading state information…'] }),\n  spinnerFacet({ id: 'loaders-arc', spin: 'arc', title: 'Arc', category: C, order: 30,\n    description: 'A quarter arc sweeping round a circle in six steps — the smooth, modern single-cell loader.',\n    tags: ['circle', 'arc', 'smooth'], labels: ['Connecting to api.example.net…', 'Negotiating TLS…', 'Waiting for response…'] }),\n  spinnerFacet({ id: 'loaders-circle', spin: 'circle', title: 'Half Circle', category: C, order: 40,\n    description: 'A half-filled circle turning in four beats — calm enough for long waits.',\n    tags: ['circle', 'half'], labels: ['Waiting for lock on /var/lib/dpkg…', 'Retrying in a moment…'] }),\n  spinnerFacet({ id: 'loaders-quarter', spin: 'quarter', title: 'Quarter Circle', category: C, order: 50,\n    description: 'A filled quadrant walking round a circle — a clock face reduced to one glyph.',\n    tags: ['circle', 'quadrant', 'clock'], labels: ['Scheduling job…', 'Waiting for a free runner…'] }),\n  spinnerFacet({ id: 'loaders-square-corners', spin: 'squareCorners', title: 'Square Corners', category: C, order: 60,\n    description: 'A square with one filled corner orbiting clockwise — geometric and easy on the eye.',\n    tags: ['square', 'geometric'], labels: ['Provisioning volume…', 'Attaching to node-03…'] }),\n  spinnerFacet({ id: 'loaders-triangle', spin: 'triangle', title: 'Triangle', category: C, order: 70,\n    description: 'Right triangles spinning through four corners at speed — a sharp, energetic loader.',\n    tags: ['triangle', 'fast'], labels: ['Indexing 48,210 files…', 'Building search index…'] }),\n  spinnerFacet({ id: 'loaders-arrows', spin: 'arrows', title: 'Arrows', category: C, order: 80,\n    description: 'An arrow pointing round all eight compass directions — reads as \"rotating\" even in a tiny font.',\n    tags: ['arrows', 'compass', 'rotate'], labels: ['Syncing repositories…', 'Rotating logs…', 'Rebalancing shards…'] }),\n  spinnerFacet({ id: 'loaders-arrows-chase', spin: 'arrows2', name: 'arrows2', title: 'Arrow Chase', category: C, order: 90,\n    description: 'A lit arrowhead marching along a row of hollow ones — direction and progress in five cells.',\n    tags: ['arrows', 'chase', 'marquee'], labels: ['Forwarding port 8443…', 'Streaming build logs…'] }),\n  spinnerFacet({ id: 'loaders-star', spin: 'star', title: 'Star', category: C, order: 100,\n    description: 'A star cycling through its point counts so it seems to twinkle — friendly, for user-facing CLIs.',\n    tags: ['star', 'twinkle'], labels: ['Generating release notes…', 'Polishing the changelog…'] }),\n  spinnerFacet({ id: 'loaders-hamburger', spin: 'hamburger', title: 'Trigram', category: C, order: 110,\n    description: 'Trigram glyphs shifting their broken line up and down — a compact, unusual loader.',\n    tags: ['trigram', 'lines'], labels: ['Parsing configuration…', 'Validating schema…'] }),\n  spinnerFacet({ id: 'loaders-toggle', spin: 'toggle', title: 'Toggle', category: C, order: 120,\n    description: 'A two-state toggle flipping back and forth — a heartbeat for something that is alive but idle.',\n    tags: ['toggle', 'two-state', 'idle'], labels: ['Listening on :8080…', 'Watching for changes…'] }),\n  spinnerFacet({ id: 'loaders-dqpb', spin: 'dqpb', title: 'dqpb', category: C, order: 130,\n    description: 'The letters d q p b rotating into each other — an ASCII-only spinner with a sense of humour.',\n    tags: ['ascii', 'letters', 'playful'], labels: ['Flipping bits…', 'Rotating letters…'] }),\n  spinnerFacet({ id: 'loaders-flip', spin: 'flip', title: 'Flip', category: C, order: 140,\n    description: 'A short line flipping up from the baseline and back down — a coin toss in a single cell.',\n    tags: ['flip', 'coin'], labels: ['Picking a leader…', 'Electing primary…'] }),\n];\n",
  "prism-term/src/facets/loaders/orbits.js": "// Orbits & shapes — picture spinners: phases, clocks, globes and bouncing balls.\nimport { spinnerFacet } from './_kit.js';\n\nconst C = 'Orbits & Shapes';\n\nexport default [\n  spinnerFacet({ id: 'loaders-moon', spin: 'moon', title: 'Moon Phases', category: C, order: 10,\n    description: 'The moon waxing and waning through eight phases — perfect for long-running, overnight jobs.',\n    tags: ['emoji', 'moon', 'phases'], labels: ['Running nightly backup…', 'Snapshotting volumes…'] }),\n  spinnerFacet({ id: 'loaders-clock', spin: 'clock', title: 'Clock', category: C, order: 20,\n    description: 'A clock face ticking through twelve hours — time is passing, literally.',\n    tags: ['emoji', 'clock', 'time'], labels: ['Waiting for cron window…', 'Sleeping until 02:00…'] }),\n  spinnerFacet({ id: 'loaders-earth', spin: 'earth', title: 'Globe', category: C, order: 30,\n    description: 'The globe turning to show each continent in turn — for network and replication waits.',\n    tags: ['emoji', 'globe', 'network'], labels: ['Replicating to eu-west…', 'Propagating DNS…'] }),\n  spinnerFacet({ id: 'loaders-hourglass', spin: 'hourglass', title: 'Hourglass', category: C, order: 40,\n    description: 'An hourglass flipping between running and empty — the universal \"this will take a while\".',\n    tags: ['emoji', 'hourglass', 'wait'], labels: ['Waiting for approval…', 'Queued behind 3 jobs…'] }),\n  spinnerFacet({ id: 'loaders-heart', spin: 'heart', title: 'Heartbeat', category: C, order: 50,\n    description: 'An outlined heart beating solid and back — a liveness signal for watchers and daemons.',\n    tags: ['heart', 'liveness', 'daemon'], labels: ['Monitoring health…', 'All checks passing…'] }),\n  spinnerFacet({ id: 'loaders-bouncing-ball', spin: 'bouncingBall', name: 'bouncingBall', title: 'Bouncing Ball', category: C, order: 60,\n    description: 'A ball bouncing wall to wall inside parentheses — the playful indeterminate bar.',\n    tags: ['ball', 'bounce', 'indeterminate'], labels: ['Rebalancing partitions…', 'Shuffling data…'] }),\n];\n",
  "prism-term/src/facets/loaders/originals.js": "// Prism originals — spinners with a bit of personality, generated from small\n// formulas so every frame is exactly the same width.\nimport { spinnerFacet, braille, range } from './_kit.js';\nimport { mix, hsl } from '../../color.js';\n\nconst lerp = (a, b, k) => a + (b - a) * k;\n\n// A ball under gravity bouncing across eight cells, with a two-dot motion trail.\nconst bounceFrames = (() => {\n  const N = 40, out = [], pos = [];\n  for (let k = 0; k < N; k++) {\n    const u = k / N;                            // one lap: right then back\n    const x = u < 0.5 ? u * 2 * 15 : (1 - u) * 2 * 15;\n    const hop = (u * 6) % 1;                    // six hops per lap\n    const y = 3 - 3 * 4 * hop * (1 - hop);      // parabola, floor at y=3\n    pos.push([x, y]);\n  }\n  for (let k = 0; k < N; k++) out.push(braille(8, [pos[k], pos[(k - 1 + N) % N], pos[(k - 2 + N) % N]]));\n  return out;\n})();\n\n// Five dots riding a traveling sine wave.\nconst dotWave = range(16, (k) => braille(5, range(5, (i) => [i * 2 + 0.5, 1.5 - 1.5 * Math.sin((k / 16) * Math.PI * 2 - i * 0.8)])));\n\n// A nucleus with two electrons on tilted orbits.\nconst electrons = range(24, (k) => {\n  const a = (k / 24) * Math.PI * 2;\n  const dots = [[3, 1], [4, 1], [3, 2], [4, 2]];\n  for (let j = 0; j < 3; j++) {\n    const b = a - j * 0.35;\n    dots.push([3.5 + 3.5 * Math.cos(b), 1.5 + 1.4 * Math.sin(b) * 0.9]);\n    dots.push([3.5 + 2.6 * Math.cos(-b * 1.5 + 1.3), 1.5 + 1.5 * Math.sin(-b * 1.5 + 1.3)]);\n  }\n  return braille(4, dots);\n});\n\n// Concentric rings expanding from the center and fading.\nconst ripple = range(14, (k) => {\n  const dots = [];\n  for (const off of [0, 7]) {\n    const r = ((k + off) % 14) / 14 * 3.2;\n    if (r < 0.3) { dots.push([2.5, 1.5]); continue; }\n    for (let s = 0; s < 24; s++) {\n      const a = (s / 24) * Math.PI * 2;\n      dots.push([2.5 + r * Math.cos(a), 1.5 + r * 0.55 * Math.sin(a)]);\n    }\n  }\n  return braille(3, dots);\n});\n\n// A clock hand sweeping a two-cell dial, with a fixed hub.\nconst clockHand = range(12, (k) => {\n  const a = (k / 12) * Math.PI * 2 - Math.PI / 2;\n  const dots = [[1.5, 1.5]];\n  for (let s = 0.6; s <= 2; s += 0.45) dots.push([1.5 + s * Math.cos(a) * 1.15, 1.5 + s * Math.sin(a)]);\n  return braille(2, dots);\n});\n\n// Morse \"PRISM\" on a scrolling tape, with a signal lamp.\nconst morse = (() => {\n  const code = { P: '.--.', R: '.-.', I: '..', S: '...', M: '--' };\n  const units = [];\n  for (const ch of 'PRISM') {\n    for (const [j, s] of [...code[ch]].entries()) {\n      units.push(...Array(s === '.' ? 1 : 3).fill(1));\n      if (j < code[ch].length - 1) units.push(0);\n    }\n    units.push(0, 0, 0);\n  }\n  units.push(0, 0, 0, 0);\n  const T = 12;\n  return range(units.length, (k) => {\n    const lamp = units[k] ? '●' : '○';\n    const tape = range(T, (i) => (units[(k + i) % units.length] ? '━' : ' ')).join('');\n    return `${lamp} ${tape}`;\n  });\n})();\n\n// White light entering a prism and fanning out as a spectrum.\nconst prismSweep = range(12, (k) => {\n  const beam = range(3, (i) => (i === k % 3 ? '━' : '─')).join('');\n  const fan = range(6, (j) => {\n    const spread = (j + 1) * 0.28;\n    return braille(1, [0, 1].flatMap((dx) => [1.5 - spread, 1.5 - spread / 3, 1.5 + spread / 3, 1.5 + spread].map((y) => [dx, y])));\n  }).join('');\n  return `${beam}◆${fan}`;\n});\n\n// An ECG trace scrolling left: baseline, P wave, QRS spike, T wave.\nconst ecg = (() => {\n  const beat = [2, 2, 2, 1.6, 2, 2, 3, 0, 3, 2, 2, 1.4, 1.2, 1.6, 2, 2, 2, 2, 2, 2];\n  return range(beat.length, (k) => {\n    const dots = [];\n    let prev = null;\n    for (let x = 0; x < 16; x++) {\n      const y = beat[(k + x) % beat.length];\n      if (prev !== null) for (let s = 0; s <= 1; s += 0.5) dots.push([x - 1 + s, lerp(prev, y, s)]);\n      dots.push([x, y]);\n      prev = y;\n    }\n    return braille(8, dots);\n  });\n})();\n\n// Two paddles rallying a ball across an eight-cell court.\nconst pong = range(32, (k) => {\n  const u = k / 32;\n  const bx = 1 + (u < 0.5 ? u * 2 : 2 - u * 2) * 13;\n  const by = 1.5 + 1.5 * Math.sin(u * Math.PI * 4);\n  const pl = Math.max(0, Math.min(2, by - 0.5)), pr = Math.max(0, Math.min(2, 1.5 + 1.5 * Math.sin((u + 0.04) * Math.PI * 4) - 0.5));\n  return braille(8, [[0, pl], [0, pl + 1], [15, pr], [15, pr + 1], [bx, by]]);\n});\n\n// A snake on a serpentine path through a 8×4 dot field, eating and growing.\nconst snakeGame = (() => {\n  const path = [];\n  for (let y = 0; y < 4; y++) for (let i = 0; i < 8; i++) path.push([y % 2 ? 7 - i : i, y]);\n  const N = path.length;\n  return range(N, (k) => {\n    const len = 2 + Math.floor((k / N) * 5);\n    const body = range(len, (j) => path[(k - j + N) % N]);\n    const food = path[(k + 6) % N];\n    return braille(4, [...body, food]);\n  });\n})();\n\n// A rotating two-arm spiral.\nconst spiral = range(16, (k) => {\n  const dots = [];\n  for (let arm = 0; arm < 2; arm++) for (let s = 0; s < 6; s++) {\n    const r = 0.3 + s * 0.3, a = (k / 16) * Math.PI * 2 + s * 0.7 + arm * Math.PI;\n    dots.push([1.5 + r * Math.cos(a) * 1.2, 1.5 + r * Math.sin(a)]);\n  }\n  return braille(2, dots);\n});\n\n// A chomping mouth working through a row of pellets.\nconst pacman = range(8, (k) => {\n  const open = [0.9, 0.6, 0.25, 0.05, 0.25, 0.6, 0.9, 1][k];\n  const head = [];\n  for (let y = 0; y < 4; y++) for (let x = 0; x < 4; x++) {\n    const dx = x - 1.5, dy = y - 1.5;\n    if (dx * dx + dy * dy > 4.2) continue;\n    const ang = Math.abs(Math.atan2(dy, dx));\n    if (dx > 0 && ang < open) continue;\n    head.push([x, y]);\n  }\n  // Pellets every other dot, sliding one dot left per frame into the mouth.\n  const pellets = range(5, (p) => [1 + p * 2 - (k % 2), 1.5]);\n  return braille(2, head) + braille(5, pellets);\n});\n\n// Three dots breathing in sequence.\nconst dotsSizes = ['∙', '•', '●', '•'];\nconst gradientDots = range(12, (k) => range(3, (i) => dotsSizes[Math.floor((k / 3 + 4 - i) % 4)]).join(' '));\n\n// A real 8-bit counter (binary increments), not random noise.\nconst bits = range(64, (k) => ((k + 176) & 255).toString(2).padStart(8, '0'));\n\n// Embers rising out of three cells of flame.\nconst ember = (() => {\n  let s = 1234567;\n  const rnd = () => ((s = (s * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff);\n  return range(20, () => {\n    const dots = [];\n    for (let x = 0; x < 6; x++) {\n      const hgt = 1 + Math.floor(rnd() * 3.2);\n      for (let y = 3; y > 3 - hgt; y--) dots.push([x, y]);\n      if (rnd() < 0.35) dots.push([x, Math.floor(rnd() * 2)]);\n    }\n    return braille(3, dots);\n  });\n})();\n\nconst O = 'Orbits & Shapes', B = 'Braille Spinners', P = 'Bars & Pulses', T = 'Truecolor Spinners', CL = 'Classic Spinners';\n\nexport default [\n  spinnerFacet({ id: 'loaders-gravity-bounce', name: 'gravity-bounce', title: 'Gravity Bounce', category: O, order: 70,\n    spin: { frames: bounceFrames, interval: 45, colorAt: (t, i, k, th) => mix(th.info, th.ink, 0.35) },\n    description: 'A braille ball hopping across eight cells under real parabolic gravity, trailing two dots of motion blur.',\n    tags: ['physics', 'ball', 'gravity', 'original'], labels: ['Rebalancing load…', 'Bouncing requests…'] }),\n  spinnerFacet({ id: 'loaders-dot-wave', name: 'dot-wave', title: 'Dot Wave', category: B, order: 80,\n    spin: { frames: dotWave, interval: 60 },\n    description: 'Five braille dots riding a traveling sine wave — a calm, readable loader for long tasks.',\n    tags: ['braille', 'wave', 'original'], labels: ['Syncing…', 'Applying migrations…'] }),\n  spinnerFacet({ id: 'loaders-electrons', name: 'electrons', title: 'Electron Orbits', category: O, order: 80,\n    spin: { frames: electrons, interval: 55, colorAt: (t, i, k, th) => (i === 1 || i === 2 ? mix(th.warn, th.ink, 0.2) : th.info) },\n    description: 'Two electrons circling a warm nucleus on tilted orbits, drawn in four braille cells.',\n    tags: ['braille', 'atom', 'orbit', 'original'], labels: ['Splitting workloads…', 'Charging capacitors…'] }),\n  spinnerFacet({ id: 'loaders-ripple', name: 'ripple', title: 'Ripple', category: P, order: 140,\n    spin: { frames: ripple, interval: 70, colorAt: (t, i, k, th) => mix(th.accent, th.info, 0.5 + 0.5 * Math.sin(t * 3 + i)) },\n    description: 'Concentric braille rings expanding from a point and fading out — a sonar ping with depth.',\n    tags: ['braille', 'ripple', 'sonar', 'original'], labels: ['Discovering services…', 'Waiting for gossip…'] }),\n  spinnerFacet({ id: 'loaders-clock-hand', name: 'clock-hand', title: 'Clock Hand', category: O, order: 90,\n    spin: { frames: clockHand, interval: 110 },\n    description: 'A braille clock hand sweeping a two-cell dial round a fixed hub — Unicode-only, no emoji needed.',\n    tags: ['braille', 'clock', 'time', 'original'], labels: ['Waiting for maintenance window…', 'Sleeping 30s…'] }),\n  spinnerFacet({ id: 'loaders-morse', name: 'morse', title: 'Morse Lamp', category: P, order: 150,\n    spin: { frames: morse, interval: 90, colorAt: (t, i, k, th) => (i === 0 ? (morse[k][0] === '●' ? th.warn : th.dim) : th.accent) },\n    description: 'A signal lamp flashing PRISM in real Morse timing while the code scrolls past on a tape.',\n    tags: ['morse', 'signal', 'lamp', 'original'], labels: ['Transmitting…', 'Signalling peers…'] }),\n  spinnerFacet({ id: 'loaders-prism-sweep', name: 'prism-sweep', title: 'Prism Refraction', category: T, order: 100,\n    spin: { frames: prismSweep, interval: 80, colorAt: (t, i, k, th) => (i < 3 ? (i === k % 3 ? 0xffffff : th.muted) : i === 3 ? th.ink : hsl((i - 4) * 52 - t * 90, 0.85, 0.62)) },\n    description: 'A pulse of white light hitting a prism and fanning into a turning rainbow — Prism\\'s signature loader.',\n    tags: ['prism', 'rainbow', 'light', 'original'], labels: ['Refracting facets…', 'Splitting the spectrum…'] }),\n  spinnerFacet({ id: 'loaders-ecg', name: 'ecg', title: 'ECG', category: T, order: 110,\n    spin: { frames: ecg, interval: 55, colorAt: (t, i, k, th) => mix(th.dim, th.neg, 0.35 + i / 10) },\n    description: 'An ECG trace scrolling across eight braille cells — P wave, QRS spike, T wave. A heartbeat for health checks.',\n    tags: ['braille', 'ecg', 'heartbeat', 'health', 'original'], labels: ['Health check passing…', 'Monitoring vitals…'] }),\n  spinnerFacet({ id: 'loaders-pong', name: 'pong', title: 'Pong', category: O, order: 100,\n    spin: { frames: pong, interval: 50 },\n    description: 'Two braille paddles rallying a ball across an eight-cell court, forever.',\n    tags: ['braille', 'pong', 'game', 'original'], labels: ['Negotiating protocol…', 'Exchanging keys…'] }),\n  spinnerFacet({ id: 'loaders-snake-game', name: 'snake-game', title: 'Snake Game', category: B, order: 90,\n    spin: { frames: snakeGame, interval: 75 },\n    description: 'A braille snake slithering a serpentine path, eating the dot ahead of it and growing longer.',\n    tags: ['braille', 'snake', 'game', 'original'], labels: ['Consuming queue…', 'Eating backlog…'] }),\n  spinnerFacet({ id: 'loaders-spiral', name: 'spiral', title: 'Spiral', category: B, order: 100,\n    spin: { frames: spiral, interval: 60 },\n    description: 'A two-armed braille spiral turning on the spot — hypnotic in a single pair of cells.',\n    tags: ['braille', 'spiral', 'original'], labels: ['Compiling shaders…', 'Hashing…'] }),\n  spinnerFacet({ id: 'loaders-chomp', name: 'chomp', title: 'Chomp', category: O, order: 110,\n    spin: { frames: pacman, interval: 90, colorAt: (t, i, k, th) => (i < 2 ? th.warn : th.ink) },\n    description: 'A round braille mouth chomping its way through a row of pellets — a cheerful queue-drain loader.',\n    tags: ['braille', 'chomp', 'game', 'original'], labels: ['Draining queue…', 'Processing 1,204 items…'] }),\n  spinnerFacet({ id: 'loaders-breathing-dots', name: 'breathing-dots', title: 'Breathing Dots', category: T, order: 120,\n    spin: { frames: gradientDots, interval: 110, colorAt: (t, i, k, th) => mix(th.accent, th.crit, 0.5 + 0.5 * Math.sin(t * 2.4 - i * 1.2)) },\n    description: 'Three dots swelling in sequence while their color drifts between accent and violet — a chat-style wait.',\n    tags: ['dots', 'typing', 'chat', 'original'], labels: ['Assistant is typing…', 'Composing reply…'] }),\n  spinnerFacet({ id: 'loaders-bit-counter', name: 'bit-counter', title: 'Bit Counter', category: CL, order: 150,\n    spin: { frames: bits, interval: 120 },\n    description: 'An eight-bit register counting up in real binary, one increment per tick — low bits flicker, high bits creep. Pure ASCII.',\n    tags: ['ascii', 'binary', 'counter', 'original'], labels: ['Counting blocks...', 'Walking the bitmap...'] }),\n  spinnerFacet({ id: 'loaders-ember', name: 'ember', title: 'Ember', category: T, order: 130,\n    spin: { frames: ember, interval: 90, colorAt: (t, i, k, th) => [0xff4f1f, 0xff9a2e, 0xffd25e][(i + k) % 3] },\n    description: 'Three cells of braille flame flickering with embers breaking loose — for burn-in, soak and stress tests.',\n    tags: ['braille', 'fire', 'flame', 'original'], labels: ['Burning in…', 'Stress testing 8 cores…'] }),\n];\n",
  "prism-term/src/facets/loaders/truecolor.js": "// Truecolor spinners — multi-cell loaders with a palette sweeping through them.\nimport { spinnerFacet } from './_kit.js';\n\nconst C = 'Truecolor Spinners';\n\nexport default [\n  spinnerFacet({ id: 'loaders-orbit', spin: 'orbit', title: 'Orbit', category: C, order: 10,\n    description: 'A planet with a short tail circling a lit sun in braille, shaded ice-blue — elegant for network waits.',\n    tags: ['braille', 'orbit', 'planet'], labels: ['Establishing tunnel…', 'Handshaking with 10.20.4.11…'] }),\n  spinnerFacet({ id: 'loaders-thinking', spin: 'thinking', title: 'Thinking', category: C, order: 20,\n    description: 'Three blobs bobbing on a phase-shifted wave in aurora colors — the AI typing indicator.',\n    tags: ['braille', 'ai', 'typing'], labels: ['Thinking…', 'Reading the logs…', 'Forming a hypothesis…'] }),\n  spinnerFacet({ id: 'loaders-wave', spin: 'wave', title: 'Wave', category: C, order: 30,\n    description: 'A sine wave of eighth blocks rolling across eight cells in ocean blues.',\n    tags: ['blocks', 'wave', 'sine'], labels: ['Streaming audio…', 'Tuning in…'] }),\n  spinnerFacet({ id: 'loaders-equalizer', spin: 'equalizer', title: 'Equalizer', category: C, order: 40,\n    description: 'A five-band audio equalizer bouncing to an unheard beat, in synthwave colors.',\n    tags: ['blocks', 'equalizer', 'audio', 'music'], labels: ['Analyzing spectrum…', 'Normalizing loudness…'] }),\n  spinnerFacet({ id: 'loaders-dna', spin: 'dna', title: 'DNA', category: C, order: 50,\n    description: 'Two braille helix strands twisting round each other with base-pair rungs, candy-colored.',\n    tags: ['braille', 'dna', 'helix'], labels: ['Sequencing…', 'Aligning reads…'] }),\n  spinnerFacet({ id: 'loaders-radar', spin: 'radar', title: 'Scanner Beam', category: C, order: 60,\n    description: 'A block beam sweeping back and forth with a fading afterglow — scanning for something.',\n    tags: ['blocks', 'scan', 'beam'], labels: ['Indexing archive…', 'Sweeping caches…'] }),\n  spinnerFacet({ id: 'loaders-prism', spin: 'prism', title: 'Prism Facets', category: C, order: 70,\n    description: 'Faceted diamonds catching a rainbow as they turn — Prism\\'s own loader.',\n    tags: ['diamond', 'rainbow', 'prism'], labels: ['Composing facets…', 'Refracting…'] }),\n  spinnerFacet({ id: 'loaders-binary', spin: 'binary', title: 'Binary', category: C, order: 80,\n    description: 'A six-digit register of bits flickering in matrix green — for encodes, hashes and checksums.',\n    tags: ['binary', 'bits', 'ascii digits'], labels: ['Computing SHA-256…', 'Verifying checksum…'] }),\n  spinnerFacet({ id: 'loaders-katakana', spin: 'matrix', name: 'matrix', title: 'Digital Rain', category: C, order: 90,\n    description: 'Half-width katakana and digits flickering in green — a six-cell slice of digital rain.',\n    tags: ['katakana', 'matrix', 'rain'], labels: ['Decrypting…', 'Following the white rabbit…'] }),\n];\n",
  "prism-term/src/facets/maps/_world.js": "// Shared world geometry for the maps gallery: coarse hand-traced continents\n// (copied from objects/globe.js so galleries stay independent), point-in-land\n// tests, an aspect-correct equirectangular projection, cached land masks,\n// great-circle interpolation and a set of hub locations.\nexport const LAND = [\n  // North America (outer), Hudson Bay (hole — even-odd)\n  [[-166, 68], [-156, 71], [-140, 69.5], [-128, 70], [-110, 68], [-95, 68], [-82, 67], [-78, 72], [-64, 67], [-64, 60], [-56, 52], [-60, 46], [-70, 43], [-74, 40], [-76, 35], [-81, 31], [-80, 26], [-82, 29], [-85, 30], [-90, 29.5], [-97, 27], [-97, 22], [-95, 18.5], [-90, 21], [-87, 21], [-88, 16], [-83, 15], [-83, 10], [-79, 9], [-77, 8], [-80, 7], [-85, 10], [-92, 14], [-96, 16], [-105, 20], [-106, 23], [-112, 29], [-110, 23], [-117, 32], [-121, 35], [-124, 40], [-124, 46], [-123, 49], [-127, 51], [-133, 56], [-140, 60], [-150, 61], [-155, 58], [-165, 55], [-158, 58], [-166, 60], [-166, 64]],\n  [[-95, 60], [-90, 57], [-82, 55], [-79, 52], [-80, 57], [-77, 60], [-78, 63], [-86, 64], [-94, 62]],\n  // Greenland, Iceland\n  [[-73, 78], [-60, 82], [-30, 83], [-20, 80], [-20, 72], [-25, 69], [-40, 65], [-44, 60], [-50, 62], [-53, 67], [-58, 75]],\n  [[-24, 65.5], [-22, 66.5], [-15, 66.5], [-13.5, 65], [-18, 63.4], [-22, 63.8]],\n  // South America\n  [[-77, 8], [-72, 12], [-63, 10.5], [-60, 8], [-52, 5], [-50, 0], [-44, -2], [-35, -5], [-35, -9], [-39, -15], [-40, -22], [-48, -26], [-53, -34], [-58, -38], [-62, -41], [-65, -45], [-68, -50], [-69, -55], [-73, -53], [-75, -47], [-73, -40], [-71, -30], [-70, -18], [-76, -14], [-81, -6], [-80, -2], [-78, 2]],\n  // Eurasia (outer), Black Sea + Caspian (holes)\n  [[-9, 37], [-9, 43], [-2, 43.5], [-1, 46], [-4.5, 48], [2, 51], [5, 53], [8, 54], [10, 57], [8, 58], [5, 62], [10, 64], [15, 68], [20, 70], [28, 71], [33, 69], [40, 67], [45, 68], [55, 69], [60, 70], [68, 73], [80, 73], [90, 76], [105, 78], [112, 74], [130, 72], [140, 72], [150, 71], [160, 70], [170, 70], [179, 66], [170, 60], [162, 58], [156, 51], [150, 59], [142, 59], [136, 54], [140, 48], [135, 43], [130, 42], [129, 35], [126, 34.5], [125, 39], [122, 40], [121, 37], [119, 35], [121, 31], [122, 29], [119, 25], [114, 22], [110, 21], [106, 20], [108, 16], [109, 12], [105, 9], [101, 13], [99, 10], [100, 6], [104, 1.5], [101, 3], [98, 8], [98, 15], [94, 16], [92, 21], [88, 22], [85, 20], [80, 16], [80, 10], [77, 8], [73, 16], [72, 21], [69, 22], [66, 25], [62, 25], [57, 26], [56, 24], [59, 22], [55, 17], [52, 16], [45, 13], [43, 13], [39, 21], [35, 28], [34, 31], [35, 32], [36, 35], [33, 36.5], [28, 37], [26, 40], [22, 37], [19, 42], [13.5, 45.5], [12, 44], [16, 38], [12, 42], [8, 44], [3, 43], [0, 39], [-2, 37], [-6, 36]],\n  [[28, 41], [28, 46], [34, 46], [41, 42], [36, 41.5]],\n  [[47, 45], [51, 47], [54, 44], [54, 37], [49, 37], [49, 41]],\n  // Britain, Ireland, Japan\n  [[-5, 50], [1.5, 51], [1.7, 53], [-0.5, 54], [-2, 56], [-2, 58], [-5, 58.5], [-6, 56], [-4.8, 54.8], [-3, 53.5], [-4.5, 52]],\n  [[-10, 51.5], [-6, 52], [-6, 55], [-8, 55], [-10, 54]],\n  [[130, 31], [132, 34], [135, 34], [140, 35], [142, 39], [141, 42], [145, 44], [141, 45.5], [140, 42], [139, 38], [136, 36], [132, 35]],\n  // Africa, Madagascar\n  [[-17, 21], [-16, 28], [-10, 30], [-6, 36], [0, 36], [10, 37], [11, 33], [20, 31], [25, 32], [32, 31], [34, 28], [39, 18], [43, 12], [51, 12], [45, 2], [40, -3], [40, -10], [40, -15], [35, -20], [35, -25], [32, -29], [27, -34], [20, -35], [18, -32], [15, -27], [12, -18], [13, -12], [12, -5], [9, -1], [9, 4], [5, 6], [-2, 5], [-8, 4.5], [-13, 8], [-17, 14]],\n  [[44, -25], [47, -25], [50, -15], [49, -12], [44, -17]],\n  // Maritime SE Asia, Australia, New Zealand\n  [[95, 5.5], [98, 4], [104, -2], [106, -6], [102, -4], [97, 1]],\n  [[109, 2], [111, -3], [116, -4], [119, 1], [117, 7], [115, 5]],\n  [[105, -6], [114, -7], [114, -8.5], [106, -7.5]],\n  [[131, -1], [141, -2.5], [148, -6], [150, -10], [143, -9], [138, -8], [133, -4]],\n  [[114, -22], [114, -34], [118, -35], [124, -33], [130, -32], [135, -35], [138, -35], [140, -38], [146, -39], [150, -37], [153, -31], [153, -25], [146, -19], [145, -15], [142, -11], [141, -17], [136, -12], [132, -11], [129, -15], [125, -14], [122, -18]],\n  [[172, -34], [178, -38], [175, -41.5], [172, -41]],\n  [[173, -41], [174, -42], [171, -44], [168, -46.5], [166.5, -46], [170, -43]],\n];\n\n// Per-polygon bounding boxes: a point outside a box contributes an even number\n// of ray crossings (or none), so the box test can skip it without changing parity.\nconst BOXES = LAND.map((poly) => {\n  let a = Infinity, b = -Infinity, c = Infinity, d = -Infinity;\n  for (const [x, y] of poly) { a = Math.min(a, x); b = Math.max(b, x); c = Math.min(c, y); d = Math.max(d, y); }\n  return [a, b, c, d];\n});\n\n/** Even-odd point-in-land test on (lon, lat) degrees; Antarctica is a wavy band. */\nexport function inLand(lon, lat) {\n  if (lat < -68 + 3 * Math.sin((lon * Math.PI) / 60)) return true;\n  let inside = false;\n  for (let k = 0; k < LAND.length; k++) {\n    const box = BOXES[k];\n    if (lat < box[2] || lat > box[3] || lon > box[1] || lon < box[0]) continue;\n    const poly = LAND[k];\n    for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {\n      const [xi, yi] = poly[i], [xj, yj] = poly[j];\n      if ((yi > lat) !== (yj > lat) && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) inside = !inside;\n    }\n  }\n  return inside;\n}\n\n/** The default crop: the inhabited latitudes. */\nexport const WORLD = { lon0: -180, lon1: 180, lat0: 80, lat1: -58 };\n\n/**\n * Equirectangular projection fitted into a canvas with the canvas's pixel aspect,\n * centered. `stretch` > 1 lets the map grow taller than true aspect to fill space.\n */\nexport function project(cv, { view = WORLD, x = 0, y = 0, w = cv.width, h = cv.height, stretch = 1.15 } = {}) {\n  const dLon = view.lon1 - view.lon0, dLat = view.lat0 - view.lat1;\n  const sx = Math.min(w / dLon, ((h * cv.aspect) / dLat) * stretch);\n  const sy = Math.min(h / dLat, (sx / cv.aspect) * stretch);\n  const mw = dLon * sx, mh = dLat * sy;\n  const ox = x + (w - mw) / 2, oy = y + (h - mh) / 2;\n  return {\n    sx, sy, ox, oy, w: mw, h: mh, view,\n    x: (lon) => ox + (lon - view.lon0) * sx,\n    y: (lat) => oy + (view.lat0 - lat) * sy,\n    lon: (px) => view.lon0 + (px - ox) / sx,\n    lat: (py) => view.lat0 - (py - oy) / sy,\n  };\n}\n\nconst maskCache = new Map();\n/** Land mask for a projection: Uint8Array over the canvas pixels (1 = land). Cached. */\nexport function landMask(cv, P) {\n  const key = `${cv.width}x${cv.height}:${P.ox.toFixed(2)},${P.oy.toFixed(2)},${P.sx.toFixed(4)},${P.sy.toFixed(4)},${P.view.lon0},${P.view.lat0}`;\n  let m = maskCache.get(key);\n  if (m) return m;\n  m = new Uint8Array(cv.width * cv.height);\n  for (let y = 0; y < cv.height; y++) {\n    const lat = P.lat(y + 0.5);\n    if (lat > P.view.lat0 || lat < P.view.lat1) continue;\n    for (let x = 0; x < cv.width; x++) {\n      const lon = P.lon(x + 0.5);\n      if (lon < P.view.lon0 || lon > P.view.lon1) continue;\n      if (inLand(lon, lat)) m[y * cv.width + x] = 1;\n    }\n  }\n  if (maskCache.size > 24) maskCache.clear();\n  maskCache.set(key, m);\n  return m;\n}\n\n/** Coast pixels: land with at least one sea neighbor (4-connected). */\nexport function coastOf(mask, w, h) {\n  const out = new Uint8Array(w * h);\n  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {\n    const i = y * w + x;\n    if (!mask[i]) continue;\n    if (x === 0 || y === 0 || x === w - 1 || y === h - 1 || !mask[i - 1] || !mask[i + 1] || !mask[i - w] || !mask[i + w]) out[i] = 1;\n  }\n  return out;\n}\n\nconst RAD = Math.PI / 180;\nconst vec = (lon, lat) => [Math.cos(lat * RAD) * Math.cos(lon * RAD), Math.cos(lat * RAD) * Math.sin(lon * RAD), Math.sin(lat * RAD)];\n\n/** n+1 points [lon, lat] along the great circle from a to b (spherical lerp). */\nexport function greatCircle(a, b, n = 48) {\n  const p = vec(a[0], a[1]), q = vec(b[0], b[1]);\n  const d = Math.acos(Math.max(-1, Math.min(1, p[0] * q[0] + p[1] * q[1] + p[2] * q[2])));\n  const out = [];\n  for (let i = 0; i <= n; i++) {\n    const k = i / n;\n    const s0 = d ? Math.sin((1 - k) * d) / Math.sin(d) : 1 - k, s1 = d ? Math.sin(k * d) / Math.sin(d) : k;\n    const x = s0 * p[0] + s1 * q[0], y = s0 * p[1] + s1 * q[1], z = s0 * p[2] + s1 * q[2];\n    out.push([Math.atan2(y, x) / RAD, Math.atan2(z, Math.hypot(x, y)) / RAD]);\n  }\n  return out;\n}\n\n/** Great-circle distance in km. */\nexport function distanceKm(a, b) {\n  const p = vec(a[0], a[1]), q = vec(b[0], b[1]);\n  return 6371 * Math.acos(Math.max(-1, Math.min(1, p[0] * q[0] + p[1] * q[1] + p[2] * q[2])));\n}\n\n/** Hub cities as [code, name, lon, lat]. Codes are airport-style, not real sites. */\nexport const HUBS = [\n  ['IAD', 'Ashburn', -77.5, 39], ['PDX', 'Portland', -122.7, 45.5], ['DFW', 'Dallas', -96.8, 32.8],\n  ['GRU', 'Sao Paulo', -46.6, -23.5], ['LHR', 'London', -0.1, 51.5], ['FRA', 'Frankfurt', 8.7, 50.1],\n  ['BOM', 'Mumbai', 72.9, 19.1], ['SIN', 'Singapore', 103.8, 1.35], ['NRT', 'Tokyo', 139.7, 35.7],\n  ['SYD', 'Sydney', 151.2, -33.9], ['JNB', 'Johannesburg', 28, -26.2], ['SCL', 'Santiago', -70.7, -33.4],\n];\nexport const hub = (code) => HUBS.find((h) => h[0] === code);\n\n/** Draw a projected polyline of [lon, lat] points, splitting at the antimeridian. */\nexport function drawGeoPath(cv, P, pts, color, upto = 1) {\n  const n = Math.max(1, Math.round((pts.length - 1) * upto));\n  for (let i = 0; i < n; i++) {\n    const [a, b] = [pts[i], pts[i + 1]];\n    if (Math.abs(a[0] - b[0]) > 180) continue;\n    cv.line(P.x(a[0]), P.y(a[1]), P.x(b[0]), P.y(b[1]), typeof color === 'function' ? color(i / n) : color);\n  }\n}\n",
  "prism-term/src/facets/maps/floor.js": "// Grids & Floorplans: a data-hall rack floorplan with inlet temperatures and\n// airflow, and a warehouse floor of robots shuttling shelf pods.\nimport { mix, gradient } from '../../color.js';\nimport { glyphs, put, putR, safe, clamp, smooth, ATTR } from '../charts/_data.js';\n\nconst ROWS = ['A', 'B', 'C', 'D', 'E', 'F'];\n\nconst datacenter = {\n  id: 'maps-datacenter',\n  title: 'Data Hall Floorplan',\n  category: 'Grids & Floorplans',\n  description: 'A data-hall floorplan: rack rows with inlet temperatures, cold and hot aisles with moving airflow, CRAC units and a hotspot being cooled.',\n  tags: ['datacenter', 'floorplan', 'racks', 'temperature', 'airflow'],\n  kind: 'scene',\n  size: { w: 80, h: 22 },\n  minSize: { w: 44, h: 12 },\n  loop: 20,\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    const panelW = W >= 70 ? 22 : 0;\n    const floorW = W - panelW;\n    const racks = Math.max(6, Math.min(12, Math.floor((floorW - 8) / 4)));\n    const rw = 4; // \" 24\" plus a gap\n    const x0 = 4;\n    // Layout: aisle, back-to-back rack pair, aisle, ... Aisles alternate cold and hot.\n    const nRows = H >= 14 ? 6 : 4, pairs = nRows / 2;\n    const ah = Math.max(1, Math.min(3, Math.floor((H - 2 - nRows) / (pairs + 1))));\n    const aisles = [], rackYs = [];\n    let yy = 1;\n    for (let p = 0; p <= pairs; p++) {\n      aisles.push({ y: yy, h: ah, cold: p % 2 === 0 });\n      yy += ah;\n      if (p < pairs) { rackYs.push(yy, yy + 1); yy += 2; }\n    }\n    const floorH = yy; // first row below the floor\n    const heat = gradient([th.info, th.pos, th.warn, th.neg]);\n    const base = ROWS.map((_, r) => Array.from({ length: 12 }, (_, i) => 21.5 + 2.2 * ctx.noise.noise2(r * 0.9 + 0.3, i * 0.45) + (i > 8 ? 0.8 : 0)));\n    const HOT = [2, 6]; // row C, rack 07\n    const temp = (r, i, lt) => {\n      const d = Math.abs(r - HOT[0]) * 1.4 + Math.abs(i - HOT[1]);\n      // The hotspot builds for 8 s, then the CRAC boost pulls it down.\n      const k = lt < 8 ? smooth(lt / 8) : lt < 11 ? 1 : 1 - smooth(clamp((lt - 11) / 6, 0, 1));\n      return base[r][i] + 11 * k * Math.exp(-d * d * 0.35) + 0.15 * Math.sin(lt * 2 + i + r);\n    };\n    return (t) => {\n      const lt = t % 20;\n      const boost = lt >= 9 && lt < 17;\n      // Room outline.\n      const fx1 = x0 + racks * rw + 1;\n      b.text(1, 0, g.tl + g.h.repeat(fx1 - 2) + g.tr, th.line);\n      for (let y = 1; y < floorH; y++) { b.set(1, y, g.v, th.line); b.set(fx1, y, g.v, th.line); }\n      if (floorH < H) b.text(1, floorH, g.bl + g.h.repeat(fx1 - 2) + g.br, th.line);\n      put(b, 3, 0, ` HALL 2 · ROWS A-${ROWS[nRows - 1]} `, fx1 - 4, th.muted);\n      // Aisles with drifting airflow marks along their middle row.\n      for (const a of aisles) {\n        const c = a.cold ? mix(th.bg, th.info, 0.6) : mix(th.bg, th.neg, 0.5);\n        const speed = a.cold ? (boost ? 9 : 5) : -4;\n        const mid = a.y + Math.floor((a.h - 1) / 2);\n        for (let y = a.y; y < a.y + a.h; y++) for (let x = x0; x < fx1 - 1; x++) {\n          const ph = (((x - lt * speed + (y - mid) * 2) % 6) + 6) % 6;\n          if (y === mid && ph < 1) b.set(x, y, a.cold ? (g.u ? '›' : '>') : (g.u ? '‹' : '<'), c, undefined, ATTR.BOLD);\n          else if ((x + y) % 3 === 0) b.set(x, y, g.u ? '·' : '.', mix(th.bg, c, 0.45));\n        }\n        b.text(2, mid, a.cold ? 'C' : 'H', c, undefined, ATTR.BOLD);\n      }\n      // Racks with inlet temperature.\n      let hottest = { v: 0 };\n      for (let r = 0; r < nRows; r++) {\n        const y = rackYs[r];\n        b.text(2, y, ROWS[r], th.muted, undefined, ATTR.BOLD);\n        for (let i = 0; i < racks; i++) {\n          const v = temp(r, i, lt);\n          if (v > hottest.v) hottest = { v, r, i };\n          const k = clamp((v - 18) / 16, 0, 1);\n          const bg = mix(th.bg, heat(k), 0.75);\n          const x = x0 + i * rw;\n          b.text(x, y, ` ${Math.round(v)} `.slice(0, rw - 1).padEnd(rw - 1), v > 30 ? 0xffffff : th.ink, bg, v > 30 ? ATTR.BOLD : 0);\n        }\n      }\n      // CRAC units on the end walls.\n      const fan = boost ? 96 : 62;\n      for (const a of aisles) if (a.cold) for (let y = a.y; y < a.y + a.h; y++) b.set(fx1, y, g.u ? '█' : '#', boost ? th.info : mix(th.bg, th.info, 0.6));\n      if (panelW) {\n        const px = W - panelW + 1, pw = panelW - 1;\n        let y = 0;\n        b.text(px, y++, 'HALL 2', th.ink, undefined, ATTR.BOLD);\n        const load = 412 + 6 * Math.sin(t * 0.5);\n        put(b, px, y++, `IT load  ${load.toFixed(0)} kW`, pw, th.muted);\n        put(b, px, y++, `PUE      ${(1.36 + (hottest.v > 28 ? 0.04 : 0)).toFixed(2)}`, pw, th.muted);\n        y++;\n        b.text(px, y++, 'HOTTEST INLET', th.dim);\n        const hc = hottest.v > 30 ? th.neg : hottest.v > 27 ? th.warn : th.pos;\n        put(b, px, y++, `${ROWS[hottest.r]}-${String(hottest.i + 1).padStart(2, '0')}  ${hottest.v.toFixed(1)}°C`, pw, hc, undefined, ATTR.BOLD);\n        put(b, px, y++, hottest.v > 30 ? `${g.warn} above 30°C limit` : hottest.v > 27 ? 'rising' : 'within range', pw, hc);\n        y++;\n        b.text(px, y++, 'COOLING', th.dim);\n        for (const name of ['CRAC-1', 'CRAC-2']) {\n          if (y >= H) break;\n          put(b, px, y++, `${name} fan ${fan}% ${boost ? g.up : ''}`, pw, boost ? th.info : th.muted);\n        }\n        if (y < H) put(b, px, y++, `supply ${boost ? '16.0' : '18.0'}°C`, pw, th.muted);\n        if (H - 1 > y) {\n          // Color key.\n          const ky = H - 1;\n          let x = b.text(px, ky, '18', th.dim);\n          for (let k = 0; k < pw - 6; k++) b.set(x + k, ky, ' ', undefined, mix(th.bg, heat(k / (pw - 7)), 0.75));\n          b.text(x + pw - 6, ky, '34', th.dim);\n        }\n      }\n    };\n  },\n};\n\nconst warehouse = {\n  id: 'maps-warehouse-bots',\n  title: 'Warehouse Robots',\n  category: 'Grids & Floorplans',\n  description: 'A goods-to-person warehouse floor: robots loop the aisles carrying shelf pods to pick stations, with a fleet list of tasks and battery.',\n  tags: ['warehouse', 'robots', 'fleet', 'grid', 'logistics'],\n  kind: 'scene',\n  size: { w: 80, h: 22 },\n  minSize: { w: 44, h: 12 },\n  loop: 24,\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    const panelW = W >= 70 ? 24 : 0;\n    const fw = W - panelW, fh = H - 1;\n    // Pods sit in 2×(bw) blocks separated by one-cell aisles; stations line the left wall.\n    const bw = 6, bh = 2, left = 6;\n    const bx = (i) => left + 1 + i * (bw + 1), by = (j) => 1 + j * (bh + 1);\n    const nx = Math.max(2, Math.floor((fw - left - 2) / (bw + 1))), ny = Math.max(2, Math.floor((fh - 2) / (bh + 1)));\n    const aisleX = (i) => bx(i) - 1, aisleY = (j) => by(j) - 1;\n    const stations = Array.from({ length: Math.min(4, ny) }, (_, k) => ({ id: `PS-${k + 1}`, y: aisleY(Math.round(((k + 0.5) / Math.min(4, ny)) * ny)) }));\n    // Each robot loops a rectangle of aisles from its station out to a pod block and back.\n    const bots = Array.from({ length: 8 }, (_, k) => {\n      const st = stations[k % stations.length];\n      const tx = 1 + ((k * 3) % (nx - 1)), ty = clamp(Math.round((st.y - 1) / (bh + 1)) + ((k % 3) - 1), 0, ny - 1);\n      const x0 = aisleX(0), x1 = aisleX(tx + 1), y0 = Math.min(st.y, aisleY(ty)), y1 = Math.max(st.y, aisleY(ty + 1));\n      const rect = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];\n      const per = 2 * (x1 - x0) + 2 * (y1 - y0);\n      return { id: `R-${String(k + 1).padStart(2, '0')}`, rect, per, ph: ctx.rng(), speed: 5 + ctx.rng() * 3, st, pod: `P-${String(10 + tx * ny + ty).padStart(3, '0')}`, bat: 38 + ctx.rng.int(0, 58) };\n    });\n    const posOn = (bot, d) => {\n      let s = ((d % bot.per) + bot.per) % bot.per;\n      const [[x0, y0], [x1], [, y1]] = bot.rect;\n      const w = x1 - x0, h = y1 - y0;\n      if (s < w) return [x0 + s, y0, 'r'];\n      s -= w; if (s < h) return [x1, y0 + s, 'd'];\n      s -= h; if (s < w) return [x1 - s, y1, 'l'];\n      s -= w; return [x0, y1 - s, 'u'];\n    };\n    const colors = [th.accent, th.info, th.pos, th.warn, th.crit, th.neg, th.accent, th.info];\n    return (t) => {\n      // Floor grid: pods and aisles.\n      const podC = mix(th.bg, th.muted, 0.38);\n      for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) for (let yy = 0; yy < bh; yy++) for (let xx = 0; xx < bw; xx++) {\n        const x = bx(i) + xx, y = by(j) + yy;\n        if (x < fw && y < fh) b.set(x, y, g.u ? '▒' : ':', podC);\n      }\n      for (const s of stations) {\n        b.text(0, s.y, s.id, th.ink, mix(th.bg, th.accent, 0.3), ATTR.BOLD);\n        b.set(5, s.y, g.u ? '▌' : '|', th.accent);\n      }\n      const rows = [];\n      bots.forEach((bot, k) => {\n        const d = t * bot.speed + bot.ph * bot.per;\n        const [x, y, dir] = posOn(bot, d);\n        for (let k2 = 1; k2 <= 3; k2++) {\n          const [tx, ty] = posOn(bot, d - k2);\n          if (tx < fw && ty < fh) b.set(tx, ty, g.u ? '·' : '.', mix(th.bg, colors[k], 0.6 - k2 * 0.15));\n        }\n        const carrying = dir === 'l' || dir === 'u';\n        const glyph = g.u ? ({ r: '▶', l: '◀', u: '▲', d: '▼' })[dir] : ({ r: '>', l: '<', u: '^', d: 'v' })[dir];\n        if (x < fw && y < fh) b.set(x, y, glyph, carrying ? th.bg : colors[k], carrying ? colors[k] : undefined, ATTR.BOLD);\n        rows.push({ bot, k, carrying });\n      });\n      if (panelW) {\n        const px = fw + 1;\n        b.text(px, 0, 'FLEET', th.ink, undefined, ATTR.BOLD);\n        putR(b, W, 0, `${bots.length} active`, th.muted);\n        rows.slice(0, H - 3).forEach(({ bot, k, carrying }, i) => {\n          const y = 1 + i;\n          b.set(px, y, g.dot, colors[k]);\n          b.text(px + 2, y, bot.id, th.ink);\n          put(b, px + 7, y, carrying ? `${bot.pod}${g.u ? '→' : '>'}${bot.st.id}` : `to ${bot.pod}`, 11, carrying ? th.ink : th.muted);\n          const bat = Math.round(bot.bat - (t % 24) * 0.2);\n          putR(b, W, y, `${bat}%`, bat < 30 ? th.neg : bat < 50 ? th.warn : th.dim);\n        });\n        const picks = 1840 + Math.floor(t * 1.7);\n        put(b, px, H - 2, `picks today ${picks.toLocaleString('en-US')}`, W - px, th.muted);\n      }\n      put(b, 0, H - 1, `Zone B · ${nx * ny} pod blocks · ${stations.length} pick stations`, fw, th.dim);\n    };\n  },\n};\n\nexport default [datacenter, warehouse];\n",
  "prism-term/src/facets/maps/radar.js": "// Radar & Sonar: a phosphor radar sweep painting moving tracks, and an\n// active sonar with expanding pings, echoes and a bearing-time waterfall.\nimport { mix } from '../../color.js';\nimport { glyphs, put, putR, safe, clamp, ATTR } from '../charts/_data.js';\n\nconst TAU = Math.PI * 2;\nconst wrap = (a) => ((a % TAU) + TAU) % TAU;\nconst deg = (a) => String(Math.round((a * 180) / Math.PI) % 360).padStart(3, '0');\n\nconst radar = {\n  id: 'maps-radar',\n  title: 'Radar Sweep',\n  category: 'Radar & Sonar',\n  description: 'A phosphor PPI radar: the sweep paints moving tracks that fade until the next pass, over range rings, with a live track table.',\n  tags: ['radar', 'sweep', 'ppi', 'tracks', 'phosphor'],\n  kind: 'scene',\n  size: { w: 80, h: 22 },\n  minSize: { w: 30, h: 10 },\n  loop: 12,\n  glyphs: 'braille',\n  canvas: 'line',\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    const tableW = W >= 64 ? 30 : 0;\n    const cols = W - tableW;\n    const cv = ctx.canvas('line', { cols, rows: H });\n    const A = cv.aspect, cx = cv.width / 2, cy = cv.height / 2;\n    const R = Math.min(cv.width / 2 - 2, (cv.height / 2) * A - 2);\n    const REV = 4; // seconds per revolution\n    const green = th.pos, dim = mix(th.bg, th.pos, 0.28);\n    // Tracks fly straight lines through the scope; positions are in units of R.\n    const tracks = Array.from({ length: 7 }, (_, i) => {\n      const a = ctx.rng.range(0, TAU), r = ctx.rng.range(0.25, 0.95), h = ctx.rng.range(0, TAU);\n      return { id: `${String(410 + i * 7).padStart(3, '0')}`, x: Math.cos(a) * r, y: Math.sin(a) * r, vx: Math.cos(h) * 0.012, vy: Math.sin(h) * 0.012, alt: 80 + ctx.rng.int(0, 30) * 10, spd: 240 + ctx.rng.int(0, 22) * 10 };\n    });\n    const clutter = Array.from({ length: 26 }, () => { const a = ctx.rng.range(0, TAU), r = ctx.rng.range(0.03, 0.2) ** 1.2; return [Math.cos(a) * r, Math.sin(a) * r]; });\n    const pos = (tr, t) => {\n      // Bounce inside the scope so every track stays on screen through the loop.\n      let x = tr.x + tr.vx * t, y = tr.y + tr.vy * t;\n      const ff = (v) => { const m = ((v + 1) % 4 + 4) % 4; return m < 2 ? m - 1 : 3 - m; };\n      x = ff(x) * 0.92; y = ff(y) * 0.92;\n      return [x, y];\n    };\n    const toPx = (x, y) => [cx + x * R, cy + (y * R) / A];\n    return (t) => {\n      const sweep = wrap((t / REV) * TAU - Math.PI / 2);\n      cv.clear();\n      for (const k of [0.25, 0.5, 0.75, 1]) cv.ellipse(cx, cy, R * k, (R * k) / A, k === 1 ? mix(th.bg, th.pos, 0.5) : dim);\n      for (let i = 0; i < 12; i++) {\n        const a = (i / 12) * TAU;\n        const r0 = i % 3 === 0 ? 0.0 : 0.94;\n        cv.line(cx + Math.cos(a) * R * r0, cy + (Math.sin(a) * R * r0) / A, cx + Math.cos(a) * R, cy + (Math.sin(a) * R) / A, i % 3 === 0 ? mix(th.bg, th.pos, 0.18) : dim);\n      }\n      // The afterglow wedge behind the beam.\n      for (let k = 22; k >= 0; k--) {\n        const a = sweep - k * 0.035;\n        const c = k === 0 ? mix(green, 0xffffff, 0.5) : mix(th.bg, green, 0.55 * (1 - k / 23) ** 1.5);\n        cv.line(cx, cy, cx + Math.cos(a) * R, cy + (Math.sin(a) * R) / A, c);\n      }\n      const glow = (x, y) => {\n        const age = wrap(sweep - wrap(Math.atan2(y, x))) / TAU * REV; // seconds since painted\n        return Math.exp(-age / 1.6);\n      };\n      for (const [x, y] of clutter) { const k = glow(x, y); const [px, py] = toPx(x, y); cv.set(px, py, mix(th.bg, green, 0.25 + 0.5 * k)); }\n      const fresh = [];\n      for (const tr of tracks) {\n        const [x, y] = pos(tr, t);\n        const k = glow(x, y);\n        // Persistence: the last three paints stay as fading history dots.\n        for (let h = 3; h >= 1; h--) { const [hx, hy] = pos(tr, t - h * REV); const [px, py] = toPx(hx, hy); cv.set(px, py, mix(th.bg, green, 0.18 + 0.08 * (3 - h))); }\n        const [px, py] = toPx(x, y);\n        const c = mix(th.bg, mix(green, 0xffffff, 0.35), 0.3 + 0.7 * k);\n        for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) if (dx * dy === 0) cv.set(px + dx, py + dy, c);\n        fresh.push({ tr, x, y, k, px, py });\n      }\n      cv.paint(b);\n      // Freshly painted tracks carry their id tag until they fade.\n      if (cols >= 40) for (const { tr, k, px, py } of fresh) {\n        if (k < 0.3) continue;\n        const tx = Math.round(px / cv.sx) + 1, ty = Math.round(py / cv.sy) - 1;\n        if (ty >= 0 && tx + 3 <= cols) b.text(tx, ty, tr.id, mix(th.bg, th.pos, 0.35 + 0.65 * k));\n      }\n      // Cardinal labels just inside the outer ring.\n      if (H >= 12) {\n        const lab = (s, a) => { const x = Math.round((cx + Math.cos(a) * R * 0.86) / cv.sx), y = Math.round((cy + (Math.sin(a) * R * 0.86) / A) / cv.sy); b.text(clamp(x - 1, 0, cols - 3), clamp(y, 0, H - 1), s, mix(th.bg, th.pos, 0.6)); };\n        lab('000', -Math.PI / 2); lab('090', 0); lab('180', Math.PI / 2); lab('270', Math.PI);\n      }\n      if (tableW) {\n        const x0 = cols + 1;\n        b.text(x0, 0, 'APPROACH  ', th.ink, undefined, ATTR.BOLD);\n        putR(b, W, 0, `SWP ${deg(wrap(sweep + Math.PI / 2))}`, th.pos);\n        b.text(x0, 1, 'TRK  BRG  RNG  ALT  SPD', th.dim);\n        fresh.slice(0, H - 4).forEach(({ tr, x, y, k }, i) => {\n          const brg = wrap(Math.atan2(y, x) + Math.PI / 2), rng = Math.hypot(x, y) * 40;\n          const c = k > 0.6 ? th.ink : th.muted;\n          const row = `${tr.id}  ${deg(brg)}  ${rng.toFixed(0).padStart(3)}  ${String(tr.alt).padStart(3)}  ${tr.spd}`;\n          if (k > 0.6) b.set(x0 - 1, 2 + i, g.u ? '▸' : '>', th.pos);\n          b.text(x0, 2 + i, row, c, undefined, k > 0.85 ? ATTR.BOLD : 0);\n        });\n        if (H >= 12) put(b, x0, H - 1, 'RNG nm · ALT FL · SPD kt', W - x0, th.dim);\n      }\n    };\n  },\n};\n\nconst CONTACTS = [\n  { id: 'S1', cls: 'merchant', brg: 0.9, r: 0.72, drift: 0.02 },\n  { id: 'S2', cls: 'biologic', brg: 2.4, r: 0.38, drift: -0.05 },\n  { id: 'S3', cls: 'submerged', brg: 4.1, r: 0.86, drift: 0.035 },\n  { id: 'S4', cls: 'fishing', brg: 5.3, r: 0.55, drift: -0.015 },\n];\n\nconst sonar = {\n  id: 'maps-sonar',\n  title: 'Active Sonar',\n  category: 'Radar & Sonar',\n  description: 'An active sonar: pings ripple out, contacts light up and echo as the wavefront reaches them, and a bearing-time waterfall scrolls beside.',\n  tags: ['sonar', 'ping', 'waterfall', 'contacts', 'echo'],\n  kind: 'scene',\n  size: { w: 80, h: 22 },\n  minSize: { w: 30, h: 10 },\n  loop: 12,\n  glyphs: 'braille',\n  canvas: 'line',\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    const fallW = W >= 64 ? Math.floor(W * 0.42) : 0;\n    const cols = W - fallW - (fallW ? 1 : 0);\n    const cv = ctx.canvas('line', { cols, rows: H });\n    const A = cv.aspect, cx = cv.width / 2, cy = cv.height / 2;\n    const R = Math.min(cv.width / 2 - 1, (cv.height / 2) * A - 1);\n    const PING = 4, SPEED = 1 / 2.6; // a ping reaches the edge in 2.6 s\n    const sea = th.info;\n    const ramp = g.u ? ' ·∙:;=+*#█' : ' .:-=+*#%@';\n    const contactAt = (c, t) => c.brg + c.drift * t;\n    return (t) => {\n      const ping = Math.floor(t / PING), since = t - ping * PING;\n      const front = since * SPEED; // in units of R\n      cv.clear();\n      for (const k of [0.33, 0.66, 1]) cv.ellipse(cx, cy, R * k, (R * k) / A, mix(th.bg, sea, k === 1 ? 0.45 : 0.18));\n      cv.line(cx - R, cy, cx + R, cy, mix(th.bg, sea, 0.12));\n      cv.line(cx, cy - R / A, cx, cy + R / A, mix(th.bg, sea, 0.12));\n      if (front < 1.05) {\n        for (let w = 0; w < 3; w++) {\n          const r = (front - w * 0.025) * R;\n          if (r > 0) cv.ellipse(cx, cy, r, r / A, mix(th.bg, mix(sea, 0xffffff, 0.4), 0.9 - w * 0.3));\n        }\n      }\n      const lit = [];\n      for (const c of CONTACTS) {\n        const a = contactAt(c, t);\n        const x = cx + Math.sin(a) * c.r * R, y = cy - (Math.cos(a) * c.r * R) / A;\n        const hit = since - c.r / SPEED; // seconds since the wavefront reached it\n        const k = hit >= 0 ? Math.exp(-hit / 1.2) : Math.exp(-(hit + PING) / 1.2) * 0.6;\n        const col = mix(th.bg, c.cls === 'submerged' ? th.warn : mix(sea, 0xffffff, 0.3), 0.25 + 0.75 * k);\n        for (let dy = 0; dy < 2; dy++) for (let dx = 0; dx < 2; dx++) cv.set(x + dx, y + dy, col);\n        // The echo travels back as a small ring.\n        if (hit >= 0 && hit < 1) { const er = hit * R * 0.12; cv.ellipse(x, y, er, er / A, mix(th.bg, sea, 0.6 * (1 - hit))); }\n        lit.push({ c, x, y, k, a });\n      }\n      cv.paint(b);\n      for (const { c, x, y, k } of lit) {\n        const tx = Math.round(x / cv.sx) + 2, ty = Math.round(y / cv.sy);\n        if (k > 0.25 && tx + 2 < cols && ty >= 0 && ty < H) b.text(tx, ty, c.id, c.cls === 'submerged' ? th.warn : th.ink, undefined, ATTR.BOLD);\n      }\n      b.text(0, 0, `PING ${String(ping % 1000).padStart(3, '0')}`, th.info, undefined, ATTR.BOLD);\n      if (H >= 12) b.text(0, H - 1, `${(since).toFixed(1)}s`, th.dim);\n      if (fallW) {\n        // Bearing-time waterfall: newest row on top, contacts as bright streaks.\n        const x0 = cols + 1, fw = fallW, head = 1, rows = H - head - (H >= 12 ? 1 : 0);\n        b.text(x0, 0, 'BRG 000', th.dim);\n        putR(b, x0 + fw, 0, '359', th.dim);\n        b.text(x0 + Math.floor(fw / 2) - 1, 0, '180', th.dim);\n        for (let r = 0; r < rows; r++) {\n          const tt = t - r * 0.5; // each row is half a second older\n          for (let i = 0; i < fw; i++) {\n            const brg = (i / fw) * TAU;\n            let v = 0.18 + 0.22 * (ctx.noise.noise2(i * 0.35, Math.floor(tt * 2) * 0.7) + 0.5);\n            for (const c of CONTACTS) {\n              let d = Math.abs(wrap(contactAt(c, tt)) - brg); d = Math.min(d, TAU - d);\n              v += (c.cls === 'biologic' ? 0.45 : 0.85) * Math.exp(-((d / 0.06) ** 2));\n            }\n            v = clamp(v, 0, 1);\n            const ch = ramp[Math.min(ramp.length - 1, Math.floor(v * ramp.length))];\n            b.set(x0 + i, head + r, ch, mix(th.bg, sea, 0.25 + 0.75 * v));\n          }\n        }\n        if (H >= 12) {\n          const y = H - 1;\n          let x = x0;\n          for (const c of CONTACTS) { if (x + 8 > x0 + fw) break; x = b.text(x, y, `${c.id} ${deg(wrap(contactAt(c, t)))}`, c.cls === 'submerged' ? th.warn : th.muted) + 2; }\n        }\n      }\n    };\n  },\n};\n\nexport default [radar, sonar];\n",
  "prism-term/src/facets/maps/routes.js": "// Routes & Arcs: great-circle flights, traceroute hops plotted on a map, a\n// schematic transit map with moving trains, and a GPS trail over contours.\nimport { mix } from '../../color.js';\nimport { glyphs, put, putR, safe, clamp, smooth, ATTR } from '../charts/_data.js';\nimport { project, landMask, coastOf, greatCircle, distanceKm, drawGeoPath, hub } from './_world.js';\n\n/** Land drawn as a sparse dot lattice plus a brighter coast, as flat pixel pairs. */\nfunction dottedLand(cv, P, every = 2) {\n  const mask = landMask(cv, P), coast = coastOf(mask, cv.width, cv.height);\n  const out = [];\n  for (let y = 0; y < cv.height; y++) for (let x = 0; x < cv.width; x++) {\n    const i = y * cv.width + x;\n    if (coast[i]) out.push(i, 1);\n    else if (mask[i] && x % every === 0 && y % every === 0) out.push(i, 0);\n  }\n  return out;\n}\n\nconst ROUTES = [['LHR', 'IAD'], ['FRA', 'SIN'], ['SIN', 'SYD'], ['NRT', 'PDX'], ['IAD', 'GRU'], ['LHR', 'BOM'], ['JNB', 'FRA'], ['DFW', 'NRT'], ['SCL', 'GRU'], ['BOM', 'SIN']];\n\nconst flights = {\n  id: 'maps-flights',\n  title: 'Great-Circle Flights',\n  category: 'Routes & Arcs',\n  description: 'Flights trace great-circle arcs between hubs on a dotted world: planes with fading contrails, arrival pulses and a live departures board.',\n  tags: ['flights', 'great circle', 'arcs', 'world map', 'routes'],\n  kind: 'scene',\n  size: { w: 80, h: 22 },\n  minSize: { w: 40, h: 10 },\n  loop: 12,\n  glyphs: 'braille',\n  canvas: 'line',\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    const foot = H >= 16 ? 3 : 0;\n    const rows = H - foot;\n    const cv = ctx.canvas('line', { cols: W, rows });\n    const P = project(cv, { stretch: 1.25 });\n    const land = dottedLand(cv, P, 3);\n    const landC = mix(th.bg, th.muted, 0.35), coastC = mix(th.bg, th.muted, 0.6);\n    const colors = [th.accent, th.info, th.pos, th.warn, th.crit];\n    const routes = ROUTES.map(([a, z], i) => {\n      const A = hub(a), Z = hub(z);\n      const pts = greatCircle([A[2], A[3]], [Z[2], Z[3]], 64);\n      const km = distanceKm([A[2], A[3]], [Z[2], Z[3]]);\n      // Whole trips per loop so the animation wraps cleanly; long hauls fly slower.\n      const trips = Math.max(1, Math.round(12 / (2 + km / 2600)));\n      return { a, z, pts, km, trips, ph: (i * 0.137) % 1, c: colors[i % colors.length], no: `PX ${100 + ((i * 37) % 800)}` };\n    });\n    return (t) => {\n      cv.clear();\n      const px = cv.px;\n      for (let k = 0; k < land.length; k += 2) px[land[k]] = land[k + 1] ? coastC : landC;\n      const live = [];\n      for (const r of routes) {\n        drawGeoPath(cv, P, r.pts, mix(th.bg, r.c, 0.3));\n        const p = (t / 12 * r.trips + r.ph) % 1;\n        const head = p * (r.pts.length - 1);\n        // Contrail: the last 30% of the trip so far, brightening to the plane.\n        const tail = Math.max(0, head - (r.pts.length - 1) * 0.4);\n        for (let i = Math.floor(tail); i < Math.floor(head); i++) {\n          const a = r.pts[i], z = r.pts[i + 1];\n          if (Math.abs(a[0] - z[0]) > 180) continue;\n          const k = (i - tail) / (head - tail || 1);\n          cv.line(P.x(a[0]), P.y(a[1]), P.x(z[0]), P.y(z[1]), mix(r.c, 0xffffff, 0.15 * k));\n        }\n        const i0 = Math.min(r.pts.length - 2, Math.floor(head)), f = head - i0;\n        const [lo0, la0] = r.pts[i0], [lo1, la1] = r.pts[i0 + 1];\n        const lon = Math.abs(lo1 - lo0) > 180 ? lo1 : lo0 + (lo1 - lo0) * f;\n        const hx = P.x(lon), hy = P.y(la0 + (la1 - la0) * f);\n        for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) if (dx * dy === 0) cv.set(hx + dx, hy + dy, 0xffffff);\n        if (p > 0.9) {\n          const Z = hub(r.z), k = (p - 0.9) / 0.1, rr = 1 + k * 5;\n          cv.ellipse(P.x(Z[2]), P.y(Z[3]), rr, rr / cv.aspect, mix(th.bg, r.c, 1 - k));\n        }\n        live.push({ r, p });\n      }\n      cv.paint(b);\n      if (W >= 60) for (const code of ['LHR', 'IAD', 'SIN', 'NRT', 'GRU', 'SYD', 'JNB', 'BOM']) {\n        const h = hub(code);\n        const x = Math.round(P.x(h[2]) / cv.sx), y = Math.round(P.y(h[3]) / cv.sy) + 1;\n        if (y < rows && x + 3 < W) b.text(Math.max(0, x - 1), y, code, th.muted);\n      }\n      if (foot) {\n        const y0 = H - foot;\n        b.text(0, y0, 'FLIGHT  ROUTE      PROGRESS              DIST', th.dim, undefined, ATTR.BOLD);\n        // The board pages through the flights, two at a time.\n        const page = Math.floor(t / 3) % Math.ceil(live.length / (foot - 1));\n        live.slice(page * (foot - 1), page * (foot - 1) + foot - 1).forEach(({ r, p }, i) => {\n          const y = y0 + 1 + i;\n          b.text(0, y, r.no, th.ink, undefined, ATTR.BOLD);\n          b.text(8, y, `${r.a}${g.u ? '→' : '>'}${r.z}`, r.c);\n          const bw = 20, f = Math.round(p * bw);\n          for (let k = 0; k < bw; k++) b.set(19 + k, y, k < f ? (g.u ? '━' : '=') : (g.u ? '─' : '-'), k < f ? r.c : th.line);\n          b.text(40, y, `${Math.round(r.km).toLocaleString('en-US')} km`, th.muted);\n          if (W >= 70) putR(b, W, y, p > 0.9 ? 'landing' : p < 0.08 ? 'departed' : 'en route', p > 0.9 ? th.pos : th.muted);\n        });\n      }\n    };\n  },\n};\n\n// Hops: [host, ip, rtt ms, lon, lat] — null host is a hop that never answers.\nconst HOPS = [\n  ['gw.office.example', '10.40.0.1', 0.8, 8.7, 50.1],\n  ['edge1.fra.example.net', '198.51.100.17', 4.9, 8.7, 50.1],\n  ['core2.mrs.example.net', '198.51.100.41', 14.8, 5.4, 43.3],\n  [null, null, 0, 5.4, 43.3],\n  ['sub1.cai.example.net', '203.0.113.9', 52.1, 31.2, 30.0],\n  ['core1.bom.example.net', '203.0.113.66', 118.4, 72.9, 19.1],\n  ['core1.maa.example.net', '203.0.113.70', 131.0, 80.3, 13.1],\n  ['edge2.sin.example.net', '203.0.113.130', 162.7, 103.8, 1.35],\n  ['www.shop.example', '192.0.2.80', 163.2, 103.8, 1.35],\n];\n\nconst traceroute = {\n  id: 'maps-traceroute',\n  title: 'Traceroute Map',\n  category: 'Routes & Arcs',\n  description: 'traceroute from Frankfurt to Singapore, hop by hop: each reply is plotted on the map as the classic output prints below with RTTs.',\n  tags: ['traceroute', 'network', 'hops', 'latency', 'map'],\n  kind: 'scene',\n  size: { w: 80, h: 22 },\n  minSize: { w: 44, h: 12 },\n  loop: 12,\n  glyphs: 'braille',\n  canvas: 'line',\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    const out = Math.min(HOPS.length + 1, Math.max(3, Math.floor(H * 0.48)));\n    const rows = H - out;\n    const cv = ctx.canvas('line', { cols: W, rows });\n    const P = project(cv, { view: { lon0: -12, lon1: 122, lat0: 58, lat1: -6 }, stretch: 1.6 });\n    const land = dottedLand(cv, P, 2);\n    const landC = mix(th.bg, th.muted, 0.38), coastC = mix(th.bg, th.muted, 0.7);\n    const STEP = 1, START = 0.5;\n    const rttC = (ms) => (ms < 40 ? th.pos : ms < 140 ? th.warn : th.neg);\n    return (t) => {\n      const lt = t % 12;\n      const shown = clamp(Math.floor((lt - START) / STEP) + 1, 0, HOPS.length);\n      cv.clear();\n      const px = cv.px;\n      for (let k = 0; k < land.length; k += 2) px[land[k]] = land[k + 1] ? coastC : landC;\n      let prev = null;\n      for (let i = 0; i < shown; i++) {\n        const [host, , ms, lon, lat] = HOPS[i];\n        if (!host) continue;\n        const x = P.x(lon), y = P.y(lat);\n        if (prev) {\n          const arc = greatCircle(prev, [lon, lat], 24);\n          const age = lt - (START + i * STEP);\n          drawGeoPath(cv, P, arc, rttC(ms), clamp(age / 0.6, 0.05, 1));\n        }\n        const rr = i === shown - 1 ? 1 + ((lt * 2) % 1) * 4 : 0;\n        if (rr) cv.ellipse(x, y, rr, rr / cv.aspect, mix(th.bg, rttC(ms), 1 - ((lt * 2) % 1)));\n        cv.set(x, y, 0xffffff); cv.set(x + 1, y, 0xffffff);\n        prev = [lon, lat];\n      }\n      cv.paint(b);\n      // Hop numbers on the map (co-located hops share a label).\n      const seen = new Set();\n      for (let i = 0; i < shown; i++) {\n        const [host, , , lon, lat] = HOPS[i];\n        if (!host) continue;\n        const cx = Math.round(P.x(lon) / cv.sx), cy = Math.round(P.y(lat) / cv.sy);\n        const key = `${cx},${cy}`;\n        const label = seen.has(key) ? null : String(i + 1);\n        if (label && cy - 1 >= 0 && cx + 2 < W) b.text(cx + 1, cy - 1, label, th.ink, undefined, ATTR.BOLD);\n        seen.add(key);\n      }\n      // The printed output.\n      const y0 = rows;\n      const done = lt > START + HOPS.length * STEP + 0.4;\n      if (done) put(b, 0, y0, `${g.check} www.shop.example reached in ${HOPS.length} hops, ${HOPS.at(-1)[2]} ms`, W, th.pos, undefined, ATTR.BOLD);\n      else put(b, 0, y0, 'traceroute to www.shop.example (192.0.2.80), 30 hops max', W, th.muted);\n      const list = HOPS.slice(0, shown).map((h, i) => [h, i]);\n      list.slice(-(out - 1)).forEach(([[host, ip, ms], i], r) => {\n        const y = y0 + 1 + r;\n        let x = b.text(0, y, String(i + 1).padStart(2), th.dim);\n        if (!host) { b.text(x + 2, y, '* * *', th.dim); return; }\n        x = put(b, x + 2, y, host, W - x - 2, th.ink);\n        if (W >= 64) x = b.text(x + 1, y, `(${ip})`, th.muted);\n        const probes = W >= 76 ? 3 : W >= 60 ? 2 : 1;\n        for (let k = 0; k < probes; k++) {\n          const v = ms * (1 + 0.012 * Math.sin(i * 7 + k * 3));\n          x = b.text(x + 2, y, `${v.toFixed(1)} ms`, rttC(v));\n        }\n      });\n    };\n  },\n};\n\n// Schematic transit map on a 78×16 design grid; lines are axis-aligned polylines.\nconst LINES = [\n  { name: 'Red', tone: 'neg', pts: [[2, 8], [76, 8]], stations: [[2, 8, 'West Docks', 'a'], [16, 8, 'Harbour', 'b'], [34, 8, 'Central', 'al'], [50, 8, 'Museum', 'br'], [64, 8, 'Mill Lane', 'br'], [76, 8, 'East Gate', 'a']] },\n  { name: 'Blue', tone: 'info', pts: [[28, 1], [28, 5], [34, 5], [34, 13], [44, 13], [44, 15]], stations: [[28, 1, 'Northgate', 'r'], [34, 8, '', ''], [34, 11, 'Quayside', 'bl'], [40, 13, 'Riverside', 'b'], [44, 15, 'Docklands', 'r']] },\n  { name: 'Green', tone: 'pos', pts: [[6, 3], [50, 3], [50, 13], [74, 13]], stations: [[6, 3, 'University', 'a'], [18, 3, 'Library', 'a'], [28, 3, '', ''], [50, 3, 'Parkside', 'r'], [50, 8, '', ''], [74, 13, 'Airport', 'b']] },\n  { name: 'Amber', tone: 'warn', pts: [[10, 15], [10, 11], [64, 11], [64, 1]], stations: [[10, 15, 'Old Town', 'r'], [18, 11, 'Market', 'b'], [34, 11, '', ''], [50, 11, '', ''], [64, 8, '', ''], [64, 1, 'Stadium', 'r']] },\n];\n\n/** Label origin for an anchor: a/b above/below centered, r right, al above-left, bl below-left, br below-right. */\nfunction labelAt(x, y, name, anchor) {\n  const n = name.length;\n  switch (anchor) {\n    case 'a': return [x - Math.floor(n / 2), y - 1];\n    case 'b': return [x - Math.floor(n / 2), y + 1];\n    case 'r': return [x + 2, y];\n    case 'al': return [x - n - 1, y - 1];\n    case 'bl': return [x - n - 1, y + 1];\n    default: return [x + 1, y + 1];\n  }\n}\n\n/** Cells along an axis-aligned polyline, each with the glyph joining its neighbors. */\nfunction trace(pts) {\n  const cells = [];\n  for (let s = 0; s < pts.length - 1; s++) {\n    const [x0, y0] = pts[s], [x1, y1] = pts[s + 1];\n    const n = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0));\n    for (let i = s === 0 ? 0 : 1; i <= n; i++) cells.push([x0 + Math.sign(x1 - x0) * i, y0 + Math.sign(y1 - y0) * i]);\n  }\n  return cells;\n}\n\nconst subway = {\n  id: 'maps-subway',\n  title: 'Transit Map',\n  category: 'Routes & Arcs',\n  description: 'A schematic metro map with four colored lines, interchanges and trains running end to end; the status board reports a delay.',\n  tags: ['subway', 'metro', 'transit', 'schematic', 'trains'],\n  kind: 'scene',\n  size: { w: 80, h: 22 },\n  minSize: { w: 44, h: 12 },\n  loop: 24,\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    const board = H >= 20 ? 4 : H >= 16 ? 2 : 0;\n    const mapH = H - board;\n    const sx = (W - 2) / 78, sy = (mapH - 2) / 15;\n    const X = (x) => Math.round(1 + x * sx), Y = (y) => Math.round(y * sy + (mapH >= 18 ? 1 : 0));\n    const lines = LINES.map((l) => {\n      const pts = l.pts.map(([x, y]) => [X(x), Y(y)]);\n      const cells = trace(pts);\n      const glyph = cells.map(([x, y], i) => {\n        const p = cells[i - 1], n = cells[i + 1];\n        const hz = (p && p[1] === y) || (n && n[1] === y);\n        const vt = (p && p[0] === x) || (n && n[0] === x);\n        if (hz && vt && p && n) {\n          const dx = (p[0] - x) + (n[0] - x), dy = (p[1] - y) + (n[1] - y);\n          if (!g.u) return '+';\n          return dx > 0 ? (dy > 0 ? '┏' : '┗') : dy > 0 ? '┓' : '┛';\n        }\n        return vt && !hz ? (g.u ? '┃' : '|') : g.u ? '━' : '-';\n      });\n      const st = l.stations.map(([x, y, name, anchor]) => ({ x: X(x), y: Y(y), name, anchor }));\n      return { ...l, c: th[l.tone], cells, glyph, st };\n    });\n    // Interchanges: any station cell shared by two or more lines.\n    const count = new Map();\n    for (const l of lines) for (const s of l.st) { const k = `${s.x},${s.y}`; count.set(k, (count.get(k) || 0) + 1); }\n    return (t) => {\n      const lt = t % 24;\n      const delayed = lt >= 8 && lt < 18; // Blue line signal fault\n      for (const l of lines) l.cells.forEach(([x, y], i) => b.set(x, y, l.glyph[i], l.c, undefined, ATTR.BOLD));\n      // Trains shuttle end to end; two per line, half a cycle apart.\n      for (const l of lines) {\n        const n = l.cells.length;\n        const speed = l.name === 'Blue' && delayed ? 0.35 : 1;\n        for (let k = 0; k < 2; k++) {\n          const u = ((t * speed * 6) / n + k * 0.5) % 2;\n          const p = u < 1 ? u : 2 - u;\n          const i = Math.round(smooth(p) * (n - 1));\n          const [x, y] = l.cells[i];\n          b.set(x, y, g.u ? '■' : '#', 0xffffff, l.c, ATTR.BOLD);\n        }\n      }\n      for (const l of lines) for (const s of l.st) {\n        const ic = (count.get(`${s.x},${s.y}`) || 0) > 1;\n        b.set(s.x, s.y, ic ? (g.u ? '◉' : '@') : g.u ? '●' : 'o', ic ? th.ink : l.c, ic ? th.bg : undefined, ATTR.BOLD);\n        if (!s.name) continue;\n        const [nx, ny] = labelAt(s.x, s.y, s.name, s.anchor);\n        if (ny >= 0 && ny < mapH) b.text(clamp(nx, 0, W - s.name.length), ny, s.name, th.muted);\n      }\n      if (board) {\n        const y0 = mapH;\n        const status = [\n          ['Red', 'Good service', th.pos], ['Blue', delayed ? 'Minor delays: signal fault at Quayside' : 'Good service', delayed ? th.warn : th.pos],\n          ['Green', 'Good service', th.pos], ['Amber', lt >= 18 ? 'Part closure Stadium-Mill Lane (event)' : 'Good service', lt >= 18 ? th.info : th.pos],\n        ];\n        const perRow = board >= 4 ? 1 : 2, colW = Math.floor(W / perRow);\n        status.forEach(([name, msg, c], i) => {\n          const row = Math.floor(i / perRow), col = i % perRow;\n          if (row >= board) return;\n          const x = col * colW, y = y0 + row;\n          const l = lines.find((q) => q.name === name);\n          b.text(x, y, ` ${name.padEnd(5)} `, th.bg, l.c, ATTR.BOLD);\n          put(b, x + 8, y, msg, colW - 9, c);\n        });\n      }\n    };\n  },\n};\n\nconst trail = {\n  id: 'maps-topo-trail',\n  title: 'Topo Trail',\n  category: 'Routes & Arcs',\n  description: 'A GPS track climbing a contoured mountain: index contours, a hiker drawing the trail, and an elevation profile with distance and ascent.',\n  tags: ['topographic', 'contours', 'gps', 'hiking', 'elevation'],\n  kind: 'scene',\n  size: { w: 80, h: 22 },\n  minSize: { w: 40, h: 10 },\n  loop: 16,\n  glyphs: 'braille',\n  canvas: 'line',\n  order: 40,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    const prof = H >= 14 ? 4 : 0;\n    const rows = H - prof;\n    const cv = ctx.canvas('line', { cols: W, rows });\n    const cw = cv.width, ch = cv.height, A = cv.aspect;\n    // Terrain: a summit plus ridges and fbm detail, in metres.\n    const sumX = cw * 0.68, sumY = ch * 0.38;\n    const elev = (x, y) => {\n      const dx = (x - sumX) / cw, dy = ((y - sumY) * A) / cw;\n      const m = Math.exp(-(dx * dx + dy * dy) * 9);\n      return 1050 + 950 * m + 160 * ctx.noise.fbm((x / cw) * 3.2, ((y * A) / cw) * 3.2, 0, 4) + 90 * Math.exp(-(((x - cw * 0.25) / cw) ** 2 + (((y - ch * 0.7) * A) / cw) ** 2) * 30);\n    };\n    const E = new Float32Array(cw * ch);\n    for (let y = 0; y < ch; y++) for (let x = 0; x < cw; x++) E[y * cw + x] = elev(x, y);\n    // Contour pixels: where a 50 m band boundary falls between neighbors.\n    const contour = [];\n    for (let y = 0; y < ch - 1; y++) for (let x = 0; x < cw - 1; x++) {\n      const i = y * cw + x, a = Math.floor(E[i] / 50), r = Math.floor(E[i + 1] / 50), d = Math.floor(E[i + cw] / 50);\n      if (a !== r || a !== d) { const lvl = Math.max(a, r, d); contour.push(i, lvl % 5 === 0 ? 1 : 0); }\n    }\n    // The trail: greedy uphill switchbacks from the trailhead to the summit.\n    const path = [[cw * 0.08, ch * 0.9]];\n    let [px, py] = path[0];\n    for (let s = 0; s < 400; s++) {\n      const dxs = sumX - px, dys = (sumY - py) * A, d = Math.hypot(dxs, dys);\n      if (d < 2) break;\n      const zig = Math.sin(s * 0.09) * 0.9;\n      const ang = Math.atan2(dys, dxs) + zig * Math.min(1, d / (cw * 0.2));\n      px += Math.cos(ang) * 1.5; py += (Math.sin(ang) * 1.5) / A;\n      path.push([px, py]);\n    }\n    const at = (x, y) => E[clamp(Math.round(y), 0, ch - 1) * cw + clamp(Math.round(x), 0, cw - 1)];\n    const elevs = path.map(([x, y]) => at(x, y));\n    const dist = [0];\n    for (let i = 1; i < path.length; i++) dist.push(dist[i - 1] + Math.hypot(path[i][0] - path[i - 1][0], (path[i][1] - path[i - 1][1]) * A) * 0.055);\n    const lo = Math.min(...elevs), hi = Math.max(...elevs);\n    const lineC = mix(th.bg, th.muted, 0.35), idxC = mix(th.bg, th.warn, 0.55);\n    return (t) => {\n      const lt = t % 16;\n      const prog = smooth(clamp(lt / 13, 0, 1));\n      const n = Math.max(1, Math.round(prog * (path.length - 1)));\n      cv.clear();\n      const pxs = cv.px;\n      for (let k = 0; k < contour.length; k += 2) pxs[contour[k]] = contour[k + 1] ? idxC : lineC;\n      for (let i = 0; i < n; i++) cv.line(path[i][0], path[i][1], path[i + 1][0], path[i + 1][1], th.accent);\n      const [hx, hy] = path[n];\n      for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) cv.set(hx + dx, hy + dy, 0xffffff);\n      cv.set(sumX, sumY, th.neg); cv.set(sumX + 1, sumY, th.neg);\n      cv.paint(b);\n      const sx = Math.round(sumX / cv.sx), sy = Math.round(sumY / cv.sy);\n      if (sy - 1 >= 0 && W >= 50) b.text(clamp(sx - 6, 0, W - 14), sy - 1, `${g.up} Cairn Peak ${Math.round(hi)}`, th.ink, undefined, ATTR.BOLD);\n      b.text(0, 0, 'Ridge Loop', th.ink, undefined, ATTR.BOLD);\n      if (prof) {\n        const y0 = rows, pw = W - (W >= 60 ? 20 : 0);\n        for (let x = 0; x < pw; x++) {\n          const i = Math.round((x / (pw - 1)) * (path.length - 1));\n          const v = (elevs[i] - lo) / (hi - lo || 1);\n          const h8 = Math.round(v * (prof - 1) * 8) + 2;\n          const done = i <= n;\n          for (let r = 0; r < prof - 1; r++) {\n            const fill = clamp(h8 - r * 8, 0, 8);\n            const chh = g.u ? ' ▁▂▃▄▅▆▇█'[fill] : ' ..::==##'[fill];\n            if (fill) b.set(x, y0 + prof - 2 - r, chh, done ? mix(th.bg, th.accent, 0.8) : mix(th.bg, th.muted, 0.4));\n          }\n        }\n        const climbed = elevs.slice(0, n + 1).reduce((a, v, i, arr) => a + (i && v > arr[i - 1] ? v - arr[i - 1] : 0), 0);\n        put(b, 0, H - 1, `${dist[n].toFixed(1)} km · ${g.up} ${Math.round(climbed)} m · ${Math.round(elevs[n])} m`, pw, th.muted);\n        if (W >= 60) {\n          const x0 = pw + 2;\n          b.text(x0, y0, 'elapsed', th.dim);\n          const mins = Math.round(prog * 194);\n          b.text(x0, y0 + 1, `${Math.floor(mins / 60)}h ${String(mins % 60).padStart(2, '0')}m`, th.ink, undefined, ATTR.BOLD);\n          b.text(x0, y0 + 2, `${(dist[n] / Math.max(0.1, mins / 60)).toFixed(1)} km/h`, th.muted);\n        }\n      }\n    };\n  },\n};\n\nexport default [flights, traceroute, subway, trail];\n",
  "prism-term/src/facets/maps/world.js": "// World Maps: a dotted world with pulsing data centers, a follow-the-sun\n// choropleth of pseudo-regions, and a day/night terminator with city lights.\nimport { mix } from '../../color.js';\nimport { glyphs, put, putR, safe, clamp, smooth, NONE, ATTR } from '../charts/_data.js';\nimport { project, landMask, coastOf, inLand, HUBS } from './_world.js';\n\nconst RAD = Math.PI / 180;\n\n/** Simulated UTC hour for a time t (hoursPerSecond of sim time), starting at 14:00. */\nconst utcHour = (t, hps) => (14 + t * hps) % 24;\nconst hhmm = (h) => `${String(Math.floor(h) % 24).padStart(2, '0')}:${String(Math.floor((h % 1) * 60)).padStart(2, '0')}`;\n\nconst cities = {\n  id: 'maps-world-cities',\n  title: 'Data Center Map',\n  category: 'World Maps',\n  description: 'A dotted braille world map with pulsing data-center markers, health-colored ripples, region codes and a live traffic footer.',\n  tags: ['world map', 'braille', 'data centers', 'pulse', 'regions'],\n  kind: 'scene',\n  size: { w: 80, h: 22 },\n  minSize: { w: 40, h: 10 },\n  loop: 12,\n  glyphs: 'braille',\n  canvas: 'line',\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    const foot = H >= 14 ? 2 : 0;\n    const rows = H - foot;\n    const cv = ctx.canvas('line', { cols: W, rows });\n    const P = project(cv, { stretch: 1.25 });\n    const mask = landMask(cv, P), coast = coastOf(mask, cv.width, cv.height);\n    // A dot lattice reads as \"map\" without turning continents into solid blobs.\n    const landPx = [];\n    for (let y = 0; y < cv.height; y++) for (let x = 0; x < cv.width; x++) {\n      const i = y * cv.width + x;\n      if (coast[i]) landPx.push(i, 1);\n      else if (mask[i] && x % 2 === 0 && y % 2 === 0) landPx.push(i, 0);\n    }\n    const landC = mix(th.bg, th.muted, 0.55), coastC = mix(th.bg, th.muted, 0.85);\n    const sites = HUBS.slice(0, W >= 60 ? 12 : 8).map(([code, , lon, lat], i) => ({ code, x: P.x(lon), y: P.y(lat), ph: i * 0.37, lon }));\n    // Health over the loop: Mumbai degrades for a while, Sao Paulo blips.\n    const health = (s, lt) => (s.code === 'BOM' && lt > 4 && lt < 9 ? 'warn' : s.code === 'GRU' && lt > 7.5 && lt < 8.5 ? 'neg' : 'pos');\n    const labelDx = { LHR: -4, FRA: 1, IAD: 1, DFW: -4, PDX: -4, NRT: 1, SIN: -4, SYD: 1, BOM: -4, JNB: 1, GRU: 1, SCL: -4 };\n    return (t) => {\n      const lt = t % 12;\n      cv.clear();\n      const px = cv.px;\n      for (let k = 0; k < landPx.length; k += 2) px[landPx[k]] = landPx[k + 1] ? coastC : landC;\n      for (const s of sites) {\n        const c = th[health(s, lt)];\n        const cycle = ((t * 0.5 + s.ph) % 1);\n        const r = 1.5 + cycle * (s.code === 'BOM' && health(s, lt) !== 'pos' ? 11 : 7);\n        cv.ellipse(s.x, s.y, r, r / cv.aspect, mix(th.bg, c, 1 - smooth(cycle) * 0.85));\n        for (let dy = -1; dy <= 0; dy++) for (let dx = -1; dx <= 0; dx++) cv.set(s.x + dx + 0.5, s.y + dy + 0.5, 0xffffff);\n      }\n      cv.paint(b);\n      if (W >= 60) {\n        for (const s of sites) {\n          const cx = Math.round(s.x / cv.sx) + labelDx[s.code], cy = Math.round(s.y / cv.sy);\n          if (cy >= 0 && cy < rows && cx >= 0 && cx + 3 <= W) b.text(cx, cy, s.code, health(s, lt) === 'pos' ? th.ink : th[health(s, lt)], undefined, ATTR.BOLD);\n        }\n      }\n      if (foot) {\n        const y = H - 2;\n        const bad = sites.filter((s) => health(s, lt) !== 'pos');\n        let x = b.text(0, y, g.dot, th.pos);\n        x = b.text(x + 1, y, `${sites.length - bad.length} healthy`, th.muted);\n        for (const s of bad) { x = b.text(x + 3, y, g.dot, th[health(s, lt)]); x = b.text(x + 1, y, `${s.code} ${health(s, lt) === 'warn' ? 'degraded' : 'down'}`, th[health(s, lt)], undefined, ATTR.BOLD); }\n        const rps = 1.18 + 0.12 * Math.sin(t * 0.9) + 0.03 * ctx.noise.noise2(t, 3);\n        if (W >= 70) putR(b, W, y, `edge ${rps.toFixed(2)}M req/s · p50 ${Math.round(38 + 6 * Math.sin(t * 0.7))} ms`, th.muted);\n        put(b, 0, H - 1, `${sites.length} regions · ${sites.length * 3} zones · anycast 192.0.2.0/24`, W, th.dim);\n      }\n    };\n  },\n};\n\nconst choropleth = {\n  id: 'maps-world-choropleth',\n  title: 'Follow-the-Sun Choropleth',\n  category: 'World Maps',\n  description: 'Active users by region as a choropleth of pseudo-regions: demand rolls west with the daylight while a legend and UTC clock keep score.',\n  tags: ['choropleth', 'world map', 'regions', 'diurnal', 'half blocks'],\n  kind: 'scene',\n  size: { w: 80, h: 22 },\n  minSize: { w: 40, h: 10 },\n  loop: 12,\n  canvas: 'field',\n  palette: 'viridis',\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    const foot = H >= 14 ? 2 : 1;\n    const rows = H - foot;\n    const cv = ctx.canvas('field', { cols: W, rows });\n    const P = project(cv, { stretch: 1.25 });\n    const mask = landMask(cv, P);\n    // Pseudo-regions: Voronoi cells around seeded land points, each with a population weight.\n    const seeds = [];\n    while (seeds.length < 64) {\n      const lon = ctx.rng.range(-170, 180), lat = ctx.rng.range(-55, 75);\n      if (inLand(lon, lat)) seeds.push({ lon, lat, x: P.x(lon), y: P.y(lat), pop: 0.25 + 0.75 * ctx.rng() ** 1.6 });\n    }\n    const region = new Int16Array(cv.width * cv.height).fill(-1);\n    for (let y = 0; y < cv.height; y++) for (let x = 0; x < cv.width; x++) {\n      const i = y * cv.width + x;\n      if (!mask[i]) continue;\n      let best = 0, bd = Infinity;\n      for (let k = 0; k < seeds.length; k++) { const dx = seeds[k].x - x, dy = (seeds[k].y - y) / cv.aspect; const d = dx * dx + dy * dy; if (d < bd) { bd = d; best = k; } }\n      region[i] = best;\n    }\n    const border = new Uint8Array(cv.width * cv.height);\n    for (let y = 1; y < cv.height; y++) for (let x = 1; x < cv.width; x++) {\n      const i = y * cv.width + x;\n      if (region[i] >= 0 && ((region[i - 1] >= 0 && region[i - 1] !== region[i]) || (region[i - cv.width] >= 0 && region[i - cv.width] !== region[i]))) border[i] = 1;\n    }\n    const pal = ctx.palette('viridis');\n    const CLASSES = 6, LIMITS = ['<5k', '5k', '20k', '50k', '100k', '200k+'];\n    const ocean = mix(th.bg, th.info, 0.08);\n    const cls = new Uint8Array(seeds.length);\n    const shades = Array.from({ length: CLASSES }, (_, k) => pal(0.12 + (k / (CLASSES - 1)) * 0.86));\n    const borderC = shades.map((c) => mix(c, th.bg, 0.45));\n    return (t) => {\n      const hour = utcHour(t, 2);\n      let total = 0;\n      for (let k = 0; k < seeds.length; k++) {\n        const local = (hour + seeds[k].lon / 15 + 24) % 24;\n        // Usage peaks in the evening and bottoms out before dawn.\n        const day = 0.08 + 0.92 * Math.max(0, Math.sin(((local - 7) / 17) * Math.PI)) ** 1.3 * (local > 7 ? 1 : 0);\n        const v = seeds[k].pop * day;\n        total += v * 380;\n        cls[k] = clamp(Math.floor(v * CLASSES * 1.05), 0, CLASSES - 1);\n      }\n      cv.clear(ocean);\n      const px = cv.px;\n      for (let i = 0; i < px.length; i++) { const r = region[i]; if (r >= 0) px[i] = border[i] ? borderC[cls[r]] : shades[cls[r]]; }\n      if (cv.mode === 'ascii') {\n        // One pixel per cell: the class as a density glyph over a tinted cell, so it reads with or without color.\n        for (let y = 0; y < cv.height; y++) for (let x = 0; x < cv.width; x++) {\n          const i = y * cv.width + x, r = region[i];\n          if (r < 0) b.set(x, y, ' ', undefined, ocean);\n          else b.set(x, y, '.:-=+#'[cls[r]], shades[cls[r]], mix(th.bg, shades[cls[r]], border[i] ? 0.15 : 0.35));\n        }\n      } else cv.paint(b);\n      const y = H - foot;\n      let x = b.text(0, y, 'users', th.muted);\n      const sw = W >= 70 ? 6 : 3;\n      for (let k = 0; k < CLASSES; k++) {\n        for (let j = 0; j < sw; j++) b.set(x + 1 + k * sw + j, y, g.u ? '█' : '#', shades[k]);\n        if (foot > 1 && sw >= 6) b.text(x + 1 + k * sw, y + 1, LIMITS[k], th.dim);\n      }\n      putR(b, W, y, `UTC ${hhmm(hour)}`, th.ink, undefined, ATTR.BOLD);\n      if (foot > 1 && W >= 70) putR(b, W, y + 1, `${(total / 1000).toFixed(2)}M active`, th.muted);\n    };\n  },\n};\n\nconst daynight = {\n  id: 'maps-world-daynight',\n  title: 'Day / Night Terminator',\n  category: 'World Maps',\n  description: 'The sun sweeps west over a half-block world: a soft terminator with twilight, city lights on the night side and world clocks below.',\n  tags: ['terminator', 'day night', 'world clock', 'sun', 'city lights'],\n  kind: 'scene',\n  size: { w: 80, h: 22 },\n  minSize: { w: 40, h: 10 },\n  loop: 12,\n  canvas: 'field',\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    const foot = H >= 14 ? 2 : 1;\n    const rows = H - foot;\n    const cv = ctx.canvas('field', { cols: W, rows });\n    const P = project(cv, { stretch: 1.25 });\n    const mask = landMask(cv, P);\n    const decl = -7.8 * RAD; // mid-October\n    // Per-pixel sin/cos of latitude and longitude, computed once.\n    const N = cv.width * cv.height;\n    const sLat = new Float32Array(N), cLat = new Float32Array(N), lonA = new Float32Array(N), inMap = new Uint8Array(N);\n    for (let y = 0; y < cv.height; y++) for (let x = 0; x < cv.width; x++) {\n      const i = y * cv.width + x, lat = P.lat(y + 0.5), lon = P.lon(x + 0.5);\n      if (lat > P.view.lat0 || lat < P.view.lat1 || lon < -180 || lon > 180) continue;\n      inMap[i] = 1; sLat[i] = Math.sin(lat * RAD); cLat[i] = Math.cos(lat * RAD); lonA[i] = lon * RAD;\n    }\n    const dayLand = mix(th.bg, th.pos, 0.42), nightLand = mix(th.bg, th.line, 0.55);\n    const daySea = mix(th.bg, th.info, 0.3), nightSea = mix(th.bg, th.info, 0.06);\n    const dusk = mix(th.bg, th.warn, 0.35);\n    const LV = 16;\n    const ramp = (a, c) => Array.from({ length: LV + 1 }, (_, k) => mix(a, c, k / LV));\n    const landR = ramp(nightLand, dayLand), seaR = ramp(nightSea, daySea);\n    const lights = [];\n    while (lights.length < 46) {\n      const lon = ctx.rng.range(-125, 150), lat = ctx.rng.range(-40, 60);\n      if (inLand(lon, lat)) lights.push([P.x(lon), P.y(lat), lon * RAD, lat * RAD, ctx.rng()]);\n    }\n    for (const [, , lon, lat] of HUBS) lights.push([P.x(lon), P.y(lat), lon * RAD, lat * RAD, 1]);\n    const clocks = ['PDX', 'IAD', 'LHR', 'BOM', 'SIN', 'NRT', 'SYD'];\n    // ASCII terminals get one glyph per cell instead of half-block pixels.\n    const glyph = cv.mode === 'ascii' ? new Array(N).fill(' ') : null;\n    return (t) => {\n      const hour = utcHour(t, 2);\n      const sunLon = ((12 - hour) * 15) * RAD;\n      const sD = Math.sin(decl), cD = Math.cos(decl);\n      const px = cv.px;\n      for (let i = 0; i < N; i++) {\n        if (!inMap[i]) { px[i] = NONE; continue; }\n        const z = sLat[i] * sD + cLat[i] * cD * Math.cos(lonA[i] - sunLon);\n        const k = clamp((z + 0.12) / 0.24, 0, 1);\n        const base = mask[i] ? landR[Math.round(smooth(k) * LV)] : seaR[Math.round(smooth(k) * LV)];\n        px[i] = Math.abs(z) < 0.05 && mask[i] ? mix(base, dusk, 0.5) : base;\n        if (glyph) glyph[i] = mask[i] ? (k > 0.5 ? '#' : ':') : k > 0.5 ? '~' : ' ';\n      }\n      for (const [x, y, lon, lat, w] of lights) {\n        const z = Math.sin(lat) * sD + Math.cos(lat) * cD * Math.cos(lon - sunLon);\n        if (z < -0.04) {\n          cv.set(x, y, mix(nightLand, th.warn, clamp(0.25 + w * 0.6 + 0.08 * Math.sin(t * 3 + x), 0, 1)));\n          if (glyph) glyph[Math.floor(y) * cv.width + Math.floor(x)] = '*';\n        }\n      }\n      if (glyph) {\n        for (let y = 0; y < cv.height; y++) for (let x = 0; x < cv.width; x++) {\n          const i = y * cv.width + x;\n          if (inMap[i]) b.set(x, y, glyph[i], glyph[i] === '*' ? px[i] : mix(px[i], th.ink, 0.25), px[i]);\n        }\n      } else cv.paint(b);\n      // The subsolar point.\n      const sx = Math.round(P.x(((sunLon / RAD) + 540) % 360 - 180) / cv.sx), sy = Math.round(P.y(decl / RAD) / cv.sy);\n      if (sx >= 0 && sx < W && sy >= 0 && sy < rows) b.set(sx, sy, g.u ? '☼' : '*', 0xffffff, undefined, ATTR.BOLD);\n      // World clocks.\n      const y = H - foot;\n      const per = Math.floor(W / clocks.length);\n      clocks.forEach((code, i) => {\n        const h = HUBS.find((c) => c[0] === code);\n        const local = (hour + Math.round(h[2] / 15) + 24) % 24;\n        const z = Math.sin(h[3] * RAD) * sD + Math.cos(h[3] * RAD) * cD * Math.cos(h[2] * RAD - sunLon);\n        const day = z > 0;\n        if (per >= 10) {\n          b.text(i * per, y, code, th.muted);\n          b.text(i * per + 4, y, hhmm(local), day ? th.ink : th.info, undefined, ATTR.BOLD);\n          if (foot > 1) b.text(i * per, y + 1, day ? (g.u ? '☼ day' : 'day') : (g.u ? '☾ night' : 'night'), day ? th.warn : th.dim);\n        } else if (i < 4) b.text(i * 10, y, `${code} ${hhmm(local)}`, day ? th.ink : th.info);\n      });\n    };\n  },\n};\n\nexport default [cities, choropleth, daynight];\n",
  "prism-term/src/facets/objects/_raster.js": "// Shared software-3D helpers for the objects/sim/lab galleries: a z-buffered\n// triangle rasterizer with interpolated intensity (flat or Gouraud), a mesh\n// drawer with camera-facing lighting normals, an analytic lit sphere, and a\n// deterministic star backdrop. Everything writes straight into a Canvas.\n\nimport { NONE, luminance } from '../../color.js';\n\nexport function zbuffer(cv) {\n  const z = new Float32Array(cv.width * cv.height);\n  z.fill(Infinity);\n  return z;\n}\n\n/**\n * Fill a triangle. a/b/c are projected [x, y, depth] (smaller depth = nearer);\n * ia/ib/ic are per-vertex values interpolated into shade(value, x, y) → color.\n */\nexport function tri(cv, z, a, b, c, ia, ib, ic, shade) {\n  const W = cv.width, H = cv.height, px = cv.px;\n  const x0 = Math.max(0, Math.floor(Math.min(a[0], b[0], c[0])));\n  const x1 = Math.min(W - 1, Math.ceil(Math.max(a[0], b[0], c[0])));\n  const y0 = Math.max(0, Math.floor(Math.min(a[1], b[1], c[1])));\n  const y1 = Math.min(H - 1, Math.ceil(Math.max(a[1], b[1], c[1])));\n  const area = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);\n  if (!(Math.abs(area) > 1e-9) || x0 > x1 || y0 > y1) return;\n  const inv = 1 / area;\n  for (let y = y0; y <= y1; y++) {\n    const py = y + 0.5;\n    for (let x = x0; x <= x1; x++) {\n      const pxc = x + 0.5;\n      const w0 = ((b[0] - pxc) * (c[1] - py) - (b[1] - py) * (c[0] - pxc)) * inv;\n      const w1 = ((c[0] - pxc) * (a[1] - py) - (c[1] - py) * (a[0] - pxc)) * inv;\n      const w2 = 1 - w0 - w1;\n      if (w0 < -1e-4 || w1 < -1e-4 || w2 < -1e-4) continue;\n      const d = w0 * a[2] + w1 * b[2] + w2 * c[2];\n      const i = y * W + x;\n      if (d >= z[i]) continue;\n      const col = shade(w0 * ia + w1 * ib + w2 * ic, x, y);\n      if (col === NONE) continue;\n      z[i] = d;\n      px[i] = col;\n    }\n  }\n}\n\nconst sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];\nconst cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];\nconst dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];\nconst norm = (a) => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; };\nexport { sub, cross, dot, norm };\n\n/**\n * Draw a polygon mesh. `transform` maps model → camera space (camera at\n * z = -distance looking +z), `project` maps camera space → [x, y, depth].\n * Normals are oriented towards the camera, which for a closed mesh behind a\n * z-buffer is the outward normal of every visible face.\n *   light      unit vector pointing *towards* the light, camera space\n *   shade(i, faceIndex, x, y) → color, with i the lit intensity in 0..1+\n *   normals    optional per-vertex model normals → Gouraud shading\n */\nexport function drawMesh(cv, z, mesh, { transform, project, light, shade, ambient = 0.16, specular = 0, shininess = 24, camera = [0, 0, -3.2], normals = null, faces = null }) {\n  const V = mesh.vertices.map(transform);\n  const P = V.map(project);\n  const lit = (n, p) => {\n    let l = Math.max(0, dot(n, light));\n    let s = 0;\n    if (specular > 0) {\n      const v = norm(sub(camera, p));\n      const h = norm([light[0] + v[0], light[1] + v[1], light[2] + v[2]]);\n      s = specular * Math.pow(Math.max(0, dot(n, h)), shininess);\n    }\n    return ambient + (1 - ambient) * l + s;\n  };\n  const VN = normals ? normals.map((n) => norm(sub(transform(n), transform([0, 0, 0])))) : null;\n  const list = faces || mesh.faces;\n  list.forEach((f, fi) => {\n    const a = V[f[0]], b = V[f[1]], c = V[f[2]];\n    let n = norm(cross(sub(b, a), sub(c, a)));\n    if (dot(n, sub(a, camera)) > 0) n = [-n[0], -n[1], -n[2]];\n    const flat = lit(n, a);\n    for (let k = 1; k + 1 < f.length; k++) {\n      const i0 = f[0], i1 = f[k], i2 = f[k + 1];\n      let ia = flat, ib = flat, ic = flat;\n      if (VN) {\n        const o = (i) => (dot(VN[i], n) < 0 ? [-VN[i][0], -VN[i][1], -VN[i][2]] : VN[i]);\n        ia = lit(o(i0), V[i0]); ib = lit(o(i1), V[i1]); ic = lit(o(i2), V[i2]);\n      }\n      tri(cv, z, P[i0], P[i1], P[i2], ia, ib, ic, (i, x, y) => shade(i, fi, x, y));\n    }\n  });\n  return P;\n}\n\n/**\n * Analytic sphere at canvas (cx, cy) with radius r (horizontal pixels).\n * shade(nx, ny, nz, x, y) gets the camera-space unit normal (nz < 0 faces the\n * viewer) and returns a color (or NONE to leave the pixel). zr is the radius\n * in depth units, so overlapping spheres intersect correctly in the z-buffer.\n */\nexport function sphere(cv, z, cx, cy, r, depth, shade, zr = r * 0.01) {\n  const W = cv.width, H = cv.height, px = cv.px, asp = cv.aspect;\n  const ry = r / asp;\n  const x0 = Math.max(0, Math.floor(cx - r - 1)), x1 = Math.min(W - 1, Math.ceil(cx + r + 1));\n  const y0 = Math.max(0, Math.floor(cy - ry - 1)), y1 = Math.min(H - 1, Math.ceil(cy + ry + 1));\n  for (let y = y0; y <= y1; y++) {\n    const ny = ((y + 0.5 - cy) * asp) / r;\n    for (let x = x0; x <= x1; x++) {\n      const nx = (x + 0.5 - cx) / r;\n      const q = nx * nx + ny * ny;\n      if (q > 1) continue;\n      const nz = -Math.sqrt(1 - q);\n      const d = depth + nz * zr;\n      const i = y * W + x;\n      if (z && d >= z[i]) continue;\n      const col = shade(nx, ny, nz, x, y);\n      if (col === NONE) continue;\n      if (z) z[i] = d;\n      px[i] = col;\n    }\n  }\n}\n\n/** Deterministic twinkling stars; call draw(cv, t) each frame (before the subject). */\nexport function starfield(rng, n, tint = 0xc8d4ff) {\n  const stars = Array.from({ length: n }, () => ({ x: rng(), y: rng(), b: 0.25 + rng() * 0.75, ph: rng() * 6.28, sp: 0.6 + rng() * 2.4 }));\n  // bg: pass the theme background — stars fade in from it, and on a light\n  // theme (a daylight sky) only the brightest show, as faint gray specks.\n  return (cv, t, strength = 1, bg = 0x000000) => {\n    const day = luminance(bg) > 0.5;\n    for (const s of stars) {\n      const tw = 0.55 + 0.45 * Math.sin(t * s.sp + s.ph);\n      const k = s.b * tw * strength;\n      if (day && k < 0.6) continue;\n      cv.set(s.x * cv.width, s.y * cv.height, day ? blend(bg, 0x7a8494, (k - 0.6) * 0.8) : blend(bg, tint, k));\n    }\n  };\n}\n\n/** Scale a packed color, saturating. */\nexport function lit(c, k) {\n  if (c === NONE) return NONE;\n  const r = Math.min(255, ((c >> 16) & 255) * k), g = Math.min(255, ((c >> 8) & 255) * k), b = Math.min(255, (c & 255) * k);\n  return (r << 16) | (g << 8) | (b | 0);\n}\n\n/** Linear mix of two packed colors, t clamped to [0, 1] (no NONE handling — callers pass real colors). */\nexport function blend(a, b, t) {\n  t = t < 0 ? 0 : t > 1 ? 1 : t;\n  const r = ((a >> 16) & 255) + ((((b >> 16) & 255) - ((a >> 16) & 255)) * t);\n  const g = ((a >> 8) & 255) + ((((b >> 8) & 255) - ((a >> 8) & 255)) * t);\n  const bl = (a & 255) + (((b & 255) - (a & 255)) * t);\n  return (r << 16) | (g << 8) | (bl | 0);\n}\n\n/** Rotation matrix helpers (row-major 3×3 arrays). */\nexport function rotX(a) { const c = Math.cos(a), s = Math.sin(a); return [1, 0, 0, 0, c, -s, 0, s, c]; }\nexport function rotY(a) { const c = Math.cos(a), s = Math.sin(a); return [c, 0, s, 0, 1, 0, -s, 0, c]; }\nexport function rotZ(a) { const c = Math.cos(a), s = Math.sin(a); return [c, -s, 0, s, c, 0, 0, 0, 1]; }\nexport function mul(A, B) {\n  const out = new Array(9);\n  for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) out[r * 3 + c] = A[r * 3] * B[c] + A[r * 3 + 1] * B[3 + c] + A[r * 3 + 2] * B[6 + c];\n  return out;\n}\nexport function apply(M, p) { return [M[0] * p[0] + M[1] * p[1] + M[2] * p[2], M[3] * p[0] + M[4] * p[1] + M[5] * p[2], M[6] * p[0] + M[7] * p[1] + M[8] * p[2]]; }\nexport function transpose(M) { return [M[0], M[3], M[6], M[1], M[4], M[7], M[2], M[5], M[8]]; }\n\n/** Perspective projector onto a canvas: unit radius ≈ fill × the shorter visual side. */\nexport function perspective(cv, { distance = 3.2, fill = 0.42, cx, cy } = {}) {\n  const W = cv.width, H = cv.height, asp = cv.aspect;\n  const s = Math.min(W, H * asp) * fill * distance;\n  const ox = cx ?? W / 2, oy = cy ?? H / 2;\n  return (p) => { const d = p[2] + distance; const k = s / Math.max(0.05, d); return [ox + p[0] * k, oy + (p[1] * k) / asp, d]; };\n}\n\n/**\n * Scanline-fill a polygon ([[x,y],…], even-odd) calling fn(x, y, old) → color\n * for every covered pixel — for blended/glassy fills that need the pixel below.\n */\nexport function fillPoly(cv, pts, fn) {\n  if (pts.length < 3) return;\n  const W = cv.width, H = cv.height, px = cv.px;\n  let minY = Infinity, maxY = -Infinity;\n  for (const p of pts) { if (p[1] < minY) minY = p[1]; if (p[1] > maxY) maxY = p[1]; }\n  const ya = Math.max(0, Math.ceil(minY - 0.5)), yb = Math.min(H - 1, Math.floor(maxY - 0.5));\n  const xs = [];\n  for (let y = ya; y <= yb; y++) {\n    const sy = y + 0.5;\n    xs.length = 0;\n    for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {\n      const [xi, yi] = pts[i], [xj, yj] = pts[j];\n      if ((yi > sy) !== (yj > sy)) xs.push(xi + ((sy - yi) / (yj - yi)) * (xj - xi));\n    }\n    xs.sort((a, b) => a - b);\n    for (let k = 0; k + 1 < xs.length; k += 2) {\n      const xa = Math.max(0, Math.ceil(xs[k] - 0.5)), xb = Math.min(W - 1, Math.floor(xs[k + 1] - 0.5));\n      for (let x = xa; x <= xb; x++) {\n        const i = y * W + x;\n        const c = fn(x, y, px[i]);\n        if (c !== NONE) px[i] = c;\n      }\n    }\n  }\n}\n\n/**\n * Soft elliptical floor shadow. `under` is the color behind transparent\n * pixels (pass ctx.theme.bg) so the shadow darkens the real background.\n */\nexport function shadow(cv, cx, cy, rx, ry, strength, under) {\n  const x0 = Math.max(0, Math.floor(cx - rx)), x1 = Math.min(cv.width - 1, Math.ceil(cx + rx));\n  const y0 = Math.max(0, Math.floor(cy - ry)), y1 = Math.min(cv.height - 1, Math.ceil(cy + ry));\n  for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) {\n    const dx = (x + 0.5 - cx) / rx, dy = (y + 0.5 - cy) / ry;\n    const q = dx * dx + dy * dy;\n    if (q >= 1) continue;\n    const a = strength * (1 - q) * (1 - q);\n    const i = y * cv.width + x;\n    const old = cv.px[i] === NONE ? under : cv.px[i];\n    cv.px[i] = blend(old, 0x000000, a);\n  }\n}\n",
  "prism-term/src/facets/objects/analog-clock.js": "// A wall clock in braille: a double bezel, minute and hour ticks, numerals,\n// tapered baton hands that pass over the numerals, a sweeping second hand with\n// a counterweight, and a date window. Time starts at 10:08:36 — the\n// watchmaker's favorite pose — and runs in real seconds.\nimport { ATTR } from '../../buffer.js';\nimport { blend } from './_raster.js';\n\nexport default {\n  title: 'Analog Clock',\n  gallery: 'objects',\n  category: 'Mechanical',\n  kind: 'widget',\n  tags: ['clock', 'time', 'watch', 'braille', 'hands'],\n  order: 20,\n  description: 'A braille wall clock with a bezel, ticks, numerals, tapered baton hands and a smoothly sweeping second hand.',\n  size: { w: 40, h: 20 },\n  minSize: { w: 14, h: 7 },\n  loop: 60,\n  canvas: 'line',\n  palette: 'prism',\n  params: { rate: { default: 1, min: 1, max: 600, label: 'Time multiplier' } },\n  setup(ctx) {\n    const cv = ctx.canvas('line');\n    const rate = ctx.param('rate', 1);\n    const th = ctx.theme;\n    const W = cv.width, H = cv.height, asp = cv.aspect;\n    const R = Math.min(W / 2 - 1.5, (H * asp) / 2 - 1.5);\n    const cx = W / 2, cy = H / 2;\n    // Clock-face polar → canvas pixels (angle 0 at twelve, clockwise).\n    const P = (a, r) => [cx + Math.sin(a) * r, cy - (Math.cos(a) * r) / asp];\n    // Baton hand: a quad from a short tail behind the hub to a narrow tip.\n    const hand = (a, len, tail, width, color) => {\n      const ox = Math.cos(a) * width * 0.5, oy = (Math.sin(a) * width * 0.5) / asp;\n      const t0 = P(a + Math.PI, tail), t1 = P(a, len);\n      cv.polygon([[t0[0] - ox, t0[1] - oy], [t0[0] + ox, t0[1] + oy], [t1[0] + ox * 0.25, t1[1] + oy * 0.25], [t1[0] - ox * 0.25, t1[1] - oy * 0.25]], color);\n      cv.line(t0[0], t0[1], t1[0], t1[1], color);\n    };\n    const bezel = blend(th.line, th.ink, 0.35);\n    const minor = blend(th.bg, th.muted, 0.85);\n    const start = 10 * 3600 + 8 * 60 + 36;\n    const cellX = (x) => Math.round(x / cv.sx - 0.5), cellY = (y) => Math.round(y / cv.sy - 0.5);\n    return (t) => {\n      cv.clear();\n      const b = ctx.buffer, cw = ctx.width, ch = ctx.height;\n      const secs = start + t * rate;\n      // Layered like a real dial: bezel and ticks, then numerals and date over\n      // them (as text), then the hands painted over everything.\n      cv.ellipse(cx, cy, R, R / asp, bezel);\n      if (R > 12) for (const d of [0.7, 1.4]) cv.ellipse(cx, cy, R - d, (R - d) / asp, bezel);\n      for (let m = 0; m < 60; m++) {\n        const a = (m / 60) * Math.PI * 2;\n        const major = m % 5 === 0;\n        if (!major && R < 12) continue;\n        const [x0, y0] = P(a, R * (major ? (m % 15 ? 0.82 : 0.78) : 0.89)), [x1, y1] = P(a, R * 0.94);\n        cv.line(x0, y0, x1, y1, major ? th.ink : minor);\n      }\n      cv.paint(b);\n      cv.clear();\n      if (R > 8) {\n        const all = R >= 30;\n        for (let n = 1; n <= 12; n++) {\n          if (!all && n % 3) continue;\n          const label = String(n);\n          const [x, y] = P((n / 12) * Math.PI * 2, R * 0.7);\n          const X = Math.min(cw - label.length, Math.max(0, cellX(x) - (label.length >> 1)));\n          b.text(X, Math.min(ch - 1, Math.max(0, cellY(y))), label, n % 3 ? th.muted : th.ink, undefined, ATTR.BOLD);\n        }\n      }\n      if (R >= 22) {\n        const day = String(13 + (Math.floor(secs / 86400) % 18));\n        const [x, y] = P(Math.PI / 2, R * 0.42);\n        b.text(cellX(x) - 1, cellY(y), day, th.bg, th.muted, ATTR.BOLD);\n      }\n      // Hands: the hour and minute advance continuously, the second hand sweeps.\n      const s = secs % 60, mi = (secs / 60) % 60, h = (secs / 3600) % 12;\n      hand((h / 12) * Math.PI * 2, R * 0.5, R * 0.1, Math.max(1, R * 0.12), th.ink);\n      hand((mi / 60) * Math.PI * 2, R * 0.8, R * 0.12, Math.max(1, R * 0.08), blend(th.ink, th.info, 0.3));\n      const sa = (s / 60) * Math.PI * 2;\n      const st = P(sa, R * 0.88), sb = P(sa + Math.PI, R * 0.2);\n      cv.line(sb[0], sb[1], st[0], st[1], th.neg);\n      if (R > 12) { const [wx, wy] = P(sa + Math.PI, R * 0.17); cv.circle(wx, wy, R * 0.035 + 0.6, th.neg, true); }\n      cv.circle(cx, cy, Math.max(1, R * 0.05), th.neg, true);\n      cv.paint(b);\n    };\n  },\n};\n",
  "prism-term/src/facets/objects/atom.js": "// The atom symbol, built for real: a nucleus of lit, z-buffered protons and\n// neutrons, three tilted orbits drawn as anti-aliased (bilinear-splatted) rings\n// that pass behind and in front of it, and electrons with glowing heads and\n// fading trails. Everything loops exactly every twelve seconds.\nimport { zbuffer, sphere, perspective, rotX, rotY, rotZ, mul, apply, norm, blend } from './_raster.js';\nimport { NONE, luminance } from '../../color.js';\n\nconst LOOP = 12;\n\nexport default {\n  title: 'Atom',\n  gallery: 'objects',\n  category: 'Icons & Logos',\n  kind: 'art',\n  tags: ['atom', 'science', 'orbit', 'electron', 'nucleus', 'physics'],\n  order: 20,\n  description: 'A lit nucleus of protons and neutrons inside three tilted orbits, with glowing electrons trailing light.',\n  size: { w: 56, h: 22 },\n  minSize: { w: 18, h: 7 },\n  loop: LOOP,\n  canvas: 'field',\n  palette: 'prism',\n  params: { electrons: { default: 3, min: 1, max: 6, label: 'Electrons' } },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const z = zbuffer(cv);\n    const W = cv.width, H = cv.height, asp = cv.aspect, th = ctx.theme;\n    const pal = ctx.palette();\n    const nE = Math.round(ctx.param('electrons', 3));\n    const dist = 5;\n    const project = perspective(cv, { distance: dist, fill: 0.43 });\n    const scale = Math.min(W, H * asp) * 0.43; // pixels per world unit at the origin\n    const light = norm([-0.5, -0.65, -0.6]);\n    const half = norm([light[0], light[1], light[2] - 1]); // Blinn half-vector (viewer at -z)\n    const dark = luminance(th.bg) < 0.5;\n    const proton = blend(0xe8505b, th.neg, 0.35), neutron = blend(0x9fb3c8, th.muted, 0.3);\n    // Nucleons packed on two shells; protons and neutrons alternate (seeded).\n    const nucleons = [];\n    const shell = (n, rad) => {\n      for (let i = 0; i < n; i++) {\n        const y = 1 - (2 * (i + 0.5)) / n, r = Math.sqrt(1 - y * y), a = i * 2.39996;\n        nucleons.push({ p: [Math.cos(a) * r * rad, y * rad, Math.sin(a) * r * rad], c: ctx.rng() < 0.5 ? proton : neutron });\n      }\n    };\n    shell(10, 0.2); shell(3, 0.07);\n    const nr = Math.max(0.12, 0.9 / scale); // nucleon radius (world), never below ~1px\n    const orbitCol = [0.02, 0.36, 0.68].map((f) => blend(pal(f), th.info, 0.2));\n    const tilt = [0, 1, 2].map((k) => mul(rotZ((k * Math.PI) / 3 + 0.1), rotX(1.08)));\n    const spd = [4, -3, 5, 6, -4, 7];\n\n    const acc = new Float32Array(W * H * 4); // r, g, b, alpha (premultiplied)\n    const splat1 = (i, d, col, a) => {\n      if (z[i] < d - 0.02) return; // hidden behind the nucleus\n      const o = i * 4;\n      acc[o] += ((col >> 16) & 255) * a; acc[o + 1] += ((col >> 8) & 255) * a; acc[o + 2] += (col & 255) * a; acc[o + 3] += a;\n    };\n    const splat = (x, y, d, col, a) => {\n      x -= 0.5; y -= 0.5;\n      const x0 = Math.floor(x), y0 = Math.floor(y), fx = x - x0, fy = y - y0;\n      for (let j = 0; j < 4; j++) {\n        const xx = x0 + (j & 1), yy = y0 + (j >> 1);\n        if (xx < 0 || yy < 0 || xx >= W || yy >= H) continue;\n        splat1(yy * W + xx, d, col, a * ((j & 1) ? fx : 1 - fx) * ((j >> 1) ? fy : 1 - fy));\n      }\n    };\n    const glow = (x, y, d, col, a, sigma) => {\n      const R = Math.ceil(sigma * 2.5), Ry = Math.ceil(R / asp);\n      for (let yy = Math.floor(y) - Ry; yy <= Math.floor(y) + Ry; yy++) for (let xx = Math.floor(x) - R; xx <= Math.floor(x) + R; xx++) {\n        if (xx < 0 || yy < 0 || xx >= W || yy >= H) continue;\n        const dx = xx + 0.5 - x, dy = (yy + 0.5 - y) * asp;\n        const w = Math.exp(-(dx * dx + dy * dy) / (2 * sigma * sigma));\n        if (w > 0.02) splat1(yy * W + xx, d, col, a * w);\n      }\n    };\n\n    // Splat orbit M from angle a0 to a1 in n steps, each weighted by the screen\n    // distance it covers, so the line is evenly bright even where it is seen edge-on.\n    const arc = (M, a0, a1, n, col, k) => {\n      let prev = null;\n      for (let s = 0; s <= n; s++) {\n        const f = s / n, a = a0 + (a1 - a0) * f;\n        const q = apply(M, [Math.cos(a), Math.sin(a), 0]);\n        const p = project(q);\n        if (prev) splat((p[0] + prev[0]) / 2, (p[1] + prev[1]) / 2, p[2], col, Math.hypot(p[0] - prev[0], p[1] - prev[1]) * k(f, q));\n        prev = p;\n      }\n    };\n\n    return (t) => {\n      cv.clear();\n      z.fill(Infinity);\n      acc.fill(0);\n      const ph = (t / LOOP) * Math.PI * 2;\n      const G = mul(rotY(Math.sin(ph) * 0.45), mul(rotX(0.18 + Math.cos(ph) * 0.14), rotZ(Math.sin(ph * 2) * 0.08)));\n      // Nucleus: slowly tumbling, lit spheres, z-buffered against each other.\n      const N = mul(G, mul(rotY(ph), rotX(ph * 2)));\n      for (const n of nucleons) {\n        const q = apply(N, n.p), [sx, sy, sd] = project(q);\n        const rp = (nr * scale * dist) / sd;\n        sphere(cv, z, sx, sy, rp, sd, (nx, ny, nz) => {\n          const dif = Math.max(0, nx * light[0] + ny * light[1] + nz * light[2]);\n          const spec = Math.pow(Math.max(0, nx * half[0] + ny * half[1] + nz * half[2]), 28);\n          return blend(blend(0x000000, n.c, 0.22 + 0.78 * dif), 0xffffff, spec * 0.8);\n        }, nr);\n      }\n      // Orbits, brighter on the near side.\n      const orbits = tilt.map((T) => mul(G, T));\n      const steps = Math.ceil(Math.PI * 2 * scale * 4);\n      orbits.forEach((M, k) => arc(M, 0, Math.PI * 2, steps, orbitCol[k], (f, q) => 0.42 - q[2] * 0.22));\n      // Electrons: a glowing head and a trail fading back along the orbit.\n      for (let e = 0; e < nE; e++) {\n        const k = e % 3, M = orbits[k], w = (spd[e] * Math.PI * 2) / LOOP;\n        const a0 = w * t + e * 2.1;\n        const col = blend(orbitCol[k], 0xffffff, 0.55);\n        arc(M, a0 - Math.sign(w) * 1.3, a0, Math.ceil(scale * 2.5), col, (f) => 0.6 * Math.pow(f, 2.2));\n        const q = apply(M, [Math.cos(a0), Math.sin(a0), 0]);\n        const [x, y, d] = project(q);\n        glow(x, y, d, col, 1.1, Math.max(0.7, scale * 0.06));\n      }\n      // Composite the light: additive on dark themes, ink-like on light ones.\n      const px = cv.px;\n      for (let i = 0; i < W * H; i++) {\n        const o = i * 4, a = acc[o + 3];\n        if (a < 0.01) continue;\n        const base = px[i] === NONE ? th.bg : px[i];\n        if (dark) {\n          const r = Math.min(255, ((base >> 16) & 255) + acc[o]), g = Math.min(255, ((base >> 8) & 255) + acc[o + 1]), b = Math.min(255, (base & 255) + acc[o + 2]);\n          px[i] = (r << 16) | (g << 8) | (b | 0);\n        } else {\n          const col = ((acc[o] / a) << 16) | ((acc[o + 1] / a) << 8) | ((acc[o + 2] / a) | 0);\n          px[i] = blend(base, blend(col, th.ink, 0.35), Math.min(1, a * 1.8));\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/objects/dna.js": "// A DNA double helix spinning about its axis while the whole molecule slowly\n// yaws and rolls in perspective. Backbones, base-pair rungs (A–T / G–C colored\n// halves) and nucleotide beads are depth-sorted and shaded front-to-back, so\n// the near strand reads in front of the far one even at braille resolution.\nimport { rotate, projector } from '../../math3d.js';\nimport { mix, NONE } from '../../color.js';\nimport { fadeMark } from '../../palettes.js';\n\nexport default {\n  title: 'DNA Helix',\n  gallery: 'objects',\n  category: '3D Solids',\n  kind: 'scene',\n  tags: ['dna', 'helix', 'biology', '3d'],\n  order: 40,\n  description: 'Rotating double helix in perspective — depth-sorted, shaded backbones, A–T/G–C base-pair rungs and drifting motes.',\n  canvas: 'line',\n  palette: 'prism',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const { width: W, height: H, aspect } = cv;\n    const th = ctx.theme;\n    const rand = ctx.rng;\n    const pal = ctx.palette();\n    const speed = ctx.param('speed', 1);\n    const fill = 0.42;\n    const unit = Math.min(W, H * aspect) * fill;\n    const L = Math.max(1, (W / 2 / unit) * 0.92);\n    const R = Math.min(0.55, (H * aspect) / unit / 2 * 0.62);\n    const turns = Math.max(1.5, (L * 2) / 1.35);\n    const kx = (turns * Math.PI * 2) / (2 * L);\n    const proj = projector(cv, { distance: 3.4, fill });\n    const BASES = [[th.pos, th.neg], [th.neg, th.pos], [th.warn, th.info], [th.info, th.warn]];\n    const NR = Math.round(turns * 10);\n    const pairs = Array.from({ length: NR }, () => BASES[Math.floor(rand() * 4)]);\n    const NS = Math.max(60, Math.round(W * 0.9));\n    const bg = ctx.bg === NONE ? 0 : ctx.bg;\n    const motes = Array.from({ length: Math.round(W * H * 0.0006) + 8 }, () => ({ x: rand() * W, y: rand() * H, v: 0.5 + rand(), p: rand() * 6.28 }));\n\n    const shade = (c, k) => (bg === ctx.bg ? fadeMark(th, c, k) : mix(bg, c, k));\n    return (t, dt) => {\n      const spin = t * 0.9 * speed;\n      const ay = 0.3 * Math.sin(t * 0.21), az = 0.16 * Math.sin(t * 0.13 + 1), ax = 0.25 * Math.sin(t * 0.17);\n      const P = (x, phase) => {\n        const a = x * kx + spin + phase;\n        const p = rotate([x, R * Math.cos(a), R * Math.sin(a)], ax, ay, az);\n        return proj(p);\n      };\n      const segs = [];\n      for (let s = 0; s < 2; s++) {\n        let prev = P(-L, s * Math.PI);\n        for (let i = 1; i <= NS; i++) {\n          const x = -L + (2 * L * i) / NS;\n          const cur = P(x, s * Math.PI);\n          const u = i / NS;\n          segs.push({ a: prev, b: cur, d: (prev[2] + cur[2]) / 2, c: pal(u * 0.45 + s * 0.5 + t * 0.02), w: 1 });\n          prev = cur;\n        }\n      }\n      for (let i = 0; i < NR; i++) {\n        const x = -L + (2 * L * (i + 0.5)) / NR;\n        const a = P(x, 0), b = P(x, Math.PI);\n        const m = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2];\n        segs.push({ a, b: m, d: (a[2] + m[2]) / 2, c: pairs[i][0], w: 0 });\n        segs.push({ a: m, b, d: (m[2] + b[2]) / 2, c: pairs[i][1], w: 0 });\n        segs.push({ bead: a, d: a[2] - 0.01, c: pal(((i + 0.5) / NR) * 0.45 + t * 0.02) });\n        segs.push({ bead: b, d: b[2] - 0.01, c: pal(((i + 0.5) / NR) * 0.45 + 0.5 + t * 0.02) });\n      }\n      let dmin = Infinity, dmax = -Infinity;\n      for (const s of segs) { if (s.d < dmin) dmin = s.d; if (s.d > dmax) dmax = s.d; }\n      const span = dmax - dmin || 1;\n      segs.sort((p, q) => q.d - p.d);\n\n      cv.clear();\n      for (const m of motes) {\n        m.y -= m.v * dt * H * 0.03;\n        if (m.y < 0) { m.y += H; m.x = rand() * W; }\n        cv.set(m.x + Math.sin(t * 0.5 + m.p) * 3, m.y, shade(th.muted, 0.35 + 0.25 * Math.sin(t + m.p)));\n      }\n      for (const s of segs) {\n        const near = 1 - (s.d - dmin) / span;\n        const k = 0.28 + 0.72 * near * near;\n        if (s.bead) {\n          const r = (1 + near * 1.6) * (cv.sx / 2);\n          cv.ellipse(s.bead[0], s.bead[1], r, r / aspect, shade(s.c, Math.min(1, k + 0.15)), true);\n        } else {\n          cv.line(s.a[0], s.a[1], s.b[0], s.b[1], shade(s.c, Math.min(1, s.w ? k : k * 1.1)));\n          // Thicken the near half of the backbones.\n          if (s.w && near > 0.55) cv.line(s.a[0], s.a[1] + 1, s.b[0], s.b[1] + 1, shade(s.c, k * 0.8));\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/objects/donut.js": "// donut.c, done properly: a torus splatted into a z-buffer at a density matched\n// to the canvas resolution (surface points, normals and materials precomputed\n// once; one 3×3 matrix per frame), Lambert + Blinn-Phong lit, hole-filled, and\n// dressed in glossy frosting with a wavy drip edge and sprinkles. It spins on\n// its axis, wobbles, and flips over every few seconds. `--set style=solid`\n// renders a theme-lit ring instead.\nexport default {\n  title: 'Donut',\n  gallery: 'objects',\n  category: '3D Solids',\n  kind: 'scene',\n  tags: ['donut', 'torus', 'donut.c', '3d', 'lighting'],\n  order: 10,\n  description: 'The donut.c torus, z-buffered and properly lit — frosted with sprinkles, or --set style=solid.',\n  canvas: 'hires',\n  palette: 'candy',\n  setup(ctx) {\n    const cv = ctx.canvas(undefined, { dither: true });\n    const solid = ctx.param('style', ctx.params.palette ? 'solid' : 'frosted') === 'solid';\n    const sprinkleColors = ctx.palette('candy');\n    const ramp = ctx.params.palette\n      ? ctx.palette()\n      : ctx.palette([0x05060a, ctx.theme.accent, ctx.theme.info, 0xffffff]);\n    const frost = ctx.param('frosting', '#ff6fae');\n    const frostC = typeof frost === 'string' ? parseInt(frost.replace('#', ''), 16) : frost;\n    const flipEvery = ctx.param('flip', 9);\n    const { width: W, height: H, px, aspect } = cv;\n    const zbuf = new Float32Array(W * H);\n    const R = 1, r = 0.46, dist = 5;\n    const scale = Math.min(W, H * aspect) * 0.29 * dist;\n    const kmax = scale / (dist - (R + r));\n    const nP = Math.ceil(2 * Math.PI * (R + r) * kmax * 1.05);\n    const nT = Math.ceil(2 * Math.PI * r * kmax * 1.1);\n    const N = nP * nT;\n\n    // Surface samples: position, normal, material (0 dough, 1 frosting, 2+ sprinkle).\n    const P = new Float32Array(N * 3), Nn = new Float32Array(N * 3), mat = new Uint8Array(N), tone = new Float32Array(N);\n    const sprink = [];\n    const rand = ctx.rng;\n    for (let k = 0; k < 14; k++) sprink.push(sprinkleColors(k / 14));\n    const cellsP = 46, cellsT = 12, seeds = [];\n    for (let a = 0; a < cellsT * cellsP; a++) seeds.push(rand() < 0.62 ? { u: rand(), v: rand(), ang: rand() * Math.PI, c: 2 + Math.floor(rand() * 14) } : null);\n    for (let i = 0, n = 0; i < nT; i++) {\n      const th = (i / nT) * Math.PI * 2, ct = Math.cos(th), st = Math.sin(th);\n      for (let j = 0; j < nP; j++, n++) {\n        const ph = (j / nP) * Math.PI * 2, cp = Math.cos(ph), sp = Math.sin(ph);\n        const ring = R + r * ct;\n        P[n * 3] = ring * cp; P[n * 3 + 1] = r * st; P[n * 3 + 2] = ring * sp;\n        Nn[n * 3] = ct * cp; Nn[n * 3 + 1] = st; Nn[n * 3 + 2] = ct * sp;\n        tone[n] = 0.5 + 0.5 * st;\n        if (solid) continue;\n        const edge = 0.18 + 0.13 * Math.sin(ph * 9 + Math.sin(ph * 4) * 2) + 0.06 * Math.sin(ph * 23);\n        if (st > edge) continue; // dough (underside)\n        let m = 1;\n        // Sprinkles: one jittered sugar strand per parameter-space cell.\n        const fu = (ph / (Math.PI * 2)) * cellsP, fv = (th / (Math.PI * 2)) * cellsT;\n        const cu = Math.floor(fu), cvv = Math.floor(fv);\n        const s = seeds[cvv * cellsP + cu];\n        if (s && st < edge - 0.12) {\n          const du = (fu - cu - (0.25 + s.u * 0.5)) * 1.6, dv = (fv - cvv - (0.25 + s.v * 0.5)) * 3.2;\n          const along = du * Math.cos(s.ang) + dv * Math.sin(s.ang), across = -du * Math.sin(s.ang) + dv * Math.cos(s.ang);\n          if (Math.abs(along) < 0.36 && Math.abs(across) < 0.09) m = s.c;\n        }\n        mat[n] = m;\n      }\n    }\n\n    // Light from upper-left-front (screen y grows downward, camera looks along +z).\n    const L = [-0.45, -0.7, -0.55], ll = Math.hypot(...L);\n    const lx = L[0] / ll, ly = L[1] / ll, lz = L[2] / ll;\n    const hl = Math.hypot(lx, ly, lz - 1);\n    const hx = lx / hl, hy = ly / hl, hz = (lz - 1) / hl;\n    const back = new Int32Array(W * H);\n    const plainBg = cv.mode === 'ascii' || cv.mode === 'braille';\n    const glow = ctx.theme.panel, base = ctx.bg === -1 ? 0 : ctx.bg;\n    for (let y = 0, i = 0; y < H; y++)\n      for (let x = 0; x < W; x++, i++) {\n        const dx = (x - W / 2) / W, dy = ((y - H / 2) * aspect) / W;\n        const k = Math.max(0, 1 - Math.sqrt(dx * dx + dy * dy) * 1.6);\n        back[i] = plainBg ? -1 : mixc(base, glow, k * k);\n      }\n    const smooth = (u) => u * u * (3 - 2 * u);\n\n    return (t) => {\n      // Spin about its own axis, present the frosted top tilted toward us, roll gently,\n      // and every `flipEvery` seconds do a full somersault.\n      const spin = t * 0.8;\n      const fp = flipEvery > 0 ? (t % flipEvery) / flipEvery : 0;\n      const flip = fp > 0.8 ? smooth((fp - 0.8) / 0.2) * Math.PI * 2 : 0;\n      const tilt = 1.0 + Math.sin(t * 0.6) * 0.35 + flip, roll = Math.sin(t * 0.37) * 0.45;\n      const cs = Math.cos(spin), ss = Math.sin(spin), ca = Math.cos(tilt), sa = Math.sin(tilt), cr = Math.cos(roll), sr = Math.sin(roll);\n      // M = Rz(roll) · Rx(tilt) · Ry(spin)\n      const b10 = sa * ss, b11 = ca, b12 = -sa * cs;   // row 1 of Rx·Ry (row 0 is [cs, 0, ss])\n      const b20 = -ca * ss, b21 = sa, b22 = ca * cs;   // row 2\n      const M00 = cr * cs - sr * b10, M01 = -sr * b11, M02 = cr * ss - sr * b12;\n      const M10 = sr * cs + cr * b10, M11 = cr * b11, M12 = sr * ss + cr * b12;\n      const M20 = b20, M21 = b21, M22 = b22;\n\n      px.set(back);\n      zbuf.fill(0);\n      const ox = W / 2, oy = H / 2;\n      for (let n = 0, q = 0; n < N; n++, q += 3) {\n        const nx0 = Nn[q], ny0 = Nn[q + 1], nz0 = Nn[q + 2];\n        const nx = M00 * nx0 + M01 * ny0 + M02 * nz0;\n        const ny = M10 * nx0 + M11 * ny0 + M12 * nz0;\n        const nz = M20 * nx0 + M21 * ny0 + M22 * nz0;\n        if (nz > 0.42) continue; // back-facing even at the widest view angle — skip before transforming the point\n        const x0 = P[q], y0 = P[q + 1], z0 = P[q + 2];\n        const x = M00 * x0 + M01 * y0 + M02 * z0;\n        const y = M10 * x0 + M11 * y0 + M12 * z0;\n        const d = M20 * x0 + M21 * y0 + M22 * z0 + dist;\n        if (nx * x + ny * y + nz * d >= 0) continue;\n        const iz = 1 / d, k = scale * iz;\n        const sx = (ox + x * k) | 0, sy = (oy + (y * k) / aspect) | 0;\n        if (sx < 0 || sy < 0 || sx >= W || sy >= H) continue;\n        const p = sy * W + sx;\n        if (iz <= zbuf[p]) continue;\n        zbuf[p] = iz;\n        const diff = Math.max(0, nx * lx + ny * ly + nz * lz);\n        const sd = Math.max(0, nx * hx + ny * hy + nz * hz);\n        if (solid) {\n          const s2 = sd * sd, s4 = s2 * s2, s8 = s4 * s4, s32 = s8 * s8 * s8 * s8;\n          px[p] = ramp(Math.min(1, 0.12 + diff * 0.72 + s32 * 0.5));\n          continue;\n        }\n        const m = mat[n];\n        let rr, gg, bb, gloss;\n        if (m === 0) {\n          const tn = tone[n];\n          rr = 196 + 40 * tn; gg = 120 + 52 * tn; bb = 58 + 40 * tn;\n          const s2 = sd * sd, s4 = s2 * s2;\n          gloss = s4 * s4 * s4 * 0.12;\n        } else {\n          const c = m === 1 ? frostC : sprink[m - 2];\n          rr = (c >> 16) & 255; gg = (c >> 8) & 255; bb = c & 255;\n          const s2 = sd * sd, s4 = s2 * s2, s8 = s4 * s4, s16 = s8 * s8;\n          gloss = s16 * s16 * s16 * 0.85;\n        }\n        const lit = 0.24 + diff * 0.86, g255 = gloss * 255;\n        rr = rr * lit + g255; gg = gg * lit + g255; bb = bb * lit + g255;\n        px[p] = ((rr > 255 ? 255 : rr) << 16) | ((gg > 255 ? 255 : gg) << 8) | (bb > 255 ? 255 : bb | 0);\n      }\n      // Fill single-pixel splat gaps: an empty pixel flanked by surface on both sides.\n      for (let y = 1; y < H - 1; y++)\n        for (let x = 1, p = y * W + 1; x < W - 1; x++, p++) {\n          if (zbuf[p] !== 0) continue;\n          if (zbuf[p - 1] && zbuf[p + 1]) { px[p] = zbuf[p - 1] > zbuf[p + 1] ? px[p - 1] : px[p + 1]; zbuf[p] = zbuf[p - 1]; }\n          else if (zbuf[p - W] && zbuf[p + W]) { px[p] = px[p - W]; zbuf[p] = zbuf[p - W]; }\n        }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n\nfunction mixc(a, b, t) {\n  const r = ((a >> 16) & 255) + ((((b >> 16) & 255) - ((a >> 16) & 255)) * t);\n  const g = ((a >> 8) & 255) + ((((b >> 8) & 255) - ((a >> 8) & 255)) * t);\n  const bl = (a & 255) + (((b & 255) - (a & 255)) * t);\n  return (r << 16) | (g << 8) | (bl | 0);\n}\n",
  "prism-term/src/facets/objects/gears.js": "// A gear train: three spur gears of 16, 10 and 8 teeth meshing at their pitch\n// circles, each turning at the ratio it should (and in alternating directions).\n// Rendered per pixel from the analytic tooth profile, so every edge is\n// anti-aliased: spun-metal highlights that stay put while the gear turns,\n// bevelled rims, recessed webs with lightening holes, raised hubs, steel axles\n// and a soft drop shadow seen through the holes.\nimport { blend } from './_raster.js';\nimport { NONE } from '../../color.js';\n\nconst N = [16, 10, 8];\nconst HOLES = [6, 5, 0];\nconst METALS = [0xc9a43a, 0xa9b8c8, 0xc8804c];\nconst LIGHT = Math.atan2(-1, -1); // from the upper left (screen y grows down)\n\nexport default {\n  title: 'Gear Train',\n  gallery: 'objects',\n  category: 'Mechanical',\n  kind: 'scene',\n  tags: ['gears', 'mechanical', 'machine', 'ratio', 'clockwork'],\n  order: 10,\n  description: 'Three spur gears (16, 10 and 8 teeth) meshing at true ratios, anti-aliased, with spun-metal shading and drop shadows.',\n  size: { w: 64, h: 22 },\n  minSize: { w: 20, h: 7 },\n  loop: 6,\n  canvas: 'field',\n  palette: 'gold',\n  params: { speed: { default: 1, min: 0.1, max: 5, label: 'Speed' } },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const speed = ctx.param('speed', 1);\n    const W = cv.width, H = cv.height, asp = cv.aspect, th = ctx.theme;\n    // Layout in \"unit\" space (gear 0 pitch radius = 1), then fit and center.\n    const r = N.map((n) => n / N[0]);\n    const add = 1 / (N[0] / 2); // addendum = one module (2r/N)\n    const a01 = -0.42, a12 = 1.2;\n    const u0 = [0, 0];\n    const u1 = [u0[0] + Math.cos(a01) * (r[0] + r[1]), u0[1] + Math.sin(a01) * (r[0] + r[1])];\n    const u2 = [u1[0] + Math.cos(a12) * (r[1] + r[2]), u1[1] + Math.sin(a12) * (r[1] + r[2])];\n    const U = [u0, u1, u2];\n    let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;\n    U.forEach(([x, y], g) => { const R = r[g] + add; x0 = Math.min(x0, x - R); x1 = Math.max(x1, x + R); y0 = Math.min(y0, y - R); y1 = Math.max(y1, y + R); });\n    const unit = Math.min((W - 3) / (x1 - x0), (H * asp - 3) / (y1 - y0));\n    const C = U.map(([x, y]) => [W / 2 + (x - (x0 + x1) / 2) * unit, H / 2 + ((y - (y0 + y1) / 2) * unit) / asp]);\n    const R = r.map((v) => v * unit);\n    const tip = add * unit, root = add * 1.25 * unit;\n    const sh = [Math.max(1, unit * 0.05), Math.max(1, (unit * 0.05) / asp)]; // shadow offset (px)\n\n    // Tooth profile radius at angle a (gear-local): root land, flank, tip land.\n    const prof = (g, a) => {\n      const u = (a * N[g]) / (Math.PI * 2);\n      const f = Math.abs(u - Math.round(u));\n      if (f < 0.17) return R[g] + tip;\n      if (f < 0.31) return R[g] + tip - ((f - 0.17) / 0.14) * (tip + root);\n      return R[g] - root;\n    };\n    // Coverage of gear g at a point (dx, dy in horizontal-pixel units from its center).\n    const cover = (g, dx, dy, ang) => {\n      const d = Math.hypot(dx, dy);\n      if (d > R[g] + tip + 1) return 0;\n      const a = Math.atan2(dy, dx) - ang[g];\n      let c = Math.min(1, Math.max(0, prof(g, a) - d + 0.5));\n      if (c > 0 && HOLES[g]) {\n        const step = (Math.PI * 2) / HOLES[g];\n        const s = a - Math.round(a / step) * step;\n        const hx = d * Math.cos(s) - R[g] * 0.58, hy = d * Math.sin(s);\n        c *= Math.min(1, Math.max(0, Math.hypot(hx, hy) - R[g] * 0.15 + 0.5));\n      }\n      return c;\n    };\n    const shade = (g, dx, dy, ang) => {\n      const d = Math.hypot(dx, dy) + 1e-6, Rg = R[g], a = Math.atan2(dy, dx);\n      const facing = Math.cos(a - LIGHT); // +1 on the edge nearest the light\n      // Spun finish: a bright bow-tie streak aimed at the light, fixed on screen.\n      let k = 0.5 + 0.34 * Math.pow(Math.abs(facing), 10) + 0.08 * facing;\n      const local = a - ang[g];\n      const edge = prof(g, local) - d;\n      if (edge < 1.4) k += 0.32 * facing; // bevelled outline (teeth and rim)\n      const web0 = Rg * 0.32, web1 = Rg - root - Rg * 0.12;\n      if (HOLES[g] && d > web0 && d < web1) {\n        k -= 0.14; // recessed web\n        if (d - web0 < 1.1) k -= 0.2 * facing; // inner wall of the recess\n        if (web1 - d < 1.1) k += 0.2 * facing; // outer wall\n        const step = (Math.PI * 2) / HOLES[g];\n        const s = local - Math.round(local / step) * step;\n        const hx = d * Math.cos(s) - Rg * 0.58, hy = d * Math.sin(s);\n        if (Math.hypot(hx, hy) - Rg * 0.15 < 1.2) k += 0.22 * Math.cos(Math.atan2(hy, hx) + a - s + Math.PI - LIGHT); // hole wall\n      }\n      if (d < Rg * 0.26) {\n        k += 0.1; // raised hub\n        if (Rg * 0.26 - d < 1.1) k += 0.3 * facing;\n      }\n      if (d < Rg * 0.1) return blend(0x2a2e33, 0xe8eef4, Math.max(0, 0.35 + 0.5 * facing * (d / (Rg * 0.1)))); // steel axle\n      if (d < Rg * 0.14) return blend(0x000000, METALS[g], 0.22); // bore\n      return k <= 1 ? blend(0x000000, METALS[g], Math.max(0.08, k)) : blend(METALS[g], 0xffffff, Math.min(0.85, k - 1));\n    };\n\n    return (t) => {\n      cv.clear();\n      // Gear 0 turns half a revolution per loop; the others follow by rolling\n      // contact: θb = -(Na/Nb)(θa - α) + α + π - π/Nb (a tooth of a along α sits in a gap of b).\n      const follow = (ta, na, nb, alpha) => -(na / nb) * (ta - alpha) + alpha + Math.PI - Math.PI / nb;\n      const ang = [(t * speed * Math.PI) / 6];\n      ang[1] = follow(ang[0], N[0], N[1], a01);\n      ang[2] = follow(ang[1], N[1], N[2], a12);\n      for (let py = 0; py < H; py++) for (let px = 0; px < W; px++) {\n        let col = NONE, best = 0;\n        let shadowC = 0;\n        for (let g = 0; g < 3; g++) {\n          const dx = px + 0.5 - C[g][0], dy = (py + 0.5 - C[g][1]) * asp;\n          const c = cover(g, dx, dy, ang);\n          if (c > best) { best = c; col = shade(g, dx, dy, ang); }\n          shadowC = Math.max(shadowC, cover(g, dx - sh[0], dy - sh[1] * asp, ang));\n        }\n        if (best <= 0 && shadowC <= 0) continue;\n        const under = shadowC > 0 ? blend(th.bg, 0x000000, 0.45 * shadowC) : th.bg;\n        cv.set(px, py, best >= 1 ? col : best > 0 ? blend(under, col, best) : under);\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/objects/geodesic.js": "// A geodesic sphere: an icosahedron subdivided (class I, frequency 2 by\n// default) and pushed out to the sphere, drawn as braille wire with hidden-line\n// treatment — far edges dotted and dim, near edges solid and depth-cued — the\n// twelve five-way vertices picked out, and a scan band sweeping over the dome.\nimport { meshes } from '../../math3d.js';\nimport { perspective, rotX, rotY, mul, apply, norm, blend } from './_raster.js';\n\nfunction geodesic(freq) {\n  const ico = meshes.icosahedron();\n  const verts = [], index = new Map(), edges = new Set(), pent = new Set();\n  const vid = (p) => {\n    const n = norm(p), key = n.map((x) => Math.round(x * 1e4)).join(',');\n    if (!index.has(key)) { index.set(key, verts.length); verts.push(n); }\n    return index.get(key);\n  };\n  const edge = (a, b) => edges.add(a < b ? `${a},${b}` : `${b},${a}`);\n  for (const [ia, ib, ic] of ico.faces) {\n    const A = ico.vertices[ia], B = ico.vertices[ib], C = ico.vertices[ic];\n    const P = (i, j) => { const k = freq - i - j; return vid([(A[0] * k + B[0] * i + C[0] * j) / freq, (A[1] * k + B[1] * i + C[1] * j) / freq, (A[2] * k + B[2] * i + C[2] * j) / freq]); };\n    for (let i = 0; i < freq; i++) for (let j = 0; i + j < freq; j++) {\n      const a = P(i, j), b = P(i + 1, j), c = P(i, j + 1);\n      edge(a, b); edge(b, c); edge(c, a);\n      if (i + j + 2 <= freq) { const d = P(i + 1, j + 1); edge(b, d); edge(d, c); }\n    }\n    pent.add(P(0, 0)); pent.add(P(freq, 0)); pent.add(P(0, freq));\n  }\n  return { verts, edges: [...edges].map((e) => e.split(',').map(Number)), pent: [...pent] };\n}\n\nexport default {\n  title: 'Geodesic Sphere',\n  gallery: 'objects',\n  category: 'Wireframes',\n  kind: 'scene',\n  tags: ['geodesic', 'icosahedron', 'dome', 'wireframe', 'braille', 'buckminster'],\n  order: 30,\n  description: 'A subdivided icosahedron in braille wire: far edges dotted, near edges depth-cued, pentagon vertices lit, a scan sweeping by.',\n  size: { w: 44, h: 22 },\n  minSize: { w: 14, h: 7 },\n  loop: 12,\n  canvas: 'line',\n  palette: 'theme',\n  params: { frequency: { default: 2, min: 1, max: 6, label: 'Subdivision frequency' } },\n  setup(ctx) {\n    const cv = ctx.canvas('line');\n    const th = ctx.theme;\n    const g = geodesic(Math.round(ctx.param('frequency', 2)));\n    const dist = 3.4;\n    const project = perspective(cv, { distance: dist, fill: 0.44 });\n    const far = blend(th.bg, th.muted, 0.32);\n    const accent = th.accent;\n\n    return (t) => {\n      cv.clear();\n      const ph = (t / 12) * Math.PI * 2;\n      const R = mul(rotX(0.35 + Math.sin(ph) * 0.15), rotY(ph));\n      const V = g.verts.map((v) => apply(R, v));\n      const P = V.map(project);\n      const scan = Math.sin(ph * 2) * 1.1; // scan plane height, sweeping top ↔ bottom\n      // Far edges first (dotted), then near edges (solid, depth-cued, scan-lit).\n      const front = [], back = [];\n      for (const e of g.edges) {\n        const a = V[e[0]], b = V[e[1]];\n        const m = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2];\n        (m[0] * m[0] + m[1] * m[1] + m[2] * (m[2] + dist) < 0 ? front : back).push(e);\n      }\n      const plot = (e, dotted) => {\n        const a = P[e[0]], b = P[e[1]];\n        const n = Math.max(1, Math.ceil(Math.max(Math.abs(b[0] - a[0]), Math.abs(b[1] - a[1]))));\n        const ya = V[e[0]][1], yb = V[e[1]][1], za = V[e[0]][2], zb = V[e[1]][2];\n        for (let s = 0; s <= n; s++) {\n          if (dotted && s % 3) continue;\n          const f = s / n, x = a[0] + (b[0] - a[0]) * f, y = a[1] + (b[1] - a[1]) * f;\n          if (dotted) { cv.set(x, y, far); continue; }\n          const wy = ya + (yb - ya) * f, wz = za + (zb - za) * f;\n          const lit = Math.exp(-(((wy - scan) / 0.12) ** 2));\n          const near = (1 - wz) / 2;\n          const c = blend(th.bg, blend(th.info, th.ink, 0.4 * near), 0.35 + 0.65 * near * near);\n          cv.set(x, y, lit > 0.02 ? blend(c, 0xffffff, lit * 0.8) : c);\n        }\n      };\n      for (const e of back) plot(e, true);\n      for (const e of front) plot(e, false);\n      for (const i of g.pent) {\n        const v = V[i];\n        if (v[0] * v[0] + v[1] * v[1] + v[2] * (v[2] + dist) >= 0) continue;\n        const [x, y] = P[i];\n        cv.circle(x, y, 1.6, accent, true);\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/objects/globe.js": "// Holographic data globe: a dotted Earth (coarse hand-traced continents on a\n// lat/lon dot lattice) spinning in braille, with a graticule, atmosphere rim,\n// pulsing region markers and great-circle arcs carrying packets between them.\nconst LAND = [\n  // North America (outer), Hudson Bay (hole — even-odd)\n  [[-166, 68], [-156, 71], [-140, 69.5], [-128, 70], [-110, 68], [-95, 68], [-82, 67], [-78, 72], [-64, 67], [-64, 60], [-56, 52], [-60, 46], [-70, 43], [-74, 40], [-76, 35], [-81, 31], [-80, 26], [-82, 29], [-85, 30], [-90, 29.5], [-97, 27], [-97, 22], [-95, 18.5], [-90, 21], [-87, 21], [-88, 16], [-83, 15], [-83, 10], [-79, 9], [-77, 8], [-80, 7], [-85, 10], [-92, 14], [-96, 16], [-105, 20], [-106, 23], [-112, 29], [-110, 23], [-117, 32], [-121, 35], [-124, 40], [-124, 46], [-123, 49], [-127, 51], [-133, 56], [-140, 60], [-150, 61], [-155, 58], [-165, 55], [-158, 58], [-166, 60], [-166, 64]],\n  [[-95, 60], [-90, 57], [-82, 55], [-79, 52], [-80, 57], [-77, 60], [-78, 63], [-86, 64], [-94, 62]],\n  // Greenland, Iceland\n  [[-73, 78], [-60, 82], [-30, 83], [-20, 80], [-20, 72], [-25, 69], [-40, 65], [-44, 60], [-50, 62], [-53, 67], [-58, 75]],\n  [[-24, 65.5], [-22, 66.5], [-15, 66.5], [-13.5, 65], [-18, 63.4], [-22, 63.8]],\n  // South America\n  [[-77, 8], [-72, 12], [-63, 10.5], [-60, 8], [-52, 5], [-50, 0], [-44, -2], [-35, -5], [-35, -9], [-39, -15], [-40, -22], [-48, -26], [-53, -34], [-58, -38], [-62, -41], [-65, -45], [-68, -50], [-69, -55], [-73, -53], [-75, -47], [-73, -40], [-71, -30], [-70, -18], [-76, -14], [-81, -6], [-80, -2], [-78, 2]],\n  // Eurasia (outer), Black Sea + Caspian (holes)\n  [[-9, 37], [-9, 43], [-2, 43.5], [-1, 46], [-4.5, 48], [2, 51], [5, 53], [8, 54], [10, 57], [8, 58], [5, 62], [10, 64], [15, 68], [20, 70], [28, 71], [33, 69], [40, 67], [45, 68], [55, 69], [60, 70], [68, 73], [80, 73], [90, 76], [105, 78], [112, 74], [130, 72], [140, 72], [150, 71], [160, 70], [170, 70], [179, 66], [170, 60], [162, 58], [156, 51], [150, 59], [142, 59], [136, 54], [140, 48], [135, 43], [130, 42], [129, 35], [126, 34.5], [125, 39], [122, 40], [121, 37], [119, 35], [121, 31], [122, 29], [119, 25], [114, 22], [110, 21], [106, 20], [108, 16], [109, 12], [105, 9], [101, 13], [99, 10], [100, 6], [104, 1.5], [101, 3], [98, 8], [98, 15], [94, 16], [92, 21], [88, 22], [85, 20], [80, 16], [80, 10], [77, 8], [73, 16], [72, 21], [69, 22], [66, 25], [62, 25], [57, 26], [56, 24], [59, 22], [55, 17], [52, 16], [45, 13], [43, 13], [39, 21], [35, 28], [34, 31], [35, 32], [36, 35], [33, 36.5], [28, 37], [26, 40], [22, 37], [19, 42], [13.5, 45.5], [12, 44], [16, 38], [12, 42], [8, 44], [3, 43], [0, 39], [-2, 37], [-6, 36]],\n  [[28, 41], [28, 46], [34, 46], [41, 42], [36, 41.5]],\n  [[47, 45], [51, 47], [54, 44], [54, 37], [49, 37], [49, 41]],\n  // Britain, Ireland, Japan\n  [[-5, 50], [1.5, 51], [1.7, 53], [-0.5, 54], [-2, 56], [-2, 58], [-5, 58.5], [-6, 56], [-4.8, 54.8], [-3, 53.5], [-4.5, 52]],\n  [[-10, 51.5], [-6, 52], [-6, 55], [-8, 55], [-10, 54]],\n  [[130, 31], [132, 34], [135, 34], [140, 35], [142, 39], [141, 42], [145, 44], [141, 45.5], [140, 42], [139, 38], [136, 36], [132, 35]],\n  // Africa, Madagascar\n  [[-17, 21], [-16, 28], [-10, 30], [-6, 36], [0, 36], [10, 37], [11, 33], [20, 31], [25, 32], [32, 31], [34, 28], [39, 18], [43, 12], [51, 12], [45, 2], [40, -3], [40, -10], [40, -15], [35, -20], [35, -25], [32, -29], [27, -34], [20, -35], [18, -32], [15, -27], [12, -18], [13, -12], [12, -5], [9, -1], [9, 4], [5, 6], [-2, 5], [-8, 4.5], [-13, 8], [-17, 14]],\n  [[44, -25], [47, -25], [50, -15], [49, -12], [44, -17]],\n  // Maritime SE Asia, Australia, New Zealand\n  [[95, 5.5], [98, 4], [104, -2], [106, -6], [102, -4], [97, 1]],\n  [[109, 2], [111, -3], [116, -4], [119, 1], [117, 7], [115, 5]],\n  [[105, -6], [114, -7], [114, -8.5], [106, -7.5]],\n  [[131, -1], [141, -2.5], [148, -6], [150, -10], [143, -9], [138, -8], [133, -4]],\n  [[114, -22], [114, -34], [118, -35], [124, -33], [130, -32], [135, -35], [138, -35], [140, -38], [146, -39], [150, -37], [153, -31], [153, -25], [146, -19], [145, -15], [142, -11], [141, -17], [136, -12], [132, -11], [129, -15], [125, -14], [122, -18]],\n  [[172, -34], [178, -38], [175, -41.5], [172, -41]],\n  [[173, -41], [174, -42], [171, -44], [168, -46.5], [166.5, -46], [170, -43]],\n];\n\n// Cloud-region-ish hubs (lon, lat) and the links that carry traffic.\nconst HUBS = [[-77.5, 39], [-122, 45.5], [-46.6, -23.5], [-0.1, 51.5], [8.7, 50.1], [72.9, 19.1], [103.8, 1.35], [139.7, 35.7], [151.2, -33.9], [28, -26.2]];\nconst LINKS = [[0, 3], [1, 7], [3, 4], [4, 5], [5, 6], [6, 7], [6, 8], [0, 2], [3, 9], [0, 1], [7, 8], [2, 9]];\n\nfunction lerpHub(a, b, t) {\n  const r = ((a >> 16) & 255) + ((((b >> 16) & 255) - ((a >> 16) & 255)) * t);\n  const g = ((a >> 8) & 255) + ((((b >> 8) & 255) - ((a >> 8) & 255)) * t);\n  const bl = (a & 255) + (((b & 255) - (a & 255)) * t);\n  return (r << 16) | (g << 8) | (bl | 0);\n}\n\n// Per-polygon bounding boxes: a point outside a box contributes an even number\n// of ray crossings (or none), so the box test can skip it without changing parity.\nconst BOXES = LAND.map((poly) => {\n  let a = Infinity, b = -Infinity, c = Infinity, d = -Infinity;\n  for (const [x, y] of poly) { a = Math.min(a, x); b = Math.max(b, x); c = Math.min(c, y); d = Math.max(d, y); }\n  return [a, b, c, d];\n});\n\nfunction inLand(lon, lat) {\n  if (lat < -68 + 3 * Math.sin((lon * Math.PI) / 60)) return true; // Antarctica\n  let inside = false;\n  for (let k = 0; k < LAND.length; k++) {\n    const box = BOXES[k];\n    if (lat < box[2] || lat > box[3] || lon > box[1] || lon < box[0]) continue;\n    const poly = LAND[k];\n    for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {\n      const [xi, yi] = poly[i], [xj, yj] = poly[j];\n      if ((yi > lat) !== (yj > lat) && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) inside = !inside;\n    }\n  }\n  return inside;\n}\n\n// Land lattices are pure functions of the dot spacing; cache them across resizes.\nconst latticeCache = new Map();\nfunction landLattice(step) {\n  const key = step.toFixed(2);\n  if (latticeCache.has(key)) return latticeCache.get(key);\n  const land = [];\n  for (let lat = -88; lat <= 88; lat += step) {\n    const ring = Math.max(1, Math.round((360 / step) * Math.cos((lat * Math.PI) / 180)));\n    for (let k = 0; k < ring; k++) {\n      const lon = -180 + (k / ring) * 360;\n      if (inLand(lon, lat)) land.push(toVec(lon, lat));\n    }\n  }\n  latticeCache.set(key, land);\n  return land;\n}\n\nconst toVec = (lon, lat) => {\n  const a = (lon * Math.PI) / 180, b = (lat * Math.PI) / 180;\n  return [Math.cos(b) * Math.sin(a), -Math.sin(b), -Math.cos(b) * Math.cos(a)];\n};\n\nexport default {\n  title: 'Data Globe',\n  gallery: 'objects',\n  category: 'Globes & Planets',\n  kind: 'scene',\n  tags: ['globe', 'earth', 'world', 'arcs', '3d'],\n  order: 30,\n  description: 'Holographic dotted Earth in braille — graticule, atmosphere, pulsing hubs and packet-carrying great-circle arcs.',\n  canvas: 'line',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const th = ctx.theme;\n    const { width: W, height: H, aspect } = cv;\n    const R = Math.min(W, H * aspect) * 0.42;\n    const cx = W / 2, cy = H / 2;\n    // Dot lattice spacing adapted to resolution: roughly one dot per 2 pixels at the equator.\n    const step = Math.max(1.2, Math.min(4, 360 / (Math.PI * 2 * R) * 2.2));\n    const land = landLattice(step);\n    const grat = [];\n    for (let lat = -60; lat <= 60; lat += 30) for (let lon = -180; lon < 180; lon += 1.5) grat.push(toVec(lon, lat));\n    for (let lon = -180; lon < 180; lon += 30) for (let lat = -90; lat <= 90; lat += 1.5) grat.push(toVec(lon, lat));\n    const hubs = HUBS.map(([lon, lat]) => toVec(lon, lat));\n    const arcs = LINKS.map(([a, b], i) => {\n      const p = hubs[a], q = hubs[b];\n      const dot = Math.max(-1, Math.min(1, p[0] * q[0] + p[1] * q[1] + p[2] * q[2]));\n      const om = Math.acos(dot);\n      const n = Math.max(24, Math.ceil(om * R * 1.6));\n      const pts = [];\n      for (let s = 0; s <= n; s++) {\n        const u = s / n;\n        const ka = Math.sin((1 - u) * om) / Math.sin(om), kb = Math.sin(u * om) / Math.sin(om);\n        const lift = 1 + Math.sin(u * Math.PI) * (0.08 + om * 0.06);\n        pts.push([(p[0] * ka + q[0] * kb) * lift, (p[1] * ka + q[1] * kb) * lift, (p[2] * ka + q[2] * kb) * lift]);\n      }\n      return { pts, speed: 0.22 + (i % 4) * 0.05, phase: i * 0.37 };\n    });\n    const hubCols = [th.warn, th.pos, th.neg, th.info, th.crit];\n    const halo = Array.from({ length: Math.ceil(R * 2.2) }, () => ({ a: ctx.rng() * Math.PI * 2, r: ctx.rng(), p: ctx.rng() * 6.28 }));\n    const scaleC = (c, k) => {\n      const r = ((c >> 16) & 255) * k, g = ((c >> 8) & 255) * k, b = (c & 255) * k;\n      return ((r > 255 ? 255 : r) << 16) | ((g > 255 ? 255 : g) << 8) | (b > 255 ? 255 : b | 0);\n    };\n\n    return (t) => {\n      cv.clear();\n      const spin = t * 0.28, tilt = 0.38;\n      const cS = Math.cos(spin), sS = Math.sin(spin), cT = Math.cos(tilt), sT = Math.sin(tilt);\n      // Spin about the polar axis, then tilt toward the viewer. Returns [sx, sy, z] (z<0 = facing us).\n      const proj = (v) => {\n        const x = v[0] * cS - v[2] * sS, z = v[0] * sS + v[2] * cS;\n        const y = v[1] * cT - z * sT, z2 = v[1] * sT + z * cT;\n        return [cx + x * R, cy + (y * R) / aspect, z2, x, y];\n      };\n      const occluded = (p) => p[2] > 0 && p[3] * p[3] + p[4] * p[4] < 1;\n\n      for (const g of grat) {\n        const p = proj(g);\n        if (p[2] > 0) continue;\n        cv.set(p[0], p[1], scaleC(th.dim, 0.35 + -p[2] * 0.35));\n      }\n      // Atmosphere: a crisp limb plus a sparse, shimmering dotted halo.\n      cv.circle(cx, cy, R + 1, scaleC(th.info, 0.7));\n      for (let k = 0; k < halo.length; k++) {\n        const h = halo[k], a = h.a + t * 0.05, rr = R + 2.5 + h.r * R * 0.08;\n        const tw = 0.25 + 0.35 * (0.5 + 0.5 * Math.sin(t * 2 + h.p));\n        cv.set(cx + Math.cos(a) * rr, cy + (Math.sin(a) * rr) / aspect, scaleC(th.info, tw * (1 - h.r)));\n      }\n      for (const v of land) {\n        const p = proj(v);\n        if (p[2] > 0) {\n          if (p[2] > 0.35 && p[3] * p[3] + p[4] * p[4] < 0.8) cv.set(p[0], p[1], scaleC(th.accent, 0.13)); // far side, faintly\n          continue;\n        }\n        const face = -p[2];\n        const lightK = 0.35 + 0.65 * Math.max(0, -p[3] * 0.45 - p[4] * 0.35 + face * 0.75);\n        cv.set(p[0], p[1], scaleC(th.accent, 0.45 + lightK * 0.75));\n      }\n      for (const a of arcs) {\n        const head = ((t * a.speed + a.phase) % 1.4) / 1.2;\n        const n = a.pts.length - 1;\n        for (let s = 0; s <= n; s++) {\n          const p = proj(a.pts[s]);\n          if (occluded(p)) continue;\n          const u = s / n;\n          const trail = u <= head ? Math.max(0, 1 - (head - u) * 5) : 0;\n          const k = 0.4 + trail * 0.9;\n          cv.set(p[0], p[1], trail > 0.92 ? 0xffffff : scaleC(lerpHub(th.info, th.crit, u), k));\n        }\n      }\n      hubs.forEach((h, i) => {\n        const p = proj(h);\n        if (p[2] > 0.05) return;\n        const c = hubCols[i % hubCols.length];\n        const pulse = ((t * 0.8 + i * 0.29) % 1);\n        cv.circle(p[0], p[1], 1.5 + pulse * R * 0.09, scaleC(c, 1 - pulse));\n        cv.circle(p[0], p[1], 1.2, c, true);\n      });\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/objects/hourglass.js": "// An hourglass in a turned-wood stand. The sand is conserved: each frame the\n// top level and the bottom heap are solved (by bisection over the glass's real\n// profile) so the two always hold exactly the sand there is. The top drains\n// with a funnel dip, a flickering grain stream falls through the neck, the heap\n// below grows as a cone, and when the top runs dry the glass turns over — eased,\n// shrinking to fit as it turns — while the sand slides back against the neck.\nimport { blend } from './_raster.js';\nimport { easing, smoothstep } from '../../easing.js';\nimport { NONE } from '../../color.js';\n\nconst FLIP = 1.4;\nconst NS = 72; // integration rows per bulb\n\nexport default {\n  title: 'Hourglass',\n  gallery: 'objects',\n  category: 'Mechanical',\n  kind: 'scene',\n  tags: ['hourglass', 'sand', 'timer', 'waiting', 'time'],\n  order: 30,\n  description: 'Sand drains through the neck into a growing heap, volume conserved; when the top runs dry the glass flips over.',\n  size: { w: 30, h: 22 },\n  minSize: { w: 12, h: 9 },\n  loop: 9.4,\n  canvas: 'field',\n  palette: 'gold',\n  params: { duration: { default: 8, min: 2, max: 60, label: 'Seconds per run' } },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const run = ctx.param('duration', 8);\n    const W = cv.width, H = cv.height, asp = cv.aspect, th = ctx.theme;\n    const CAP = 1.12, FRAME = 1.18;\n    const hh = Math.max(2, Math.min((H * asp * 0.5 - 0.5) / CAP, (W * 0.5 - 1) / (0.56 * FRAME)));\n    const hw = hh * 0.56;\n    const cx = W / 2, cy = H / 2;\n    const glass = blend(th.muted, 0x9cc8f0, 0.5);\n    const sand = (k) => blend(0x6b4a12, 0xf2c75c, k);\n    const hash = (x, y) => ((((x * 73856093) ^ (y * 19349663)) >>> 0) % 1009) / 1009;\n    // Glass half-width (px) at normalized height y ∈ [-1, 1]; 0 is the neck.\n    const prof = (y) => {\n      const a = Math.min(1, Math.abs(y));\n      return hw * (0.07 + 0.93 * Math.pow(Math.sin(a * Math.PI * 0.5), 1.3)) * (a > 0.9 ? 1 - (a - 0.9) * 3 : 1);\n    };\n    const rows = Array.from({ length: NS }, (_, i) => (i + 0.5) / NS);\n    const bulbArea = rows.reduce((s, y) => s + 2 * prof(y), 0) / NS;\n    const SAND = bulbArea * 0.8;\n    const solve = (f, target, lo, hi) => { for (let i = 0; i < 22; i++) { const m = (lo + hi) / 2; if (f(m) < target) lo = m; else hi = m; } return (lo + hi) / 2; };\n    // Top bulb, resting on the neck: sand where y > -S + D·g(x), g a centered bump (the funnel).\n    const DIPW = 0.32 * hw;\n    const topWidth = (y, S, D) => {\n      const p = prof(y), q = D > 0 ? (y + S) / D : y + S > 0 ? 2 : -1;\n      if (q <= 0) return 0;\n      if (q >= 1) return 2 * p;\n      return 2 * Math.max(0, p - DIPW * Math.sqrt(-Math.log(q)));\n    };\n    const topArea = (S, D) => rows.reduce((s, y) => s + topWidth(-y, S, D), 0) / NS;\n    // Bottom heap: sand where y > L - K(1 - |x|/hw) — a cone whose base meets the walls.\n    const heapWidth = (y, L, K) => {\n      const p = prof(y);\n      if (y >= L) return 2 * p;\n      return 2 * Math.min(p, Math.max(0, hw * (1 - (L - y) / K)));\n    };\n    const heapArea = (L, K) => rows.reduce((s, y) => s + heapWidth(y, L, K), 0) / NS;\n    const S_FULL = solve((S) => topArea(S, 0), SAND, 0, 1);\n    const coneK = (fallen) => 0.34 * (1 - 0.55 * fallen);\n    const L_FULL = solve((L) => -heapArea(L, coneK(1)), -SAND, 0, 1 + coneK(1));\n\n    return (t) => {\n      cv.clear();\n      const cycle = run + FLIP;\n      const k = ((t % cycle) + cycle) % cycle;\n      const flipping = k > run;\n      const fill = flipping ? 0 : 1 - k / run; // share of the sand still on top\n      const fp = flipping ? (k - run) / FLIP : 0;\n      const rot = easing.inOutCubic(fp) * Math.PI;\n      // While upright: solve the top level and the heap so their areas add up.\n      const D = 0.24 * Math.sqrt(1 - fill) * Math.min(1, fill * 5);\n      const S = fill > 0 ? solve((s) => topArea(s, D), fill * SAND, 0, 1 + D) : 0;\n      const K = coneK(1 - fill);\n      const L = fill < 1 ? solve((l) => -heapArea(l, K), -(1 - fill) * SAND, 0, 1 + K) : 1 + K;\n      // During the flip the heap slides back down onto the neck (local y → 0).\n      const slide = flipping ? smoothstep(0.45, 0.95, fp) : 0;\n      const cr = Math.cos(rot), sr = Math.sin(rot);\n      const halfW = hw * FRAME + 0.6, halfH = hh * CAP;\n      const sc = Math.min(1, (W / 2 - 0.5) / (Math.abs(cr) * halfW + Math.abs(sr) * halfH), (H * asp / 2 - 0.5) / (Math.abs(sr) * halfW + Math.abs(cr) * halfH));\n      const flick = Math.floor(t * 24);\n      const streamW = Math.max(0.55, hw * 0.04);\n      for (let py = 0; py < H; py++) for (let px = 0; px < W; px++) {\n        const dx = (px + 0.5 - cx) / sc, dy = ((py + 0.5 - cy) * asp) / sc;\n        const lx = dx * cr + dy * sr, ly = -dx * sr + dy * cr;\n        const y = ly / hh, ay = Math.abs(y), ax = Math.abs(lx);\n        if (ay > CAP || ax > halfW) continue;\n        // Turned-wood end caps and the two pillars.\n        if (ay > 1) {\n          if (ax > hw * FRAME) continue;\n          const grain = 0.08 * Math.sin(lx * 1.7 + hash(px >> 2, py) * 2);\n          cv.set(px, py, blend(0x2e1c0e, 0x9a6a3a, 0.35 + 0.45 * (1 - (ay - 1) / (CAP - 1)) + grain));\n          continue;\n        }\n        const pillarR = Math.max(0.6, hw * 0.05) * (1 + 0.6 * Math.pow(Math.abs(Math.sin(y * Math.PI * 1.5)), 4));\n        const pd = Math.abs(ax - hw * 1.08);\n        if (pd < pillarR) { cv.set(px, py, blend(0x2e1c0e, 0x9a6a3a, 0.75 - 0.4 * (pd / pillarR) * Math.sign(lx))); continue; }\n        const w = prof(y);\n        const edgeD = ax - w;\n        if (edgeD > 0.9) continue;\n        let c = NONE;\n        if (edgeD < 0.4) {\n          if (flipping) {\n            // The full bulb is local y > 0: heap at rest → slab against the neck.\n            if (y > 0) {\n              const heap = L_FULL - coneK(1) * Math.max(0, 1 - ax / hw);\n              const lo = heap + (0 - heap) * slide, hi = 1 + (S_FULL - 1) * slide;\n              if (y > lo && y < hi) c = sand(0.5 + hash(px, py) * 0.4);\n            }\n          } else if (y < 0) {\n            const surf = -S + D * Math.exp(-((lx / DIPW) ** 2));\n            if (fill > 0 && y > surf) c = sand(0.55 + hash(px, py) * 0.4 - Math.min(0.15, (y - surf) * 0.3));\n          } else {\n            const surf = L - K * Math.max(0, 1 - ax / hw);\n            if (fill < 1 && y > surf) c = sand(0.5 + hash(px, py) * 0.4 - Math.min(0.15, (y - surf) * 0.25));\n            else if (fill > 0 && ax < streamW && hash(px, py + flick) > 0.22) c = sand(0.8 + hash(px + 7, py + flick) * 0.2);\n          }\n        }\n        // Glass: a faint tint inside, an anti-aliased wall, and a highlight streak.\n        const wall = Math.max(0, 1 - Math.abs(edgeD) / 0.9);\n        const base = c === NONE ? blend(th.bg, glass, 0.07) : c;\n        let out = wall > 0 ? blend(base, glass, 0.75 * wall) : base;\n        if (ay > 0.25 && ay < 0.86 && lx < -w * 0.42 && lx > -w * 0.66) out = blend(out, 0xffffff, c === NONE ? 0.12 : 0.22);\n        cv.set(px, py, out);\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/objects/moon-phases.js": "// The lunar month in a loop: a cratered Moon (maria from fBm, ray craters\n// with rims) lit by a Sun that swings around it — new, crescent, quarter,\n// gibbous, full and back — with faint earthshine on the night side and the\n// phase name and illumination underneath.\nimport { sphere, starfield, blend } from './_raster.js';\nimport { ATTR } from '../../buffer.js';\n\nconst PHASES = ['New Moon', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous', 'Full Moon', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent'];\n\nexport default {\n  title: 'Moon Phases',\n  gallery: 'objects',\n  category: 'Globes & Planets',\n  kind: 'scene',\n  tags: ['moon', 'phases', 'lunar', 'craters', 'space'],\n  order: 25,\n  description: 'A cratered Moon cycling through its phases with earthshine on the dark side and the phase named below.',\n  size: { w: 48, h: 22 },\n  minSize: { w: 16, h: 8 },\n  loop: 16,\n  canvas: 'field',\n  palette: 'mono',\n  params: { month: { default: 16, min: 4, max: 60, label: 'Seconds per lunar month' } },\n  setup(ctx) {\n    const month = ctx.param('month', 16);\n    const showLabel = ctx.height >= 10;\n    const rows = showLabel ? ctx.height - 2 : ctx.height;\n    const cv = ctx.canvas(undefined, { rows });\n    const W = cv.width, H = cv.height, asp = cv.aspect;\n    const R = Math.min(W * 0.32, H * asp * 0.42);\n    const cx = W / 2, cy = H / 2;\n    const stars = starfield(ctx.rng, Math.round((W * H) / 80));\n    const noise = ctx.noise;\n    const craters = Array.from({ length: 34 }, () => {\n      const lon = (ctx.rng() - 0.5) * Math.PI * 1.9, lat = Math.asin(ctx.rng() * 2 - 1) * 0.92;\n      return { v: [Math.cos(lat) * Math.sin(lon), Math.sin(lat), -Math.cos(lat) * Math.cos(lon)], r: 0.04 + Math.pow(ctx.rng(), 2.2) * 0.16 };\n    });\n    // Albedo per surface direction: highlands, darker maria, crater floors and bright rims.\n    const albedo = (n) => {\n      let a = 0.78 + noise.fbm(n[0] * 2.2 + 3, n[1] * 2.2, n[2] * 2.2, 4) * 0.25;\n      const mare = noise.fbm(n[0] * 1.1 - 7, n[1] * 1.1 + 2, n[2] * 1.1, 3);\n      if (mare > 0.06) a *= 0.62;\n      for (const c of craters) {\n        const d = Math.acos(Math.max(-1, Math.min(1, n[0] * c.v[0] + n[1] * c.v[1] + n[2] * c.v[2])));\n        if (d < c.r) a *= 0.72 + 0.2 * (d / c.r);\n        else if (d < c.r * 1.25) a *= 1.18;\n      }\n      return Math.max(0.15, Math.min(1.1, a));\n    };\n    // The Moon is tidally locked, so its face never turns: bake albedo per pixel once.\n    const alb = new Float32Array(W * H);\n    sphere(cv, null, cx, cy, R, 1, (nx, ny, nz, x, y) => { alb[y * W + x] = albedo([nx, ny, nz]); return 0; });\n    const W2 = ctx.width;\n    return (t) => {\n      cv.clear();\n      stars(cv, t, 0.8, ctx.theme.bg);\n      const phase = ((t / month + 0.31) % 1 + 1) % 1; // 0 new → 0.5 full; opens on a waxing gibbous\n      const ang = phase * Math.PI * 2;\n      // Sun direction (towards the light), swinging from behind the Moon to behind the viewer.\n      const L = [Math.sin(ang), -0.08, Math.cos(ang)];\n      const ll = Math.hypot(...L);\n      L[0] /= ll; L[1] /= ll; L[2] /= ll;\n      sphere(cv, null, cx, cy, R, 1, (nx, ny, nz, x, y) => {\n        const a = alb[y * W + x];\n        const d = nx * L[0] + ny * L[1] + nz * L[2];\n        const day = Math.max(0, Math.min(1, (d + 0.04) / 0.12)); // soft terminator\n        const lambert = Math.max(0, d) * 0.85 + 0.15;\n        const lightC = blend(0x000000, 0xf4efe2, Math.min(1, a * lambert * day));\n        const earthshine = blend(0x000000, 0x5a6c8c, 0.12 * a * (1 - day));\n        return day > 0 ? blend(earthshine, lightC, day) : earthshine;\n      });\n      cv.paint(ctx.buffer);\n      if (showLabel) {\n        const idx = Math.round(phase * 8) % 8;\n        const illum = Math.round(((1 - Math.cos(ang)) / 2) * 100);\n        const label = `${PHASES[idx]} · ${illum}% lit`;\n        const x = Math.max(0, Math.floor((W2 - label.length) / 2));\n        ctx.buffer.text(x, ctx.height - 1, label.length <= W2 ? label : PHASES[idx], ctx.theme.ink, undefined, ATTR.BOLD);\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/objects/newton-cradle.js": "// Newton's cradle: five chrome balls on strings. The end ball swings in on a\n// pendulum arc, the momentum passes through the row, and the far ball swings\n// out — with a glint at each strike, faint motion ghosts on the moving balls,\n// chrome that reflects a studio sky and floor, and a brushed-steel frame.\nimport { sphere, blend } from './_raster.js';\nimport { NONE } from '../../color.js';\n\nconst BALLS = 5;\nconst PERIOD = 1.6; // seconds for a full right → left exchange and back\nconst AMP = 0.55;   // swing amplitude (radians)\n\nexport default {\n  title: \"Newton's Cradle\",\n  gallery: 'objects',\n  category: 'Mechanical',\n  kind: 'scene',\n  tags: ['newton', 'cradle', 'pendulum', 'momentum', 'chrome', 'desk toy'],\n  order: 40,\n  description: 'Five chrome balls on strings trading momentum on pendulum arcs, with strike glints and motion ghosts.',\n  size: { w: 64, h: 20 },\n  minSize: { w: 36, h: 10 },\n  loop: PERIOD * 5,\n  canvas: 'field',\n  palette: 'ice',\n  params: {},\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const W = cv.width, H = cv.height, asp = cv.aspect, th = ctx.theme;\n    const HP = H * asp; // canvas height in horizontal-pixel units\n    // Fit: the row plus both swings across the width; strings and ball down the height.\n    const rb = Math.max(1.4, Math.min(HP / 9.5, W / (2 * BALLS + 2 * Math.sin(AMP) * 5.6 + 3.6)));\n    const Ls = Math.min(rb * 5.6, HP - rb * 3.4);\n    const pivotY = Math.max(1, (HP - (Ls + rb * 1.8)) / 2);\n    const cx = W / 2;\n    const reach = (BALLS - 1) * rb + Math.sin(AMP) * Ls + rb;\n    const steel = blend(th.muted, 0x9aa4ae, 0.5);\n    const env = (rx, ry) => {\n      // Chrome: a graded sky with a softbox, a thin dark horizon, a graded floor.\n      if (ry < -0.04) return blend(blend(0x4f6a88, 0xeef4ff, Math.min(1, -ry * 0.95)), 0xffffff, Math.pow(Math.max(0, -rx * 0.5 - ry * 0.85), 12));\n      if (ry < 0.07) return 0x161b22;\n      return blend(0x262b31, 0x8d939b, Math.min(1, (ry - 0.07) * 1.4));\n    };\n    const ballAt = (i, a) => {\n      const x0 = cx + (i - (BALLS - 1) / 2) * rb * 2;\n      return [x0 + Math.sin(a) * Ls, pivotY + Math.cos(a) * Ls, x0];\n    };\n    // The right ball swings out and back on the first half, the left on the second.\n    const angle = (i, p) => {\n      const v = Math.sin(p * Math.PI * 2);\n      if (i === BALLS - 1) return v > 0 ? AMP * v : 0;\n      if (i === 0) return v < 0 ? AMP * v : 0;\n      return 0;\n    };\n\n    return (t) => {\n      cv.clear();\n      const ph = (((t % PERIOD) + PERIOD) % PERIOD) / PERIOD;\n      // Frame: top bar, two posts, base.\n      const barY = pivotY / asp, baseY = (pivotY + Ls + rb * 1.6) / asp;\n      const fx0 = cx - reach - rb * 0.8, fx1 = cx + reach + rb * 0.8;\n      for (let x = Math.floor(fx0); x <= Math.ceil(fx1); x++) {\n        const k = 0.75 + 0.12 * Math.sin(x * 0.9);\n        cv.set(x, barY, blend(0x000000, steel, k));\n        cv.set(x, baseY, blend(0x000000, steel, k * 0.85));\n        if (rb > 2.5) cv.set(x, baseY + 1, blend(0x000000, steel, 0.45));\n      }\n      for (let y = Math.floor(barY); y <= Math.ceil(baseY); y++) { cv.set(fx0, y, blend(0x000000, steel, 0.7)); cv.set(fx1, y, blend(0x000000, steel, 0.55)); }\n      // Soft contact shadows on the base, under wherever each ball is.\n      if (rb > 2.5) for (let i = 0; i < BALLS; i++) {\n        const [bx] = ballAt(i, angle(i, ph));\n        for (let x = Math.floor(bx - rb); x <= Math.ceil(bx + rb); x++) {\n          const k = 1 - Math.abs(x + 0.5 - bx) / rb;\n          if (k > 0) cv.set(x, baseY - 1, blend(th.bg, 0x000000, 0.35 * k));\n        }\n      }\n      // Strings, then motion ghosts, then the balls.\n      const stringC = blend(th.bg, th.muted, 0.75);\n      for (let i = 0; i < BALLS; i++) {\n        const a = angle(i, ph), [bx, by, x0] = ballAt(i, a);\n        cv.line(x0, barY, bx - Math.sin(a) * rb * 0.9, (by - rb * 0.9 * Math.cos(a)) / asp, stringC);\n      }\n      for (const i of [0, BALLS - 1]) {\n        for (let g = 3; g >= 1; g--) {\n          const a = angle(i, ph - g * 0.018);\n          if (Math.abs(a - angle(i, ph)) < 0.04) continue;\n          const [bx, by] = ballAt(i, a);\n          sphere(cv, null, bx, by / asp, rb * 0.92, 0, () => blend(th.bg, 0x9fc2e6, 0.1 + 0.05 * (3 - g)));\n        }\n      }\n      for (let i = 0; i < BALLS; i++) {\n        const [bx, by] = ballAt(i, angle(i, ph));\n        sphere(cv, null, bx, by / asp, rb, 0, (nx, ny, nz) => {\n          let c = env(-2 * nz * nx, -2 * nz * ny); // reflect the view ray (0, 0, 1) about n\n          c = blend(c, 0xffffff, Math.pow(Math.max(0, -nx * 0.45 - ny * 0.6 - nz * 0.66), 40));\n          return blend(c, 0x000000, 0.35 * (1 + nz * 1.2)); // darken toward the limb\n        });\n      }\n      // A glint where the returning ball strikes the row (ph ≈ 0 on the left, 0.5 on the right).\n      for (const hit of [0, 0.5]) {\n        let d = Math.abs(ph - hit); d = Math.min(d, 1 - d);\n        if (d > 0.05) continue;\n        const k = 1 - d / 0.05, side = hit === 0 ? -1 : 1;\n        const [bx, by] = ballAt(side < 0 ? 0 : BALLS - 1, 0);\n        const gx = bx - side * rb, gy = by / asp, gr = Math.max(1.2, rb * 0.9);\n        for (let y = Math.floor(gy - gr / asp); y <= Math.ceil(gy + gr / asp); y++) for (let x = Math.floor(gx - gr); x <= Math.ceil(gx + gr); x++) {\n          const r = Math.hypot(x + 0.5 - gx, (y + 0.5 - gy) * asp) / gr;\n          if (r >= 1) continue;\n          const old = cv.get(x, y);\n          cv.set(x, y, blend(old === NONE ? th.bg : old, 0xfff4c2, k * (1 - r) * (1 - r)));\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/objects/prism-logo.js": "// Prism's mark, in the terminal: a glass triangular prism turning slowly in\n// 3D. A white beam strikes one face, bends through the glass, and leaves the\n// other face as a fanned spectrum. The glass is drawn back-to-front with\n// tinted, Fresnel-brightened faces and depth-cued edges; the fan is shaded\n// per pixel so the colors blend smoothly, and a pulse of light runs through.\nimport { perspective, fillPoly, rotX, rotY, mul, apply, blend } from './_raster.js';\nimport { ATTR } from '../../buffer.js';\nimport { NONE, gradient, luminance } from '../../color.js';\nimport { SPECTRUM as REFRACTION } from '../../palettes.js';\n\n// The fan is Refraction's own spectrum in physical order (rose bends least, violet\n// most): the luminous ink tier at Night, the vivid tier on light pages.\nconst fanOf = (dark) => gradient(REFRACTION.map((st) => (dark ? st.night : st.dayVivid)).reverse());\n\nexport default {\n  title: 'Prism',\n  gallery: 'objects',\n  category: 'Icons & Logos',\n  kind: 'art',\n  tags: ['prism', 'logo', 'spectrum', 'light', 'refraction', 'glass'],\n  order: 5,\n  description: \"Prism's mark: a glass prism turning in 3D, splitting a white beam into a smoothly blended, pulsing spectrum.\",\n  size: { w: 72, h: 24 },\n  minSize: { w: 24, h: 8 },\n  loop: 14,\n  canvas: 'field',\n  palette: 'prism',\n  params: { label: { default: 'on', options: ['on', 'off'], label: 'Wordmark' } },\n  setup(ctx) {\n    const showLabel = ctx.param('label', 'on') !== 'off' && ctx.height >= 12;\n    const rows = showLabel ? ctx.height - 2 : ctx.height;\n    const cv = ctx.canvas(undefined, { rows });\n    const W = cv.width, H = cv.height, th = ctx.theme;\n    const project = perspective(cv, { distance: 4.6, fill: 0.34, cx: W * 0.44, cy: H * 0.52 });\n    const A = [0, -0.95, 0], B = [-0.92, 0.64, 0], C = [0.92, 0.64, 0], Dz = 0.55;\n    const verts = [[...A], [...B], [...C]].flatMap((p) => [[p[0], p[1], -Dz], [p[0], p[1], Dz]]);\n    // indices: A0 A1 B0 B1 C0 C1  (0 = front face, 1 = back face)\n    const faces = [\n      { v: [0, 2, 4], kind: 'cap' }, { v: [1, 5, 3], kind: 'cap' },\n      { v: [0, 1, 3, 2], kind: 'left' }, { v: [0, 4, 5, 1], kind: 'right' }, { v: [2, 3, 5, 4], kind: 'base' },\n    ];\n    const edges = [[0, 2], [2, 4], [4, 0], [1, 3], [3, 5], [5, 1], [0, 1], [2, 3], [4, 5]];\n    // On light themes \"white light\" can't glow over the page, so the beam and\n    // the glass edges are drawn in ink instead and the glass tint deepens.\n    const dark = luminance(th.bg) < 0.5;\n    const SPECTRUM = fanOf(dark);\n    const glass = blend(th.bg, dark ? 0x9fc4ff : 0x5f8fcf, 0.55);\n    const beamInk = dark ? 0xffffff : blend(th.ink, 0x4a6a90, 0.35);\n    const edgeInk = dark ? 0xe8f2ff : blend(th.ink, 0x3b5f8a, 0.4);\n    const lerp3 = (p, q, k) => [p[0] + (q[0] - p[0]) * k, p[1] + (q[1] - p[1]) * k, p[2] + (q[2] - p[2]) * k];\n    // Blend/add against the real background so soft edges don't fringe to black.\n    const under = (x, y) => { const c = cv.get(x, y); return c === NONE ? th.bg : c; };\n    const over = (x, y, c, a) => { if (x >= 0 && y >= 0 && x < W && y < H) cv.set(x, y, blend(under(x, y), c, Math.min(1, a))); };\n    const glowAdd = (x, y, c) => {\n      x = Math.floor(x); y = Math.floor(y);\n      if (x < 0 || y < 0 || x >= W || y >= H) return;\n      const o = under(x, y);\n      cv.set(x, y, (Math.min(255, ((o >> 16) & 255) + ((c >> 16) & 255)) << 16) | (Math.min(255, ((o >> 8) & 255) + ((c >> 8) & 255)) << 8) | Math.min(255, (o & 255) + (c & 255)));\n    };\n\n    return (t) => {\n      cv.clear();\n      const M = mul(rotX(-0.2 + Math.sin(t * 0.37) * 0.05), rotY(0.18 + Math.sin(t * 0.45) * 0.45));\n      const V = verts.map((p) => apply(M, p));\n      const P = V.map(project);\n      const proj = (p) => project(apply(M, p));\n      const E = proj(lerp3(A, B, 0.6)), X = proj(lerp3(A, C, 0.64));\n      const pulse = (t * 0.55) % 1;\n\n      // Incoming white beam, anti-aliased: intensity falls off with distance from its axis.\n      const slope = 0.16;\n      const y0 = E[1] - E[0] * slope;\n      for (let x = 0; x <= Math.min(W - 1, Math.floor(E[0])); x++) {\n        const yl = y0 + (x + 0.5) * slope;\n        const f = x / Math.max(1, E[0]);\n        const boost = 1 + 0.9 * Math.exp(-Math.pow((f - pulse * 1.6) * 6, 2));\n        for (let y = Math.floor(yl - 2.5); y <= Math.ceil(yl + 2.5); y++) {\n          const d = y + 0.5 - yl;\n          const k = Math.exp(-(d * d) / 0.55);\n          if (k < 0.04) continue;\n          if (!dark) { over(x, y, beamInk, 0.85 * k * Math.min(1.3, boost)); continue; }\n          const c = Math.round(Math.min(255, 235 * k * boost));\n          glowAdd(x, y, (c << 16) | (c << 8) | Math.min(255, c + 12));\n        }\n      }\n\n      // Spectrum fan leaving the far face, shaded per pixel.\n      const base = 0.12 + Math.sin(t * 0.45) * 0.08, spread = 0.2 + Math.sin(t * 0.31) * 0.035;\n      const a0 = base - spread, a1 = base + spread;\n      for (let y = 0; y < H; y++) for (let x = Math.max(0, Math.floor(X[0])); x < W; x++) {\n        const dx = x + 0.5 - X[0], dy = y + 0.5 - X[1];\n        if (dx <= 0.5) continue;\n        const phi = Math.atan2(dy, dx);\n        if (phi < a0 - 0.03 || phi > a1 + 0.03) continue;\n        const f = (phi - a0) / (a1 - a0);\n        const edge = Math.min(1, Math.max(0, Math.min(f + 0.15, 1.15 - f) / 0.15));\n        const dist = Math.hypot(dx, dy);\n        const fade = Math.min(1, dist / 5) * (0.75 + 0.25 * Math.exp(-dist / (W * 0.6)));\n        const travel = 1 + 0.55 * Math.exp(-Math.pow((dist / (W * 0.55) - (pulse * 1.6 - 0.6)) * 4, 2));\n        const c = SPECTRUM(Math.max(0, Math.min(1, f)));\n        over(x, y, c, edge * fade * 0.92 * travel);\n      }\n\n      // Glass, back to front.\n      const order = faces.map((f, i) => ({ f, i, d: f.v.reduce((s, k) => s + P[k][2], 0) / f.v.length })).sort((a, b) => b.d - a.d);\n      for (const { f } of order) {\n        const pts = f.v.map((k) => [P[k][0], P[k][1]]);\n        const a = V[f.v[0]], b = V[f.v[1]], c = V[f.v[2]];\n        const n = [(b[1] - a[1]) * (c[2] - a[2]) - (b[2] - a[2]) * (c[1] - a[1]), (b[2] - a[2]) * (c[0] - a[0]) - (b[0] - a[0]) * (c[2] - a[2]), (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])];\n        const nl = Math.hypot(...n) || 1;\n        const facing = Math.abs(n[2] / nl);\n        const alpha = f.kind === 'cap' ? 0.16 : 0.1 + (1 - facing) * 0.3;\n        const tone = f.kind === 'left' ? blend(glass, 0xffffff, 0.25) : f.kind === 'base' ? blend(glass, th.bg, 0.4) : glass;\n        fillPoly(cv, pts, (x, y, old) => {\n          const under = old === NONE ? th.bg : old;\n          // A soft specular sheen sweeping across the faces.\n          const sheen = Math.max(0, 1 - Math.abs(((x / W) - 0.35 - Math.sin(t * 0.45) * 0.2) * 7)) * 0.18;\n          return blend(under, blend(tone, 0xffffff, sheen), alpha + sheen);\n        });\n      }\n      // Refracted beam inside the glass.\n      cv.line(E[0], E[1], X[0], X[1], blend(th.bg, beamInk, 0.55));\n      // Edges, depth-cued.\n      const dmin = Math.min(...P.map((p) => p[2])), dmax = Math.max(...P.map((p) => p[2]));\n      for (const [i, j] of edges) {\n        const d = ((P[i][2] + P[j][2]) / 2 - dmin) / Math.max(1e-6, dmax - dmin);\n        // Near edges crisp, far edges barely there — reads as glass, not wireframe.\n        const k = d < 0.5 ? 0.95 - d * 0.5 : 0.32 - (d - 0.5) * 0.4;\n        cv.line(P[i][0], P[i][1], P[j][0], P[j][1], blend(th.bg, edgeInk, Math.max(0.08, k)));\n      }\n      cv.paint(ctx.buffer);\n\n      if (showLabel) {\n        const word = ctx.width >= 30 ? 'P  R  I  S  M' : 'PRISM';\n        const x0 = Math.max(0, Math.round(ctx.width * 0.44 - word.length / 2));\n        for (let i = 0; i < word.length; i++) {\n          if (word[i] === ' ') continue;\n          ctx.buffer.text(x0 + i, ctx.height - 1, word[i], SPECTRUM(i / Math.max(1, word.length - 1)), undefined, ATTR.BOLD);\n        }\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/objects/refraction.js": "// Refraction — the Prism principle, drawn as a hero. A hairline of white light\n// rises out of the dark, strikes the left face of a glass prism (apex up), runs\n// level through the glass and leaves the far face as a fanned spectrum that\n// lands on a little wavelength screen. Physically true order: violet bends most,\n// so it leaves at the bottom of the fan; red bends least and stays on top.\n// Shaded per pixel in physical units, so the geometry holds in every canvas mode\n// (sextant, quad, half, braille and plain ASCII). A packet of light runs along\n// beam and fan every 3 s, and the fan breathes; both loop seamlessly in 12 s.\nimport { ATTR } from '../../buffer.js';\nimport { gradient, luminance } from '../../color.js';\nimport { SPECTRUM as REFRACTION } from '../../palettes.js';\nimport { blend } from './_raster.js';\n\nconst TAU = Math.PI * 2;\nconst clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);\nconst smooth = (a, b, v) => { const k = clamp01((v - a) / (b - a)); return k * k * (3 - 2 * k); };\n// distance from p to segment ab, and the position along it (0..1)\nfunction seg(px, py, ax, ay, bx, by) {\n  const vx = bx - ax, vy = by - ay, l2 = vx * vx + vy * vy || 1;\n  const u = clamp01(((px - ax) * vx + (py - ay) * vy) / l2);\n  const dx = px - ax - vx * u, dy = py - ay - vy * u;\n  return [Math.sqrt(dx * dx + dy * dy), u];\n}\nconst add = (o, c, k) => {\n  const r = Math.min(255, ((o >> 16) & 255) + ((c >> 16) & 255) * k), g = Math.min(255, ((o >> 8) & 255) + ((c >> 8) & 255) * k), b = Math.min(255, (o & 255) + (c & 255) * k);\n  return (r << 16) | (g << 8) | (b | 0);\n};\n\nexport default {\n  title: 'Refraction',\n  gallery: 'objects',\n  category: 'Icons & Logos',\n  kind: 'art',\n  tags: ['prism', 'refraction', 'spectrum', 'light', 'beam', 'dispersion', 'hero', 'glass', 'wavelength'],\n  order: 4,\n  description: 'White light in, every color out: a beam strikes a glass prism and fans into a true-order spectrum (violet bends most) on a wavelength screen.',\n  size: { w: 96, h: 28 },\n  minSize: { w: 32, h: 10 },\n  loop: 12,\n  canvas: 'hires',\n  palette: 'prism',\n  params: { labels: { default: 'on', options: ['on', 'off'], label: 'Instrument labels' } },\n  setup(ctx) {\n    const th = ctx.theme, cols = ctx.width, rows = ctx.height;\n    const uni = ctx.caps?.unicode !== false;\n    const labels = ctx.param('labels', 'on') !== 'off' && cols >= 56 && rows >= 14;\n    const caption = labels && rows >= 18 && cols >= 64;\n    const cv = ctx.canvas();\n    const sx = cv.width / cols, sy = cv.height / rows;       // pixels per cell\n    const W = cols, Hp = rows * 2;                             // physical units: one cell = 1 × 2\n    const dark = luminance(th.bg) < 0.5;\n    const ground = th.bg;\n    // Rose (least bent) on top → violet (most bent) at the bottom, in the vivid tier (light, not pastel).\n    const fan = gradient(REFRACTION.map((st) => (dark ? st.nightVivid : st.dayVivid)).reverse());\n    const beamInk = dark ? 0xffffff : blend(th.accent, th.ink, 0.35);\n    const edgeLit = dark ? 0xf1ecff : 0x5b3ea8, edgeDim = dark ? 0x8f97c4 : blend(0x5b3ea8, th.bg, 0.45);\n    const glassTint = dark ? 0xb9c6ff : 0x7353bb;\n\n    // ── geometry (physical units) ──\n    const room = caption ? 2 : 0;                              // keep the caption row clear\n    const h = Math.min((Hp - room) * 0.74, W * 0.44), s = (2 * h) / Math.sqrt(3);\n    const yA = (Hp - room) * 0.11, yB = yA + h;\n    const xc = W * (labels ? 0.42 : 0.44);\n    const Ax = xc, Ay = yA, Bx = xc - s / 2, By = yB, Cx = xc + s / 2, Cy = yB;\n    const depth = 0.56;                                       // where the level ray crosses the faces\n    const Ex = Ax + (Bx - Ax) * depth, Ey = Ay + (By - Ay) * depth;\n    const Xx = Ax + (Cx - Ax) * depth, Xy = Ey;\n    const rise = Math.tan(0.16);                               // the beam climbs into the face\n    const y0 = Ey + Ex * rise;\n    const Lb = Math.hypot(Ex, y0 - Ey);\n    const screen = labels ? W - 6.5 : W + 4;                   // the wavelength screen (off-canvas when unlabeled)\n    const Lf = Math.max(1, screen - Xx);\n    const inside = (x, y) => {                                 // apex-up triangle test\n      if (y < Ay || y > By) return false;\n      const half = ((y - Ay) / h) * (s / 2);\n      return x >= xc - half && x <= xc + half;\n    };\n\n    // ── labels (cells) ──\n    const lab = [];\n    const lamText = (t) => (uni ? t : t.replace(/λ ?/g, '').replace(/·/g, '-').replace(/–/g, '-'));\n    const rowOf = (y) => Math.max(0, Math.min(rows - 1, Math.floor(y / 2)));\n\n    return (t) => {\n      cv.clear();\n      // breathing (seamless over the 12 s loop) and the light packet (every 3 s)\n      const b1 = Math.sin((t / 12) * TAU), b2 = Math.sin((t / 6) * TAU + 1.3);\n      const th0 = 0.065 + 0.012 * b1, th1 = 0.5 + 0.03 * b2; // red (top) … violet (bottom), radians below level\n      const P = 3, u = (t % P) / P, total = Lb + Lf * 1.25, pkt = u * (total + 18) - 9;\n      const packet = (l) => Math.exp(-Math.pow((l - pkt) / 5.5, 2));\n      const sheenX = xc + (s / 2) * Math.sin((t / 12) * TAU) * 0.8;\n      const arrive = packet(Lb);                               // the packet hits the face\n      lab.length = 0;\n\n      for (let j = 0; j < cv.height; j++) {\n        const Y = ((j + 0.5) / sy) * 2;\n        for (let i = 0; i < cv.width; i++) {\n          const X = (i + 0.5) / sx;\n          let c = ground, lit = false;\n          const inGlass = inside(X, Y);\n\n          // spectrum fan, from the exit face to the screen\n          if (X > Xx && X <= screen + 0.6 && !inGlass) {\n            const dx = X - Xx, dy = Y - Xy, dist = Math.hypot(dx, dy);\n            const a = Math.atan2(dy, dx);\n            const aa = Math.max(0.012, 0.5 / Math.max(1, dist));\n            const e = Math.min(smooth(th0 - aa, th0 + aa, a), 1 - smooth(th1 - aa, th1 + aa, a));\n            if (e > 0.01) {\n              const f = clamp01((a - th0) / (th1 - th0));\n              const band = 0.5 + 0.5 * Math.cos(f * 7 * TAU - Math.PI);   // seven soft cores, like the web art\n              const glow = Math.exp(-Math.pow((f - 0.5) / 0.75, 2));\n              const near = smooth(0, 1.6, dist), far = 0.82 + 0.18 * Math.exp(-dist / (W * 0.45));\n              const k = e * near * far * (0.72 + 0.16 * band) * glow * (1 + 0.5 * packet(Lb + dist));\n              const col = fan(f);\n              if (X >= screen - 0.4) c = dark ? add(blend(c, col, 0.85), col, 0.25) : blend(c, col, 0.95); // the screen catches it\n              else c = dark ? add(blend(c, col, Math.min(1, k * 0.8)), col, k * 0.22) : blend(c, col, Math.min(1, k * 0.9));\n              lit = true;\n            }\n          }\n\n          // the glass: a faint tint, lighter at the apex, with a sheen sweeping across\n          if (inGlass) {\n            const v = (Y - Ay) / h;\n            const sheen = Math.max(0, 1 - Math.abs((X - sheenX) / (s * 0.12))) * 0.1;\n            c = blend(c, glassTint, (dark ? 0.085 - v * 0.035 : 0.08 - v * 0.03) + sheen * (dark ? 1 : 0.5));\n            // the light runs level through the glass (it is already splitting)\n            const [dIn, uIn] = seg(X, Y, Ex, Ey, Xx, Xy);\n            if (dark) c = add(c, 0xa99cff, 0.11 * Math.exp(-(dIn * dIn) / 9));   // lit from inside\n            if (dIn < 1.6) {\n              const k = Math.exp(-(dIn * dIn) / 0.12) * (0.55 + 0.45 * packet(Lb + uIn * (Xx - Ex)));\n              c = dark ? add(c, 0xffffff, k * 0.6) : blend(c, beamInk, k * 0.55);\n            }\n            lit = true;\n          }\n\n          // incoming beam: core + bloom, emerging out of the dark on the left\n          if (X <= Ex + 0.4) {\n            const yl = y0 - X * rise, d = Math.abs(Y - yl) * Math.cos(0.16);\n            if (d < 3.2) {\n              const l = X / Math.cos(0.16);\n              const k = (Math.exp(-(d * d) / 0.07) + 0.28 * Math.exp(-(d * d) / 1.4)) * (0.25 + 0.75 * smooth(0, Lb * 0.7, l)) * (1 + 0.85 * packet(l));\n              c = dark ? add(c, 0xf4f2ff, Math.min(1.2, k)) : blend(c, beamInk, Math.min(1, k * 0.85));\n              lit = true;\n            }\n          }\n\n          // edges: the lit left face crisp, the far ones softer, the base a hairline\n          if (X > Bx - 1 && X < Cx + 1 && Y > Ay - 1 && Y < By + 1) {\n            const [d1] = seg(X, Y, Ax, Ay, Bx, By), [d2] = seg(X, Y, Ax, Ay, Cx, Cy), [d3] = seg(X, Y, Bx, By, Cx, Cy);\n            const line = (d, w) => clamp01(1 - (d - w) / 0.38);\n            const k1 = line(d1, 0.2), k2 = line(d2, 0.16) * 0.8, k3 = line(d3, 0.14) * 0.6;\n            if (k1 > 0.01) { c = blend(c, edgeLit, k1); lit = true; }\n            if (k2 > 0.01) { c = blend(c, blend(edgeDim, edgeLit, 0.55), k2); lit = true; }\n            if (k3 > 0.01) { c = blend(c, edgeDim, k3); lit = true; }\n          }\n\n          // glints: the impact, the exit and the apex\n          const near3 = Math.abs(X - xc) < s * 0.62 && Y < By + 3;\n          if (!near3) { /* no glints out here */ } else if (dark) {\n            const gi = Math.hypot(X - Ex, (Y - Ey) * 0.9), gx = Math.hypot(X - Xx, Y - Xy), ga = Math.hypot(X - Ax, (Y - Ay) * 1.2);\n            const k = 0.85 * Math.exp(-(gi * gi) / (2.2 + 2.5 * arrive)) * (0.6 + 0.6 * arrive) + 0.35 * Math.exp(-(gx * gx) / 1.6) + 0.5 * Math.exp(-(ga * ga) / 0.5);\n            if (k > 0.02) { c = add(c, 0xe9e4ff, k); lit = true; }\n          } else {\n            const gi = Math.hypot(X - Ex, Y - Ey);\n            const k = 0.35 * Math.exp(-(gi * gi) / 2.4) * (0.6 + 0.6 * arrive);\n            if (k > 0.02) { c = blend(c, beamInk, k); lit = true; }\n          }\n\n          if (lit) cv.set(i, j, c);\n        }\n      }\n      cv.paint(ctx.buffer);\n\n      if (labels) {\n        const ink = th.muted, faint = th.dim;\n        // WHITE LIGHT IN — under the beam's tail\n        const inRow = Math.min(rows - 1 - (caption ? 1 : 0), rowOf(y0 - 2 * rise) + 1);\n        const faceX = xc - ((Math.min(By, inRow * 2 + 2) - Ay) / h) * (s / 2);   // the left face at that row\n        if (1 + 14 + 1 < faceX) ctx.buffer.text(1, inRow, 'WHITE LIGHT IN', faint, undefined, 0);\n        // the screen's scale, red on top: 700 … 400 nm\n        for (const [nm, f] of [[700, 0], [600, 1 / 3], [500, 2 / 3], [400, 1]]) {\n          const a = th0 + f * (th1 - th0), y = Xy + Math.tan(a) * (screen - Xx);\n          const r = rowOf(y);\n          if (r < rows - (caption ? 1 : 0)) ctx.buffer.text(W - 4, r, String(nm), f === 0 || f === 1 ? ink : faint, undefined, 0);\n        }\n        // EVERY COLOR OUT — above the top ray, right-aligned against the screen\n        const topY = Xy + Math.tan(th0) * (screen - Xx);\n        const outRow = rowOf(topY) - 2;\n        const word = 'EVERY COLOR OUT';\n        if (outRow >= 0) ctx.buffer.text(Math.floor(screen) - word.length - 1, outRow, word, th.accent, undefined, ATTR.BOLD);\n        if (caption) {\n          const capText = lamText('FIG. 001 · THE PRISM PRINCIPLE · λ 380–700 NM');\n          const x0 = Math.max(0, Math.round(xc - capText.length / 2));\n          ctx.buffer.text(x0, rows - 1, capText, faint, undefined, 0);\n        }\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/objects/ringed-planet.js": "// A gas giant with a tilted ring system: noise-warped cloud bands that\n// rotate, a soft terminator, the ring split by the planet (back half hidden,\n// front half crossing the disk), the planet's shadow falling across the ring,\n// and a twinkling starfield behind it all.\nimport { sphere, starfield, blend } from './_raster.js';\nimport { gradient, NONE } from '../../color.js';\n\nexport default {\n  title: 'Ringed Planet',\n  gallery: 'objects',\n  category: 'Globes & Planets',\n  kind: 'scene',\n  tags: ['planet', 'saturn', 'rings', 'space', '3d'],\n  order: 20,\n  description: 'A banded gas giant turning under a tilted ring system, with the planet shadowing its own rings.',\n  size: { w: 72, h: 24 },\n  minSize: { w: 20, h: 7 },\n  loop: 20,\n  canvas: 'field',\n  palette: 'gold',\n  params: { tilt: { default: 0.32, min: 0.08, max: 0.7, label: 'Ring tilt' } },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const tilt = ctx.param('tilt', 0.32);\n    const W = cv.width, H = cv.height, asp = cv.aspect;\n    const R = Math.min(W * 0.16, H * asp * 0.3);\n    const cx = W / 2, cy = H / 2;\n    const stars = starfield(ctx.rng, Math.round((W * H) / 70));\n    const bands = ctx.params.palette ? ctx.palette() : gradient(['#5b3a1e', '#c9925a', '#f3d8a8', '#b06a3a', '#e8c594', '#7a4b2a', '#f6e2bd', '#a8693d']);\n    const ringPal = gradient(['#6b5a45', '#d8c39b', '#efe1c0', '#8a7357', '#cdb68c', '#5d4e3c']);\n    const noise = ctx.noise;\n    const L = (() => { const v = [-0.82, -0.18, -0.55]; const l = Math.hypot(...v); return v.map((x) => x / l); })();\n    const sinT = Math.sin(tilt), cosT = Math.cos(tilt);\n    const rin = R * 1.35, rout = R * 2.25;\n\n    // Ring sample at canvas pixel; returns color or NONE, and which half it's in.\n    const ringAt = (x, y) => {\n      const dx = x + 0.5 - cx, dy = ((y + 0.5 - cy) * asp);\n      // In-plane coordinates: the ring plane is tilted about the x axis.\n      const u = dx, v = dy / sinT;\n      const rr = Math.hypot(u, v);\n      if (rr < rin || rr > rout) return null;\n      const f = (rr - rin) / (rout - rin);\n      // Cassini division and fine ringlets.\n      // Cassini division, a few broad ringlets, soft inner and outer edges.\n      const gap = 1 - 0.85 * Math.exp(-Math.pow((f - 0.62) / 0.03, 2));\n      const dens = gap * (0.7 + 0.3 * Math.sin(f * 17 + 0.6)) * Math.min(1, f / 0.08, (1 - f) / 0.06);\n      // Planet shadow: ring points on the far side of the planet from the light.\n      const P = [u, v * sinT, -v * cosT];\n      const along = P[0] * L[0] + P[1] * L[1] + P[2] * L[2];\n      const perp = Math.hypot(P[0] - along * L[0], P[1] - along * L[1], P[2] - along * L[2]);\n      const shade = along < 0 && perp < R ? 0.22 : 1;\n      return { c: blend(0x000000, ringPal(f), Math.min(1, shade * 1.05)), front: v > 0, a: dens };\n    };\n\n    return (t) => {\n      cv.clear();\n      stars(cv, t, 0.9, ctx.theme.bg);\n      const rot = t * 0.18;\n      const x0 = Math.max(0, Math.floor(cx - rout)), x1 = Math.min(W - 1, Math.ceil(cx + rout));\n      const y0 = Math.max(0, Math.floor(cy - (rout * sinT) / asp) - 1), y1 = Math.min(H - 1, Math.ceil(cy + (rout * sinT) / asp) + 1);\n      const ringPass = (front) => {\n        for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) {\n          const s = ringAt(x, y);\n          if (!s || s.front !== front || s.a < 0.05) continue;\n          const i = y * W + x;\n          const under = cv.px[i] === NONE ? ctx.theme.bg : cv.px[i];\n          cv.px[i] = blend(under, s.c, Math.min(1, s.a * 0.95));\n        }\n      };\n      ringPass(false);\n      sphere(cv, null, cx, cy, R, 1, (nx, ny, nz) => {\n        // Rotate the surface under the bands (longitude), tilt with the rings.\n        const lat = Math.asin(Math.max(-1, Math.min(1, ny * cosT + nz * sinT)));\n        const lon = Math.atan2(nx, ny * sinT - nz * cosT) + rot;\n        const warp = noise.fbm(Math.cos(lon) * 1.6, lat * 5, Math.sin(lon) * 1.6, 3) * 0.22;\n        const f = ((lat / Math.PI + 0.5) * 3.2 + warp) % 1;\n        const diff = Math.max(0, nx * L[0] + ny * L[1] + nz * L[2]);\n        const k = Math.min(1, 0.05 + Math.pow(diff, 0.8) * 1.05);\n        const limb = 0.75 + 0.25 * Math.pow(-nz, 0.4);\n        return blend(0x000000, bands((f + 1) % 1), k * limb);\n      });\n      ringPass(true);\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/objects/shaded-cube.js": "// The first thing anyone writes in software 3D: a spinning cube — here done\n// with a z-buffer, Lambert + Blinn-Phong lighting, six palette-tinted faces,\n// a gentle bob, and a soft contact shadow that breathes with the height.\nimport { meshes } from '../../math3d.js';\nimport { zbuffer, drawMesh, perspective, shadow, rotX, rotY, mul, apply, norm, blend } from './_raster.js';\n\nexport default {\n  title: 'Shaded Cube',\n  gallery: 'objects',\n  category: '3D Solids',\n  kind: 'scene',\n  tags: ['cube', '3d', 'lighting', 'z-buffer', 'phong'],\n  order: 5,\n  description: 'A z-buffered cube with Lambert and Blinn-Phong lighting, palette-tinted faces and a soft contact shadow.',\n  size: { w: 60, h: 22 },\n  minSize: { w: 18, h: 7 },\n  loop: 12,\n  canvas: 'field',\n  palette: 'prism',\n  params: { speed: { default: 1, min: 0.1, max: 4, label: 'Speed' } },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const z = zbuffer(cv);\n    const speed = ctx.param('speed', 1);\n    const pal = ctx.palette();\n    // One hue per axis (the cube's faces come in ±x, ±y, ±z pairs), so the three\n    // faces in view are always three clearly different colors.\n    const faceColors = [0, 0.06, 0.34, 0.4, 0.64, 0.7].map((f) => blend(pal(f), ctx.theme.ink, 0.1));\n    const mesh = meshes.cube();\n    const dist = 4.2;\n    const project = perspective(cv, { distance: dist, fill: 0.25, cy: cv.height * 0.44 });\n    const light = norm([-0.55, -0.75, -0.55]);\n    const camera = [0, 0, -dist];\n    // Rotation taking the body diagonal (1,1,1) onto the vertical axis (Rodrigues).\n    const CORNER = (() => {\n      const d = norm([1, 1, 1]), up = [0, -1, 0];\n      const ax = norm([d[1] * up[2] - d[2] * up[1], d[2] * up[0] - d[0] * up[2], d[0] * up[1] - d[1] * up[0]]);\n      const ang = Math.acos(d[0] * up[0] + d[1] * up[1] + d[2] * up[2]);\n      const c = Math.cos(ang), s = Math.sin(ang), C = 1 - c, [x, y, zz] = ax;\n      return [c + x * x * C, x * y * C - zz * s, x * zz * C + y * s, y * x * C + zz * s, c + y * y * C, y * zz * C - x * s, zz * x * C - y * s, zz * y * C + x * s, c + zz * zz * C];\n    })();\n    return (t) => {\n      const tt = t * speed;\n      cv.clear();\n      z.fill(Infinity);\n      const bob = Math.sin(tt * 1.3) * 0.12;\n      // Stood on a vertex (body diagonal vertical) and spun about it, viewed from a\n      // little above: three faces are always in view, never a flat face-on frame.\n      // Keep the camera low: from ~35° above you would be looking straight at a top face.\n      const M = mul(rotX(0.14 + Math.sin(tt * 0.5) * 0.08), mul(rotY(tt * 0.8), CORNER));\n      const floorY = project([0, 1.45, 0])[1];\n      const k = 1 - bob * 1.8;\n      shadow(cv, cv.width / 2, floorY, Math.min(cv.width, cv.height * cv.aspect) * 0.3 * k, Math.max(1.2, (Math.min(cv.width, cv.height * cv.aspect) * 0.07 * k) / cv.aspect), 0.6, ctx.theme.bg);\n      const P = drawMesh(cv, z, mesh, {\n        transform: (p) => { const q = apply(M, p); return [q[0], q[1] + bob, q[2]]; },\n        project, light, camera, specular: 0.5, shininess: 30, ambient: 0.42,\n        shade: (i, fi) => {\n          const c = faceColors[fi];\n          if (i <= 1) return blend(0x000000, c, Math.max(0, i));\n          return blend(c, 0xffffff, Math.min(1, i - 1));\n        },\n      });\n      // Bevel highlights on the edges that are actually visible (depth-tested).\n      for (const [i, j] of mesh.edges) {\n        const a = P[i], c = P[j];\n        const n = Math.ceil(Math.max(Math.abs(c[0] - a[0]), Math.abs(c[1] - a[1]))) * 2 + 1;\n        for (let s2 = 0; s2 <= n; s2++) {\n          const f = s2 / n;\n          const x = Math.floor(a[0] + (c[0] - a[0]) * f), y = Math.floor(a[1] + (c[1] - a[1]) * f);\n          if (x < 0 || y < 0 || x >= cv.width || y >= cv.height) continue;\n          const k = y * cv.width + x;\n          if (a[2] + (c[2] - a[2]) * f > z[k] + 0.06) continue;\n          cv.px[k] = blend(cv.px[k] < 0 ? ctx.theme.bg : cv.px[k], 0xffffff, 0.32);\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/objects/spinning-coin.js": "// A gold coin spinning on its edge, ray-cast per pixel with 2×2 supersampling:\n// an exact cylinder (two faces and a reeded rim), embossed relief on both faces\n// (a prism mark on heads, a star on tails) bump-mapped from a height field, and\n// metal shading from a studio environment — so the faces flash as they sweep\n// past the softbox. A soft shadow below breathes with the coin's silhouette.\nimport { NONE } from '../../color.js';\nimport { blend, rotX, rotY, mul } from './_raster.js';\n\nconst LOOP = 6;\nconst THICK = 0.085;\n\n// Signed distance to an equilateral triangle (point up) of \"radius\" r.\nfunction triSD(x, y, r) {\n  const k = Math.sqrt(3);\n  x = Math.abs(x) - r; y = y + r / k;\n  if (x + k * y > 0) { const nx = (x - k * y) / 2, ny = (-k * x - y) / 2; x = nx; y = ny; }\n  x -= Math.min(Math.max(x, -2 * r), 0);\n  return -Math.hypot(x, y) * Math.sign(y);\n}\n// Signed distance to a five-pointed star.\nfunction starSD(x, y, r, rf) {\n  const k1 = [0.809016994375, -0.587785252292], k2 = [-k1[0], k1[1]];\n  x = Math.abs(x);\n  let d = 2 * Math.max(k1[0] * x + k1[1] * y, 0); x -= d * k1[0]; y -= d * k1[1];\n  d = 2 * Math.max(k2[0] * x + k2[1] * y, 0); x -= d * k2[0]; y -= d * k2[1];\n  x = Math.abs(x); y -= r;\n  const ba = [rf * -k1[1] - 0, rf * k1[0] - 1];\n  const h = Math.min(Math.max((x * ba[0] + y * ba[1]) / (ba[0] * ba[0] + ba[1] * ba[1]), 0), r);\n  return Math.hypot(x - ba[0] * h, y - ba[1] * h) * Math.sign(y * ba[0] - x * ba[1]);\n}\nconst ss = (a, b, x) => { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); };\n\n// Relief height (0…1) at face coordinates (u, v), unit radius.\nfunction relief(u, v, heads) {\n  const r = Math.hypot(u, v);\n  let h = ss(0.9, 0.86, r) * ss(0.78, 0.82, r) * 0.9; // raised rim ring\n  for (let i = 0; i < 16; i++) { // beads inside the rim\n    const a = (i / 16) * Math.PI * 2, dx = u - Math.cos(a) * 0.71, dy = v - Math.sin(a) * 0.71;\n    h = Math.max(h, ss(0.05, 0.02, Math.hypot(dx, dy)) * 0.7);\n  }\n  const d = heads ? triSD(u, -v - 0.06, 0.42) : starSD(u, -v, 0.48, 0.45);\n  h = Math.max(h, ss(0.03, -0.03, d) * (heads ? 0.8 + 0.2 * ss(-0.02, -0.25, d) : 0.9));\n  if (heads) h -= ss(-0.12, -0.15, d) * 0.45 * ss(-0.3, -0.27, d); // inset facet line\n  return h;\n}\n\n// The relief is baked once into height + gradient textures (shared by every\n// instance), so a face hit costs a lookup rather than three field evaluations.\nconst TEX = 128;\nlet baked = null;\nfunction bake() {\n  if (baked) return baked;\n  baked = [true, false].map((heads) => {\n    const h = new Float32Array(TEX * TEX), gx = new Float32Array(TEX * TEX), gy = new Float32Array(TEX * TEX);\n    for (let j = 0; j < TEX; j++) for (let i = 0; i < TEX; i++) h[j * TEX + i] = relief(((i + 0.5) / TEX) * 2 - 1, ((j + 0.5) / TEX) * 2 - 1, heads);\n    const step = 2 / TEX;\n    for (let j = 0; j < TEX; j++) for (let i = 0; i < TEX; i++) {\n      const at = (a, b) => h[Math.min(TEX - 1, Math.max(0, b)) * TEX + Math.min(TEX - 1, Math.max(0, a))];\n      gx[j * TEX + i] = (at(i + 1, j) - at(i - 1, j)) / (2 * step);\n      gy[j * TEX + i] = (at(i, j + 1) - at(i, j - 1)) / (2 * step);\n    }\n    return { h, gx, gy };\n  });\n  return baked;\n}\n\nexport default {\n  title: 'Spinning Coin',\n  gallery: 'objects',\n  category: 'Icons & Logos',\n  kind: 'scene',\n  tags: ['coin', 'gold', 'money', 'spin', 'metal', 'raycast'],\n  order: 30,\n  description: 'A ray-cast gold coin spinning on edge: embossed faces, a reeded rim, studio reflections and a soft shadow.',\n  size: { w: 40, h: 20 },\n  minSize: { w: 12, h: 6 },\n  loop: LOOP,\n  canvas: 'field',\n  palette: 'gold',\n  params: { speed: { default: 1, min: 0.2, max: 4, label: 'Spin speed' } },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const speed = ctx.param('speed', 1);\n    const W = cv.width, H = cv.height, asp = cv.aspect, th = ctx.theme;\n    const S = Math.min(W * 0.46, H * asp * 0.4); // coin radius in pixels\n    const cx = W / 2, cy = H * 0.45;\n    const gold = 0xe0a82a, deep = 0x5a3a08, rimGold = 0xc58a1a;\n    const tex = bake();\n\n    // Studio environment seen in a reflection direction (y down = toward the floor).\n    const env = (rx, ry, rz) => {\n      const sky = 0.5 - ry * 0.45; // bright above, dark below\n      const box = Math.pow(Math.max(0, -rx * 0.62 - ry * 0.3 - rz * 0.72), 14) * 1.5; // softbox upper-left-front\n      const strip = Math.pow(Math.max(0, rx * 0.85 - rz * 0.5), 24) * 0.9; // rim light from the right\n      return sky + box + strip;\n    };\n    // Shade with a slightly perspective view ray through (x, y) so flat faces\n    // still carry a gradient across them, as a real lens would see.\n    const metal = (base, nx, ny, nz, x, y) => {\n      const vl = Math.hypot(x * 0.35, y * 0.35, 1), vx = (x * 0.35) / vl, vy = (y * 0.35) / vl, vz = 1 / vl;\n      const dn = vx * nx + vy * ny + vz * nz;\n      const e = env(vx - 2 * dn * nx, vy - 2 * dn * ny, vz - 2 * dn * nz);\n      const c = blend(deep, base, Math.min(1, 0.25 + e * 0.75));\n      return e > 1 ? blend(c, 0xfff6d6, Math.min(1, (e - 1) * 0.8)) : c;\n    };\n\n    // Per-frame rotation (view → coin is Mᵀ), read by trace() for every ray.\n    let M = [1, 0, 0, 0, 1, 0, 0, 0, 1], dx = 0, dy = 0, dz = 1, interior = false;\n    const inner = Math.pow(Math.max(0, 1 - 1.6 / S), 2);\n    // Trace one orthographic ray through (x, y) in coin-radius units → color or\n    // NONE. Sets `interior` when the hit is flat face well inside the rim, where a\n    // single sample is enough (silhouette, rim and relief edges get 2×2 supersampling).\n    const trace = (x, y) => {\n      interior = false;\n      const o0 = M[0] * x + M[3] * y - M[6] * 4, o1 = M[1] * x + M[4] * y - M[7] * 4, o2 = M[2] * x + M[5] * y - M[8] * 4;\n      let best = Infinity, col = NONE;\n      // Faces at z = ±THICK.\n      if (dz > 1e-6 || dz < -1e-6) for (let side = -1; side <= 1; side += 2) {\n        const s = (side * THICK - o2) / dz;\n        const u = o0 + s * dx, v = o1 + s * dy, r2 = u * u + v * v;\n        if (s >= best || r2 > 1) continue;\n        const heads = side < 0;\n        const tx = heads ? u : -u; // tails reads correctly from its own side\n        const ti = Math.min(TEX - 1, ((tx + 1) * 0.5 * TEX) | 0) + Math.min(TEX - 1, ((v + 1) * 0.5 * TEX) | 0) * TEX;\n        const T = heads ? tex[0] : tex[1];\n        const gx = T.gx[ti] * 0.06, gy = T.gy[ti] * 0.06;\n        // Bumped normal in coin space, then to view space (M ·).\n        const cx0 = heads ? -gx : gx, cy0 = -gy, cz0 = side;\n        let nx = M[0] * cx0 + M[1] * cy0 + M[2] * cz0, ny = M[3] * cx0 + M[4] * cy0 + M[5] * cz0, nz = M[6] * cx0 + M[7] * cy0 + M[8] * cz0;\n        const L = Math.hypot(nx, ny, nz); nx /= L; ny /= L; nz /= L;\n        best = s; col = metal(blend(gold, 0xffe9a0, T.h[ti] * 0.35), nx, ny, nz, x, y);\n        interior = r2 < inner && gx * gx + gy * gy < 0.02; // flat face, not a relief edge\n      }\n      // Reeded rim: x² + y² = 1 with |z| ≤ THICK.\n      const A = dx * dx + dy * dy;\n      if (A > 1e-9) {\n        const B = 2 * (o0 * dx + o1 * dy), C = o0 * o0 + o1 * o1 - 1, D = B * B - 4 * A * C;\n        if (D >= 0) {\n          const s = (-B - Math.sqrt(D)) / (2 * A), zz = o2 + s * dz;\n          if (s < best && Math.abs(zz) <= THICK) {\n            const phi = Math.atan2(o1 + s * dy, o0 + s * dx), a = phi + Math.sin(phi * 90) * 0.05;\n            const c0 = Math.cos(a), c1 = Math.sin(a);\n            col = metal(rimGold, M[0] * c0 + M[1] * c1, M[3] * c0 + M[4] * c1, M[6] * c0 + M[7] * c1, x, y);\n            interior = false;\n          }\n        }\n      }\n      return col;\n    };\n\n    return (t) => {\n      cv.clear();\n      const spin = (t / LOOP) * Math.PI * 2 * 2 * speed;\n      const tilt = 0.12 + Math.sin(t * 1.1) * 0.05;\n      const cs = Math.cos(spin);\n      M = mul(rotX(tilt), rotY(spin));\n      dx = M[6]; dy = M[7]; dz = M[8]; // the view ray (0, 0, 1) in coin space\n      // Shadow on the floor: an ellipse as wide as the coin's silhouette.\n      const silW = Math.max(Math.abs(cs), THICK * 1.5), sy0 = cy + (S * 1.18) / asp;\n      const shW = S * (0.25 + 0.75 * silW), shH = Math.max(1, (S * 0.13) / asp);\n      for (let py = Math.floor(sy0 - shH * 2); py <= Math.ceil(sy0 + shH * 2); py++) for (let px = Math.floor(cx - shW * 1.5); px <= Math.ceil(cx + shW * 1.5); px++) {\n        const q = ((px + 0.5 - cx) / shW) ** 2 + ((py + 0.5 - sy0) / shH) ** 2;\n        if (q < 2.2 && px >= 0 && py >= 0 && px < W && py < H) cv.set(px, py, blend(th.bg, 0x000000, 0.5 * Math.exp(-q * 1.6)));\n      }\n      // Only scan the coin's actual silhouette width this frame.\n      const halfW = S * Math.min(1, Math.abs(cs) + THICK * 2 + 0.04) + 2;\n      const bx0 = Math.max(0, Math.floor(cx - halfW)), bx1 = Math.min(W - 1, Math.ceil(cx + halfW));\n      const by0 = Math.max(0, Math.floor(cy - S / asp - 2)), by1 = Math.min(H - 1, Math.ceil(cy + S / asp + 2));\n      const px = cv.px, invS = 1 / S;\n      for (let py = by0; py <= by1; py++) {\n        const yc = (py + 0.5 - cy) * asp * invS, y0 = (py + 0.25 - cy) * asp * invS, y1 = (py + 0.75 - cy) * asp * invS;\n        for (let pxi = bx0; pxi <= bx1; pxi++) {\n          const xc = (pxi + 0.5 - cx) * invS;\n          if (xc * xc + yc * yc > 1.1) continue;\n          const i = py * W + pxi;\n          const c = trace(xc, yc);\n          if (c !== NONE && interior) { px[i] = c; continue; }\n          // Near an edge: 2×2 supersample and blend coverage over what's below.\n          let r = 0, g = 0, b = 0, hits = 0;\n          for (let k = 0; k < 4; k++) {\n            const sc = trace((pxi + (k & 1 ? 0.75 : 0.25) - cx) * invS, k & 2 ? y1 : y0);\n            if (sc === NONE) continue;\n            r += (sc >> 16) & 255; g += (sc >> 8) & 255; b += sc & 255; hits++;\n          }\n          if (!hits) continue;\n          const avg = ((r / hits) << 16) | ((g / hits) << 8) | ((b / hits) | 0);\n          px[i] = hits === 4 ? avg : blend(px[i] === NONE ? th.bg : px[i], avg, hits / 4);\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/objects/studio-sphere.js": "// A studio sphere: per-pixel Blinn-Phong with a key light that orbits the\n// subject (you see it pass behind), a cool rim light, subtle latitude\n// banding, and a floor shadow cast away from the light.\nimport { sphere, shadow, zbuffer, blend } from './_raster.js';\n\nexport default {\n  title: 'Studio Sphere',\n  gallery: 'objects',\n  category: '3D Solids',\n  kind: 'scene',\n  tags: ['sphere', 'lighting', 'phong', 'rim light', '3d'],\n  order: 12,\n  description: 'Per-pixel lit sphere with an orbiting key light, cool rim light, soft banding and a moving floor shadow.',\n  size: { w: 56, h: 22 },\n  minSize: { w: 16, h: 7 },\n  loop: 10,\n  canvas: 'field',\n  palette: 'ocean',\n  params: { speed: { default: 1, min: 0.1, max: 4, label: 'Speed' } },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const z = zbuffer(cv);\n    const speed = ctx.param('speed', 1);\n    const th = ctx.theme;\n    const base = blend(th.accent, th.info, 0.35);\n    const rim = blend(th.info, 0xffffff, 0.35);\n    const W = cv.width, H = cv.height;\n    const r = Math.min(W * 0.2, H * cv.aspect * 0.34);\n    const cx = W / 2, cy = H * 0.45;\n    return (t) => {\n      const tt = t * speed;\n      cv.clear();\n      z.fill(Infinity);\n      const a = tt * 0.7;\n      const L = [Math.cos(a) * 0.85, -0.55, -0.55 + Math.sin(a) * 0.45];\n      const ll = Math.hypot(...L);\n      const l = [L[0] / ll, L[1] / ll, L[2] / ll];\n      // Shadow slides away from the light across the floor.\n      shadow(cv, cx - l[0] * r * 0.9, cy + r / cv.aspect * 1.18, r * (1.05 + Math.abs(l[0]) * 0.35), Math.max(1.2, (r * 0.22) / cv.aspect), 0.6, th.bg);\n      // The light itself: a small glow dot on its orbit (in front or behind).\n      const lx = cx + Math.cos(a) * r * 1.65, ly = cy - r * 0.95 / cv.aspect, behind = L[2] > 0;\n      const glow = () => { for (let d = 3; d >= 0; d--) cv.circle(lx, ly, 0.6 + d, blend(th.bg, 0xfff2c8, 0.22 + (3 - d) * 0.25), true); };\n      if (behind) glow();\n      sphere(cv, z, cx, cy, r, 1, (nx, ny, nz) => {\n        const n = [nx, ny, nz];\n        const diff = Math.max(0, n[0] * l[0] + n[1] * l[1] + n[2] * l[2]);\n        // Blinn half-vector with the viewer at -z.\n        let hx = l[0], hy = l[1], hz = l[2] - 1;\n        const hl = Math.hypot(hx, hy, hz); hx /= hl; hy /= hl; hz /= hl;\n        const spec = Math.pow(Math.max(0, nx * hx + ny * hy + nz * hz), 42);\n        const fres = Math.pow(1 - Math.abs(nz), 3);\n        const band = 0.94 + 0.06 * Math.sin(Math.asin(Math.max(-1, Math.min(1, ny))) * 14);\n        let c = blend(0x020306, base, Math.min(1, (0.1 + diff * 0.9) * band));\n        c = blend(c, rim, fres * 0.55);\n        c = blend(c, 0xffffff, Math.min(1, spec * 0.95));\n        return c;\n      });\n      if (!behind) glow();\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/objects/torus-knot.js": "// A torus knot as a braille wire tube with true hidden-line removal: the tube\n// surface is rasterized into a depth buffer first, then only the wire points\n// that survive the depth test are plotted — rings around the tube and strands\n// along it, rainbow along the knot and depth-cued. It morphs (eased) through\n// the (2,3) trefoil, (2,5), (3,4) and (3,5) knots while it turns.\nimport { zbuffer, tri, perspective, rotX, rotY, rotZ, mul, apply, norm, cross } from './_raster.js';\nimport { easing } from '../../easing.js';\nimport { scale as dim } from '../../color.js';\n\nconst KNOTS = [[2, 3], [2, 5], [3, 4], [3, 5]];\nconst HOLD = 3, MORPH = 1.5;\nconst SEG = 240, SIDES = 8, TUBE = 0.1;\n\nconst knot = ([p, q], u) => {\n  const r = 2 + Math.cos(q * u);\n  return [(r * Math.cos(p * u)) / 3.2, (r * Math.sin(p * u)) / 3.2, -Math.sin(q * u) / 3.2];\n};\n\nexport default {\n  title: 'Torus Knot',\n  gallery: 'objects',\n  category: 'Wireframes',\n  kind: 'scene',\n  tags: ['knot', 'torus', 'wireframe', 'braille', 'hidden-line', 'morph'],\n  order: 20,\n  description: 'A braille wire tube with true hidden-line removal, morphing through the (2,3), (2,5), (3,4) and (3,5) torus knots.',\n  size: { w: 48, h: 22 },\n  minSize: { w: 16, h: 7 },\n  loop: KNOTS.length * (HOLD + MORPH),\n  canvas: 'line',\n  palette: 'prism',\n  params: { rings: { default: 4, min: 1, max: 8, label: 'Ring spacing (segments)' } },\n  setup(ctx) {\n    const cv = ctx.canvas('line');\n    const z = zbuffer(cv);\n    const W = cv.width, H = cv.height;\n    const scratch = { width: W, height: H, px: new Int32Array(W * H) };\n    const pal = ctx.palette();\n    const every = Math.max(1, Math.round(ctx.param('rings', 4)));\n    const dist = 3.6;\n    const project = perspective(cv, { distance: dist, fill: 0.46 });\n    const period = KNOTS.length * (HOLD + MORPH);\n    const centre = new Array(SEG);\n    const proj = Array.from({ length: SEG }, () => new Array(SIDES));\n\n    return (t) => {\n      cv.clear();\n      z.fill(Infinity);\n      const tl = ((t % period) + period) % period;\n      const k = Math.floor(tl / (HOLD + MORPH)), f = tl - k * (HOLD + MORPH);\n      const m = f < HOLD ? 0 : easing.inOutCubic((f - HOLD) / MORPH);\n      const A = KNOTS[k], B = KNOTS[(k + 1) % KNOTS.length];\n      // Mostly face-on (tilted, wobbling) and spinning in its own plane, so the\n      // crossings stay readable instead of collapsing edge-on.\n      const R = mul(rotX(0.45 + Math.sin(t * 0.4) * 0.2), mul(rotY(Math.sin(t * 0.27) * 0.4), rotZ(t * 0.25)));\n      for (let i = 0; i < SEG; i++) {\n        const u = (i / SEG) * Math.PI * 2;\n        const a = knot(A, u), b = knot(B, u);\n        centre[i] = apply(R, [a[0] + (b[0] - a[0]) * m, a[1] + (b[1] - a[1]) * m, a[2] + (b[2] - a[2]) * m]);\n      }\n      // Tube cross-sections from a smooth frame: T along the curve, N ⟂ T and z.\n      let dmin = Infinity, dmax = -Infinity;\n      for (let i = 0; i < SEG; i++) {\n        const p0 = centre[(i + SEG - 1) % SEG], p1 = centre[(i + 1) % SEG], c = centre[i];\n        const T = norm([p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]]);\n        const N = norm(cross(T, [0.3, 0.2, 1])), Bn = cross(T, N);\n        for (let s = 0; s < SIDES; s++) {\n          const a = (s / SIDES) * Math.PI * 2, ca = Math.cos(a) * TUBE, sa = Math.sin(a) * TUBE;\n          const v = [c[0] + N[0] * ca + Bn[0] * sa, c[1] + N[1] * ca + Bn[1] * sa, c[2] + N[2] * ca + Bn[2] * sa];\n          const P = project(v);\n          proj[i][s] = P;\n          if (P[2] < dmin) dmin = P[2];\n          if (P[2] > dmax) dmax = P[2];\n        }\n      }\n      // Pass 1: the tube surface into the depth buffer only.\n      for (let i = 0; i < SEG; i++) {\n        const j = (i + 1) % SEG;\n        for (let s = 0; s < SIDES; s++) {\n          const s2 = (s + 1) % SIDES;\n          tri(scratch, z, proj[i][s], proj[j][s], proj[j][s2], 0, 0, 0, () => 1);\n          tri(scratch, z, proj[i][s], proj[j][s2], proj[i][s2], 0, 0, 0, () => 1);\n        }\n      }\n      // Pass 2: wire points that pass the depth test.\n      const span = Math.max(1e-6, dmax - dmin);\n      const plot = (a, b, u) => {\n        const n = Math.max(2, Math.ceil(Math.max(Math.abs(b[0] - a[0]), Math.abs(b[1] - a[1])) * 1.5));\n        for (let q = 0; q < n; q++) {\n          const f2 = q / n, x = a[0] + (b[0] - a[0]) * f2, y = a[1] + (b[1] - a[1]) * f2, d = a[2] + (b[2] - a[2]) * f2;\n          const xi = Math.floor(x), yi = Math.floor(y);\n          if (xi < 0 || yi < 0 || xi >= W || yi >= H) continue;\n          const idx = yi * W + xi;\n          if (d > z[idx] + TUBE * 0.35) continue;\n          cv.px[idx] = dim(pal(u), 1.05 - 0.6 * ((d - dmin) / span));\n        }\n      };\n      for (let i = 0; i < SEG; i++) {\n        const u = i / SEG, j = (i + 1) % SEG;\n        if (i % every === 0) for (let s = 0; s < SIDES; s++) plot(proj[i][s], proj[i][(s + 1) % SIDES], u);\n        for (let s = 0; s < SIDES; s += 2) plot(proj[i][s], proj[j][s], u);\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/objects/twisty-cube.js": "// A 3×3×3 twisty cube that scrambles itself and then solves itself: twelve\n// random quarter turns, then the same turns undone in reverse — eased, with a\n// pause between moves, while the whole cube slowly orbits. 27 cubies with real\n// orientation matrices, black plastic bodies and inset stickers, z-buffered\n// and lit.\nimport { zbuffer, tri, perspective, rotX, rotY, rotZ, mul, apply, norm, blend } from './_raster.js';\nimport { easing } from '../../easing.js';\n\nconst FACE_COLORS = {\n  '1,0,0': 0xd7263d, '-1,0,0': 0xf77f00, '0,-1,0': 0xf1f1ec, '0,1,0': 0xffd100, '0,0,-1': 0x1fa64a, '0,0,1': 0x1e5bd8,\n};\nconst DIRS = [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]];\nconst ROT = [rotX, rotY, rotZ];\n\nexport default {\n  title: 'Twisty Cube',\n  gallery: 'objects',\n  category: '3D Solids',\n  kind: 'scene',\n  tags: ['puzzle cube', 'twisty puzzle', 'cube', '3d', 'scramble'],\n  order: 30,\n  description: 'A 3×3×3 puzzle cube that scrambles itself with twelve eased quarter turns, then solves itself in reverse.',\n  size: { w: 56, h: 24 },\n  minSize: { w: 18, h: 8 },\n  loop: 13.2,\n  canvas: 'field',\n  palette: 'prism',\n  params: { moves: { default: 12, min: 4, max: 30, label: 'Scramble length' } },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const z = zbuffer(cv);\n    const nMoves = Math.round(ctx.param('moves', 12));\n    const project = perspective(cv, { distance: 7.5, fill: 0.15 });\n    const light = norm([-0.35, -0.6, -0.75]);\n    const body = blend(0x0b0c0f, ctx.theme.panel, 0.25);\n    // Scramble: random turns (never the same axis twice running), then undone.\n    const scramble = [];\n    let lastAxis = -1;\n    for (let i = 0; i < nMoves; i++) {\n      let axis;\n      do axis = Math.floor(ctx.rng() * 3); while (axis === lastAxis);\n      lastAxis = axis;\n      scramble.push({ axis, layer: Math.floor(ctx.rng() * 3) - 1, dir: ctx.rng() < 0.5 ? 1 : -1 });\n    }\n    const moves = [...scramble, ...scramble.slice().reverse().map((m) => ({ ...m, dir: -m.dir }))];\n    const DUR = 0.55, TURN = 0.42;\n    const period = moves.length * DUR;\n\n    const fresh = () => {\n      const out = [];\n      for (let x = -1; x <= 1; x++) for (let y = -1; y <= 1; y++) for (let zz = -1; zz <= 1; zz++) {\n        const stickers = DIRS.filter((d) => (d[0] && d[0] === x) || (d[1] && d[1] === y) || (d[2] && d[2] === zz)).map((d) => ({ d, c: FACE_COLORS[d.join(',')] }));\n        out.push({ pos: [x, y, zz], M: [1, 0, 0, 0, 1, 0, 0, 0, 1], stickers });\n      }\n      return out;\n    };\n    const turn = (cubies, m, ang) => {\n      const R = ROT[m.axis](ang);\n      for (const c of cubies) {\n        if (Math.round(c.pos[m.axis]) !== m.layer) continue;\n        c.pos = apply(R, c.pos).map(Math.round);\n        c.M = mul(R, c.M);\n      }\n    };\n\n    // Square on the face with outward normal d, half-size h, pushed out to `out`.\n    const faceSquare = (d, h, out) => {\n      const u = [0, 0, 0], v = [0, 0, 0];\n      const axes = [0, 1, 2].filter((k) => !d[k]); // the two in-plane axes\n      u[axes[0]] = h; v[axes[1]] = h;\n      const c = d.map((x) => x * out);\n      return [[c[0] - u[0] - v[0], c[1] - u[1] - v[1], c[2] - u[2] - v[2]], [c[0] + u[0] - v[0], c[1] + u[1] - v[1], c[2] + u[2] - v[2]], [c[0] + u[0] + v[0], c[1] + u[1] + v[1], c[2] + u[2] + v[2]], [c[0] - u[0] + v[0], c[1] - u[1] + v[1], c[2] - u[2] + v[2]]];\n    };\n\n    return (t) => {\n      cv.clear();\n      z.fill(Infinity);\n      const tl = ((t % period) + period) % period;\n      const k = Math.floor(tl / DUR);\n      const phase = (tl - k * DUR) / TURN;\n      const cubies = fresh();\n      for (let i = 0; i < k; i++) turn(cubies, moves[i], (Math.PI / 2) * moves[i].dir);\n      const cur = moves[k];\n      const ang = phase < 1 ? easing.inOutCubic(phase) * (Math.PI / 2) * cur.dir : 0;\n      const view = mul(rotX(0.5 + Math.sin(t * 0.3) * 0.08), rotY(t * 0.35 + 0.65));\n      const spin = ROT[cur.axis](ang);\n      for (const c of cubies) {\n        const moving = phase < 1 && Math.round(c.pos[cur.axis]) === cur.layer;\n        const base = moving ? mul(view, spin) : view;\n        // world = base · (T + M · p)\n        const R = mul(base, c.M);\n        const off = apply(base, c.pos.map((x) => x * 1.02));\n        const proj = (p) => { const q = apply(R, p); return project([q[0] + off[0], q[1] + off[1], q[2] + off[2]]); };\n        for (const d of DIRS) {\n          const corners = faceSquare(d, 0.49, 0.49);\n          const wn = norm(apply(R, d));\n          const i = 0.12 + 0.6 * Math.max(0, wn[0] * light[0] + wn[1] * light[1] + wn[2] * light[2]);\n          const P = corners.map(proj);\n          const col = blend(0x000000, body, Math.min(1, i + 0.25));\n          tri(cv, z, P[0], P[1], P[2], 0, 0, 0, () => col);\n          tri(cv, z, P[0], P[2], P[3], 0, 0, 0, () => col);\n        }\n        for (const s of c.stickers) {\n          const corners = faceSquare(s.d, 0.4, 0.5);\n          const wn = norm(apply(R, s.d));\n          const i = 0.38 + 0.72 * Math.max(0, wn[0] * light[0] + wn[1] * light[1] + wn[2] * light[2]);\n          const P = corners.map((p) => { const q = proj(p); q[2] -= 0.004; return q; });\n          const col = blend(0x000000, s.c, Math.min(1, i));\n          tri(cv, z, P[0], P[1], P[2], 0, 0, 0, () => col);\n          tri(cv, z, P[0], P[2], P[3], 0, 0, 0, () => col);\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/objects/wireframe.js": "// Rotating polyhedra in braille: cube → icosahedron → torus → octahedron, each\n// handing off with a shrink/spring crossfade. Edges are depth-cued along their\n// length, vertices glow, and a tilted orbit ring of dust circles the solid.\nimport { meshes, rotate } from '../../math3d.js';\nimport { easing } from '../../easing.js';\nimport { fadeMark } from '../../palettes.js';\n\nexport default {\n  title: 'Wireframe Solids',\n  gallery: 'objects',\n  category: 'Wireframes',\n  kind: 'scene',\n  tags: ['wireframe', 'polyhedra', '3d', 'braille'],\n  order: 20,\n  description: 'Braille wireframes morphing cube → icosahedron → torus → octahedron with depth-cued edges and glowing vertices.',\n  canvas: 'line',\n  palette: 'prism',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette();\n    const hold = ctx.param('hold', 6);\n    const { width: W, height: H, aspect } = cv;\n    const shapes = [meshes.cube(), meshes.icosahedron(), meshes.torus(0.72, 0.3, 20, 10), meshes.octahedron()];\n    const dist = 3.4;\n    const scale = Math.min(W, H * aspect) * 0.3 * dist;\n    const dust = Array.from({ length: 90 }, (_, i) => ({ a: (i / 90) * Math.PI * 2 + ctx.rng() * 0.05, r: 1.45 + ctx.rng() * 0.12, y: (ctx.rng() - 0.5) * 0.05 }));\n\n    const project = (p) => {\n      const d = p[2] + dist;\n      const k = scale / Math.max(0.3, d);\n      return [W / 2 + p[0] * k, H / 2 + (p[1] * k) / aspect, d];\n    };\n\n    // Depth cue: toward black on glass; on light pages far edges fade into the page.\n    const day = ctx.theme.mode === 'light';\n    const spark = day ? ctx.theme.ink : 0xffffff;\n    const shade = (c, k) => {\n      if (day) return fadeMark(ctx.theme, c, Math.min(1, k));\n      const r = ((c >> 16) & 255) * k, g = ((c >> 8) & 255) * k, b = (c & 255) * k;\n      return ((r > 255 ? 255 : r) << 16) | ((g > 255 ? 255 : g) << 8) | (b > 255 ? 255 : b | 0);\n    };\n\n    const line3 = (a, b, ca, cb) => {\n      const n = Math.max(1, Math.ceil(Math.max(Math.abs(b[0] - a[0]), Math.abs(b[1] - a[1]))));\n      for (let s = 0; s <= n; s++) {\n        const u = s / n;\n        const x = a[0] + (b[0] - a[0]) * u, y = a[1] + (b[1] - a[1]) * u;\n        const ra = (ca >> 16) & 255, ga = (ca >> 8) & 255, ba = ca & 255;\n        const c = ((ra + (((cb >> 16) & 255) - ra) * u) << 16) | ((ga + (((cb >> 8) & 255) - ga) * u) << 8) | ((ba + ((cb & 255) - ba) * u) | 0);\n        cv.set(x, y, c);\n      }\n    };\n\n    const drawMesh = (m, t, alpha, size) => {\n      if (alpha <= 0.01 || size <= 0.01) return;\n      const ax = t * 0.53, ay = t * 0.71, az = t * 0.29;\n      const pts = m.vertices.map((v) => project(rotate([v[0] * size, v[1] * size, v[2] * size], ax, ay, az)));\n      const depthK = (d) => Math.max(0.3, Math.min(1, (dist + 1.3 - d) / 2.2)) * alpha;\n      const hue = t * 0.04;\n      const cols = pts.map((p, i) => shade(pal(hue + (i / pts.length) * 0.5), depthK(p[2]) * 1.1));\n      for (const [a, b] of m.edges) line3(pts[a], pts[b], cols[a], cols[b]);\n      for (let i = 0; i < pts.length; i++) {\n        const p = pts[i];\n        const g = depthK(p[2]);\n        if (g < 0.45) continue;\n        const c = shade(spark, g);\n        cv.set(p[0], p[1], c); cv.set(p[0] + 1, p[1], c); cv.set(p[0] - 1, p[1], c);\n        cv.set(p[0], p[1] + 1, c); cv.set(p[0], p[1] - 1, c);\n      }\n    };\n\n    return (t) => {\n      cv.clear();\n      const cycle = t / hold;\n      const idx = Math.floor(cycle) % shapes.length;\n      const f = cycle - Math.floor(cycle);\n      const trans = 0.22; // fraction of each hold spent crossfading into the next shape\n      if (f > 1 - trans) {\n        const u = (f - (1 - trans)) / trans;\n        drawMesh(shapes[idx], t, 1 - u, 1 - easing.inCubic(u) * 0.7);\n        drawMesh(shapes[(idx + 1) % shapes.length], t, u, easing.outBack(u));\n      } else drawMesh(shapes[idx], t, 1, 1);\n\n      // Orbit ring: tilted, slowly precessing dust; far side dimmer.\n      const tilt = 0.42 + Math.sin(t * 0.3) * 0.1;\n      for (const d of dust) {\n        const a = d.a + t * 0.35;\n        const p = project(rotate([Math.cos(a) * d.r, d.y, Math.sin(a) * d.r], tilt, 0, 0.18));\n        const k = Math.max(0.15, Math.min(1, (dist + 1.6 - p[2]) / 3.2));\n        cv.set(p[0], p[1], shade(pal(a / (Math.PI * 2) + t * 0.05), k * 0.8));\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/ops/_kit.js": "// Shared kit for the Linux & Ops gallery.\n//\n// Ops facets imitate real tools, so they're mostly text laid out on a grid.\n// Everything here is stateless — content is a pure function of t — which keeps\n// facets deterministic, resize-safe, and able to jump to any moment (catalog\n// thumbnails, snapshots, the curl site).\n//\n// ASCII mode (caps.unicode === false: TERM=linux, LANG=C, serial consoles):\n// G() hands out 7-bit fallbacks, and the writer transliterates anything that\n// slips through, one character for one character so columns never shift.\n\nimport { ATTR } from '../../buffer.js';\nimport { NONE, mix } from '../../color.js';\n\nexport { ATTR, NONE };\n\nexport const uni = (ctx) => ctx.caps?.unicode !== false;\n\n/** Glyph set: Unicode where available, single-column ASCII otherwise. */\nexport function G(ctx) {\n  return uni(ctx)\n    ? { h: '─', v: '│', tl: '┌', tr: '┐', bl: '└', br: '┘', tee: '├', teeL: '┤', teeT: '┬', teeB: '┴', cross: '┼',\n      branch: '├─', last: '└─', pipe: '│ ', dot: '●', ring: '○', ok: '✓', bad: '✗', arrow: '→', up: '↑', down: '↓',\n      full: '█', dark: '▓', mid: '▒', light: '░', ell: '…', deg: '°', bullet: '•', tri: '▸', pl: '', plThin: '',\n      branchIcon: '', warn: '⚠', spark: '▁▂▃▄▅▆▇█', lt: '‹', gt: '›' }\n    : { h: '-', v: '|', tl: '+', tr: '+', bl: '+', br: '+', tee: '+', teeL: '+', teeT: '+', teeB: '+', cross: '+',\n      branch: '|-', last: '`-', pipe: '| ', dot: '*', ring: 'o', ok: '+', bad: 'x', arrow: '>', up: '^', down: 'v',\n      full: '#', dark: '#', mid: '=', light: '.', ell: '.', deg: ' ', bullet: '*', tri: '>', pl: '>', plThin: '>',\n      branchIcon: '', warn: '!', spark: '_.-=+*#@', lt: '<', gt: '>' };\n}\n\n/** Theme-derived terminal colors (what a 16-color scheme would call them). */\nexport function C(ctx) {\n  const t = ctx.theme;\n  return {\n    fg: t.ink, muted: t.muted, dim: t.dim, line: t.line, bg: ctx.bg, panel: t.panel, panel2: t.panel2,\n    // text ON a colored fill (status bars, selections): the theme's own background color, also\n    // with --transparent (where bg is the terminal default and would vanish on the fill)\n    onFill: t.bg,\n    green: t.pos, red: t.neg, yellow: t.warn, blue: t.info, magenta: t.crit, accent: t.accent,\n    cyan: mix(t.info, t.pos, 0.45), orange: mix(t.warn, t.neg, 0.4),\n  };\n}\n\n// One-for-one transliteration so ASCII mode never shifts a column.\nconst ASCII_MAP = {\n  '─': '-', '━': '-', '═': '=', '│': '|', '┃': '|', '║': '|', '┌': '+', '┐': '+', '└': '+', '┘': '+', '├': '+', '┤': '+',\n  '┬': '+', '┴': '+', '┼': '+', '╭': '+', '╮': '+', '╰': '+', '╯': '+', '╔': '+', '╗': '+', '╚': '+', '╝': '+',\n  '●': '*', '○': 'o', '◉': '@', '◯': 'o', '✓': '+', '✔': '+', '✗': 'x', '✖': 'x', '→': '>', '←': '<', '↑': '^', '↓': 'v',\n  '█': '#', '▓': '#', '▒': '=', '░': '.', '▏': '|', '▎': '|', '▍': '|', '▌': '|', '▋': '|', '▊': '|', '▉': '#',\n  '▁': '_', '▂': '_', '▃': '.', '▄': '-', '▅': '=', '▆': '+', '▇': '*', '…': '.', '°': ' ', '•': '*', '▸': '>', '▶': '>',\n  '·': '.', '×': 'x', 'µ': 'u', '⚠': '!', '❯': '>', '❭': '>', '✘': 'x', '⇡': '^', '⇣': 'v', '±': '+', '⎈': '*', '–': '-', '—': '-', '‹': '<', '›': '>', '«': '<', '»': '>', '': '>', '': '>', '': ' ',\n};\nexport function asciify(s) {\n  let out = '';\n  for (const ch of String(s)) {\n    const cp = ch.codePointAt(0);\n    out += cp >= 0x20 && cp <= 0x7e ? ch : ASCII_MAP[ch] ?? (cp < 0x20 ? ' ' : '?');\n  }\n  return out;\n}\n\n/** Text writer bound to a facet's surface: clips, and asciifies in ASCII mode. */\nexport function writer(ctx) {\n  const b = ctx.buffer;\n  const a = !uni(ctx);\n  const W = () => b.width, H = () => b.height;\n  const fix = (s) => (a ? asciify(s) : String(s));\n  const put = (x, y, s, fg, bg, attr = 0) => (y < 0 || y >= b.height ? x + [...String(s)].length : b.text(x, y, fix(s), fg, bg, attr));\n  return {\n    put,\n    /** Write [text, fg, attr?, bg?] segments left to right; returns the end column. */\n    seg(x, y, parts) { for (const p of parts) if (p) x = put(x, y, p[0], p[1], p[3], p[2] || 0); return x; },\n    /** Right-align text ending at column `xEnd` (exclusive). */\n    right(xEnd, y, s, fg, bg, attr = 0) { const t = fix(s); return put(xEnd - [...t].length, y, t, fg, bg, attr); },\n    fill(x, y, w, h, ch, fg, bg) { b.fill(x, y, w, h, ch, fg, bg); },\n    rowBg(y, bg, x = 0, w = b.width) { b.fill(x, y, w, 1, ' ', undefined, bg); },\n    hline(x, y, w, ch, fg) { if (w > 0) put(x, y, (a ? asciify(ch) : ch).repeat(w), fg); },\n    get width() { return W(); },\n    get height() { return H(); },\n    ascii: a,\n  };\n}\n\n// ── deterministic time → content helpers ────────────────────────────────────\n\n/** Integer hash → [0, 1). Same inputs, same output, everywhere. */\nexport function hash(n, salt = 0) {\n  let h = (Math.imul((n | 0) ^ 0x9e3779b9, 0x85ebca6b) ^ Math.imul(salt | 0, 0xc2b2ae35)) >>> 0;\n  h ^= h >>> 15; h = Math.imul(h, 0x2c1b3c6d) >>> 0;\n  h ^= h >>> 12; h = Math.imul(h, 0x297a2d39) >>> 0;\n  h ^= h >>> 15;\n  return (h >>> 0) / 4294967296;\n}\nexport const pick = (arr, n, salt) => arr[Math.floor(hash(n, salt) * arr.length)];\n\n/** Smooth wander in [0, 1] — meters that drift like real load. */\nexport function walk(ctx, key, t, speed = 0.35) {\n  const v = ctx.noise.fbm(key * 7.31 + 0.5, t * speed, (ctx.seed || 0) * 0.13, 3);\n  return Math.min(1, Math.max(0, 0.5 + v * 0.95));\n}\n\n/** Visible substring of `text` typed at `cps` starting at `start` seconds. */\nexport function typed(text, t, start = 0, cps = 18) {\n  const n = Math.max(0, Math.floor((t - start) * cps));\n  return { text: text.slice(0, n), done: n >= text.length, at: start + text.length / cps };\n}\n\n/** Blinking cursor state (500 ms phase). */\nexport const blink = (t, period = 1) => (t % period) < period / 2;\n\nconst BASE = Date.UTC(2026, 9, 13, 14, 2, 11) / 1000;\nconst z2 = (n) => String(n).padStart(2, '0');\n/** Wall clock derived from t (UTC, fictional but stable). */\nexport function clock(t, offset = 0) {\n  const s = Math.floor(BASE + offset + t);\n  const d = new Date(s * 1000);\n  return {\n    hms: `${z2(d.getUTCHours())}:${z2(d.getUTCMinutes())}:${z2(d.getUTCSeconds())}`,\n    hm: `${z2(d.getUTCHours())}:${z2(d.getUTCMinutes())}`,\n    date: `${d.getUTCFullYear()}-${z2(d.getUTCMonth() + 1)}-${z2(d.getUTCDate())}`,\n    day: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d.getUTCDay()],\n    mon: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][d.getUTCMonth()],\n    dd: z2(d.getUTCDate()),\n    sec: s,\n  };\n}\n\nexport const pad = (s, w) => { s = String(s); return s.length >= w ? s.slice(0, w) : s + ' '.repeat(w - s.length); };\nexport const lpad = (s, w) => { s = String(s); return s.length >= w ? s.slice(-w) : ' '.repeat(w - s.length) + s; };\nexport const fixed = (n, d = 1) => Number(n).toFixed(d);\nexport function human(bytes, si = false) {\n  const k = si ? 1000 : 1024, u = si ? ['B', 'kB', 'MB', 'GB', 'TB'] : ['B', 'K', 'M', 'G', 'T'];\n  let i = 0;\n  while (bytes >= k && i < u.length - 1) { bytes /= k; i++; }\n  return `${bytes >= 100 || i === 0 ? Math.round(bytes) : bytes.toFixed(1)}${u[i]}`;\n}\n\n/**\n * Stream of lines that arrive at `rate` per second; returns the last `rows`\n * line indices visible at t (stateless). `pre` lines are already on screen at t = 0.\n */\nexport function stream(t, rate, rows, pre = rows) {\n  const total = pre + Math.floor(t * rate);\n  const out = [];\n  for (let i = Math.max(0, total - rows); i < total; i++) out.push(i);\n  return out;\n}\n\n/**\n * htop-style meter: `label[|||||||        23.4%]`. parts: [{ v: 0..1, color }]\n * stacked left to right; `text` is right-aligned inside the brackets.\n */\nexport function meter(w, ctx, x, y, width, label, parts, text, { labelColor, bracketColor } = {}) {\n  const c = C(ctx);\n  const lab = label;\n  let cx = w.put(x, y, lab, labelColor ?? c.cyan, undefined, ATTR.BOLD);\n  cx = w.put(cx, y, '[', bracketColor ?? c.fg, undefined, ATTR.BOLD);\n  const inner = Math.max(0, x + width - cx - 1);\n  const t = String(text);\n  let used = 0;\n  for (const p of parts) {\n    const n = Math.max(0, Math.min(inner - used, Math.round(p.v * inner)));\n    if (n) w.put(cx + used, y, '|'.repeat(n), p.color);\n    used += n;\n  }\n  const tx = cx + inner - t.length;\n  if (inner > t.length + 1) w.put(tx, y, t, used > tx - cx ? c.fg : c.muted);\n  w.put(cx + inner, y, ']', bracketColor ?? c.fg, undefined, ATTR.BOLD);\n}\n\n/** Simple sparkline over values in [0,1]; Unicode bars or an ASCII ramp. */\nexport function spark(w, ctx, x, y, values, color) {\n  const g = G(ctx).spark;\n  let s = '';\n  for (const v of values) s += g[Math.max(0, Math.min(g.length - 1, Math.round(v * (g.length - 1))))];\n  w.put(x, y, s, color);\n}\n\n/** A `user@host:dir$ ` prompt; returns the column after it. */\nexport function bashPrompt(w, ctx, x, y, { user = 'ops', host = 'node03', dir = '~', root = false } = {}) {\n  const c = C(ctx);\n  return w.seg(x, y, [[`${user}@${host}`, root ? c.red : c.green, ATTR.BOLD], [':', c.fg], [dir, c.blue, ATTR.BOLD], [root ? '# ' : '$ ', c.fg]]);\n}\n\n/**\n * Script a terminal session: a list of { cmd, out: (t, rows) => lines[], dur }\n * steps, typed then printed. Returns the visible lines (array of segment arrays)\n * ending at time t, newest last — callers draw the last `rows` of them.\n */\nexport function session(ctx, t, steps, { cps = 22, promptOpts = {}, gap = 0.6 } = {}) {\n  const lines = [];\n  let at = 0.3;\n  // A step may carry its own `prompt` (the prompt it is typed at) and `after`\n  // (the prompt once it finishes) — so prompts can show git state, exit codes, etc.\n  for (let i = 0; i < steps.length; i++) {\n    const step = steps[i];\n    const po = step.prompt ?? promptOpts;\n    const ty = typed(step.cmd, t, at, cps);\n    lines.push({ prompt: po, cmd: ty.text, cursor: !ty.done && t >= at, typing: true });\n    if (!ty.done) return lines;\n    const outStart = ty.at + 0.25 + (step.wait ?? 0);\n    if (t < outStart) { lines.at(-1).cursor = blink(t, 0.8); return lines; }\n    const out = step.out(t - outStart);\n    for (const l of out) lines.push({ parts: l });\n    at = outStart + (step.dur ?? 2.5) + gap;\n    const next = step.after ?? steps[i + 1]?.prompt ?? po;\n    if (t < at) { lines.push({ prompt: next, cmd: '', cursor: blink(t, 0.8) }); return lines; }\n  }\n  lines.push({ prompt: steps.at(-1)?.after ?? steps.at(-1)?.prompt ?? promptOpts, cmd: '', cursor: blink(t, 0.8) });\n  return lines;\n}\n\n/** Draw session() lines into rows [y0, y0+rows) — the newest at the bottom, like a real terminal. */\nexport function drawSession(w, ctx, lines, x, y0, rows, promptFn = bashPrompt) {\n  const c = C(ctx);\n  const vis = lines.slice(-rows);\n  vis.forEach((l, i) => {\n    const y = y0 + i;\n    if (l.parts) { w.seg(x, y, l.parts); return; }\n    let cx = promptFn(w, ctx, x, y, l.prompt);\n    cx = w.put(cx, y, l.cmd, c.fg);\n    if (l.cursor) w.put(cx, y, ' ', c.bg, c.fg);\n  });\n}\n\n/**\n * Split a segment line into rows of `width` cells, wrapping exactly as a tty\n * does (hard, mid-word). For real tool output that is genuinely longer than\n * the terminal — never for our own layout.\n */\nexport function wrapParts(parts, width) {\n  const rows = [[]];\n  let col = 0;\n  for (const [text, ...rest] of parts) {\n    let s = String(text);\n    while (s.length) {\n      if (col >= width) { rows.push([]); col = 0; }\n      const take = s.slice(0, width - col);\n      rows.at(-1).push([take, ...rest]);\n      col += take.length;\n      s = s.slice(take.length);\n    }\n  }\n  return rows;\n}\n",
  "prism-term/src/facets/ops/containers.js": "// Containers & clusters: docker pull, docker stats, kubectl get pods -w, rollout status, and a k9s-style console.\nimport { G, C, ATTR, writer, walk, hash, typed, bashPrompt, pad, lpad, fixed, uni } from './_kit.js';\n\n/** docker's units.HumanSize: SI units, up to 4 significant digits. */\nconst hs = (b) => { const u = ['B', 'kB', 'MB', 'GB']; let i = 0; while (b >= 1000 && i < 3) { b /= 1000; i++; } return `${Number(b.toPrecision(4))}${u[i]}`; };\nconst mb = (b) => (b >= 1e9 ? `${fixed(b / 1e9, 2)}GB` : b >= 1e6 ? `${fixed(b / 1e6, 1)}MB` : `${fixed(b / 1e3, 1)}kB`);\n\nconst pull = {\n  id: 'ops-docker-pull',\n  title: 'docker pull',\n  category: 'Containers & Clusters',\n  description: 'docker pull with parallel layers going Waiting, Downloading (byte counters, ETA; bars past 110 cols), Verifying, Extracting, Pull complete, then the digest.',\n  tags: ['docker', 'pull', 'layers', 'image', 'progress', 'registry'],\n  kind: 'progress',\n  size: { w: 80, h: 13 },\n  minSize: { w: 60, h: 6 },\n  loop: 22,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx);\n    const W = ctx.width, H = ctx.height;\n    const LAYERS = [['a2318d6c47ec', 29.1e6, 0.0], ['3f1a2b0c9d88', 43.7e6, 0.4], ['7c0e5d4b2a19', 4.2e6, 0.8], ['e81f9a0b3c47', 118.6e6, 1.1], ['0bd4c8e7f611', 1.3e6, 2.0], ['52a9e0d1c3f8', 0.31e6, 2.2], ['c6e2b7a40d95', 12.9e6, 2.6]];\n    const RATE = 12e6;\n    return (t) => {\n      const T = t % 22;\n      const cmd = 'docker pull registry.example/shop/api:2.14.1';\n      const ty = typed(cmd, T, 0.2, 26);\n      const out = [];\n      let x = bashPrompt(w, ctx, 0, 0);\n      w.put(x, 0, ty.text, c.fg);\n      if (!ty.done) { w.put(x + ty.text.length, 0, ' ', c.bg, c.fg); return; }\n      const t0 = ty.at + 0.4;\n      if (T < t0) return;\n      const s = T - t0;\n      out.push([['2.14.1: Pulling from shop/api', c.fg]]);\n      let allDone = true;\n      // Like docker's own progress: the [===>  ] bar only appears on terminals wider than 110\n      // columns; narrower ones get the byte counters and an ETA, which is what 80 columns shows.\n      const bar = (f) => (W > 110 ? `[${'='.repeat(Math.floor(f * 50))}>${' '.repeat(50 - Math.floor(f * 50))}] ` : '');\n      const prog = (cur, size, rate) => `${bar(cur / size)}${lpad(hs(cur), 8)}/${hs(size)}${W > 50 && cur < size ? ` ${Math.max(0, Math.ceil((size - cur) / rate))}s` : ''}`;\n      for (const [id, size, delay] of LAYERS) {\n        const start = delay * 1.4, dl = size / RATE, ex = size / 160e6 + 0.3;\n        let status;\n        if (s < start) { status = [['Waiting', c.muted]]; allDone = false; }\n        else if (s < start + dl) { status = [['Downloading ', c.yellow], [prog(size * (s - start) / dl, size, RATE), c.fg]]; allDone = false; }\n        else if (s < start + dl + 0.3) { status = [['Verifying Checksum', c.cyan]]; allDone = false; }\n        else if (s < start + dl + 0.6) { status = [['Download complete', c.cyan]]; allDone = false; }\n        else if (s < start + dl + 0.6 + ex) { status = [['Extracting ', c.blue], [prog(size * (s - start - dl - 0.6) / ex, size, size / ex), c.fg]]; allDone = false; }\n        else status = [['Pull complete', c.green]];\n        out.push([[`${id}: `, c.fg], ...status]);\n      }\n      if (allDone) {\n        out.push([['Digest: sha256:9b1f0c2e7d4a5f63b8e1c09d2a7f4e6b0c3d8a1f5e2b7c4d9a0e6f1b3c8d5a2e', c.muted]]);\n        out.push([['Status: ', c.fg], ['Downloaded newer image for registry.example/shop/api:2.14.1', c.fg, ATTR.BOLD]]);\n        out.push([['registry.example/shop/api:2.14.1', c.fg]]);\n      }\n      out.slice(0, H - 1).forEach((parts, i) => w.seg(0, 1 + i, parts));\n    };\n  },\n};\n\nconst stats = {\n  id: 'ops-docker-stats',\n  title: 'docker stats',\n  category: 'Containers & Clusters',\n  description: 'docker stats refreshing every half second per container: name, CPU %, memory usage against its limit, MEM %, network I/O and PIDs.',\n  tags: ['docker', 'stats', 'containers', 'cpu', 'memory', 'monitor'],\n  kind: 'tui',\n  size: { w: 80, h: 10 },\n  minSize: { w: 64, h: 4 },\n  loop: 20,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx);\n    const CS = [['shop-api-1', 0.35, 412e6, 2147e6], ['shop-api-2', 0.32, 398e6, 2147e6], ['shop-worker-1', 0.6, 820e6, 1073e6], ['shop-db-1', 0.22, 1.32e9, 4294e6],\n      ['shop-redis-1', 0.04, 61e6, 512e6], ['shop-nginx-1', 0.03, 14e6, 256e6], ['otel-collector', 0.05, 88e6, 512e6], ['node-exporter', 0.01, 18e6, 128e6]];\n    const mib = (b) => (b >= 1073741824 ? `${fixed(b / 1073741824, 2)}GiB` : `${fixed(b / 1048576, 1)}MiB`);\n    return (t) => {\n      const T = Math.floor(t * 2) / 2;\n      w.put(0, 0, `${pad('NAME', 16)}${pad('CPU %', 9)}${pad('MEM USAGE / LIMIT', 21)}${pad('MEM %', 8)}${pad('NET I/O', 19)}PIDS`, c.fg, undefined, ATTR.BOLD);\n      CS.slice(0, ctx.height - 1).forEach(([name, cpu, mem, lim], i) => {\n        const cp = cpu * 100 * (0.55 + walk(ctx, i, T, 0.6) * 0.9);\n        const m = mem * (0.96 + walk(ctx, i + 20, T, 0.1) * 0.08);\n        const rx = 1.2e9 * (i + 1) * 0.3 + T * 2.1e6 * cpu * 3, tx = rx * 0.81;\n        const pct = m / lim * 100;\n        w.seg(0, 1 + i, [[pad(name, 16), c.cyan], [pad(`${fixed(cp, 2)}%`, 9), cp > 80 ? c.red : c.fg], [pad(`${mib(m)} / ${mib(lim)}`, 21), c.fg], [pad(`${fixed(pct, 2)}%`, 8), pct > 75 ? c.yellow : c.fg], [pad(`${mb(rx)} / ${mb(tx)}`, 19), c.fg], [String(4 + ((i * 7) % 30)), c.fg]]);\n      });\n    };\n  },\n};\n\nconst STC = (c, s) => (s === 'Running' ? c.green : /Creating|Pending|Init/.test(s) ? c.yellow : s === 'Terminating' ? c.magenta : s === 'Completed' ? c.blue : c.red);\n\nconst pods = {\n  id: 'ops-kubectl-pods',\n  title: 'kubectl get pods -w',\n  category: 'Containers & Clusters',\n  description: 'kubectl get pods -w during a rolling update: new pods go Pending → ContainerCreating → Running while old ones go Terminating, appended live.',\n  tags: ['kubernetes', 'kubectl', 'pods', 'rollout', 'watch', 'k8s'],\n  kind: 'tui',\n  size: { w: 80, h: 16 },\n  minSize: { w: 60, h: 6 },\n  loop: 30,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx);\n    const old = ['api-6c8f7d9b5c-4tq2m', 'api-6c8f7d9b5c-7mzxk', 'api-6c8f7d9b5c-r9b8n'];\n    const neu = ['api-7d9c6b5f8d-2xk4p', 'api-7d9c6b5f8d-9qwlz', 'api-7d9c6b5f8d-hv6s1'];\n    const events = [[0, 'worker-5f4b9c7d6f-q8l2c', '1/1', 'Running', 0, '6d2h'], [0, 'redis-0', '1/1', 'Running', 0, '19d'], ...old.map((n) => [0, n, '1/1', 'Running', 0, '3d4h'])];\n    let at = 2;\n    neu.forEach((n, i) => {\n      events.push([at, n, '0/1', 'Pending', 0, '0s'], [at + 0.2, n, '0/1', 'ContainerCreating', 0, '0s'], [at + 2.4, n, '1/1', 'Running', 0, '3s']);\n      events.push([at + 3.2, old[i], '1/1', 'Terminating', 0, '3d4h'], [at + 4.4, old[i], '0/1', 'Terminating', 0, '3d4h']);\n      at += 5.5;\n    });\n    return (t) => {\n      const T = t % 30;\n      const ty = typed('kubectl get pods -n shop -w', T, 0.1, 30);\n      let x = bashPrompt(w, ctx, 0, 0, { dir: '~/deploy' });\n      w.put(x, 0, ty.text, c.fg);\n      if (!ty.done) return;\n      const visible = events.filter((e) => T >= ty.at + 0.3 + e[0]);\n      const lines = [[[`${pad('NAME', 28)}${pad('READY', 8)}${pad('STATUS', 20)}${pad('RESTARTS', 11)}AGE`, c.fg, ATTR.BOLD]],\n        ...visible.map(([, n, r, s, rs, age]) => [[pad(n, 28), c.fg], [pad(r, 8), c.fg], [pad(s, 20), STC(c, s)], [pad(rs, 11), c.fg], [age, c.fg]])];\n      const room = ctx.height - 1;\n      const head = lines[0];\n      const body = lines.slice(1).slice(-(room - 1));\n      [head, ...body].forEach((parts, i) => w.seg(0, 1 + i, parts));\n    };\n  },\n};\n\nconst rollout = {\n  id: 'ops-kubectl-rollout',\n  title: 'kubectl rollout status',\n  category: 'Containers & Clusters',\n  description: 'kubectl rollout status counting up \"N out of 5 new replicas have been updated…\" to \"successfully rolled out\", with a replica strip below.',\n  tags: ['kubernetes', 'kubectl', 'rollout', 'deployment', 'replicas'],\n  kind: 'progress',\n  size: { w: 80, h: 12 },\n  minSize: { w: 60, h: 5 },\n  loop: 20,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx), g = G(ctx);\n    return (t) => {\n      const T = t % 20;\n      const ty = typed('kubectl rollout status deployment/api -n shop', T, 0.1, 30);\n      let x = bashPrompt(w, ctx, 0, 0, { dir: '~/deploy' });\n      w.put(x, 0, ty.text, c.fg);\n      if (!ty.done) return;\n      const s = T - ty.at - 0.3;\n      const updated = Math.max(0, Math.min(5, Math.floor(s / 2) + 1));\n      const msg = [];\n      const wait = 'Waiting for deployment \"api\" rollout to finish: ';\n      for (let k = 1; k <= updated; k++) msg.push([`${wait}${k} out of 5 new replicas have been updated...`, c.fg]);\n      if (s > 10.5) msg.push([`${wait}1 old replicas are pending termination...`, c.fg]);\n      if (s > 11.5) msg.push([`${wait}4 of 5 updated replicas are available...`, c.fg]);\n      const done = s > 12.5;\n      if (done) msg.push(['deployment \"api\" successfully rolled out', c.green, ATTR.BOLD]);\n      const W = ctx.width, H = ctx.height;\n      const rows = [];\n      for (const [text, fg, attr] of msg) for (let i = 0; i < text.length; i += W) rows.push([text.slice(i, i + W), fg, attr]); // wraps, as a real tty would\n      const strip = H >= 6 ? 2 : 0;\n      rows.slice(-(H - 1 - strip)).forEach(([text, fg, attr], i) => w.put(0, 1 + i, text, fg, undefined, attr));\n      if (strip) {\n        const y = H - 1;\n        let cx = w.put(0, y, 'replicas ', c.muted);\n        for (let i = 0; i < 5; i++) {\n          const isNew = i < updated, ready = isNew && s > i * 2 + 1.6;\n          cx = w.put(cx, y, `${g.full}${g.full}`, isNew ? (ready ? c.green : c.yellow) : c.dim);\n          cx = w.put(cx, y, ' ', c.fg);\n        }\n        w.put(cx + 1, y, done ? '5/5 ready · revision 42' : `${Math.min(5, updated)}/5 updated · revision 42`, done ? c.green : c.muted);\n      }\n    };\n  },\n};\n\nconst k9s = {\n  id: 'ops-k9s',\n  title: 'k9s-style Pod Console',\n  category: 'Containers & Clusters',\n  description: 'A k9s-style cluster console: context/cluster header with CPU/MEM, key hints, a titled pod table colored by status, a moving selection and a crumb bar.',\n  tags: ['k9s', 'kubernetes', 'pods', 'console', 'tui', 'k8s'],\n  kind: 'tui',\n  size: { w: 80, h: 20 },\n  minSize: { w: 60, h: 10 },\n  loop: 24,\n  glyphs: 'ascii',\n  color: '256',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx), g = G(ctx);\n    const W = ctx.width, H = ctx.height;\n    const PODS = [['api-7d9c6b5f8d-2xk4p', 'Running', '10.42.1.18', 'node-a'], ['api-7d9c6b5f8d-9qwlz', 'Running', '10.42.2.7', 'node-b'], ['api-7d9c6b5f8d-hv6s1', 'Running', '10.42.3.22', 'node-c'],\n      ['worker-5f4b9c7d6f-q8l2c', 'Running', '10.42.1.31', 'node-a'], ['worker-5f4b9c7d6f-x7d9p', 'CrashLoopBackOff', '10.42.2.12', 'node-b'], ['reindex-28817640-k2j4f', 'Completed', '10.42.3.9', 'node-c'],\n      ['redis-0', 'Running', '10.42.1.4', 'node-a'], ['postgres-0', 'Running', '10.42.2.3', 'node-b'], ['migrate-2-14-1-vb7hs', 'ContainerCreating', '', 'node-c']];\n    return (t) => {\n      const cpu = Math.round(28 + walk(ctx, 1, t, 0.2) * 14), mem = Math.round(58 + walk(ctx, 2, t, 0.1) * 6);\n      const info = [['Context:', 'prod-east'], ['Cluster:', 'prod-east'], ['User:', 'ops'], ['K9s Rev:', 'v0.32.5'], ['K8s Rev:', 'v1.31.2'], ['CPU:', `${cpu}%`], ['MEM:', `${mem}%`]];\n      const keys = [['<0>', 'all'], ['<1>', 'shop'], ['<2>', 'default'], ['<a>', 'Attach'], ['<d>', 'Describe'], ['<l>', 'Logs'], ['<s>', 'Shell']];\n      const top = Math.min(info.length, H - 6);\n      for (let i = 0; i < top; i++) {\n        w.seg(1, i, [[pad(info[i][0], 9), c.orange, ATTR.BOLD], [info[i][1], c.fg, ATTR.BOLD]]);\n        if (keys[i] && W >= 64) w.seg(34, i, [[pad(keys[i][0], 5), c.blue, ATTR.BOLD], [keys[i][1], c.muted]]);\n      }\n      const y0 = top;\n      const title = ` Pods(shop)[${PODS.length}] `;\n      const boxH = H - y0 - 1;\n      const side = Math.max(0, Math.floor((W - 2 - title.length) / 2));\n      w.seg(0, y0, [[g.tl + g.h.repeat(side), c.cyan], [title, c.cyan, ATTR.BOLD], [g.h.repeat(Math.max(0, W - 2 - side - title.length)) + g.tr, c.cyan]]);\n      for (let y = y0 + 1; y < y0 + boxH - 1; y++) { w.put(0, y, g.v, c.cyan); w.put(W - 1, y, g.v, c.cyan); }\n      w.put(0, y0 + boxH - 1, g.bl + g.h.repeat(W - 2) + g.br, c.cyan);\n      const cols = (a) => ` ${pad(a[0], 26)}${pad(a[1], 6)}${pad(a[2], 18)}${pad(a[3], 9)}${pad(a[4], 11)}${a[5]}`;\n      w.put(1, y0 + 1, cols(['NAME' + (uni(ctx) ? '↑' : '^'), 'READY', 'STATUS', 'RESTARTS', 'IP', 'NODE']).slice(0, W - 2), c.fg, undefined, ATTR.BOLD);\n      const sel = Math.floor(t / 2.5) % PODS.length;\n      PODS.slice(0, Math.max(0, boxH - 3)).forEach(([n, s, ip, node], i) => {\n        const restarts = s === 'CrashLoopBackOff' ? String(7 + Math.floor(t / 8)) : '0';\n        const ready = s === 'Running' ? '1/1' : s === 'Completed' ? '0/1' : '0/1';\n        const y = y0 + 2 + i;\n        const text = cols([n, ready, s, restarts, ip || '<none>', node]).slice(0, W - 2);\n        const col = s === 'Running' ? c.cyan : s === 'Completed' ? c.muted : s === 'CrashLoopBackOff' ? c.red : c.yellow;\n        if (i === sel) { w.fill(1, y, W - 2, 1, ' ', c.bg, col); w.put(1, y, text, c.bg, col); }\n        else w.put(1, y, text, col);\n      });\n      w.seg(0, H - 1, [[' <pod> ', c.bg, ATTR.BOLD, c.orange], [' shop ', c.bg, 0, c.cyan]]);\n    };\n  },\n};\n\nexport default [pull, stats, pods, rollout, k9s];\n",
  "prism-term/src/facets/ops/df.js": "// df -h — the familiar table plus usage bars (dfc-style), red at ≥ 90 %, with /var/log filling up.\nimport { G, C, ATTR, writer, walk, pad, lpad } from './_kit.js';\n\nconst FS = [\n  ['/dev/mapper/vg0-root', 98, 61.0, '/', 0],\n  ['/dev/nvme0n1p1', 1.0, 0.21, '/boot/efi', 0],\n  ['/dev/mapper/vg0-var', 40, 22.3, '/var', 0.4],\n  ['/dev/mapper/vg0-varlog', 20, 15.9, '/var/log', 1.6],\n  ['/dev/mapper/vg0-home', 200, 41.7, '/home', 0],\n  ['/dev/sdb1', 7300, 6820, '/srv/backup', 0.2],\n  ['tmpfs', 7.8, 0.003, '/dev/shm', -1],\n  ['tmpfs', 1.6, 0.0026, '/run', -1],\n  ['nfs01.example:/export/share', 2000, 905, '/mnt/share', 0.1],\n];\nconst unit = (g) => (g >= 1000 ? `${(g / 1024).toFixed(1)}T` : g >= 10 ? `${Math.round(g)}G` : g >= 1 ? `${g.toFixed(1)}G` : `${Math.round(g * 1024)}M`);\n\nexport default {\n  id: 'ops-df',\n  title: 'df -h',\n  category: 'Disk & Storage',\n  description: 'df -h with Size/Used/Avail/Use% columns and a usage bar per mount; /var/log creeps toward full and turns red past 90%.',\n  tags: ['df', 'disk', 'filesystem', 'storage', 'usage'],\n  kind: 'tui',\n  size: { w: 80, h: 12 },\n  minSize: { w: 48, h: 5 },\n  loop: 30,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx), g = G(ctx);\n    const W = ctx.width, H = ctx.height;\n    return (t) => {\n      const nameW = Math.min(28, Math.max(10, W - 50));\n      const barW = Math.max(0, W - nameW - 40);\n      w.put(0, 0, `${pad('Filesystem', nameW)}  Size  Used Avail Use% ${pad('Mounted on', 12)}${barW > 4 ? ' Usage' : ''}`.slice(0, W), c.fg, undefined, ATTR.BOLD);\n      FS.slice(0, H - 1).forEach(([dev, size, used, mnt, growth], i) => {\n        const u = growth < 0 ? used * (0.5 + walk(ctx, i, t, 0.4)) : Math.min(size * 0.985, used + growth * t / 30 * size * 0.06 * (1 + walk(ctx, i, t, 0.2)));\n        const pct = Math.round(u / size * 100);\n        const col = pct >= 90 ? c.red : pct >= 75 ? c.yellow : c.green;\n        const y = i + 1;\n        const x = w.seg(0, y, [[pad(dev, nameW), dev.startsWith('tmpfs') ? c.muted : c.fg], [`  ${lpad(unit(size), 4)}  ${lpad(unit(u), 4)}  ${lpad(unit(size - u), 4)} `, c.fg], [lpad(`${pct}%`, 4), col, pct >= 90 ? ATTR.BOLD : 0], [` ${pad(mnt, 12)}`, c.blue]]);\n        if (barW > 4) {\n          const n = Math.round(pct / 100 * barW);\n          w.seg(x + 1, y, [['[', c.muted], [g.full.repeat(n), col], [g.light.repeat(barW - n), c.dim], [']', c.muted]]);\n        }\n      });\n    };\n  },\n};\n",
  "prism-term/src/facets/ops/htop.js": "// htop — per-core CPU meters, Mem/Swp, tasks/load/uptime, a live process table and the F-key bar.\nimport { G, C, ATTR, writer, walk, meter, clock, lpad, pad, fixed, human } from './_kit.js';\n\nconst PROCS = [\n  { pid: 1, user: 'root', cmd: '/sbin/init', base: 0.0, mem: 0.1, virt: 166e6, res: 12.1e6 },\n  { pid: 612, user: 'root', cmd: '/lib/systemd/systemd-journald', base: 0.3, mem: 0.6, virt: 88e6, res: 61e6 },\n  { pid: 941, user: 'root', cmd: '/usr/sbin/sshd -D', base: 0.0, mem: 0.1, virt: 15e6, res: 9.2e6 },\n  { pid: 1288, user: 'www-data', cmd: 'nginx: worker process', base: 3.1, mem: 0.4, virt: 61e6, res: 33e6 },\n  { pid: 1289, user: 'www-data', cmd: 'nginx: worker process', base: 2.4, mem: 0.4, virt: 61e6, res: 32e6 },\n  { pid: 2044, user: 'postgres', cmd: 'postgres: 16/main: checkpointer', base: 0.6, mem: 2.1, virt: 4.3e9, res: 330e6 },\n  { pid: 2051, user: 'postgres', cmd: 'postgres: app orders 10.20.3.40(51544) SELECT', base: 18, mem: 3.4, virt: 4.4e9, res: 540e6 },\n  { pid: 3110, user: 'app', cmd: 'node /srv/api/server.js --port 8080', base: 24, mem: 6.2, virt: 11.4e9, res: 980e6 },\n  { pid: 3172, user: 'app', cmd: 'java -Xmx2g -jar /srv/ledger/ledger.jar', base: 11, mem: 13.8, virt: 6.1e9, res: 2.1e9 },\n  { pid: 3390, user: 'redis', cmd: '/usr/bin/redis-server 127.0.0.1:6379', base: 1.8, mem: 1.2, virt: 120e6, res: 190e6 },\n  { pid: 4107, user: 'root', cmd: '/usr/bin/dockerd -H fd://', base: 0.9, mem: 0.7, virt: 2.1e9, res: 110e6 },\n  { pid: 4120, user: 'root', cmd: '/usr/bin/containerd', base: 0.4, mem: 0.3, virt: 1.9e9, res: 52e6 },\n  { pid: 5562, user: 'nobody', cmd: '/usr/bin/node_exporter --collector.systemd', base: 0.7, mem: 0.1, virt: 726e6, res: 21e6 },\n  { pid: 7781, user: 'ops', cmd: 'htop', base: 1.2, mem: 0.0, virt: 9.1e6, res: 5.2e6 },\n  { pid: 8803, user: 'app', cmd: 'python3 /srv/jobs/reindex.py --batch 500', base: 9, mem: 1.9, virt: 410e6, res: 300e6 },\n];\n\nexport default {\n  id: 'ops-htop',\n  title: 'htop',\n  category: 'System Monitors',\n  description: 'htop as you know it: per-core CPU meters, Mem/Swp bars, tasks, load average and uptime over a live, CPU-sorted process list.',\n  tags: ['htop', 'top', 'cpu', 'memory', 'processes', 'monitor'],\n  kind: 'tui',\n  size: { w: 80, h: 24 },\n  minSize: { w: 40, h: 10 },\n  loop: 20,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx);\n    const c = C(ctx), g = G(ctx);\n    const W = ctx.width, H = ctx.height;\n    const cores = W >= 60 && H >= 16 ? 8 : 4;\n    const twoCol = W >= 60;\n    return (t) => {\n      const load = cores === 8 ? 0.35 + walk(ctx, 99, t, 0.08) * 0.4 : 0.4;\n      const colW = twoCol ? Math.floor((W - 1) / 2) : W;\n      const perCol = twoCol ? cores / 2 : cores;\n      let total = 0;\n      for (let i = 0; i < cores; i++) {\n        const col = twoCol ? Math.floor(i / perCol) : 0;\n        const row = twoCol ? i % perCol : i;\n        if (row >= H - 2) continue;\n        const u = Math.min(0.92, load * (0.45 + walk(ctx, i, t, 0.6) * 0.9));\n        const sys = u * (0.15 + walk(ctx, i + 40, t, 0.4) * 0.2), nice = u * 0.08 * walk(ctx, i + 80, t, 0.3);\n        total += u;\n        const lab = lpad(String(i + 1), cores >= 10 ? 3 : 2);\n        meter(w, ctx, col * (colW + 1), row, colW, lab, [{ v: nice, color: c.blue }, { v: u - sys - nice, color: c.green }, { v: sys, color: c.red }], `${fixed(u * 100)}%`);\n      }\n      let y = twoCol ? perCol : cores;\n      // Memory and swap (left), tasks / load / uptime (right).\n      const used = 5.4e9 + walk(ctx, 200, t, 0.05) * 0.9e9, buf = 0.42e9, cache = 6.1e9, totalMem = 15.5e9;\n      const memLine = y, info = [];\n      if (y < H - 1) meter(w, ctx, 0, y++, colW, 'Mem', [{ v: used / totalMem, color: c.green }, { v: buf / totalMem, color: c.blue }, { v: cache / totalMem, color: c.yellow }], `${human(used)}/${human(totalMem)}`);\n      if (y < H - 1) meter(w, ctx, 0, y++, colW, 'Swp', [{ v: 0.055, color: c.red }], `${human(112e6)}/${human(2.0e9)}`);\n      const running = 1 + Math.round(walk(ctx, 300, t, 0.9) * 3);\n      const la = [total / cores * cores * 0.42 + 0.3, 0.98, 0.87].map((v, i) => (i === 0 ? v : v + walk(ctx, 310 + i, t, 0.02) * 0.2));\n      const up = 41 * 86400 + 3 * 3600 + 12 * 60 + 7 + Math.floor(t);\n      const upS = `${Math.floor(up / 86400)} days, ${lpad(Math.floor(up % 86400 / 3600), 2).replace(' ', '0')}:${String(Math.floor(up % 3600 / 60)).padStart(2, '0')}:${String(up % 60).padStart(2, '0')}`;\n      info.push([['Tasks: ', c.cyan], ['214', c.fg, ATTR.BOLD], [', ', c.cyan], ['1032', c.fg, ATTR.BOLD], [' thr; ', c.cyan], [String(running), c.green, ATTR.BOLD], [' running', c.cyan]]);\n      info.push([['Load average: ', c.cyan], [fixed(la[0], 2), c.fg, ATTR.BOLD], [' ', c.fg], [fixed(la[1], 2), c.fg], [' ', c.fg], [fixed(la[2], 2), c.muted]]);\n      info.push([['Uptime: ', c.cyan], [upS, c.fg, ATTR.BOLD]]);\n      if (twoCol) {\n        let iy = perCol;\n        for (const parts of info) if (iy < H - 1) w.seg(colW + 2, iy++, parts);\n        y = Math.max(y, iy);\n      } else for (const parts of info) if (y < H - 1) w.seg(1, y++, parts);\n      y = Math.max(y, memLine) + 1;\n      if (y >= H - 1) return;\n\n      // Process table: header row on green, CPU-sorted, selection bar on cyan.\n      const procs = PROCS.map((p, i) => ({ ...p, cpu: Math.max(0, p.base * (0.55 + walk(ctx, 500 + i, t, 0.7) * 0.9)) }))\n        .sort((a, b) => b.cpu - a.cpu);\n      // Same field widths for header and rows, so columns line up exactly.\n      const F = [7, 10, 4, 4, 6, 6, 2, 6, 5, 9];\n      const cols = (v) => [lpad(v[0], F[0]), ' ' + pad(v[1], F[1] - 1), lpad(v[2], F[2]), lpad(v[3], F[3]), lpad(v[4], F[4]), lpad(v[5], F[5]), lpad(v[6], F[6]), lpad(v[7], F[7]), lpad(v[8], F[8]), lpad(v[9], F[9]), '  '];\n      const head = cols(['PID', 'USER', 'PRI', 'NI', 'VIRT', 'RES', 'S', `CPU%${twoCol ? g.down : 'v'}`, 'MEM%', 'TIME+']).join('') + 'Command';\n      w.rowBg(y, c.green);\n      w.put(0, y, head.slice(0, W), c.onFill, c.green, ATTR.BOLD);\n      y++;\n      const sel = Math.floor(t / 3) % 3;\n      procs.forEach((p, i) => {\n        if (y >= H - 1) return;\n        const secs = p.cpu * 4 + t * p.cpu / 100 + p.pid % 97;\n        const time = `${Math.floor(secs / 60)}:${(secs % 60).toFixed(2).padStart(5, '0')}`;\n        const f = cols([p.pid, p.user, 20, 0, human(p.virt), human(p.res), p.cpu > 1 ? 'R' : 'S', fixed(p.cpu), fixed(p.mem), time]);\n        if (i === sel) { w.rowBg(y, c.cyan); w.put(0, y, (f.join('') + p.cmd).slice(0, W), c.onFill, c.cyan); y++; return; }\n        const sp = p.cmd.indexOf(' '), exe = sp < 0 ? p.cmd : p.cmd.slice(0, sp), base = exe.lastIndexOf('/');\n        w.seg(0, y, [[f[0], c.fg], [f[1], p.user === 'root' ? c.fg : c.muted], [f.slice(2, 4).join(''), c.fg], [f.slice(4, 6).join(''), c.cyan],\n          [f[6], p.cpu > 1 ? c.green : c.fg, p.cpu > 1 ? ATTR.BOLD : 0], [f.slice(7, 10).join(''), c.fg], [f[10], c.fg],\n          [exe.slice(0, base + 1), c.muted], [exe.slice(base + 1), c.fg, ATTR.BOLD], [sp < 0 ? '' : p.cmd.slice(sp), c.fg]]);\n        y++;\n      });\n      // F-key bar on the last row.\n      const keys = [['F1', 'Help'], ['F2', 'Setup'], ['F3', 'Search'], ['F4', 'Filter'], ['F5', 'Tree'], ['F6', 'SortBy'], ['F7', 'Nice -'], ['F8', 'Nice +'], ['F9', 'Kill'], ['F10', 'Quit']];\n      let x = 0;\n      for (const [k, l] of keys) {\n        if (x >= W) break;\n        x = w.put(x, H - 1, k, c.fg, c.bg);\n        x = w.put(x, H - 1, pad(l, 6), c.onFill, c.cyan);\n      }\n      if (x < W) w.fill(x, H - 1, W - x, 1, ' ', c.onFill, c.cyan);\n    };\n  },\n};\n",
  "prism-term/src/facets/ops/iostat.js": "// iostat -dx 2 — avg-cpu plus a device table (r/s, w/s, kB/s, await, aqu-sz, %util) with util bars.\nimport { G, C, ATTR, writer, walk, pad, lpad, fixed } from './_kit.js';\n\nconst DEVS = [['nvme0n1', 1.0, 0.25], ['nvme1n1', 0.7, 0.2], ['sda', 0.3, 2.4], ['sdb', 0.15, 6.8], ['dm-0', 0.9, 0.3], ['dm-1', 0.4, 0.4]];\n\nexport default {\n  id: 'ops-iostat',\n  title: 'iostat -dx',\n  category: 'Disk & Storage',\n  description: 'iostat -dx 2 refreshing: avg-cpu, then per-device reads/writes, throughput, await, queue size and %util with a utilization bar.',\n  tags: ['iostat', 'sysstat', 'disk', 'io', 'latency'],\n  kind: 'tui',\n  size: { w: 80, h: 13 },\n  minSize: { w: 60, h: 6 },\n  loop: 20,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx), g = G(ctx);\n    const W = ctx.width, H = ctx.height;\n    return (t) => {\n      const T = Math.floor(t / 2) * 2;\n      const v = (k, s = 0.5) => walk(ctx, k, T, s);\n      const busy = v(1, 0.15);\n      w.put(0, 0, 'avg-cpu:  %user   %nice %system %iowait  %steal   %idle', c.fg, undefined, ATTR.BOLD);\n      const us = 8 + busy * 20, sy = 2 + busy * 5, io = 0.2 + busy ** 2 * 6;\n      w.put(0, 1, `          ${lpad(fixed(us, 2), 6)}    0.00  ${lpad(fixed(sy, 2), 6)}  ${lpad(fixed(io, 2), 6)}    0.00  ${lpad(fixed(100 - us - sy - io, 2), 6)}`, c.fg);\n      if (H < 5) return;\n      const head = `${pad('Device', 9)}${lpad('r/s', 8)}${lpad('w/s', 8)}${lpad('rkB/s', 10)}${lpad('wkB/s', 10)}${lpad('r_await', 8)}${lpad('w_await', 8)}${lpad('aqu-sz', 7)}${lpad('%util', 7)}`;\n      w.put(0, 3, head.slice(0, W), c.fg, undefined, ATTR.BOLD);\n      const barW = Math.max(0, W - head.length - 3);\n      DEVS.slice(0, H - 4).forEach(([dev, load, lat], i) => {\n        const k = busy * load * (0.6 + v(10 + i, 0.8) * 0.8);\n        const rs = k * 820, ws = k * 1460, rkb = rs * 64, wkb = ws * 31, ra = lat * (1 + k), wa = lat * 1.6 * (1 + k * 2);\n        const util = Math.min(99.9, k * 100 * (dev.startsWith('sd') ? 1.8 : 0.9));\n        const y = 4 + i;\n        const x = w.seg(0, y, [[pad(dev, 9), c.cyan], [`${lpad(fixed(rs, 2), 8)}${lpad(fixed(ws, 2), 8)}${lpad(fixed(rkb, 2), 10)}${lpad(fixed(wkb, 2), 10)}${lpad(fixed(ra, 2), 8)}${lpad(fixed(wa, 2), 8)}${lpad(fixed(k * 3, 2), 7)}`, c.fg],\n          [lpad(fixed(util, 2), 7), util > 80 ? c.red : util > 50 ? c.yellow : c.fg, util > 80 ? ATTR.BOLD : 0]]);\n        if (barW > 3) { const n = Math.round(util / 100 * barW); w.seg(x + 1, y, [[g.full.repeat(n), util > 80 ? c.red : util > 50 ? c.yellow : c.green], [g.light.repeat(barW - n), c.dim]]); }\n      });\n    };\n  },\n};\n",
  "prism-term/src/facets/ops/load-watch.js": "// watch -n2 uptime — the watch header, the uptime line, and a braille chart of the 1-minute load.\nimport { C, ATTR, writer, walk, clock, fixed } from './_kit.js';\n\nexport default {\n  id: 'ops-load-watch',\n  title: 'Load Average Watch',\n  category: 'System Monitors',\n  description: 'watch -n2 uptime with a braille history of the 1/5/15-minute load against the core count, so a creeping box is obvious.',\n  tags: ['uptime', 'load average', 'watch', 'chart', 'cpu'],\n  kind: 'chart',\n  size: { w: 72, h: 14 },\n  minSize: { w: 40, h: 6 },\n  loop: 30,\n  glyphs: 'ascii',\n  color: '16',\n  canvas: 'line',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx);\n    const W = ctx.width, H = ctx.height;\n    const CORES = 8;\n    const chartTop = 3, chartH = Math.max(1, H - chartTop - 1), chartX = 5, chartW = Math.max(4, W - chartX - 1);\n    const cv = ctx.canvas('line', { cols: chartW, rows: chartH });\n    const load = (s) => 1.4 + walk(ctx, 1, s, 0.03) * 4.8 + (walk(ctx, 2, s, 0.25) - 0.5) * 1.2;\n    return (t) => {\n      const ck = clock(t);\n      const T = Math.floor(t / 2) * 2;\n      const l1 = load(T), l5 = 0.8 * load(T - 60) + 0.2 * l1, l15 = 0.9 * load(T - 300) + 0.1 * l5;\n      const head = `Every 2.0s: uptime`;\n      w.put(0, 0, head, c.fg);\n      w.right(W, 0, `node03: ${ck.day} ${ck.mon} ${ck.dd} ${ck.hms} 2026`, c.fg);\n      w.seg(0, 1, [[` ${ck.hms} up 41 days,  3:12,  2 users,  load average: `, c.fg], [fixed(l1, 2), l1 > CORES ? c.red : l1 > CORES * 0.7 ? c.yellow : c.green, ATTR.BOLD], [`, ${fixed(l5, 2)}, ${fixed(l15, 2)}`, c.fg]]);\n      if (H <= chartTop) return;\n      // Chart: the last chartW*2 samples (2 s apart), scaled to 0..CORES.\n      cv.clear();\n      const cores = cv.height * (1 - CORES / (CORES * 1.25));\n      for (let x = 0; x < cv.width; x++) cv.set(x, Math.round(cores), c.line);\n      let prev = null;\n      for (let x = 0; x < cv.width; x++) {\n        const s = T - (cv.width - 1 - x) * 2;\n        const v = load(s);\n        const y = Math.round((1 - Math.min(1, v / (CORES * 1.25))) * (cv.height - 1));\n        const col = v > CORES ? c.red : v > CORES * 0.7 ? c.yellow : c.green;\n        if (prev) cv.line(prev[0], prev[1], x, y, col); else cv.set(x, y, col);\n        for (let yy = y + 1; yy < cv.height; yy += 3) cv.set(x, yy, c.dim);\n        prev = [x, y];\n      }\n      cv.paint(ctx.buffer, chartX, chartTop);\n      w.put(0, chartTop, String(Math.round(CORES * 1.25)).padStart(4), c.muted);\n      w.put(0, chartTop + Math.round((1 - 0.8) * (chartH - 1)), `${CORES}c`.padStart(4), c.muted);\n      w.put(0, chartTop + chartH - 1, '0'.padStart(4), c.muted);\n      w.put(chartX, H - 1, `-${chartW * 4}s`, c.muted);\n      w.right(W, H - 1, 'now', c.muted);\n    };\n  },\n};\n",
  "prism-term/src/facets/ops/logs.js": "// Logs & journals: journalctl -f, an lnav-style access log, dmesg -wH, and an lnav-style histogram.\nimport { G, C, ATTR, writer, walk, hash, typed, clock, bashPrompt, wrapParts, pad, lpad, fixed, blink } from './_kit.js';\n\n/** Weighted, deterministic choice: items are [weight, ...]. */\nconst choose = (items, n, salt) => {\n  const total = items.reduce((a, it) => a + it[0], 0);\n  let r = hash(n, salt) * total;\n  for (const it of items) { r -= it[0]; if (r < 0) return it; }\n  return items.at(-1);\n};\n\n/** Entries arrive at i*gap plus jitter (monotonic); how many have arrived by t? */\nconst arrived = (t, gap, jit, salt) => {\n  const at = (i) => i * gap + hash(i, salt) * jit;\n  let n = Math.max(-1, Math.floor(t / gap) - 1);\n  while (at(n + 1) <= t) n++;\n  while (n >= 0 && at(n) > t) n--;\n  return { n, at };\n};\n\n/** Fill `rows` rows bottom-up from the newest entry, wrapping each like a tty (whole entries only). */\nfunction tail(w, entries, y0, rows, width) {\n  const out = [];\n  for (let i = entries.length - 1; i >= 0; i--) {\n    const r = wrapParts(entries[i], width);\n    if (out.length + r.length > rows) { if (!out.length) out.push(...r.slice(-rows)); break; }\n    out.unshift(...r);\n  }\n  out.forEach((parts, k) => w.seg(0, y0 + rows - out.length + k, parts));\n}\n\n// [weight, identifier, pid, priority, message]\nconst JOURNAL = [\n  [9, 'shop-api', 2210, 6, 'GET /api/v1/cart 200 18ms'],\n  [7, 'shop-api', 2210, 6, 'POST /api/v1/checkout 201 142ms'],\n  [3, 'shop-api', 2210, 4, 'pool: waited 412ms for a connection (limit 250ms)'],\n  [1.4, 'shop-api', 2210, 3, 'upstream payments.internal.example: context deadline exceeded'],\n  [1, 'shop-worker', 3118, 3, 'job reindex-28817640 failed: exit status 137'],\n  [3, 'shop-worker', 3118, 7, 'heartbeat ok (lag 3ms)'],\n  [3, 'sshd', 7702, 6, 'Accepted publickey for ops from 10.20.2.15 port 51022 ssh2: ED25519 SHA256:k3J9xQ2mR7tL0vB4nY8cW1z'],\n  [2, 'sshd', 7702, 6, 'pam_unix(sshd:session): session opened for user ops(uid=1000) by ops(uid=0)'],\n  [2, 'sshd', 7811, 6, 'Invalid user admin from 203.0.113.77 port 40112'],\n  [2, 'systemd', 1, 6, 'Started session-412.scope - Session 412 of User ops.'],\n  [2, 'systemd', 1, 6, 'logrotate.service: Deactivated successfully.'],\n  [2, 'CRON', 9001, 6, '(root) CMD (/usr/local/bin/backup.sh --quiet)'],\n  [2, 'kernel', 0, 4, '[UFW BLOCK] IN=eth0 OUT= SRC=198.51.100.23 DST=10.20.2.31 PROTO=TCP DPT=23'],\n  [2, 'postgres', 2044, 6, 'LOG:  checkpoint complete: wrote 1342 buffers (8.2%)'],\n  [1.5, 'sudo', 7790, 5, '     ops : TTY=pts/0 ; PWD=/home/ops ; USER=root ; COMMAND=/usr/bin/systemctl reload nginx'],\n  [1, 'systemd-resolved', 581, 5, 'Using degraded feature set UDP instead of UDP+EDNS0 for DNS server 10.20.0.2.'],\n  [0.6, 'kernel', 0, 4, 'nvme0n1: I/O 412 QID 3 timeout, completion polled'],\n  [0.3, 'smartd', 702, 2, 'Device: /dev/sdb [SAT], 8 Currently unreadable (pending) sectors'],\n];\n\nconst journal = {\n  id: 'ops-journalctl-follow',\n  title: 'journalctl -f',\n  category: 'Logs & Journals',\n  description: 'journalctl -f following the system journal with priority colors: crit/err red, warning yellow, notice bold, info plain and debug dim.',\n  tags: ['journalctl', 'systemd', 'journal', 'logs', 'follow', 'syslog'],\n  kind: 'tui',\n  size: { w: 80, h: 16 },\n  minSize: { w: 50, h: 5 },\n  loop: 40,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx);\n    const HIST = 10;\n    const prio = (p) => (p <= 3 ? [c.red, ATTR.BOLD] : p === 4 ? [c.yellow, ATTR.BOLD] : p === 5 ? [c.fg, ATTR.BOLD] : p === 7 ? [c.dim, 0] : [c.fg, 0]);\n    return (t) => {\n      const { n, at } = arrived(t, 0.7, 0.6, 13);\n      const entries = [];\n      for (let i = Math.max(-HIST, n - ctx.height); i <= n; i++) { // older lines have scrolled away\n        const [, id, pid, p, msg] = choose(JOURNAL, i + 1000, 5);\n        const ts = clock(i < 0 ? i * 0.7 : at(i)).hms;\n        const [fg, attr] = prio(p);\n        entries.push([[`Oct 13 ${ts} node03 ${id}${pid ? `[${pid}]` : ''}: `, c.fg], [msg, fg, attr]]);\n      }\n      const H = ctx.height;\n      const promptRow = [];\n      const total = entries.reduce((a, e) => a + wrapParts(e, ctx.width).length, 0);\n      if (total < H) { // the prompt hasn't scrolled away yet\n        const x = bashPrompt(w, ctx, 0, 0);\n        w.put(x, 0, 'journalctl -f', c.fg);\n        promptRow.push(1);\n      }\n      tail(w, entries, promptRow.length, H - promptRow.length, ctx.width);\n    };\n  },\n};\n\nconst PATHS = [[8, 'GET', '/api/v1/cart'], [6, 'GET', '/api/v1/products?page=2'], [4, 'POST', '/api/v1/checkout'], [5, 'GET', '/static/app.4f9c2e.js'],\n  [6, 'GET', '/healthz'], [2, 'GET', '/favicon.ico'], [2, 'POST', '/api/v1/login'], [1, 'GET', '/wp-login.php'], [1, 'GET', '/.env']];\nconst UAS = ['Mozilla/5.0 (X11; Linux x86_64)', 'curl/8.5.0', 'kube-probe/1.31', 'python-requests/2.32.3', 'Go-http-client/2.0'];\n\nconst access = {\n  id: 'ops-nginx-access',\n  title: 'nginx access log',\n  category: 'Logs & Journals',\n  description: 'An lnav-style view following the nginx access log: lines chopped at the edge, status codes by class, a 5xx burst tinting lines red, and a status bar.',\n  tags: ['nginx', 'access-log', 'http', 'status-codes', 'lnav', 'logs'],\n  kind: 'tui',\n  size: { w: 80, h: 16 },\n  minSize: { w: 56, h: 6 },\n  loop: 30,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx), g = G(ctx);\n    const W = ctx.width, H = ctx.height;\n    const line = (i, at) => {\n      const T = ((at % 30) + 30) % 30;\n      const burst = T > 14 && T < 19; // upstream falls over for ~5 s\n      const [, m, path] = choose(PATHS, i, 3);\n      let st = path === '/healthz' ? 200 : path.includes('wp-') || path === '/.env' ? 404 : path === '/favicon.ico' ? 304 : hash(i, 8) > 0.96 ? 499 : hash(i, 9) > 0.93 ? 301 : 200;\n      if (burst && path.startsWith('/api') && hash(i, 10) > 0.25) st = hash(i, 11) > 0.5 ? 502 : 504;\n      if (m === 'POST' && st === 200) st = path.endsWith('checkout') ? 201 : 200;\n      const ip = path.includes('wp-') || path === '/.env' ? '203.0.113.77' : path === '/healthz' ? '10.20.4.17' : `198.51.100.${10 + Math.floor(hash(i, 12) * 200)}`;\n      const ua = path === '/healthz' ? 'kube-probe/1.31' : UAS[Math.floor(hash(i, 13) * UAS.length)];\n      const bytes = st >= 500 ? 157 + (st % 10) * 3 : st === 304 ? 0 : Math.floor(300 + hash(i, 14) * 24000);\n      return { ip, ts: clock(at).hms, m, path, st, bytes, ua };\n    };\n    return (t) => {\n      const { n, at } = arrived(t, 0.24, 0.2, 21);\n      const rows = H - 2;\n      const L = [];\n      let n4 = 0, n5 = 0;\n      for (let i = Math.max(-40, n - 120); i <= n; i++) { // counts over a recent window, for the status bar\n        const e = line(i, i < 0 ? i * 0.24 : at(i));\n        if (e.st >= 500) n5++; else if (e.st >= 400) n4++;\n        if (i > n - rows) L.push(e);\n      }\n      const top = L.at(-1);\n      w.rowBg(0, c.panel2, 0, W);\n      w.seg(0, 0, [[` 2026-10-13 ${top.ts} `, c.fg, ATTR.BOLD, c.panel2], [` LOG ${g.gt} access.log ${g.gt} access_log`, c.muted, 0, c.panel2]]);\n      L.forEach((e, k) => {\n        const y = 1 + k;\n        const sc = e.st >= 500 ? c.red : e.st >= 400 ? c.yellow : e.st >= 300 ? c.cyan : c.green;\n        const tint = e.st >= 500 ? c.red : e.st >= 400 ? c.yellow : null;\n        w.seg(0, y, [\n          [e.ip, tint ?? c.cyan], [' - - [', tint ?? c.fg], [`13/Oct/2026:${e.ts} +0000`, tint ?? c.muted], ['] \"', tint ?? c.fg],\n          [`${e.m} ${e.path} HTTP/2.0`, tint ?? c.fg, ATTR.BOLD], ['\" ', tint ?? c.fg], [String(e.st), sc, ATTR.BOLD],\n          [` ${e.bytes} \"-\" \"${e.ua}\"`, tint ?? c.fg],\n        ]);\n      });\n      const y = H - 1;\n      w.rowBg(y, c.panel2, 0, W);\n      const left = ` L${String(48211 + n).replace(/\\B(?=(\\d{3})+$)/g, ',')}  100% `;\n      w.seg(0, y, [[left, c.fg, ATTR.BOLD, c.panel2], [` ${n5} errors `, n5 ? c.red : c.muted, ATTR.BOLD, c.panel2], [` ${n4} warnings `, n4 ? c.yellow : c.muted, 0, c.panel2]]);\n      w.right(W, y, '?:View Help ', c.muted, c.panel2);\n    };\n  },\n};\n\n// dmesg -wH: [t_seconds, level, subsystem, message]. Backlog first, then the live sequence.\nconst KERN = [\n  [-3.1, 6, 'EXT4-fs (dm-0)', 'mounted filesystem 3f1c9a2e-77b0-4d1e-9c55-0a8e2f61b7d4 r/w with ordered data mode. Quota mode: none.'],\n  [-2.6, 6, 'virtio_net virtio0 eth0', 'link up'],\n  [-2.2, 6, 'IPv6', 'ADDRCONF(NETDEV_CHANGE): eth0: link becomes ready'],\n  [-1.4, 4, 'perf', 'interrupt took too long (2503 > 2500), lowering kernel.perf_event_max_sample_rate to 79750'],\n  [-0.6, 6, 'br-6f1d0c', 'port 2(veth3a91c2e) entered forwarding state'],\n  [1.5, 6, 'usb 1-2', 'new high-speed USB device number 7 using xhci_hcd'],\n  [1.9, 6, 'usb-storage 1-2:1.0', 'USB Mass Storage device detected'],\n  [3.2, 5, 'sd 6:0:0:0', '[sdc] 62914560 512-byte logical blocks: (32.2 GB/30.0 GiB)'],\n  [3.3, 6, 'sd 6:0:0:0', '[sdc] Attached SCSI removable disk'],\n  [6.0, 4, 'TCP', 'request_sock_TCP: Possible SYN flooding on port 443. Sending cookies.'],\n  [9.4, 3, 'shop-worker[3118]', 'segfault at 0 ip 000055d0c4a1b2e3 sp 00007ffd2b9c41a8 error 4 in shop-worker[55d0c4a00000+1f000]'],\n  [13.0, 4, 'shop-api', 'invoked oom-killer: gfp_mask=0x140cca(GFP_HIGHUSER_MOVABLE|__GFP_COMP), order=0, oom_score_adj=0'],\n  [13.1, 6, 'CPU', '3 PID: 2210 Comm: shop-api Not tainted 6.8.0-45-generic #45-Sable'],\n  [13.2, 3, 'Memory cgroup out of memory', 'Killed process 2210 (shop-api) total-vm:2183404kB, anon-rss:1021788kB, file-rss:2304kB, UID:1001'],\n  [13.5, 6, 'oom_reaper', 'reaped process 2210 (shop-api), now anon-rss:0kB, file-rss:0kB, shmem-rss:0kB'],\n  [17.8, 4, 'nvme nvme0', 'I/O 412 QID 3 timeout, completion polled'],\n  [21.0, 6, 'virtio_net virtio1 eth1', 'link down'],\n];\n\nconst dmesg = {\n  id: 'ops-dmesg',\n  title: 'dmesg -wH',\n  category: 'Logs & Journals',\n  description: 'dmesg -wH following the kernel ring buffer with relative timestamps: a USB disk attaches, a segfault, then an oom-kill sequence in red.',\n  tags: ['dmesg', 'kernel', 'oom-killer', 'segfault', 'ring-buffer', 'logs'],\n  kind: 'tui',\n  size: { w: 80, h: 18 },\n  minSize: { w: 56, h: 6 },\n  loop: 26,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx);\n    return (t) => {\n      const T = t % 26;\n      const ty = typed('sudo dmesg -wH', T, 0.2, 18);\n      const entries = [];\n      const head = [[['ops@node03', c.green, ATTR.BOLD], [':', c.fg], ['~', c.blue, ATTR.BOLD], ['$ ', c.fg], [ty.text, c.fg], ...(ty.done ? [] : [[' ', c.bg, 0, c.fg]])]];\n      if (ty.done) {\n        const t0 = ty.at + 0.3;\n        let prev = null;\n        for (const [s, lvl, sub, msg] of KERN) {\n          if (s >= 0 && T < t0 + s) break;\n          const abs = s < 0 ? s - 40 : s; // backlog came earlier\n          const minute = clock(abs).hm;\n          const stamp = prev === null || minute !== prev.minute ? `[Oct13 ${minute}]` : `[${lpad(`+${fixed(Math.max(0.000001, abs - prev.abs), 6)}`, 11)}]`;\n          prev = { minute, abs };\n          const mc = lvl <= 3 ? c.red : lvl === 4 ? c.yellow : c.fg;\n          entries.push([[stamp, c.green], [' '], [`${sub}: `, c.orange], [msg, mc, lvl <= 4 ? ATTR.BOLD : 0]]);\n        }\n      }\n      const H = ctx.height;\n      const rows = [...head, ...entries].flatMap((e) => wrapParts(e.map(([s, fg = c.fg, a = 0, bg]) => [s, fg, a, bg]), ctx.width));\n      rows.slice(-H).forEach((parts, k) => w.seg(0, k, parts));\n    };\n  },\n};\n\nconst hist = {\n  id: 'ops-log-histogram',\n  title: 'Log Histogram',\n  category: 'Logs & Journals',\n  description: 'An lnav-style histogram of log volume in 10-second buckets: bars split into errors, warnings and normal, with the current bucket filling live.',\n  tags: ['lnav', 'histogram', 'logs', 'errors', 'volume', 'timeline'],\n  kind: 'chart',\n  size: { w: 80, h: 16 },\n  minSize: { w: 50, h: 6 },\n  loop: 60,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx), g = G(ctx);\n    const W = ctx.width, H = ctx.height;\n    const bucket = (b) => {\n      const incident = Math.max(0, 1 - Math.abs(((b % 18) + 18) % 18 - 9) / 3); // an error storm every 3 min\n      const total = Math.round(180 + walk(ctx, 1, b, 0.3) * 160 + incident * 220);\n      const err = Math.round(total * (0.004 + incident * 0.22 + hash(b, 2) * 0.006));\n      const warn = Math.round(total * (0.03 + incident * 0.08 + hash(b, 3) * 0.03));\n      return { total, err, warn };\n    };\n    return (t) => {\n      const now = clock(0).sec + t;\n      const cur = Math.floor(now / 10);\n      const frac = (now / 10) - cur;\n      const rows = H - 2;\n      const list = [];\n      for (let b = cur - rows + 1; b <= cur; b++) {\n        const v = bucket(b);\n        if (b === cur) { v.total = Math.round(v.total * frac); v.err = Math.round(v.err * frac); v.warn = Math.round(v.warn * frac); }\n        list.push({ b, ...v });\n      }\n      const max = Math.max(...list.map((v) => v.total), 1);\n      w.rowBg(0, c.panel2, 0, W);\n      w.seg(0, 0, [[` ${clock(t).date} ${clock(t).hms} `, c.fg, ATTR.BOLD, c.panel2], [` HIST ${g.gt} syslog ${g.gt} 10s buckets`, c.muted, 0, c.panel2]]);\n      const labW = 9, numW = W >= 70 ? 22 : 0, barW = Math.max(4, W - labW - numW - 1);\n      list.forEach((v, k) => {\n        const y = 1 + k;\n        const hms = new Date(v.b * 10 * 1000).toISOString().slice(11, 19);\n        w.put(0, y, hms, v.b === cur ? c.fg : c.muted, undefined, v.b === cur ? ATTR.BOLD : 0);\n        const len = Math.round(v.total / max * barW), le = Math.round(v.err / max * barW), lw = Math.round(v.warn / max * barW);\n        const n = Math.max(0, len - le - lw);\n        w.seg(labW, y, [[g.full.repeat(le), c.red], [g.full.repeat(lw), c.yellow], [g.full.repeat(n), c.green]]);\n        if (numW) w.right(W, y, `${lpad(v.total, 4)} msg ${lpad(v.warn, 3)} w ${lpad(v.err, 3)} e`, v.err > 20 ? c.red : c.muted, undefined, v.err > 20 ? ATTR.BOLD : 0);\n      });\n      const y = H - 1;\n      w.rowBg(y, c.panel2, 0, W);\n      const errs = list.reduce((a, v) => a + v.err, 0), warns = list.reduce((a, v) => a + v.warn, 0);\n      w.seg(0, y, [[' Errors ', c.red, ATTR.BOLD, c.panel2], [g.full, c.red, 0, c.panel2], [`  Warnings `, c.yellow, ATTR.BOLD, c.panel2], [g.full, c.yellow, 0, c.panel2], ['  Normal ', c.green, ATTR.BOLD, c.panel2], [g.full, c.green, 0, c.panel2]]);\n      if (W >= 66) w.right(W, y, `${errs} err  ${warns} warn  q:Back `, c.muted, c.panel2);\n    };\n  },\n};\n\nexport default [journal, access, dmesg, hist];\n",
  "prism-term/src/facets/ops/motd.js": "// Login & MOTD: an ssh login with a landscape-style MOTD, a fastfetch-style card, a getty console login and fail2ban.\nimport { C, G, ATTR, writer, walk, typed, blink, clock, bashPrompt, session, drawSession, pad, fixed, uni } from './_kit.js';\n\n/** Draw the last `H` of a list of rows ({ parts } or { prompt, cmd, cursor }) — a scrolling tty. */\nfunction screen(w, ctx, rows, promptFn = bashPrompt) {\n  const c = C(ctx);\n  rows.slice(-ctx.height).forEach((r, y) => {\n    if (r.parts) { w.seg(0, y, r.parts); return; }\n    let x = promptFn(w, ctx, 0, y, r.prompt);\n    x = w.put(x, y, r.cmd, c.fg);\n    if (r.cursor) w.put(x, y, ' ', c.bg, c.fg);\n  });\n}\n\nconst motd = {\n  id: 'ops-motd',\n  title: 'SSH Login MOTD',\n  category: 'Login & MOTD',\n  description: 'ssh into a server and get the landscape-style MOTD: system load, disk, memory, processes, the IPv4 address, pending updates and a restart notice.',\n  tags: ['motd', 'ssh', 'login', 'sysinfo', 'banner', 'updates'],\n  kind: 'tui',\n  size: { w: 80, h: 24 },\n  minSize: { w: 60, h: 8 },\n  loop: 18,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx);\n    const local = { user: 'ops', host: 'ws-07' };\n    return (t) => {\n      const T = t % 18;\n      const rows = [];\n      const ssh = typed('ssh ops@node03.prod.example', T, 0.3, 22);\n      rows.push({ prompt: local, cmd: ssh.text, cursor: !ssh.done || blink(T, 0.8) });\n      if (!ssh.done || T < ssh.at + 1) { screen(w, ctx, rows); return; }\n      const now = clock(Math.floor(ssh.at + 1));\n      const load = fixed(0.38 + walk(ctx, 1, t, 0.2) * 0.1, 2);\n      const banner = [\n        [['Welcome to Sable Linux 26.04 LTS (GNU/Linux 6.8.0-45-generic x86_64)', c.fg, ATTR.BOLD]],\n        [],\n        [[' * Documentation:  https://docs.sable.example', c.fg]],\n        [[' * Management:     https://manage.sable.example', c.fg]],\n        [[' * Support:        https://help.sable.example', c.fg]],\n        [],\n        [[` System information as of ${now.day} ${now.mon} ${now.dd} ${now.hms} UTC 2026`, c.fg]],\n        [],\n        [[`  System load:  ${pad(load, 17)}  Processes:             213`, c.fg]],\n        [['  Usage of /:   61.3% of 96.73GB   Users logged in:       1', c.fg]],\n        [['  Memory usage: 38%                IPv4 address for eth0: 10.20.2.31', c.fg]],\n        [['  Swap usage:   0%', c.fg]],\n        [],\n        [['12 updates can be applied immediately.', c.fg]],\n        [['4 of these updates are standard security updates.', c.fg]],\n        [['To see these additional updates run: apt list --upgradable', c.fg]],\n        [],\n        [['*** System restart required ***', c.yellow, ATTR.BOLD]],\n        [['Last login: Tue Oct 13 09:41:57 2026 from 10.20.2.15', c.fg]],\n      ];\n      const shown = Math.min(banner.length, Math.floor((T - ssh.at - 1) * 70) + 1); // arrives in one quick burst\n      rows[0].cursor = false;\n      banner.slice(0, shown).forEach((parts) => rows.push({ parts }));\n      if (shown === banner.length) {\n        const after = session(ctx, T - (ssh.at + 1.6), [{ cmd: 'uptime', out: () => [[[` ${clock(Math.floor(T)).hms} up  4:48,  1 user,  load average: ${load}, 0.37, 0.31`, c.fg]]], dur: 30 }]);\n        after.forEach((l) => rows.push(l.parts ? { parts: l.parts } : { prompt: {}, cmd: l.cmd, cursor: l.cursor }));\n      }\n      screen(w, ctx, rows);\n    };\n  },\n};\n\nconst card = {\n  id: 'ops-sysinfo-card',\n  title: 'System Info Card',\n  category: 'Login & MOTD',\n  description: 'A fastfetch-style system card: an original ASCII prism splitting a beam into a spectrum, beside OS, kernel, uptime, CPU, memory, disk and IP.',\n  tags: ['fastfetch', 'neofetch', 'sysinfo', 'ascii-art', 'logo', 'login'],\n  kind: 'tui',\n  size: { w: 80, h: 18 },\n  minSize: { w: 44, h: 10 },\n  loop: 12,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx), g = G(ctx);\n    const W = ctx.width, H = ctx.height;\n    const band = uni(ctx) ? '━' : '=';\n    const SPECTRUM = [c.red, c.orange, c.yellow, c.green, c.blue, c.magenta];\n    // Original art: a beam enters the left face, and a spectrum fans out of the right one.\n    const drawLogo = (y0) => {\n      const apex = 14;\n      for (let r = 0; r < 12; r++) {\n        const y = y0 + r;\n        if (r === 0) { w.put(apex, y, '.', c.fg, undefined, ATTR.BOLD); continue; }\n        if (r === 11) { w.put(apex - r, y, `/${'_'.repeat(2 * r - 1)}\\\\`, c.fg, undefined, ATTR.BOLD); }\n        else { w.put(apex - r, y, '/', c.fg, undefined, ATTR.BOLD); w.put(apex + r, y, '\\\\', c.fg, undefined, ATTR.BOLD); }\n        if (r === 5) { w.put(0, y, '_'.repeat(apex - r), c.fg, undefined, ATTR.BOLD); w.put(apex - r + 1, y, '-'.repeat(6), c.muted); }\n        if (r === 6) w.put(apex - r + 8, y, '-'.repeat(4), c.muted); // the ray crosses the glass to the far face\n        if (r >= 6) w.put(apex + r + 1, y, band.repeat(Math.max(0, 30 - (apex + r + 1))), SPECTRUM[r - 6], undefined, ATTR.BOLD);\n      }\n    };\n    return (t) => {\n      const T = t % 12;\n      const ty = typed('fastfetch', T, 0.3, 14);\n      let x = bashPrompt(w, ctx, 0, 0);\n      x = w.put(x, 0, ty.text, c.fg);\n      if (!ty.done || T < ty.at + 0.35) { w.put(x, 0, ' ', c.bg, c.fg); return; }\n      const up = 4 * 60 + 48 + Math.floor(t / 60);\n      const mem = 6.01 + walk(ctx, 3, t, 0.2) * 0.12;\n      const info = [\n        ['OS', 'Sable Linux 26.04 LTS x86_64'], ['Host', 'KVM/QEMU Standard PC (Q35 + ICH9, 2009)'], ['Kernel', 'Linux 6.8.0-45-generic'],\n        ['Uptime', `${Math.floor(up / 60)} hours, ${up % 60} mins`], ['Packages', '1287 (dpkg)'], ['Shell', 'bash 5.2.21'], ['Terminal', '/dev/pts/0'],\n        ['CPU', '8 x Virtual CPU @ 2.60 GHz'], ['Memory', `${fixed(mem, 2)} GiB / 15.61 GiB (${Math.round(mem / 15.61 * 100)}%)`],\n        ['Swap', '0 B / 4.00 GiB (0%)'], ['Disk (/)', '59.31 GiB / 96.73 GiB (61%) - ext4'], ['Local IP (eth0)', '10.20.2.31/24'], ['Locale', 'C.UTF-8'],\n      ];\n      const logo = W >= 77 && H >= 13;\n      const ix = logo ? 32 : 0;\n      if (logo) drawLogo(1);\n      const room = H - 1;\n      const palette = room >= info.length + 4;\n      const lines = Math.max(0, Math.min(info.length, room - 2 - (palette ? 2 : 0)));\n      w.seg(ix, 1, [['ops', c.blue, ATTR.BOLD], ['@', c.fg], ['node03', c.blue, ATTR.BOLD]]);\n      w.put(ix, 2, '-'.repeat(10), c.fg);\n      info.slice(0, lines).forEach(([k, v], i) => w.seg(ix, 3 + i, [[k, c.blue, ATTR.BOLD], [': ', c.fg], [v, c.fg]]));\n      if (palette) {\n        let px = ix;\n        for (const col of [c.dim, c.red, c.green, c.yellow, c.blue, c.magenta, c.cyan, c.fg]) px = w.put(px, 4 + lines, g.full.repeat(3), col);\n      }\n    };\n  },\n};\n\nconst getty = {\n  id: 'ops-getty-login',\n  title: 'Console Login',\n  category: 'Login & MOTD',\n  description: 'A tty1 console: the /etc/issue banner with hostname and addresses, a mistyped password and \"Login incorrect\", then a successful login.',\n  tags: ['getty', 'console', 'login', 'issue', 'tty', 'banner'],\n  kind: 'tui',\n  size: { w: 80, h: 18 },\n  minSize: { w: 64, h: 8 },\n  loop: 22,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx);\n    const issue = [\n      [],\n      [['Sable Linux 26.04 LTS node03 tty1', c.fg, ATTR.BOLD]],\n      [],\n      [['  +--------------------------------------------------------+', c.fg]],\n      [['  |  node03.prod.example                                   |', c.fg]],\n      [['  |  Authorized use only. All activity may be monitored.   |', c.fg]],\n      [['  +--------------------------------------------------------+', c.fg]],\n      [['  eth0: 10.20.2.31  fe80::5054:ff:fe3a:21c4', c.fg]],\n      [],\n    ];\n    return (t) => {\n      const T = t % 22;\n      const rows = issue.map((parts) => ({ parts }));\n      const cur = (on) => (on ? [[' ', c.bg, 0, c.fg]] : []);\n      // [login typed at, password entered at, verdict at, ok]\n      const tries = [[1.4, 4.2, 6.6, false], [8.6, 11.0, 12.4, true]];\n      for (const [at, pw, verdict, ok] of tries) {\n        const user = typed('ops', T, at, 7);\n        const typing = T < at + 0.6;\n        rows.push({ parts: [['node03 login: ', c.fg], [user.text, c.fg], ...cur(typing && blink(T, 0.8))] });\n        if (typing) break;\n        rows.push({ parts: [['Password: ', c.fg], ...cur(T < pw && blink(T, 0.8))] });\n        if (T < verdict) break;\n        if (!ok) { rows.push({ parts: [['Login incorrect', c.fg]] }, { parts: [] }); continue; }\n        rows.push({ parts: [['Last login: Tue Oct 13 09:41:57 on tty1', c.fg]] });\n        const sh = session(ctx, T - verdict - 0.4, [{ cmd: 'systemctl is-system-running', out: () => [[['degraded', c.yellow, ATTR.BOLD]]], dur: 30 }]);\n        sh.forEach((l) => rows.push(l.parts ? { parts: l.parts } : { prompt: {}, cmd: l.cmd, cursor: l.cursor }));\n      }\n      screen(w, ctx, rows);\n    };\n  },\n};\n\nconst f2b = {\n  id: 'ops-fail2ban',\n  title: 'fail2ban Jail',\n  category: 'Login & MOTD',\n  description: 'fail2ban-client status for the sshd jail (failed and banned counts, the banned IP list), an unban, and the status again as attempts keep coming.',\n  tags: ['fail2ban', 'ssh', 'brute-force', 'ban', 'security', 'jail'],\n  kind: 'tui',\n  size: { w: 80, h: 18 },\n  minSize: { w: 64, h: 8 },\n  loop: 24,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx);\n    const status = (failed, total, banned, list) => () => [\n      [['Status for the jail: sshd', c.fg]],\n      [['|- Filter', c.fg]],\n      [['|  |- Currently failed: ', c.fg], [String(failed), c.fg, ATTR.BOLD]],\n      [['|  |- Total failed:     ', c.fg], [String(total), c.fg, ATTR.BOLD]],\n      [['|  `- Journal matches:  _SYSTEMD_UNIT=sshd.service + _COMM=sshd', c.fg]],\n      [['`- Actions', c.fg]],\n      [['   |- Currently banned: ', c.fg], [String(banned), c.fg, ATTR.BOLD]],\n      [['   |- Total banned:     ', c.fg], ['212', c.fg, ATTR.BOLD]],\n      [['   `- Banned IP list:   ', c.fg], [list.join(' '), c.fg]],\n    ];\n    const ips = ['203.0.113.77', '198.51.100.23', '192.0.2.140', '203.0.113.9'];\n    return (t) => {\n      const lines = session(ctx, t % 24, [\n        { cmd: 'sudo fail2ban-client status sshd', out: status(3, 1287, 4, ips), dur: 4 },\n        { cmd: 'sudo fail2ban-client set sshd unbanip 198.51.100.23', out: () => [[['1', c.fg]]], dur: 1.5 },\n        { cmd: 'sudo fail2ban-client status sshd', out: status(4, 1292, 3, ips.filter((ip) => ip !== '198.51.100.23')), dur: 8 },\n      ]);\n      drawSession(w, ctx, lines, 0, 0, ctx.height);\n    };\n  },\n};\n\nexport default [motd, card, getty, f2b];\n",
  "prism-term/src/facets/ops/mtr.js": "// mtr — \"My traceroute\": the header, key help line, and per-hop Loss%/Snt/Last/Avg/Best/Wrst/StDev updating.\nimport { C, ATTR, writer, hash, walk, pad, lpad, fixed, clock } from './_kit.js';\n\nconst HOPS = [\n  ['_gateway', 0.4, 0], ['10.20.0.1', 0.9, 0], ['100.64.12.1', 3.1, 0], ['198.51.100.17', 6.8, 0],\n  ['198.51.100.94', 7.2, 3], ['203.0.113.6', 12.4, 0], ['203.0.113.41', 18.9, 0], ['api.example', 22.6, 0],\n];\n\nexport default {\n  id: 'ops-mtr',\n  title: 'mtr',\n  category: 'Network',\n  description: 'mtr\\'s curses view: header and key help, then each hop\\'s Loss%, Snt, Last, Avg, Best, Wrst and StDev updating as probes return.',\n  tags: ['mtr', 'traceroute', 'latency', 'packet loss', 'network'],\n  kind: 'tui',\n  size: { w: 80, h: 14 },\n  minSize: { w: 64, h: 6 },\n  loop: 30,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx);\n    const W = ctx.width, H = ctx.height;\n    return (t) => {\n      const snt = 18 + Math.floor(t);\n      const ck = clock(t);\n      w.put(Math.max(0, Math.floor((W - 28) / 2)), 0, 'My traceroute  [v0.95]', c.fg, undefined, ATTR.BOLD);\n      w.put(0, 1, `node03 (10.20.2.31) -> api.example (192.0.2.80)`, c.fg);\n      w.right(W, 1, `${ck.date}T${ck.hms}+0000`, c.fg);\n      w.seg(0, 2, [['Keys:  ', c.fg], ['H', c.fg, ATTR.BOLD], ['elp   ', c.fg], ['D', c.fg, ATTR.BOLD], ['isplay mode   ', c.fg], ['R', c.fg, ATTR.BOLD], ['estart statistics   ', c.fg], ['O', c.fg, ATTR.BOLD], ['rder of fields   ', c.fg], ['q', c.fg, ATTR.BOLD], ['uit', c.fg]]);\n      const hostW = W - 47;\n      w.put(hostW + 4, 3, 'Packets               Pings', c.fg, undefined, ATTR.BOLD);\n      w.put(0, 4, ` ${pad('Host', hostW - 1)}${lpad('Loss%', 6)}${lpad('Snt', 6)}${lpad('Last', 7)}${lpad('Avg', 6)}${lpad('Best', 6)}${lpad('Wrst', 6)}${lpad('StDev', 6)}`, c.fg, undefined, ATTR.BOLD);\n      HOPS.slice(0, H - 5).forEach(([host, base, lossPct], i) => {\n        const lost = lossPct ? Math.round(snt * lossPct / 100 + (hash(Math.floor(t / 4), i) > 0.5 ? 1 : 0)) : 0;\n        const loss = lost / snt * 100;\n        const last = base * (0.9 + hash(Math.floor(t), i) * 0.4);\n        const avg = base * (1.02 + walk(ctx, i, t, 0.05) * 0.06), best = base * 0.88, wrst = base * (1.6 + hash(i, 9) * 1.4) + (i > 3 ? 8 : 0);\n        const y = 5 + i;\n        w.seg(0, y, [[` ${lpad(i + 1, 2)}. `, c.fg], [pad(host, hostW - 5), i === HOPS.length - 1 ? c.fg : c.cyan, i === HOPS.length - 1 ? ATTR.BOLD : 0],\n          [lpad(`${fixed(loss, 1)}%`, 6), loss > 0 ? c.red : c.fg, loss > 0 ? ATTR.BOLD : 0],\n          [`${lpad(snt, 6)}${lpad(fixed(last), 7)}${lpad(fixed(avg), 6)}${lpad(fixed(best), 6)}${lpad(fixed(wrst), 6)}${lpad(fixed((wrst - best) / 4), 6)}`, c.fg]]);\n      });\n    };\n  },\n};\n",
  "prism-term/src/facets/ops/ncdu.js": "// ncdu — the reverse-video title, a sorted directory listing with [#####] bars, a moving selection, and totals.\nimport { C, ATTR, writer, pad, lpad, fixed } from './_kit.js';\n\nconst DIRS = [\n  ['/lib', 18.4], ['/log', 9.2], ['/cache', 2.31], ['/www', 0.884], ['/backups', 0.512], ['/spool', 0.0412],\n  ['/tmp', 0.0188], ['/mail', 0.0012], ['/opt', 0.00041], ['/local', 0.00002],\n];\nconst size = (g) => (g >= 1 ? `${lpad(fixed(g, 1), 6)} GiB` : g >= 0.001 ? `${lpad(fixed(g * 1024, 1), 6)} MiB` : `${lpad(fixed(g * 1048576, 1), 6)} KiB`);\n\nexport default {\n  id: 'ops-ncdu',\n  title: 'ncdu',\n  category: 'Disk & Storage',\n  description: 'ncdu browsing /var: sizes and [##########] bars sorted largest first, a selection that walks the list, and the total disk usage footer.',\n  tags: ['ncdu', 'du', 'disk usage', 'storage', 'cleanup'],\n  kind: 'tui',\n  size: { w: 72, h: 16 },\n  minSize: { w: 44, h: 6 },\n  loop: 20,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx);\n    const W = ctx.width, H = ctx.height;\n    return (t) => {\n      w.rowBg(0, c.fg);\n      w.put(0, 0, 'ncdu 1.19 ~ Use the arrow keys to navigate, press ? for help'.slice(0, W), c.bg, c.fg);\n      w.put(0, 1, `--- /var ${'-'.repeat(Math.max(0, W - 9))}`, c.fg, undefined, ATTR.BOLD);\n      const rows = Math.max(0, H - 3);\n      const sel = Math.floor(t / 1.6) % Math.min(rows || 1, DIRS.length);\n      const max = DIRS[0][1];\n      DIRS.slice(0, rows).forEach(([name, g], i) => {\n        const n = Math.max(0, Math.round(g / max * 10));\n        const text = `${size(g)} [${'#'.repeat(n)}${' '.repeat(10 - n)}] ${name}`;\n        const y = 2 + i;\n        if (i === sel) { w.rowBg(y, c.fg); w.put(0, y, pad(text, W), c.bg, c.fg); }\n        else w.seg(0, y, [[size(g), c.fg, ATTR.BOLD], [' [', c.fg], ['#'.repeat(n), c.fg], [' '.repeat(10 - n), c.fg], ['] ', c.fg], [name, c.fg]]);\n      });\n      if (H >= 4) {\n        w.rowBg(H - 1, c.fg);\n        w.put(0, H - 1, ' Total disk usage:  31.4 GiB  Apparent size:  30.9 GiB  Items: 214388'.slice(0, W), c.bg, c.fg);\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/ops/net-commands.js": "// Typed network commands with authentic output: `ss -tulpn`, `ip -br -c addr`, and a hop-by-hop traceroute.\nimport { C, ATTR, writer, session, drawSession, typed, hash, pad, lpad, fixed } from './_kit.js';\n\nconst looped = (t, len) => t % len;\n\nconst ss = {\n  id: 'ops-ss-listen',\n  title: 'ss -tulpn',\n  category: 'Network',\n  description: 'Typing `sudo ss -tulpn` and getting the listening sockets: Netid, State, queues, local/peer addresses and the owning processes.',\n  tags: ['ss', 'sockets', 'ports', 'listening', 'netstat', 'network'],\n  kind: 'tui',\n  size: { w: 80, h: 13 },\n  minSize: { w: 60, h: 5 },\n  loop: 12,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx);\n    const rows = [\n      ['udp', 'UNCONN', '0', '0', '127.0.0.53%lo:53', '0.0.0.0:*', 'systemd-resolve', 581],\n      ['udp', 'UNCONN', '0', '0', '0.0.0.0:51820', '0.0.0.0:*', 'wg-quick', 0],\n      ['tcp', 'LISTEN', '0', '4096', '127.0.0.53%lo:53', '0.0.0.0:*', 'systemd-resolve', 581],\n      ['tcp', 'LISTEN', '0', '128', '0.0.0.0:22', '0.0.0.0:*', 'sshd', 941],\n      ['tcp', 'LISTEN', '0', '511', '0.0.0.0:80', '0.0.0.0:*', 'nginx', 1287],\n      ['tcp', 'LISTEN', '0', '511', '0.0.0.0:443', '0.0.0.0:*', 'nginx', 1287],\n      ['tcp', 'LISTEN', '0', '200', '127.0.0.1:5432', '0.0.0.0:*', 'postgres', 2040],\n      ['tcp', 'LISTEN', '0', '511', '127.0.0.1:6379', '0.0.0.0:*', 'redis-server', 3390],\n      ['tcp', 'LISTEN', '0', '4096', '*:9100', '*:*', 'node_exporter', 5562],\n    ];\n    const out = () => [\n      [[`${pad('Netid', 6)}${pad('State', 7)}${lpad('Recv-Q', 7)}${lpad('Send-Q', 7)} ${lpad('Local Address:Port', 22)} ${pad('Peer Address:Port', 18)}Process`, c.fg, ATTR.BOLD]],\n      ...rows.map(([n, s, rq, sq, l, p, proc, pid]) => [[pad(n, 6), n === 'tcp' ? c.cyan : c.magenta], [pad(s, 7), s === 'LISTEN' ? c.green : c.yellow], [`${lpad(rq, 7)}${lpad(sq, 7)} ${lpad(l, 22)} ${pad(p, 18)}`, c.fg], [`users:((\"${proc}\"${pid ? `,pid=${pid}` : ''},fd=${3 + (pid % 9)}))`, c.muted]]),\n    ];\n    return (t) => {\n      const lines = session(ctx, looped(t, 12), [{ cmd: 'sudo ss -tulpn', out, dur: 9 }]);\n      drawSession(w, ctx, lines, 0, 0, ctx.height);\n    };\n  },\n};\n\nconst ipbr = {\n  id: 'ops-ip-brief',\n  title: 'ip -br -c addr',\n  category: 'Network',\n  description: 'Typing `ip -br -c addr` then `ip -br -c link`: interfaces in color, UP green, DOWN red, IPv4 and IPv6 addresses and MACs.',\n  tags: ['ip', 'iproute2', 'interfaces', 'addresses', 'network'],\n  kind: 'tui',\n  size: { w: 76, h: 13 },\n  minSize: { w: 56, h: 5 },\n  loop: 14,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx);\n    const ifs = [['lo', 'UNKNOWN', '127.0.0.1/8 ::1/128', '00:00:00:00:00:00'], ['eth0', 'UP', '10.20.2.31/24 fe80::5054:ff:fe3a:21c4/64', '52:54:00:3a:21:c4'],\n      ['eth1', 'DOWN', '', '52:54:00:9b:07:11'], ['docker0', 'DOWN', '172.17.0.1/16', '02:42:6c:1e:8f:2a'], ['wg0', 'UNKNOWN', '10.99.0.3/24', '']];\n    const stc = (s) => (s === 'UP' ? c.green : s === 'DOWN' ? c.red : c.fg);\n    const addr = () => ifs.map(([n, s, a]) => [[pad(n, 17), c.cyan], [pad(s, 15), stc(s), ATTR.BOLD], ...a.split(' ').filter(Boolean).map((x) => [x + ' ', x.includes(':') ? c.blue : c.magenta])]);\n    const link = () => ifs.map(([n, s, , mac]) => [[pad(n, 17), c.cyan], [pad(s, 15), stc(s), ATTR.BOLD], [mac ? pad(mac, 18) : pad('none', 18), c.yellow], [`<${n === 'lo' ? 'LOOPBACK,UP,LOWER_UP' : s === 'UP' ? 'BROADCAST,MULTICAST,UP,LOWER_UP' : s === 'DOWN' ? 'NO-CARRIER,BROADCAST,MULTICAST,UP' : 'POINTOPOINT,NOARP,UP,LOWER_UP'}>`, c.fg]]);\n    return (t) => {\n      const lines = session(ctx, looped(t, 14), [{ cmd: 'ip -br -c addr', out: addr, dur: 3 }, { cmd: 'ip -br -c link', out: link, dur: 5 }]);\n      drawSession(w, ctx, lines, 0, 0, ctx.height);\n    };\n  },\n};\n\nconst trace = {\n  id: 'ops-traceroute',\n  title: 'traceroute',\n  category: 'Network',\n  description: 'traceroute revealing the path hop by hop: three probe times per hop as they return, with * * * for a router that won\\'t answer.',\n  tags: ['traceroute', 'hops', 'routing', 'latency', 'network'],\n  kind: 'tui',\n  size: { w: 78, h: 13 },\n  minSize: { w: 56, h: 5 },\n  loop: 16,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx);\n    const hops = [['_gateway', '10.20.2.1', 0.4], ['10.20.0.1', '10.20.0.1', 0.9], ['100.64.12.1', '100.64.12.1', 3.1], [null, null, 0],\n      ['edge1.transit.example', '198.51.100.17', 6.8], ['core4.transit.example', '198.51.100.94', 7.4], ['203.0.113.6', '203.0.113.6', 12.4],\n      ['lb2.api.example', '203.0.113.41', 18.9], ['api.example', '192.0.2.80', 22.6]];\n    return (t) => {\n      const T = t % 16;\n      const H = ctx.height;\n      const head = typed('traceroute api.example', T, 0.2, 20);\n      let y = 0;\n      const lines = [[['ops@node03', c.green, ATTR.BOLD], [':', c.fg], ['~', c.blue, ATTR.BOLD], ['$ ', c.fg], [head.text, c.fg]]];\n      if (head.done) {\n        lines.push([['traceroute to api.example (192.0.2.80), 30 hops max, 60 byte packets', c.fg]]);\n        hops.forEach(([name, ip, base], i) => {\n          const at = head.at + 0.6 + i * 1.1;\n          if (T < at) return;\n          const parts = [[`${lpad(i + 1, 2)}  `, c.fg]];\n          if (!name) { const n = Math.min(3, 1 + Math.floor((T - at) / 0.5)); parts.push(['* '.repeat(n).trim(), c.red]); lines.push(parts); return; }\n          parts.push([`${name} `, c.cyan], [`(${ip}) `, c.fg]);\n          for (let p = 0; p < 3; p++) if (T >= at + p * 0.25) parts.push([` ${fixed(base * (0.92 + hash(i * 3 + p, 4) * 0.2), 3)} ms`, c.fg]);\n          lines.push(parts);\n        });\n      }\n      lines.slice(-H).forEach((parts) => w.seg(0, y++, parts));\n    };\n  },\n};\n\nexport default [ss, ipbr, trace];\n",
  "prism-term/src/facets/ops/nload.js": "// nload — Incoming and Outgoing traffic graphs drawn with # and |, and Curr/Avg/Min/Max/Ttl on the right.\nimport { C, ATTR, writer, walk, hash, fixed } from './_kit.js';\n\nconst rate = (v) => (v >= 1000 ? `${fixed(v / 1000, 2)} GBit/s` : v >= 1 ? `${fixed(v, 2)} MBit/s` : `${fixed(v * 1000, 2)} kBit/s`);\n\nexport default {\n  id: 'ops-nload',\n  title: 'nload',\n  category: 'Network',\n  description: 'nload watching eth0: scrolling Incoming and Outgoing bar graphs in # and |, with Curr, Avg, Min, Max and Ttl figures for each direction.',\n  tags: ['nload', 'bandwidth', 'traffic', 'throughput', 'network', 'graph'],\n  kind: 'chart',\n  size: { w: 80, h: 22 },\n  minSize: { w: 50, h: 10 },\n  loop: 30,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx);\n    const W = ctx.width, H = ctx.height;\n    const series = (dir, k) => {\n      const base = dir ? 18 : 140;\n      const burst = hash(Math.floor(k / 6), dir + 3) > 0.8 ? 2.6 : 1;\n      return base * (0.35 + walk(ctx, dir * 50 + 1, k, 0.15) * 0.9) * burst * (0.85 + hash(k, dir) * 0.3);\n    };\n    const panel = (y0, h, dir, label, k, ttl) => {\n      const statW = 22, gw = Math.max(4, W - statW - 1);\n      w.put(0, y0, `${label} (100% = ${dir ? '50.00 MBit/s' : '400.00 MBit/s'})`, c.fg, undefined, ATTR.BOLD);\n      const max = dir ? 50 : 400;\n      const gh = Math.max(1, h - 1);\n      const vals = [];\n      for (let x = 0; x < gw; x++) vals.push(series(dir, k - (gw - 1 - x)));\n      vals.forEach((v, x) => {\n        const n = Math.min(gh, v / max * gh);\n        const full = Math.floor(n);\n        for (let r = 0; r < full; r++) w.put(x, y0 + h - 1 - r, '#', dir ? c.green : c.cyan);\n        if (n - full > 0.35 && full < gh) w.put(x, y0 + h - 1 - full, n - full > 0.7 ? '|' : '.', dir ? c.green : c.cyan);\n      });\n      const cur = vals.at(-1), avg = vals.reduce((a, b) => a + b, 0) / vals.length;\n      const stats = [['Curr', rate(cur)], ['Avg', rate(avg)], ['Min', rate(Math.min(...vals))], ['Max', rate(Math.max(...vals))], ['Ttl', ttl]];\n      stats.forEach(([k2, v], i) => {\n        const y = y0 + h - stats.length + i;\n        if (y > y0) w.seg(gw + 1, y, [[`${k2}: `.padStart(6), c.fg], [v.padStart(15), c.fg, k2 === 'Curr' ? ATTR.BOLD : 0]]);\n      });\n    };\n    return (t) => {\n      const k = Math.floor(t * 2);\n      w.put(0, 0, 'Device eth0 [10.20.2.31] (1/3):', c.fg, undefined, ATTR.BOLD);\n      w.hline(0, 1, W, '=', c.muted);\n      const ph = Math.floor((H - 2) / 2);\n      panel(2, ph, 0, 'Incoming', k, `${fixed(1843.2 + t * 17.5 / 8, 2)} GByte`);\n      panel(2 + ph, H - 2 - ph, 1, 'Outgoing', k, `${fixed(211.7 + t * 2.3 / 8, 2)} GByte`);\n    };\n  },\n};\n",
  "prism-term/src/facets/ops/nmon.js": "// nmon CPU panel — the bordered nmon frame with per-CPU User/Sys/Wait bars drawn as U s W letters.\nimport { G, C, ATTR, writer, walk, clock, fixed, lpad } from './_kit.js';\n\nexport default {\n  id: 'ops-nmon',\n  title: 'nmon CPU Utilization',\n  category: 'System Monitors',\n  description: 'nmon\\'s CPU Utilization panel: the boxed nmon frame and per-CPU bars of U (user), s (system) and W (wait) on a 0–100 scale.',\n  tags: ['nmon', 'cpu', 'utilization', 'aix', 'monitor'],\n  kind: 'tui',\n  size: { w: 80, h: 15 },\n  minSize: { w: 56, h: 7 },\n  loop: 16,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx), g = G(ctx);\n    const W = ctx.width, H = ctx.height;\n    return (t) => {\n      const T = Math.floor(t / 2) * 2;\n      const ck = clock(T);\n      const hb = (y, text) => { w.put(0, y, g.v, c.fg); w.put(W - 1, y, g.v, c.fg); if (text) w.seg(1, y, text); };\n      const title = `${g.tl}nmon-16p${g.h.repeat(6)}[H for help]${g.h.repeat(3)}Hostname=node03${g.h.repeat(5)}Refresh= 2secs ${g.h.repeat(3)}${ck.hm}.${ck.hms.slice(6)}`;\n      w.put(0, 0, title.slice(0, W - 1), c.fg);\n      w.hline(Math.min(W - 1, [...title].length), 0, W - 1 - [...title].length, g.h, c.fg);\n      w.put(W - 1, 0, g.tr, c.fg);\n      const barW = W - 30;\n      const scale = (s) => {\n        let out = '';\n        for (const [pct, label] of [[0, '0'], [25, '25'], [50, '50'], [75, '75'], [100, '100']]) {\n          const x = Math.round(pct / 100 * (barW - 1));\n          while (out.length < x) out += ' ';\n          out = pct === 100 ? out.slice(0, barW - label.length) + label : (out + `|${label}`).slice(0, barW);\n        }\n        return out.padEnd(barW).slice(0, barW);\n      };\n      hb(1, [[' CPU Utilisation ', c.fg, ATTR.BOLD], [g.h.repeat(Math.max(0, W - 19)), c.fg]]);\n      hb(2, [['-'.repeat(26) + '+' + '-'.repeat(barW) + '+', c.muted]]);\n      hb(3, [['CPU User%  Sys% Wait%  Idle', c.fg, ATTR.BOLD], ['|' + scale() + '|', c.muted]]);\n      const cpus = Math.max(1, Math.min(8, H - 6));\n      let tu = 0, ts = 0, tw = 0;\n      for (let i = 0; i < cpus; i++) {\n        const busy = walk(ctx, i, T, 0.35);\n        const u = 6 + busy * 58, s = 2 + walk(ctx, i + 20, T, 0.5) * 10, wa = walk(ctx, i + 40, T, 0.8) ** 3 * 9;\n        const idle = Math.max(0, 100 - u - s - wa);\n        tu += u; ts += s; tw += wa;\n        const nU = Math.round(u / 100 * barW), nS = Math.round(s / 100 * barW), nW = Math.round(wa / 100 * barW);\n        hb(4 + i, [[`${lpad(i + 1, 3)} ${lpad(fixed(u), 5)} ${lpad(fixed(s), 5)} ${lpad(fixed(wa), 5)} ${lpad(fixed(idle), 5)}`, c.fg], ['|', c.muted],\n          ['U'.repeat(nU), c.green], ['s'.repeat(nS), c.red], ['W'.repeat(nW), c.blue], ['>'.repeat(Math.min(1, barW - nU - nS - nW)), c.fg],\n          [' '.repeat(Math.max(0, barW - nU - nS - nW - 1)), c.fg], ['|', c.muted]]);\n      }\n      let y = 4 + cpus;\n      if (y < H - 2) {\n        hb(y++, [['-'.repeat(26) + '+' + '-'.repeat(barW) + '+', c.muted]]);\n        const n = cpus;\n        hb(y++, [[`Avg ${lpad(fixed(tu / n), 5)} ${lpad(fixed(ts / n), 5)} ${lpad(fixed(tw / n), 5)} ${lpad(fixed(100 - (tu + ts + tw) / n), 5)}`, c.fg, ATTR.BOLD], ['|', c.muted],\n          ['U'.repeat(Math.round(tu / n / 100 * barW)), c.green], ['s'.repeat(Math.round(ts / n / 100 * barW)), c.red], ['W'.repeat(Math.round(tw / n / 100 * barW)), c.blue]]);\n        w.put(W - 1, y - 1, g.v, c.fg);\n      }\n      while (y < H - 1) hb(y++);\n      if (H > 1) { w.put(0, H - 1, g.bl, c.fg); w.hline(1, H - 1, W - 2, g.h, c.fg); w.put(W - 1, H - 1, g.br, c.fg); }\n    };\n  },\n};\n",
  "prism-term/src/facets/ops/ping.js": "// ping — replies streaming once a second, a latency sparkline, and the live rtt min/avg/max/mdev summary.\nimport { C, ATTR, writer, walk, hash, spark, fixed } from './_kit.js';\n\nexport default {\n  id: 'ops-ping',\n  title: 'ping',\n  category: 'Network',\n  description: 'ping streaming replies (with the odd late or lost packet), a latency sparkline of recent RTTs and the running rtt min/avg/max/mdev line.',\n  tags: ['ping', 'icmp', 'latency', 'rtt', 'network', 'sparkline'],\n  kind: 'tui',\n  size: { w: 78, h: 14 },\n  minSize: { w: 46, h: 5 },\n  loop: 30,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx);\n    const W = ctx.width, H = ctx.height;\n    const rtt = (k) => {\n      if (hash(k, 7) > 0.975) return null; // lost\n      const base = 22 + walk(ctx, 1, k, 0.05) * 6;\n      const spike = hash(k, 3) > 0.93 ? 30 + hash(k, 4) * 60 : 0;\n      return base + hash(k, 5) * 2.4 + spike;\n    };\n    return (t) => {\n      const n = Math.floor(t) + 1;\n      w.put(0, 0, 'PING api.example (192.0.2.80) 56(84) bytes of data.', c.fg, undefined, ATTR.BOLD);\n      const footer = H >= 8 ? 3 : 0;\n      const rows = Math.max(0, H - 1 - footer);\n      for (let i = 0; i < rows; i++) {\n        const seq = n - rows + i + 1;\n        if (seq < 1) continue;\n        const r = rtt(seq), y = 1 + i;\n        if (r === null) { w.put(0, y, `no answer yet for icmp_seq=${seq}`, c.red); continue; }\n        w.seg(0, y, [['64 bytes from ', c.fg], ['api.example (192.0.2.80)', c.cyan], [`: icmp_seq=${seq} ttl=56 time=`, c.fg], [`${fixed(r, r >= 100 ? 0 : 1)} ms`, r > 50 ? c.yellow : c.green, r > 50 ? ATTR.BOLD : 0]]);\n      }\n      if (!footer) return;\n      const hist = [];\n      for (let k = Math.max(1, n - (W - 12) + 1); k <= n; k++) hist.push(rtt(k));\n      const ok = hist.filter((x) => x !== null);\n      const min = Math.min(...ok), max = Math.max(...ok), avg = ok.reduce((a, b) => a + b, 0) / ok.length;\n      const mdev = Math.sqrt(ok.reduce((a, b) => a + (b - avg) ** 2, 0) / ok.length);\n      let lostN = 0;\n      for (let k = 1; k <= n; k++) if (rtt(k) === null) lostN++; // over the whole run, so the totals add up\n      w.put(0, H - 3, 'rtt ', c.muted);\n      spark(w, ctx, 4, H - 3, hist.map((x) => (x === null ? 1 : Math.min(1, (x - 15) / 70))), c.cyan);\n      hist.forEach((x, i) => { if (x === null) w.put(4 + i, H - 3, '!', c.red, undefined, ATTR.BOLD); });\n      w.put(0, H - 2, `${n} packets transmitted, ${n - lostN} received, ${fixed(lostN / n * 100, 0)}% packet loss, time ${n * 1001}ms`, c.fg);\n      w.put(0, H - 1, `rtt min/avg/max/mdev = ${fixed(min, 3)}/${fixed(avg, 3)}/${fixed(max, 3)}/${fixed(mdev, 3)} ms`, c.fg, undefined, ATTR.BOLD);\n    };\n  },\n};\n",
  "prism-term/src/facets/ops/prompts.js": "// Shell prompts: plain bash PS1, __git_ps1, powerline, exit-code prompts, a tmux status line and a two-line starship-style prompt.\nimport { C, G, ATTR, writer, walk, hash, typed, blink, clock, bashPrompt, session, drawSession, wrapParts, pad, lpad, fixed, uni } from './_kit.js';\n\nconst LS = [\n  ['total 64'],\n  ['drwxr-xr-x  7 ops ops 4096 Oct 13 13:58 ', '.', 'd'], ['drwxr-xr-x  5 ops ops 4096 Oct  9 11:20 ', '..', 'd'], ['drwxr-xr-x  8 ops ops 4096 Oct 13 13:58 ', '.git', 'd'],\n  ['-rw-r--r--  1 ops ops  412 Oct  2 16:44 ', '.gitignore'], ['-rw-r--r--  1 ops ops 1931 Oct 13 13:41 ', 'Makefile'], ['-rw-r--r--  1 ops ops    7 Oct 13 13:41 ', 'VERSION'],\n  ['drwxr-xr-x  4 ops ops 4096 Oct 12 18:03 ', 'cmd', 'd'], ['-rwxr-xr-x  1 ops ops 1288 Oct  6 09:12 ', 'deploy.sh', 'x'], ['drwxr-xr-x  2 ops ops 4096 Oct 13 13:41 ', 'internal', 'd'],\n  ['lrwxrwxrwx  1 ops ops   17 Oct  6 09:12 ', 'logs', 'l', ' -> /var/log/shop'],\n];\n\nconst bash = {\n  id: 'ops-prompt-bash',\n  title: 'bash Prompt',\n  category: 'Shell Prompts',\n  description: 'The classic user@host:dir$ bash prompt in green and blue: cd into a repo, a colored ls -la, cat a file, then a deploy script dry run.',\n  tags: ['bash', 'ps1', 'prompt', 'shell', 'ls', 'session'],\n  kind: 'tui',\n  size: { w: 80, h: 16 },\n  minSize: { w: 50, h: 5 },\n  loop: 20,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx);\n    const home = { dir: '~' }, repo = { dir: '~/src/shop-api' };\n    const ls = () => LS.map(([meta, name, kind, rest]) => (name === undefined ? [[meta, c.fg]] : [[meta, c.fg], [name, kind === 'd' ? c.blue : kind === 'x' ? c.green : kind === 'l' ? c.cyan : c.fg, kind ? ATTR.BOLD : 0], [rest ?? '', c.fg]]));\n    return (t) => {\n      const lines = session(ctx, t % 20, [\n        { cmd: 'cd ~/src/shop-api', out: () => [], dur: 0.3, prompt: home, after: repo },\n        { cmd: 'ls -la', out: ls, dur: 2, prompt: repo },\n        { cmd: 'cat VERSION', out: () => [[['2.14.1', c.fg]]], dur: 1, prompt: repo },\n        { cmd: './deploy.sh --dry-run', prompt: repo, dur: 8,\n          out: (s) => [['==> building shop-api 2.14.1', 0], ['==> would push registry.example/shop/api:2.14.1', 0.8], ['==> dry run: nothing changed', 1.4]].filter(([, at]) => s >= at).map(([m]) => [['==>', c.blue, ATTR.BOLD], [m.slice(3), c.fg, ATTR.BOLD]]) },\n      ]);\n      drawSession(w, ctx, lines, 0, 0, ctx.height);\n    };\n  },\n};\n\n/** bash with __git_ps1: `ops@node03:~/src/shop-api (main *+% u+1-2)$` with color hints. */\nfunction gitPrompt(w, ctx, x, y, p = {}) {\n  const c = C(ctx);\n  x = w.seg(x, y, [['ops@node03', c.green, ATTR.BOLD], [':', c.fg], ['~/src/shop-api', c.blue, ATTR.BOLD], [' (', c.fg], [p.branch ?? 'main', c.green]]);\n  const flags = [['*', c.red, p.dirty], ['+', c.green, p.staged], ['%', c.red, p.untracked]].filter((f) => f[2]);\n  if (flags.length) x = w.seg(x, y, [[' ', c.fg], ...flags.map(([s, col]) => [s, col])]);\n  if (p.up) x = w.put(x, y, ` ${p.up}`, c.fg);\n  return w.put(x, y, ')$ ', c.fg);\n}\n\nconst git = {\n  id: 'ops-prompt-git',\n  title: 'git Prompt',\n  category: 'Shell Prompts',\n  description: 'A __git_ps1 prompt tracking the repo: dirty *, staged +, untracked %, and upstream u+1 / u+2-1 / u= as files are staged, committed, rebased and pushed.',\n  tags: ['git', 'prompt', 'ps1', 'branch', 'ahead-behind', 'bash'],\n  kind: 'tui',\n  size: { w: 80, h: 16 },\n  minSize: { w: 60, h: 5 },\n  loop: 26,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx);\n    const P = {\n      s0: { dirty: 1, untracked: 1, up: 'u+1' }, s1: { staged: 1, untracked: 1, up: 'u+1' }, s2: { untracked: 1, up: 'u+2' },\n      s3: { untracked: 1, up: 'u+2-1' }, s4: { untracked: 1, up: 'u+2' }, s5: { untracked: 1, up: 'u=' },\n    };\n    const status = () => [\n      [['## ', c.fg], ['main', c.green], ['...', c.fg], ['origin/main', c.red], [' [ahead 1]', c.fg]],\n      [[' M', c.red], [' internal/cart/retry.go', c.fg]],\n      [[' M', c.red], [' internal/cart/retry_test.go', c.fg]],\n      [['??', c.red], [' notes.txt', c.fg]],\n    ];\n    return (t) => {\n      const lines = session(ctx, t % 26, [\n        { cmd: 'git status -sb', out: status, dur: 2, prompt: P.s0 },\n        { cmd: 'git add internal/', out: () => [], dur: 0.4, prompt: P.s0, after: P.s1 },\n        { cmd: 'git commit -qm \"cart: cap retry backoff at 30s\"', out: () => [], dur: 0.6, prompt: P.s1, after: P.s2 },\n        { cmd: 'git fetch -q', out: () => [], dur: 1.2, wait: 0.8, prompt: P.s2, after: P.s3 },\n        { cmd: 'git pull --rebase -q', out: () => [], dur: 0.8, wait: 0.6, prompt: P.s3, after: P.s4 },\n        { cmd: 'git push -q', out: () => [], dur: 8, wait: 1.1, prompt: P.s4, after: P.s5 },\n      ]);\n      drawSession(w, ctx, lines, 0, 0, ctx.height, gitPrompt);\n    };\n  },\n};\n\n/**\n * Powerline: colored segments joined by arrow separators. The real U+E0B0 arrow and\n * U+E0A0 branch glyph live in Powerline/Nerd fonts only, so they're opt-in (--set nerd=1);\n * the default uses ▶, which every monospace font has, and ASCII mode uses \">\".\n */\nfunction powerline(w, ctx, x, y, p = {}) {\n  const c = C(ctx), g = G(ctx);\n  const nerd = uni(ctx) && ctx.param('nerd', 0) > 0;\n  const sep = nerd ? g.pl : uni(ctx) ? '▶' : '>';\n  const segs = [];\n  if (p.err) segs.push([` ${uni(ctx) ? '✘' : 'x'} ${p.err} `, c.bg, c.red]);\n  segs.push([' ops@node03 ', c.bg, c.blue], [' ~/src/shop-api ', c.fg, c.panel2]);\n  segs.push([` ${nerd ? `${g.branchIcon} ` : ''}main${p.dirty ? ' ±' : ''} `, c.bg, p.dirty ? c.yellow : c.green]);\n  segs.forEach(([text, fg, bg], i) => {\n    x = w.put(x, y, text, fg, bg, ATTR.BOLD);\n    const next = segs[i + 1]?.[2];\n    x = w.put(x, y, sep, bg, next);\n  });\n  return w.put(x, y, ' ', c.fg);\n}\n\nconst pl = {\n  id: 'ops-prompt-powerline',\n  title: 'Powerline Prompt',\n  category: 'Shell Prompts',\n  description: 'A powerline prompt of colored segments (user@host, path, git branch going dirty and clean) plus a red exit-code segment after a failing make lint.',\n  tags: ['powerline', 'prompt', 'segments', 'git', 'exit-code', 'nerd-font'],\n  kind: 'tui',\n  size: { w: 80, h: 14 },\n  minSize: { w: 60, h: 5 },\n  loop: 22,\n  glyphs: 'ascii',\n  color: '256',\n  params: { nerd: { default: 0, min: 0, max: 1, label: 'Powerline font glyphs' } },\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx);\n    const clean = {}, dirty = { dirty: 1 }, failed = { dirty: 1, err: 2 };\n    return (t) => {\n      const lines = session(ctx, t % 22, [\n        { cmd: 'make test', prompt: dirty, dur: 2.2, wait: 0.3,\n          out: (s) => [['ok  \\tshop/api/internal/cart     \\t0.412s', 0.3], ['ok  \\tshop/api/internal/checkout \\t1.208s', 1.1]].filter(([, at]) => s >= at).map(([m]) => [['ok', c.green, ATTR.BOLD], [m.slice(2).replace(/\\t/g, '  '), c.fg]]) },\n        { cmd: 'make lint', prompt: dirty, after: failed, dur: 2.4, wait: 0.6,\n          out: () => [[['internal/cart/retry.go:42:2: ', c.fg, ATTR.BOLD], ['ineffectual assignment to err (ineffassign)', c.fg]], [['make: *** [Makefile:18: lint] Error 1', c.red]]] },\n        { cmd: 'git stash -q', prompt: failed, after: clean, dur: 8, out: () => [] },\n      ]);\n      drawSession(w, ctx, lines, 0, 0, ctx.height, powerline);\n    };\n  },\n};\n\n/** `✓ [ops@node03 ~]$` or `✗ 127 [ops@node03 ~]$` — PS1 showing the last exit status. */\nfunction exitPrompt(w, ctx, x, y, p = {}) {\n  const c = C(ctx), g = G(ctx);\n  if (!uni(ctx)) x = w.put(x, y, `[${p.code ?? 0}] `, p.code ? c.red : c.green, undefined, ATTR.BOLD); // the 7-bit spelling: [0] / [127]\n  else x = p.code ? w.put(x, y, `${g.bad} ${p.code} `, c.red, undefined, ATTR.BOLD) : w.put(x, y, `${g.ok} `, c.green, undefined, ATTR.BOLD);\n  return w.seg(x, y, [['[', c.fg], ['ops@node03', c.fg, ATTR.BOLD], [' ~]$ ', c.fg]]);\n}\n\nconst exit = {\n  id: 'ops-prompt-exitcode',\n  title: 'Exit-Code Prompt',\n  category: 'Shell Prompts',\n  description: 'A prompt that shows the last exit status: a typo gives ✗ 127, Ctrl-C gives ✗ 130, curl -f gives ✗ 22, and a clean run gives a green ✓.',\n  tags: ['prompt', 'exit-code', 'ps1', 'bash', 'troubleshooting', 'curl'],\n  kind: 'tui',\n  size: { w: 80, h: 14 },\n  minSize: { w: 60, h: 5 },\n  loop: 24,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx);\n    const ok = {}, e = (code) => ({ code });\n    return (t) => {\n      const lines = session(ctx, t % 24, [\n        { cmd: 'kubeclt get pods -n shop', prompt: ok, after: e(127), dur: 1, out: () => [[['bash: kubeclt: command not found', c.fg]]] },\n        { cmd: 'kubectl get pods -n shop | grep -c Running', prompt: e(127), after: ok, dur: 1, wait: 0.5, out: () => [[['5', c.fg]]] },\n        { cmd: 'sleep 30', prompt: ok, after: e(130), dur: 0.8, wait: 1.6, out: () => [[['^C', c.fg]]] },\n        { cmd: 'curl -sf https://api.example/healthz', prompt: e(130), after: e(22), dur: 1, wait: 0.7, out: () => [] },\n        { cmd: \"curl -s -o /dev/null -w '%{http_code}\\\\n' https://api.example/healthz\", prompt: e(22), after: ok, dur: 8, wait: 0.6, out: () => [[['503', c.fg, ATTR.BOLD]]] },\n      ], { cps: 26 });\n      drawSession(w, ctx, lines, 0, 0, ctx.height, exitPrompt);\n    };\n  },\n};\n\nconst tmux = {\n  id: 'ops-tmux-status',\n  title: 'tmux Session',\n  category: 'Shell Prompts',\n  description: 'A tmux window split in two: a log tail above, a shell below, a green active-pane border, and the status line with windows, flags and a clock.',\n  tags: ['tmux', 'status-line', 'panes', 'multiplexer', 'session', 'windows'],\n  kind: 'tui',\n  size: { w: 80, h: 20 },\n  minSize: { w: 50, h: 8 },\n  loop: 30,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx), g = G(ctx);\n    const W = ctx.width, H = ctx.height;\n    const LOG = [['INFO ', 'GET /api/v1/cart 200 18ms'], ['INFO ', 'POST /api/v1/checkout 201 142ms'], ['INFO ', 'GET /api/v1/products?page=2 200 31ms'],\n      ['WARN ', 'pool: waited 412ms for a connection'], ['INFO ', 'GET /healthz 200 1ms'], ['ERROR', 'payments.internal.example: context deadline exceeded']];\n    return (t) => {\n      const T = t % 30;\n      const topH = Math.max(2, Math.floor((H - 2) / 2)), sepY = topH, botY = sepY + 1, botH = H - 1 - botY;\n      // top pane: tail -f, one line every ~0.6 s\n      const n = Math.floor(t / 0.6) + 40;\n      for (let k = 0; k < topH; k++) {\n        const i = n - topH + 1 + k;\n        const pick = hash(i, 4) > 0.97 ? 5 : hash(i, 5) > 0.9 ? 3 : [0, 1, 2, 4][Math.floor(hash(i, 6) * 4)];\n        const [lvl, msg] = LOG[pick];\n        w.seg(0, k, [[clock(i * 0.6 - 24).hms + ' ', c.muted], [lvl, lvl === 'ERROR' ? c.red : lvl === 'WARN ' ? c.yellow : c.green, ATTR.BOLD], [` ${msg}`, c.fg]]);\n      }\n      w.hline(0, sepY, W, g.h, c.green); // active pane (below) gets the green border\n      const lines = session(ctx, T, [\n        { cmd: 'kubectl -n shop get deploy api', dur: 3, out: () => [[['NAME   READY   UP-TO-DATE   AVAILABLE   AGE', c.fg]], [['api    5/5     5            5           41d', c.fg]]] },\n        { cmd: 'kubectl -n shop rollout restart deploy/api', dur: 20, out: () => [[['deployment.apps/api restarted', c.fg]]] },\n      ]);\n      drawSession(w, ctx, lines, 0, botY, botH);\n      // status line, tmux defaults: green bar, [session] windows on the left, \"title\" time date on the right\n      const y = H - 1;\n      w.rowBg(y, c.green, 0, W);\n      const act = T > 9; // window 2 had output: tmux flags it # and shows it reversed\n      const x = w.seg(0, y, [['[prod] ', c.bg, 0, c.green], ['0:logs-', c.bg, 0, c.green], [' ', c.bg, 0, c.green], ['1:shell*', c.bg, ATTR.BOLD, c.green], [' ', c.bg, 0, c.green], [`2:deploy${act ? '#' : ''}`, act ? c.green : c.bg, 0, act ? c.bg : c.green]]);\n      const now = clock(Math.floor(t));\n      const right = `\"node03\" ${now.hm} ${now.dd}-${now.mon}-26`;\n      if (W - right.length > x + 1) w.right(W, y, right, c.bg, c.green);\n    };\n  },\n};\n\n/** Starship-style two-line prompt: modules on the first line, ❯ on the second. */\nfunction starRows(ctx, p, cmd, cursor) {\n  const c = C(ctx);\n  const u = uni(ctx);\n  const info = [];\n  if (p.kube) info.push([`kube ${p.kube}`, p.kube.startsWith('prod') ? c.red : c.blue, ATTR.BOLD], [' (shop)', c.blue, ATTR.BOLD], [' in ', c.fg]);\n  info.push([p.dir ?? 'shop-api', c.cyan, ATTR.BOLD]);\n  info.push([' on ', c.fg], [`git:${p.branch ?? 'main'}`, c.magenta, ATTR.BOLD]);\n  if (p.git) info.push([` [${p.git}]`, c.red, ATTR.BOLD]);\n  if (p.go) info.push([' via ', c.fg], ['go v1.23.2', c.cyan, ATTR.BOLD]);\n  if (p.py) info.push([' via ', c.fg], ['py v3.12.4 (.venv)', c.yellow, ATTR.BOLD]);\n  if (p.took) info.push([' took ', c.fg], [p.took, c.yellow, ATTR.BOLD]);\n  const chr = [[u ? '❯' : '>', p.err ? c.red : c.green, ATTR.BOLD], [' ', c.fg], [cmd, c.fg]];\n  if (cursor) chr.push([' ', c.bg, 0, c.fg]);\n  return [[], info, chr];\n}\n\nconst star = {\n  id: 'ops-prompt-starship',\n  title: 'Starship-Style Prompt',\n  category: 'Shell Prompts',\n  description: 'A two-line starship-style prompt: kube context (red for prod), repo, git branch and status, toolchain versions, command duration, then ❯.',\n  tags: ['starship', 'prompt', 'kubernetes', 'git', 'two-line', 'zsh'],\n  kind: 'tui',\n  size: { w: 80, h: 16 },\n  minSize: { w: 60, h: 6 },\n  loop: 26,\n  glyphs: 'ascii',\n  color: '256',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx);\n    const ahead = uni(ctx) ? '⇡1' : '^1';\n    const s0 = { kube: 'prod-east', git: `!?${ahead}`, go: 1 };\n    const s1 = { ...s0, took: '4s' };\n    const s2 = { ...s0, kube: 'staging-west' };\n    const s3 = { kube: 'staging-west', dir: 'infra', branch: 'feat/vpc', git: '?', py: 1 };\n    return (t) => {\n      const lines = session(ctx, t % 26, [\n        { cmd: 'go test ./...', prompt: s0, after: s1, dur: 3.8, wait: 0.2,\n          out: (s) => [['ok  \\tshop/api/internal/cart     \\t0.412s', 1.2], ['ok  \\tshop/api/internal/checkout \\t1.208s', 3.6]].filter(([, at]) => s >= at).map(([m]) => [['ok', c.green, ATTR.BOLD], [m.slice(2).replace(/\\t/g, '  '), c.fg]]) },\n        { cmd: 'kubectl config use-context staging-west', prompt: s1, after: s2, dur: 1.4, out: () => [[['Switched to context \"staging-west\".', c.fg]]] },\n        { cmd: 'cd ../infra && source .venv/bin/activate', prompt: s2, after: s3, dur: 8, out: () => [] },\n      ], { cps: 24 });\n      const rows = [];\n      for (const l of lines) {\n        if (l.parts) rows.push(l.parts);\n        else rows.push(...starRows(ctx, l.prompt ?? {}, l.cmd, l.cursor));\n      }\n      const vis = rows.slice(-ctx.height);\n      if (vis.length && !vis[0].length) vis.shift();\n      vis.forEach((parts, y) => w.seg(0, y, parts));\n    };\n  },\n};\n\nexport default [bash, git, pl, exit, tmux, star];\n",
  "prism-term/src/facets/ops/sensors.js": "// lm-sensors — chip blocks with per-core temperatures, high/crit limits, NVMe composite and fan RPM.\nimport { G, C, ATTR, writer, walk, fixed, lpad } from './_kit.js';\n\nexport default {\n  id: 'ops-sensors',\n  title: 'sensors',\n  category: 'System Monitors',\n  description: 'lm-sensors output: coretemp package and cores with high/crit limits, an NVMe composite and fan speeds, readings warming with load.',\n  tags: ['sensors', 'lm-sensors', 'temperature', 'fan', 'hardware'],\n  kind: 'tui',\n  size: { w: 64, h: 18 },\n  minSize: { w: 50, h: 8 },\n  loop: 20,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx), g = G(ctx);\n    const H = ctx.height;\n    const deg = g.deg === '°' ? '°C' : ' C';\n    const tc = (v, hi) => (v >= hi ? c.red : v >= hi - 12 ? c.yellow : c.green);\n    return (t) => {\n      const heat = walk(ctx, 1, t, 0.12);\n      const temp = (k, base) => base + heat * 22 + (walk(ctx, k, t, 0.6) - 0.5) * 6;\n      const lines = [];\n      const row = (label, v, limits, hi = 80) => lines.push([[label.padEnd(15), c.fg], [`${v >= 0 ? '+' : ''}${fixed(v)}${deg}`.padStart(9), tc(v, hi), ATTR.BOLD], [`  ${limits}`, c.muted]]);\n      lines.push([['coretemp-isa-0000', c.fg, ATTR.BOLD]]);\n      lines.push([['Adapter: ISA adapter', c.muted]]);\n      const pkg = temp(2, 46);\n      row('Package id 0:', pkg, `(high = +80.0${deg}, crit = +100.0${deg})`);\n      for (let i = 0; i < 4; i++) row(`Core ${i}:`, temp(10 + i, 43 + i), `(high = +80.0${deg}, crit = +100.0${deg})`);\n      lines.push([]);\n      lines.push([['nvme-pci-0100', c.fg, ATTR.BOLD]]);\n      lines.push([['Adapter: PCI adapter', c.muted]]);\n      row('Composite:', 38 + heat * 9, `(low  = -273.1${deg}, high = +84.8${deg})`, 84.8);\n      lines.push([['', c.fg], [' '.repeat(26) + `(crit = +84.8${deg})`, c.muted]]);\n      lines.push([]);\n      lines.push([['it8628-isa-0a40', c.fg, ATTR.BOLD]]);\n      lines.push([['Adapter: ISA adapter', c.muted]]);\n      const fan = (k, base) => Math.round(base + heat * 900 + (walk(ctx, k, t, 0.9) - 0.5) * 60);\n      for (const [name, base] of [['fan1:', 1080], ['fan2:', 760]]) lines.push([[name.padEnd(15), c.fg], [`${lpad(fan(name.length * 7, base), 4)} RPM`.padStart(9), c.cyan, ATTR.BOLD], ['  (min =    0 RPM)', c.muted]]);\n      lines.slice(0, H).forEach((parts, y) => w.seg(0, y, parts));\n    };\n  },\n};\n",
  "prism-term/src/facets/ops/services.js": "// systemd and process views: systemctl status, failed units, pstree -p, and list-timers counting down.\nimport { G, C, ATTR, writer, session, drawSession, clock, pad, lpad, fixed, uni } from './_kit.js';\n\nconst dur = (s) => (s >= 3600 ? `${Math.floor(s / 3600)}h ${Math.floor(s % 3600 / 60)}min` : s >= 60 ? `${Math.floor(s / 60)}min ${Math.floor(s % 60)}s` : `${Math.floor(s)}s`);\n\nconst status = {\n  id: 'ops-systemctl-status',\n  title: 'systemctl status',\n  category: 'Processes & Services',\n  description: 'systemctl status nginx.service: the green ● active (running) block, Main PID, tasks, memory, CPU, CGroup tree and a journal tail with a live reload.',\n  tags: ['systemd', 'systemctl', 'service', 'status', 'nginx', 'unit'],\n  kind: 'tui',\n  size: { w: 80, h: 20 },\n  minSize: { w: 56, h: 6 },\n  loop: 24,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx), g = G(ctx);\n    const H = ctx.height;\n    return (t) => {\n      const T = t % 24;\n      const up = 4 * 3600 + 48 * 60 + 7 + t;\n      const reloading = T > 10 && T < 11.5;\n      const reloaded = T >= 11.5;\n      const lines = [\n        [[`${g.dot} `, reloading ? c.yellow : c.green, ATTR.BOLD], ['nginx.service - A high performance web server and a reverse proxy server', c.fg, ATTR.BOLD]],\n        [['     Loaded: ', c.fg], ['loaded (/usr/lib/systemd/system/nginx.service; ', c.fg], ['enabled', c.green, ATTR.BOLD], ['; preset: ', c.fg], ['enabled', c.green, ATTR.BOLD], [')', c.fg]],\n        [['     Active: ', c.fg], [reloading ? 'reloading (reload)' : 'active (running)', reloading ? c.yellow : c.green, ATTR.BOLD], [` since Tue 2026-10-13 09:14:02 UTC; ${dur(up)} ago`, c.fg]],\n        [['       Docs: ', c.fg], ['man:nginx(8)', c.fg]],\n        [['    Process: ', c.fg], [`${reloaded ? 8612 : 1281} ${reloaded ? 'ExecReload' : 'ExecStartPre'}=/usr/sbin/nginx -t -q -g \"daemon on;\" (code=exited, status=0/SUCCESS)`, c.fg]],\n        [['   Main PID: ', c.fg], ['1287 (nginx)', c.fg]],\n        [['      Tasks: ', c.fg], ['5 (limit: 18922)', c.fg]],\n        [['     Memory: ', c.fg], [`${fixed(21.4 + (reloaded ? 1.1 : 0) + Math.sin(t / 3) * 0.2)}M (peak: 24.1M)`, c.fg]],\n        [['        CPU: ', c.fg], [`2min ${fixed(11.402 + t * 0.031, 3)}s`, c.fg]],\n        [['     CGroup: ', c.fg], ['/system.slice/nginx.service', c.fg]],\n      ];\n      const tree = uni(ctx) ? ['├─', '└─'] : ['|-', '`-'];\n      const kids = ['1287 \"nginx: master process /usr/sbin/nginx -g daemon on; master_process on;\"', ...[1288, 1289, 1290, 1291].map((p) => `${reloaded ? p + 7320 : p} \"nginx: worker process\"`)];\n      kids.forEach((k, i) => lines.push([[`             ${i === kids.length - 1 ? tree[1] : tree[0]}`, c.muted], [k, c.fg]]));\n      lines.push([]);\n      const log = [\n        [0, 'Starting nginx.service - A high performance web server and a reverse proxy server...'],\n        [0, 'Started nginx.service - A high performance web server and a reverse proxy server.'],\n      ];\n      if (T > 10) log.push([0, 'Reloading nginx.service - A high performance web server and a reverse proxy server...']);\n      if (reloaded) log.push([0, 'Reloaded nginx.service - A high performance web server and a reverse proxy server.']);\n      for (const [, msg] of log) lines.push([['Oct 13 ', c.fg], [msg.startsWith('Start') ? '09:14:02' : clock(Math.floor(t)).hms, c.fg], [' node03 systemd[1]: ', c.fg], [msg, c.fg]]);\n      lines.slice(0, H).forEach((parts, y) => w.seg(0, y, parts));\n    };\n  },\n};\n\nconst failed = {\n  id: 'ops-systemctl-failed',\n  title: 'Failed Units',\n  category: 'Processes & Services',\n  description: 'systemctl --failed shows two red ● failed units; a restart fixes the backup job and the follow-up listing drops to one.',\n  tags: ['systemd', 'failed', 'units', 'troubleshooting', 'restart'],\n  kind: 'tui',\n  size: { w: 80, h: 16 },\n  minSize: { w: 60, h: 6 },\n  loop: 22,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx), g = G(ctx);\n    const units = [['certbot-renew.service', 'Renew TLS certificates'], ['backup-db.service', 'Nightly database backup']];\n    const list = (n) => () => [\n      [[`  ${pad('UNIT', 23)}${pad('LOAD', 7)}${pad('ACTIVE', 7)}${pad('SUB', 7)}DESCRIPTION`, c.fg, ATTR.BOLD]],\n      ...units.slice(0, n).map(([u, d]) => [[`${g.dot} `, c.red, ATTR.BOLD], [pad(u, 23), c.fg], [pad('loaded', 7), c.fg], [pad('failed', 7), c.red, ATTR.BOLD], [pad('failed', 7), c.red, ATTR.BOLD], [d, c.fg]]),\n      [],\n      [[`${n} loaded units listed.`, c.fg]],\n    ];\n    return (t) => {\n      const lines = session(ctx, t % 22, [\n        { cmd: 'systemctl --failed', out: list(2), dur: 4 },\n        { cmd: 'sudo systemctl restart backup-db.service', out: () => [], dur: 1.2 },\n        { cmd: 'systemctl --failed', out: list(1), dur: 6 },\n      ]);\n      drawSession(w, ctx, lines, 0, 0, ctx.height);\n    };\n  },\n};\n\nconst pstree = {\n  id: 'ops-pstree',\n  title: 'pstree -p',\n  category: 'Processes & Services',\n  description: 'pstree -p under watch: systemd and its children as a tree with PIDs, while a cron job forks a backup script that runs and exits.',\n  tags: ['pstree', 'processes', 'tree', 'pids', 'cron'],\n  kind: 'diagram',\n  size: { w: 80, h: 16 },\n  minSize: { w: 50, h: 6 },\n  loop: 20,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx);\n    const u = uni(ctx);\n    const S = u ? { h: '─', tee: '┬', mid: '├', end: '└', bar: '│', one: '───' } : { h: '-', tee: '+', mid: '|', end: '`', bar: '|', one: '---' };\n    return (t) => {\n      const T = Math.floor((t % 20) / 2) * 2; // watch refreshes every 2 s\n      const job = T > 6 && T < 15;\n      const now = clock(Math.floor(t / 2) * 2);\n      const head = 'Every 2.0s: pstree -p';\n      w.put(0, 0, head, c.fg);\n      if (ctx.width >= 60) w.right(ctx.width, 0, `node03: ${now.day} ${now.mon} ${now.dd} ${now.hms} 2026`, c.fg);\n      const kids = [\n        ['agetty(902)', []], ['containerd(4120)', ['8*[{containerd}]']],\n        ['cron(712)', job ? ['cron(9001)───sh(9002)───backup.sh(9003)───pg_dump(9007)'.replace(/───/g, S.one)] : []],\n        ['dockerd(4107)', ['docker-proxy(4301)', '14*[{dockerd}]']], ['nginx(1287)', ['4*[nginx(1288)]']],\n        ['node(3110)', ['10*[{node}]']], ['postgres(2040)', ['postgres(2044)', 'postgres(2045)', `postgres(${job ? 9011 : 2051})`]],\n        ['redis-server(3390)', ['4*[{redis-server}]']], ['sshd(941)', ['sshd(7702)───sshd(7744)───bash(7745)───watch(7790)'.replace(/───/g, S.one)]],\n        ['systemd-journal(612)', []], ['systemd-logind(733)', []],\n      ];\n      const lines = [];\n      const root = 'systemd(1)';\n      kids.forEach(([name, sub], i) => {\n        const lead = i === 0 ? `${root}${S.h}${S.tee}${S.h}` : `${' '.repeat(root.length)} ${i === kids.length - 1 ? S.end : S.mid}${S.h}`;\n        if (!sub.length) { lines.push([[lead, c.muted], [name, name.startsWith('cron') && job ? c.yellow : c.fg]]); return; }\n        if (sub.length === 1) { lines.push([[lead, c.muted], [name, c.fg], [S.one, c.muted], [sub[0], job && sub[0].includes('backup') ? c.yellow : c.fg, job && sub[0].includes('backup') ? ATTR.BOLD : 0]]); return; }\n        const pre = ' '.repeat(root.length) + ' ' + (i === kids.length - 1 ? ' ' : S.bar) + ' ' + ' '.repeat(name.length);\n        sub.forEach((s, j) => {\n          const branch = j === 0 ? `${S.h}${S.tee}${S.h}` : ` ${j === sub.length - 1 ? S.end : S.mid}${S.h}`;\n          lines.push([[j === 0 ? lead : pre, c.muted], [j === 0 ? name : '', c.fg], [branch, c.muted], [s, s.includes('9011') ? c.yellow : c.fg]]);\n        });\n      });\n      lines.slice(0, ctx.height - 2).forEach((parts, y) => w.seg(0, y + 2, parts));\n    };\n  },\n};\n\nconst timers = {\n  id: 'ops-list-timers',\n  title: 'systemctl list-timers',\n  category: 'Processes & Services',\n  description: 'systemd timers with NEXT, LEFT counting down live, LAST and PASSED; when a timer fires its LAST/PASSED reset and NEXT rolls forward.',\n  tags: ['systemd', 'timers', 'cron', 'schedule', 'countdown'],\n  kind: 'tui',\n  size: { w: 80, h: 10 },\n  minSize: { w: 64, h: 4 },\n  loop: 60,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx);\n    // [unit, activates, period s, phase s]\n    const TIMERS = [['logrotate.timer', 'logrotate.service', 300, 120], ['backup-db.timer', 'backup-db.service', 90, 37], ['certbot-renew.timer', 'certbot-renew.service', 43200, 9000],\n      ['fstrim.timer', 'fstrim.service', 604800, 200000], ['apt-daily.timer', 'apt-daily.service', 21600, 4100], ['systemd-tmpfiles-clean.timer', 'systemd-tmpfiles-clean.service', 86400, 31000]];\n    const left = (s) => (s >= 86400 ? `${Math.floor(s / 86400)} days` : s >= 3600 ? `${Math.floor(s / 3600)}h ${Math.floor(s % 3600 / 60)}min` : s >= 60 ? `${Math.floor(s / 60)}min ${Math.floor(s % 60)}s` : `${Math.floor(s)}s`);\n    return (t) => {\n      const now = clock(0).sec + Math.floor(t);\n      const act = ctx.width >= 9 + 12 + 9 + 15 + 30 + 12;\n      w.put(0, 0, `${pad('NEXT', 9)}${pad('LEFT', 12)}${pad('LAST', 9)}${pad('PASSED', 15)}${pad('UNIT', 30)}${act ? 'ACTIVATES' : ''}`.slice(0, ctx.width), c.fg, undefined, ATTR.BOLD);\n      const rows = TIMERS.map(([u, a, p, ph]) => {\n        const next = Math.ceil((now - ph) / p) * p + ph;\n        return { u, a, next, last: next - p };\n      }).sort((x, y) => x.next - y.next);\n      const hms = (s) => new Date(s * 1000).toISOString().slice(11, 19);\n      rows.slice(0, ctx.height - 3).forEach(({ u, a, next, last }, i) => {\n        const l = next - now, pas = now - last;\n        w.seg(0, 1 + i, [[pad(hms(next), 9), c.fg], [pad(left(l), 12), l < 30 ? c.yellow : c.fg, l < 30 ? ATTR.BOLD : 0], [pad(hms(last), 9), c.fg], [pad(`${left(pas)} ago`, 15), pas < 5 ? c.green : c.fg], [pad(u, 30), c.cyan], [act ? a : '', c.fg]]);\n      });\n      if (ctx.height > TIMERS.length + 2) w.put(0, ctx.height - 1, `${TIMERS.length} timers listed.`, c.fg);\n    };\n  },\n};\n\nexport default [status, failed, pstree, timers];\n",
  "prism-term/src/facets/ops/top.js": "// procps top — the five-line summary and the classic process table with its reverse-video header.\nimport { C, ATTR, writer, walk, clock, lpad, pad, fixed } from './_kit.js';\n\nconst PROCS = [\n  ['3110', 'app', 'node', 24, 6.2, '10.6g', '935.2m', '41.2m'], ['2051', 'postgres', 'postgres', 18, 3.4, '4.4g', '540.1m', '512.0m'],\n  ['3172', 'app', 'java', 11, 13.8, '6.1g', '2.1g', '28.7m'], ['8803', 'app', 'python3', 9, 1.9, '410.2m', '300.4m', '18.1m'],\n  ['1288', 'www-data', 'nginx', 3.1, 0.4, '61.2m', '33.1m', '6.0m'], ['1289', 'www-data', 'nginx', 2.4, 0.4, '61.2m', '32.4m', '6.0m'],\n  ['3390', 'redis', 'redis-server', 1.8, 1.2, '120.4m', '190.3m', '4.1m'], ['7781', 'ops', 'top', 1.2, 0.0, '9.1m', '5.2m', '3.4m'],\n  ['4107', 'root', 'dockerd', 0.9, 0.7, '2.1g', '110.0m', '48.2m'], ['5562', 'nobody', 'node_exporter', 0.7, 0.1, '726.0m', '21.1m', '10.4m'],\n  ['612', 'root', 'systemd-journal', 0.3, 0.6, '88.1m', '61.4m', '60.1m'], ['4120', 'root', 'containerd', 0.4, 0.3, '1.9g', '52.0m', '30.3m'],\n  ['1', 'root', 'systemd', 0.0, 0.1, '166.2m', '12.1m', '8.4m'], ['941', 'root', 'sshd', 0.0, 0.1, '15.2m', '9.2m', '7.8m'],\n];\n\nexport default {\n  id: 'ops-top',\n  title: 'top',\n  category: 'System Monitors',\n  description: 'procps top: the uptime/Tasks/%Cpu(s)/MiB Mem/MiB Swap summary and a CPU-sorted process table that refreshes every few seconds.',\n  tags: ['top', 'procps', 'cpu', 'processes', 'load'],\n  kind: 'tui',\n  size: { w: 80, h: 20 },\n  minSize: { w: 50, h: 8 },\n  loop: 15,\n  glyphs: 'ascii',\n  color: 'mono',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx);\n    const W = ctx.width, H = ctx.height;\n    return (t) => {\n      const tick = Math.floor(t / 3); // top refreshes every 3 s\n      const T = tick * 3;\n      const ck = clock(T);\n      const v = (k, s = 0.5) => walk(ctx, k, T, s);\n      const us = 9 + v(1) * 9, sy = 2 + v(2) * 3, wa = v(3) * 1.2, id = 100 - us - sy - wa - 0.2;\n      const la = [0.6 + us / 20, 0.71 + v(4, 0.05) * 0.1, 0.66 + v(5, 0.02) * 0.05];\n      const free = 2104.7 - v(6, 0.1) * 300, used = 5611.3 + v(6, 0.1) * 300;\n      const running = 1 + Math.round(v(7) * 3);\n      const lines = [\n        [['top - ', c.fg], [ck.hms, c.fg, ATTR.BOLD], [' up 41 days,  3:12,  2 users,  load average: ', c.fg], [la.map((x) => fixed(x, 2)).join(', '), c.fg, ATTR.BOLD]],\n        [['Tasks: ', c.fg], ['214', c.fg, ATTR.BOLD], [' total, ', c.fg], [lpad(running, 3), c.fg, ATTR.BOLD], [' running, ', c.fg], [String(214 - running), c.fg, ATTR.BOLD], [' sleeping,   0 stopped,   0 zombie', c.fg]],\n        [['%Cpu(s): ', c.fg], [lpad(fixed(us), 4), c.fg, ATTR.BOLD], [' us, ', c.fg], [lpad(fixed(sy), 4), c.fg, ATTR.BOLD], [' sy,  0.0 ni, ', c.fg], [lpad(fixed(id), 4), c.fg, ATTR.BOLD], [' id, ', c.fg], [lpad(fixed(wa), 4), c.fg, ATTR.BOLD], [' wa,  0.0 hi,  0.2 si,  0.0 st', c.fg]],\n        [['MiB Mem : ', c.fg], [' 15872.4', c.fg, ATTR.BOLD], [' total, ', c.fg], [lpad(fixed(free), 8), c.fg, ATTR.BOLD], [' free, ', c.fg], [lpad(fixed(used), 8), c.fg, ATTR.BOLD], [' used, ', c.fg], ['  8156.4', c.fg, ATTR.BOLD], [' buff/cache', c.fg]],\n        [['MiB Swap: ', c.fg], ['  2048.0', c.fg, ATTR.BOLD], [' total, ', c.fg], ['  1936.0', c.fg, ATTR.BOLD], [' free, ', c.fg], ['   112.0', c.fg, ATTR.BOLD], [' used. ', c.fg], [lpad(fixed(9907.2 + free - 2104.7), 8), c.fg, ATTR.BOLD], [' avail Mem', c.fg]],\n      ];\n      let y = 0;\n      for (const l of lines) if (y < H) w.seg(0, y++, l);\n      y++;\n      if (y >= H) return;\n      const head = `${lpad('PID', 7)} ${pad('USER', 8)} ${lpad('PR', 2)} ${lpad('NI', 3)} ${lpad('VIRT', 7)} ${lpad('RES', 6)} ${lpad('SHR', 6)} S ${lpad('%CPU', 5)} ${lpad('%MEM', 5)} ${lpad('TIME+', 8)} COMMAND`;\n      w.rowBg(y, c.fg);\n      w.put(0, y++, head.slice(0, W), c.bg, c.fg);\n      const rows = PROCS.map((p, i) => ({ p, cpu: p[3] * (0.5 + walk(ctx, 100 + i, T, 0.8)) })).sort((a, b) => b.cpu - a.cpu);\n      for (const { p, cpu } of rows) {\n        if (y >= H) break;\n        const secs = 60 + cpu * 5 + T * cpu / 100 + Number(p[0]) % 89;\n        const time = `${Math.floor(secs / 60)}:${(secs % 60).toFixed(2).padStart(5, '0')}`;\n        const line = `${lpad(p[0], 7)} ${pad(p[1], 8)} ${lpad(20, 2)} ${lpad(0, 3)} ${lpad(p[5], 7)} ${lpad(p[6], 6)} ${lpad(p[7], 6)} ${cpu > 1 ? 'R' : 'S'} ${lpad(fixed(cpu), 5)} ${lpad(fixed(p[4]), 5)} ${lpad(time, 8)} ${p[2]}`;\n        w.put(0, y++, line.slice(0, W), c.fg, undefined, cpu > 1 ? ATTR.BOLD : 0);\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/ops/vmstat.js": "// vmstat 1 — the two-line header pinned on top, a new sample every second scrolling beneath it.\nimport { C, ATTR, writer, walk, lpad } from './_kit.js';\n\nconst H1 = 'procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----';\nconst H2 = ' r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st';\n\nexport default {\n  id: 'ops-vmstat',\n  title: 'vmstat 1',\n  category: 'System Monitors',\n  description: 'vmstat 1 streaming one line a second under its two-line header: run queue, memory, swap, block I/O, interrupts, context switches, CPU split.',\n  tags: ['vmstat', 'memory', 'io', 'cpu', 'procps'],\n  kind: 'tui',\n  size: { w: 80, h: 14 },\n  minSize: { w: 50, h: 5 },\n  loop: 20,\n  glyphs: 'ascii',\n  color: 'mono',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx);\n    const H = ctx.height;\n    const sample = (k) => {\n      const v = (key, s = 1) => walk(ctx, key, k, s);\n      const busy = v(1, 0.3);\n      const us = Math.round(6 + busy * 30), sy = Math.round(2 + busy * 7), wa = Math.round(v(2, 0.6) * 4);\n      const id = Math.max(0, 100 - us - sy - wa);\n      const free = Math.round(2155212 - busy * 380000 + v(3, 0.2) * 60000);\n      const r = Math.round(busy * 6), b = v(4) > 0.85 ? 1 : 0;\n      const bi = Math.round(v(5) ** 3 * 2400), bo = Math.round(40 + v(6) ** 2 * 900);\n      const inn = Math.round(1700 + busy * 4200), cs = Math.round(3000 + busy * 8800);\n      return `${lpad(r, 2)} ${lpad(b, 2)} ${lpad(114688, 6)} ${lpad(free, 7)} ${lpad(430112, 6)} ${lpad(8352164 - Math.round(busy * 20000), 7)} ${lpad(0, 4)} ${lpad(0, 4)} ${lpad(bi, 5)} ${lpad(bo, 5)} ${lpad(inn, 4)} ${lpad(cs, 4)} ${lpad(us, 2)} ${lpad(sy, 2)} ${lpad(id, 2)} ${lpad(wa, 2)} ${lpad(0, 2)}`;\n    };\n    return (t) => {\n      // Header pinned on top; samples arrive once a second and scroll beneath it.\n      w.put(0, 0, H1, c.muted);\n      if (H > 1) w.put(0, 1, H2, c.fg, undefined, ATTR.BOLD);\n      const rows = Math.max(0, H - 2);\n      const n = rows + Math.floor(t);\n      for (let i = 0; i < rows; i++) w.put(0, 2 + i, sample(n - rows + i), i === rows - 1 ? c.fg : c.muted, undefined, i === rows - 1 ? ATTR.BOLD : 0);\n    };\n  },\n};\n",
  "prism-term/src/facets/ops/zpool.js": "// zpool status — pool tank mid-scrub: scan progress ticking, mirror vdev tree, READ/WRITE/CKSUM columns.\nimport { C, ATTR, writer, pad, lpad, fixed, clock } from './_kit.js';\n\nconst VDEVS = [\n  ['tank', 0, 'ONLINE'], ['mirror-0', 1, 'ONLINE'], ['ata-HDD-8TB-ZA1B2C3D', 2, 'ONLINE'], ['ata-HDD-8TB-ZA1B4E5F', 2, 'ONLINE'],\n  ['mirror-1', 1, 'ONLINE'], ['ata-HDD-8TB-ZA1C7G8H', 2, 'ONLINE'], ['ata-HDD-8TB-ZA1C9J0K', 2, 'ONLINE'],\n  ['logs', 0, ''], ['nvme-SSD-1TB-S5H2NX0R1', 1, 'ONLINE'],\n];\n\nexport default {\n  id: 'ops-zpool-status',\n  title: 'zpool status',\n  category: 'Disk & Storage',\n  description: 'zpool status for a mirrored pool mid-scrub: scanned/issued throughput and % done ticking, the vdev tree, and clean error counters.',\n  tags: ['zfs', 'zpool', 'scrub', 'raid', 'mirror', 'storage'],\n  kind: 'tui',\n  size: { w: 76, h: 20 },\n  minSize: { w: 60, h: 8 },\n  loop: 30,\n  glyphs: 'ascii',\n  color: '16',\n  setup(ctx) {\n    const w = writer(ctx), c = C(ctx);\n    const H = ctx.height;\n    return (t) => {\n      const pct = 27.08 + t * 0.21;\n      const total = 3.62, scanned = total * pct / 100 * 1.12, issued = total * pct / 100;\n      const left = Math.max(0, (100 - pct) / 0.21 * 0.55 + 3300 - t * 1.6);\n      const hh = Math.floor(left / 3600), mm = Math.floor(left % 3600 / 60), ss = Math.floor(left % 60);\n      const lines = [\n        [['  pool: ', c.fg, ATTR.BOLD], ['tank', c.fg]],\n        [[' state: ', c.fg, ATTR.BOLD], ['ONLINE', c.green, ATTR.BOLD]],\n        [['  scan: ', c.fg, ATTR.BOLD], ['scrub in progress since Tue Oct 13 13:41:02 2026', c.fg]],\n        [[`        ${fixed(scanned, 2)}T / ${total}T scanned at ${fixed(1.02 + Math.sin(t) * 0.03, 2)}G/s, ${fixed(issued, 2)}T / ${total}T issued at 830M/s`, c.fg]],\n        [[`        0B repaired, `, c.fg], [`${fixed(pct, 2)}% done`, c.yellow, ATTR.BOLD], [`, ${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')} to go`, c.fg]],\n        [['config:', c.fg, ATTR.BOLD]],\n        [],\n        [[`        ${pad('NAME', 32)}${pad('STATE', 9)}${lpad('READ', 5)}${lpad('WRITE', 6)}${lpad('CKSUM', 6)}`, c.fg, ATTR.BOLD]],\n      ];\n      for (const [name, depth, state] of VDEVS) {\n        const label = `${'  '.repeat(depth)}${name}`;\n        lines.push(state\n          ? [[`        ${pad(label, 32)}`, depth === 2 ? c.fg : c.cyan], [pad(state, 9), c.green], [`${lpad(0, 5)}${lpad(0, 6)}${lpad(0, 6)}`, c.fg]]\n          : [[`        ${label}`, c.cyan]]);\n      }\n      lines.push([]);\n      lines.push([['errors: ', c.fg, ATTR.BOLD], ['No known data errors', c.fg]]);\n      lines.slice(0, H).forEach((parts, y) => w.seg(0, y, parts));\n    };\n  },\n};\n",
  "prism-term/src/facets/progress/_fonts.js": "// Pixel fonts, seven-segment maps and a ring rasterizer for the counter and\n// timer facets. Glyph rows are strings of '#' (lit) and '.' (dark).\n\nconst rows = (s) => s.split(' ');\n\n/** 4×7 digits — the odometer drums and flip cards. */\nexport const FONT47 = {\n  0: rows('.##. #..# #..# #..# #..# #..# .##.'),\n  1: rows('..#. .##. ..#. ..#. ..#. ..#. .###'),\n  2: rows('.##. #..# ...# ..#. .#.. #... ####'),\n  3: rows('###. ...# ...# .##. ...# ...# ###.'),\n  4: rows('#..# #..# #..# #### ...# ...# ...#'),\n  5: rows('#### #... ###. ...# ...# #..# .##.'),\n  6: rows('.##. #... #... ###. #..# #..# .##.'),\n  7: rows('#### ...# ..#. ..#. .#.. .#.. .#..'),\n  8: rows('.##. #..# #..# .##. #..# #..# .##.'),\n  9: rows('.##. #..# #..# .### ...# ...# .##.'),\n  ':': rows('. # # . # # .'),\n  '%': rows('##.# ##.# ..#. .#.. .#.. #.## #.##'),\n  '.': rows('.. .. .. .. .. ## ##'),\n  ' ': rows('.... .... .... .... .... .... ....'),\n};\n\n/** 3×5 banner digits — big percentages and countdowns. */\nexport const FONT35 = {\n  0: rows('### #.# #.# #.# ###'),\n  1: rows('.#. ##. .#. .#. ###'),\n  2: rows('### ..# ### #.. ###'),\n  3: rows('### ..# .## ..# ###'),\n  4: rows('#.# #.# ### ..# ..#'),\n  5: rows('### #.. ### ..# ###'),\n  6: rows('### #.. ### #.# ###'),\n  7: rows('### ..# ..# .#. .#.'),\n  8: rows('### #.# ### #.# ###'),\n  9: rows('### #.# ### ..# ###'),\n  '%': rows('#.# ..# .#. #.. #.#'),\n  ':': rows('. # . # .'),\n  '.': rows('. . . . #'),\n  ' ': rows('. . . . .'),\n};\n\n/**\n * Lay `text` out in a pixel font: returns { w, h, px } where px is a list of\n * [x, y, k] lit pixels (k = character index). `scale` repeats pixels across\n * columns so glyphs on 1×2-pixel half-block cells come out square.\n */\nexport function layout(text, font, { scale = 1, gap = 1 } = {}) {\n  const px = [];\n  let x = 0;\n  [...text].forEach((ch, k) => {\n    const gl = font[ch] || font[' '];\n    gl.forEach((row, y) => [...row].forEach((on, gx) => {\n      if (on === '#') for (let s = 0; s < scale; s++) px.push([x + gx * scale + s, y, k]);\n    }));\n    x += (gl[0].length + gap) * scale;\n  });\n  return { w: Math.max(0, x - gap * scale), h: (font[0] || font['0']).length, px };\n}\n\n/** Width in font pixels of `text` set in FONT35 with a 1-pixel gap. */\nexport const width35 = (text) => [...text].reduce((w, ch) => w + (FONT35[ch] || FONT35[' '])[0].length + 1, -1);\n\n/** Seven-segment masks, bits a b c d e f g = 1 2 4 8 16 32 64. */\nexport const SEG7 = [0x3f, 0x06, 0x5b, 0x4f, 0x66, 0x6d, 0x7d, 0x07, 0x7f, 0x6f];\n\n/** The classic 3×3 ASCII seven-segment digit (\" _ \", \"|_|\", \"|_|\"). */\nexport function seg7Ascii(d) {\n  const m = SEG7[d] ?? 0, on = (bit) => (m >> bit) & 1;\n  return [\n    ` ${on(0) ? '_' : ' '} `,\n    `${on(5) ? '|' : ' '}${on(6) ? '_' : ' '}${on(1) ? '|' : ' '}`,\n    `${on(4) ? '|' : ' '}${on(3) ? '_' : ' '}${on(2) ? '|' : ' '}`,\n  ];\n}\n\n/** Pixel runs for each segment of a 6×9 seven-segment digit: [x, y, w, h]. */\nexport const SEG7_RECTS = [\n  [1, 0, 4, 1], [5, 1, 1, 3], [5, 5, 1, 3], [1, 8, 4, 1], [0, 5, 1, 3], [0, 1, 1, 3], [1, 4, 4, 1],\n];\n\n/**\n * Rasterize a ring (or elliptical ring) into canvas pixels, filled clockwise\n * from 12 o'clock to `frac`. `color(u)` colors lit pixels (u = 0..1 along the\n * arc); the unlit track is drawn as a dotted outer line so fill reads by shape,\n * not only by color.\n */\nexport function ring(cv, { cx, cy, rx, ry, thick = 2, frac = 1, color, track }) {\n  const inner = 1 - thick / Math.min(rx, ry);\n  const edge = 1 - 1 / Math.min(rx, ry);\n  for (let y = Math.floor(cy - ry - 1); y <= Math.ceil(cy + ry + 1); y++) {\n    for (let x = Math.floor(cx - rx - 1); x <= Math.ceil(cx + rx + 1); x++) {\n      const nx = (x + 0.5 - cx) / rx, ny = (y + 0.5 - cy) / ry;\n      const d = Math.hypot(nx, ny);\n      if (d > 1 || d < inner) continue;\n      const u = (Math.atan2(nx, -ny) / (Math.PI * 2) + 1) % 1;\n      if (u <= frac) cv.set(x, y, color(u));\n      else if (track !== undefined && d >= edge && (x + y) % 2 === 0) cv.set(x, y, track);\n    }\n  }\n}\n\n/** The same ring in whole cells, for ASCII terminals: calls put(x, y, lit, u). */\nexport function ringCells(cols, rows, frac, put, thick = 1.15) {\n  const rx = cols / 2, ry = rows / 2;\n  const inner = 1 - thick / ry;\n  for (let y = 0; y < rows; y++) for (let x = 0; x < cols; x++) {\n    const nx = (x + 0.5 - rx) / rx, ny = (y + 0.5 - ry) / ry;\n    const d = Math.hypot(nx, ny);\n    if (d > 1.02 || d < inner) continue;\n    const u = (Math.atan2(nx, -ny) / (Math.PI * 2) + 1) % 1;\n    put(x, y, u <= frac, u);\n  }\n}\n",
  "prism-term/src/facets/progress/_log.js": "// Scrolling-log presentation for tool-output facets (apt, cargo, docker, git…).\n//\n// A facet builds a script of timed lines for one loop; at time t we show the\n// lines that have \"printed\" so far, scrolled so the newest sit above any pinned\n// live rows — the way a real terminal looks while the tool runs.\n\nimport { stringWidth, truncate } from '../../width.js';\n\n/**\n * Draw segmented text: segs = [[text, color, attr?], …]. Clips at `w` columns.\n * Returns the column after the last character drawn.\n */\nexport function segs(b, x, y, w, parts, ell = '…') {\n  let cx = x;\n  const end = x + w;\n  for (const [text, color, attr] of parts) {\n    if (cx >= end) break;\n    const room = end - cx;\n    const s = stringWidth(text) > room ? truncate(text, room, ell) : text;\n    cx = b.text(cx, y, s, color, undefined, attr || 0);\n  }\n  return cx;\n}\n\n/**\n * Lines whose time has come, newest last, limited to `rows`.\n * @param {Array<{at:number, parts:Array}>} script  sorted by `at`\n */\nexport function visible(script, local, rows) {\n  const out = [];\n  for (const line of script) { if (line.at <= local) out.push(line); else break; }\n  return rows > 0 ? out.slice(-rows) : [];\n}\n\n/**\n * Hard-wrap rows of segments at `w` columns, the way a terminal wraps a line\n * that is longer than the screen. Used only for genuine tool output that would\n * really wrap (wget's read-error line), never for our own layout.\n */\nexport function wrapRows(rows, w) {\n  const out = [];\n  for (const row of rows) {\n    if (row.noWrap) { out.push(row); continue; }      // live progress lines redraw in place, they never wrap\n    let cur = [], used = 0;\n    for (const [text, color, attr] of row) {\n      let chunk = '';\n      for (const ch of text) {\n        const cw = stringWidth(ch);\n        if (used + cw > w) { if (chunk) cur.push([chunk, color, attr]); out.push(cur); cur = []; chunk = ''; used = 0; }\n        chunk += ch; used += cw;\n      }\n      if (chunk) cur.push([chunk, color, attr]);\n    }\n    out.push(cur);\n  }\n  return out;\n}\n",
  "prism-term/src/facets/progress/_sim.js": "// Deterministic job simulation for progress facets.\n//\n// Real transfers aren't linear: the rate bursts, stalls, ramps up after a slow\n// start and tails off. `transfer()` precomputes a seeded rate curve once in\n// setup, integrates it, and scales it so the job finishes exactly on schedule\n// — so every number on screen (done, total, rate, ETA, percent) agrees with\n// every other. The ETA uses an exponentially smoothed rate, the way tqdm/pv do,\n// so it wobbles early and converges as the job runs.\n\nexport const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);\n\n/**\n * Loop phase: the job runs for `active` of each `loop` seconds, then holds the\n * finished state. Returns { u (0..1 progress time), local (s), done (bool), k (loop index) }.\n */\nexport function phase(t, loop, active = 0.78) {\n  const k = Math.floor(t / loop);\n  const local = t - k * loop;\n  const run = loop * active;\n  return { u: clamp01(local / run), local, done: local >= run, k, run };\n}\n\n/**\n * A transfer of `total` units completing in `seconds`.\n * @param {function} rng   ctx.rng (seeded)\n * @param {object} o       { total, seconds, steps=480, burst=0.55, stalls=1, warmup=0.12, alpha=0.08 }\n * @returns {{at(sec): {done, frac, rate, avg, eta, elapsed}}}\n */\nexport function transfer(rng, o) {\n  const { total, seconds, steps = 480, burst = 0.55, stalls = 1, warmup = 0.12, alpha = 0.08 } = o;\n  const raw = new Float64Array(steps);\n  // Low-frequency drift plus bursts plus the occasional stall.\n  const f1 = 2 + rng() * 3, f2 = 7 + rng() * 6, p1 = rng() * 6.3, p2 = rng() * 6.3;\n  const stallAt = Array.from({ length: stalls }, () => 0.25 + rng() * 0.55);\n  for (let i = 0; i < steps; i++) {\n    const x = i / steps;\n    let r = 1 + burst * 0.6 * Math.sin(x * f1 * 6.283 + p1) + burst * 0.4 * Math.sin(x * f2 * 6.283 + p2) + burst * 0.5 * (rng() - 0.5);\n    r *= Math.min(1, 0.25 + x / warmup);                 // slow start (DNS, TLS, TCP slow start)\n    for (const s of stallAt) if (Math.abs(x - s) < 0.025) r *= 0.12;   // a stall\n    if (x > 0.93) r *= 0.75;                             // tail-off\n    raw[i] = Math.max(0.05, r);\n  }\n  const cum = new Float64Array(steps + 1);\n  for (let i = 0; i < steps; i++) cum[i + 1] = cum[i] + raw[i];\n  const scale = total / cum[steps];\n  const dt = seconds / steps;\n  // Smoothed rate per step (units / second), for ETA.\n  const ewma = new Float64Array(steps);\n  let e = raw[0] * scale / dt * 0.3;\n  for (let i = 0; i < steps; i++) { e += alpha * (raw[i] * scale / dt - e); ewma[i] = e; }\n  return {\n    total,\n    seconds,\n    at(sec) {\n      const x = clamp01(sec / seconds) * steps;\n      const i = Math.min(steps - 1, Math.floor(x)), fr = x - Math.floor(x);\n      // Past the end the job is exactly done — no 0.9999999 that floors to \"99%\".\n      const done = sec >= seconds ? total : Math.min(total, (cum[i] + raw[i] * fr) * scale);\n      const rate = raw[i] * scale / dt;\n      const smooth = ewma[i];\n      const elapsed = Math.min(sec, seconds);\n      const avg = elapsed > 0 ? done / elapsed : 0;\n      const eta = done >= total ? 0 : (total - done) / Math.max(1e-9, smooth);\n      return { done, frac: done / total, rate, smooth, avg, eta, elapsed, finished: done >= total };\n    },\n  };\n}\n\n// ── formatting the way the real tools do ────────────────────────────────────\n\nconst IEC = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];\nconst SI = ['B', 'kB', 'MB', 'GB', 'TB'];\n\n/** 1536 → \"1.50 KiB\" (iec) or \"1.54 kB\" (si). */\nexport function bytes(n, { iec = true, digits = 2, pad = 0 } = {}) {\n  const base = iec ? 1024 : 1000, units = iec ? IEC : SI;\n  let u = 0, v = n;\n  while (v >= base && u < units.length - 1) { v /= base; u++; }\n  const s = u === 0 ? `${Math.round(v)} ${units[0]}` : `${v.toFixed(digits)} ${units[u]}`;\n  return s.padStart(pad);\n}\n\n/** wget-style compact size: 41.2M, 512K. */\nexport function short(n) {\n  if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(2)}G`;\n  if (n >= 1024 ** 2) return `${(n / 1024 ** 2).toFixed(1)}M`;\n  if (n >= 1024) return `${Math.round(n / 1024)}K`;\n  return `${Math.round(n)}`;\n}\n\n/** 7.3 → \"0:00:07\", or \"00:07\" with { short: true }. */\nexport function clock(s, { short: sh = false } = {}) {\n  s = Math.max(0, Math.round(s));\n  const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;\n  const mm = String(m).padStart(2, '0'), ss = String(sec).padStart(2, '0');\n  return sh && !h ? `${mm}:${ss}` : `${h}:${mm}:${ss}`;\n}\n\n/** 1234567 → \"1,234,567\". */\nexport const commas = (n) => Math.round(n).toLocaleString('en-US');\n\n/** Pick Unicode or ASCII glyphs for the current caps. */\nexport const glyphs = (ctx) => {\n  const u = ctx.caps.unicode !== false;\n  return {\n    unicode: u,\n    ok: u ? '✔' : 'OK', fail: u ? '✖' : 'XX', warn: u ? '⚠' : '!!', dot: u ? '·' : '-', ell: u ? '…' : '...',\n    arrow: u ? '→' : '->', bullet: u ? '●' : '*', hollow: u ? '○' : 'o', full: u ? '█' : '#',\n  };\n};\n",
  "prism-term/src/facets/progress/bars.js": "// Progress bars — the shapes a bar can take, each driven by a seeded transfer\n// so fills move like real work (bursts, stalls, tail-off) and finish together\n// with their percentages.\nimport { progressBar, meter } from '../../widgets/index.js';\nimport { Canvas } from '../../canvas.js';\nimport { mix, gradient, NONE } from '../../color.js';\nimport { ATTR } from '../../buffer.js';\nimport { truncate } from '../../width.js';\nimport { phase, transfer, glyphs, clamp01, clock } from './_sim.js';\nimport { light, lift } from '../charts/_data.js';\n\nconst C = 'Progress Bars';\nconst EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];\n\n/** Rows that fit: drop rows from the bottom when the tile is short. */\nconst rowsFor = (H, n, gap = 1) => {\n  const per = 1 + gap;\n  const fit = Math.max(1, Math.floor((H + gap) / per));\n  return { count: Math.min(n, fit), step: Math.min(n, fit) * per - gap <= H ? per : 1 };\n};\n\nconst pctText = (f) => `${String(Math.floor(clamp01(f) * 100)).padStart(3)}%`;\n\nexport default [\n  {\n    id: 'progress-smooth', title: 'Smooth Eighths', category: C, order: 10, kind: 'progress',\n    description: 'Eighth-block bars that move in 1/8-cell steps, so even a 20-column bar creeps forward smoothly. Three jobs at different speeds.',\n    tags: ['eighth blocks', 'smooth', 'multi'], size: { w: 60, h: 5 }, minSize: { w: 24, h: 1 }, loop: 9, color: '256',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const jobs = [\n        { label: 'assets', c: th.accent, sim: transfer(ctx.rng, { total: 1, seconds: 5.2 }) },\n        { label: 'chunks', c: th.pos, sim: transfer(ctx.rng, { total: 1, seconds: 6.6 }) },\n        { label: 'sourcemaps', c: th.info, sim: transfer(ctx.rng, { total: 1, seconds: 7.0 }) },\n      ];\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width;\n        const { local } = phase(t, 9, 1);\n        const { count, step } = rowsFor(ctx.height, jobs.length, 1);\n        const lw = W >= 40 ? 11 : 0;\n        jobs.slice(0, count).forEach((j, r) => {\n          const y = r * step, s = j.sim.at(local);\n          if (lw) b.text(1, y, j.label, th.muted);\n          const done = s.finished;\n          progressBar(b, 1 + lw, y, W - 2 - lw - 5, s.frac, { theme: th, color: j.c, ascii: !g.unicode });\n          b.text(W - 5, y, done ? ` ${g.ok}`.padStart(4) : pctText(s.frac), done ? th.pos : th.ink);\n        });\n      };\n    },\n  },\n  {\n    id: 'progress-braille', title: 'Braille Bars', category: C, order: 20, kind: 'progress',\n    description: 'Hairline progress drawn in braille dots at twice the horizontal resolution — a thin rule, a dotted track and a glowing head.',\n    tags: ['braille', 'thin', 'hairline'], size: { w: 60, h: 5 }, minSize: { w: 20, h: 1 }, loop: 8, glyphs: 'braille', color: '256',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const sims = [0, 1, 2].map((i) => transfer(ctx.rng, { total: 1, seconds: 5.2 + i * 0.6 }));\n      const grad = gradient([th.info, th.accent, th.crit]);\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width;\n        const { local } = phase(t, 8, 1);\n        const { count, step } = rowsFor(ctx.height, 3, 1);\n        const bw = Math.max(4, W - 8);\n        for (let r = 0; r < count; r++) {\n          const y = r * step, f = sims[r].at(local).frac;\n          if (!g.unicode) { progressBar(b, 1, y, bw, f, { theme: th, ascii: true }); b.text(W - 5, y, pctText(f), th.ink); continue; }\n          const cv = new Canvas(bw, 1, { mode: 'braille', threshold: 0 });\n          const fill = Math.round(f * cv.width);\n          for (let x = 0; x < cv.width; x++) {\n            const lit = x < fill;\n            const c = lit ? grad(x / cv.width) : th.line;\n            if (r === 0) { if (lit) { cv.set(x, 1, c); cv.set(x, 2, c); } else cv.set(x, 2, c); }\n            else if (r === 1) { if (lit) { cv.set(x, 1, c); cv.set(x, 2, c); } else if (x % 2 === 0) cv.set(x, 1.5, th.dim); }\n            else if (lit) { cv.set(x, 1, c); cv.set(x, 2, c); if (fill - x <= 2) { cv.set(x, 0, light(th)); cv.set(x, 3, light(th)); } }\n            else cv.set(x, 3, th.line);\n          }\n          cv.paint(b, 1, y);\n          b.text(W - 5, y, pctText(f), th.ink);\n        }\n      };\n    },\n  },\n  {\n    id: 'progress-gradient', title: 'Gradient Shimmer', category: C, order: 30, kind: 'progress',\n    description: 'Solid gradient fills — prism, aurora, fire and ocean — with a highlight sweeping along the finished part.',\n    tags: ['gradient', 'truecolor', 'shimmer'], size: { w: 60, h: 7 }, minSize: { w: 20, h: 1 }, loop: 8, color: 'truecolor',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const bars = ['prism', 'aurora', 'fire', 'ocean'].map((p, i) => ({ name: p, pal: ctx.palette(p), sim: transfer(ctx.rng, { total: 1, seconds: 5 + i * 0.45 }) }));\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width;\n        const { local } = phase(t, 8, 1);\n        const { count, step } = rowsFor(ctx.height, bars.length, 1);\n        const lw = W >= 40 ? 8 : 0;\n        const bw = Math.max(4, W - 2 - lw - 5);\n        bars.slice(0, count).forEach((bar, r) => {\n          const y = r * step, f = bar.sim.at(local).frac;\n          if (lw) b.text(1, y, bar.name, th.muted);\n          if (!g.unicode) { progressBar(b, 1 + lw, y, bw, f, { theme: th, ascii: true }); }\n          else {\n            const e = Math.round(f * bw * 8), full = e >> 3, rem = e & 7;\n            const sweep = ((t * 0.55 + r * 0.17) % 1.4) - 0.2;\n            for (let i = 0; i < bw; i++) {\n              const u = i / Math.max(1, bw - 1);\n              const pal = bar.name === 'prism' ? bar.pal(u * 0.85) : bar.pal(0.35 + 0.6 * u);\n              const glow = Math.max(0, 1 - Math.abs(u - sweep * f) * 9) * 0.55;\n              const c = lift(th, pal, glow);\n              if (i < full) b.set(1 + lw + i, y, ' ', c, c);\n              else if (i === full && rem) b.set(1 + lw + i, y, EIGHTHS[rem], c, th.panel2);\n              else b.set(1 + lw + i, y, ' ', th.dim, th.panel2);\n            }\n          }\n          b.text(W - 5, y, pctText(f), th.ink);\n        });\n      };\n    },\n  },\n  {\n    id: 'progress-segmented', title: 'Segmented LEDs', category: C, order: 40, kind: 'progress',\n    description: 'Discrete segment bars — parallelograms, squares and LED ticks — that light one by one and shift color by zone.',\n    tags: ['segments', 'led', 'zones', 'battery'], size: { w: 50, h: 5 }, minSize: { w: 22, h: 1 }, loop: 8, color: '16',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const sims = [0, 1, 2].map((i) => transfer(ctx.rng, { total: 1, seconds: 5.4 + i * 0.5, burst: 0.3 }));\n      const styles = g.unicode ? [['▰', '▱'], ['■', '□'], ['┃', '╎']] : [['#', '.'], ['=', '-'], ['|', ':']];\n      const labels = ['charge', 'sync', 'rebuild'];\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width;\n        const { local } = phase(t, 8, 1);\n        const { count, step } = rowsFor(ctx.height, 3, 1);\n        const lw = W >= 34 ? 9 : 0;\n        for (let r = 0; r < count; r++) {\n          const y = r * step, f = sims[r].at(local).frac;\n          if (lw) b.text(1, y, labels[r], th.muted);\n          const segs = Math.min(24, W - 3 - lw - 5);\n          meter(b, 1 + lw, y, segs, f, { theme: th, on: styles[r][0], off: styles[r][1], thresholds: r === 0 ? [[0, 'neg'], [0.25, 'warn'], [0.6, 'pos']] : [[0.7, 'info']], color: r === 0 ? 'neg' : 'accent' });\n          b.text(1 + lw + segs + 1, y, pctText(f), th.ink);\n        }\n      };\n    },\n  },\n  {\n    id: 'progress-indeterminate', title: 'Indeterminate', category: C, order: 50, kind: 'progress',\n    description: 'For work with no known end: a gliding block, a barber-pole stripe and a stretching Material-style band.',\n    tags: ['indeterminate', 'barber pole', 'busy'], size: { w: 60, h: 5 }, minSize: { w: 20, h: 1 }, loop: 6, color: '256',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const labels = ['waiting for server', 'negotiating', 'resolving host'];\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width;\n        const { count, step } = rowsFor(ctx.height, 3, 1);\n        const lw = W >= 44 ? 19 : 0;\n        const bw = Math.max(6, W - 2 - lw);\n        for (let r = 0; r < count; r++) {\n          const y = r * step, x0 = 1 + lw;\n          if (lw) b.text(1, y, truncate(labels[r], lw - 1), th.muted);\n          if (r === 0) {\n            // Gliding block with eighth-cell edges, easing at the walls.\n            const blk = Math.max(3, Math.round(bw * 0.18));\n            const u = 0.5 - 0.5 * Math.cos(t * 2.1);\n            const start = u * (bw - blk) * 8, end = start + blk * 8;\n            b.fill(x0, y, bw, 1, ' ', th.dim, th.panel2);\n            for (let i = 0; i < bw; i++) {\n              const a0 = i * 8, lo = Math.max(a0, start), hi = Math.min(a0 + 8, end);\n              const cov = Math.max(0, hi - lo);\n              if (cov <= 0.5) continue;\n              if (!g.unicode) { if (cov > 4) b.set(x0 + i, y, '#', th.accent, NONE); continue; }\n              if (cov >= 7.5) { b.set(x0 + i, y, ' ', th.accent, th.accent); continue; }\n              if (lo > a0) {\n                // Block enters mid-cell: paint the uncovered left part in track color over an accent ground.\n                const left = Math.round(lo - a0);\n                if (left <= 0) b.set(x0 + i, y, ' ', th.accent, th.accent);\n                else if (left < 8) b.set(x0 + i, y, EIGHTHS[left], th.panel2, th.accent);\n              } else {\n                const n = Math.round(cov);\n                if (n >= 8) b.set(x0 + i, y, ' ', th.accent, th.accent);\n                else if (n > 0) b.set(x0 + i, y, EIGHTHS[n], th.accent, th.panel2);\n              }\n            }\n          } else if (r === 1) {\n            const off = Math.floor(t * 8);\n            for (let i = 0; i < bw; i++) {\n              const on = ((i + off) >> 1) % 2 === 0;\n              b.set(x0 + i, y, g.unicode ? '╱' : '/', on ? th.bg : th.warn, on ? th.warn : mix(th.warn, th.bg, 0.55));\n            }\n          } else {\n            // Head runs ahead, tail catches up — the stretching band.\n            const cyc = (t * 0.55) % 1;\n            const head = Math.min(1, cyc * 1.6), tail = Math.max(0, cyc * 1.6 - 0.6);\n            b.fill(x0, y, bw, 1, ' ', th.dim, mix(th.bg, th.info, 0.15));\n            for (let i = Math.floor(tail * bw); i < Math.ceil(head * bw); i++) b.set(x0 + i, y, g.unicode ? ' ' : '=', th.info, g.unicode ? th.info : NONE);\n          }\n        }\n      };\n    },\n  },\n  {\n    id: 'progress-ascii-classic', title: 'ASCII Classics', category: C, order: 60, kind: 'progress',\n    description: 'The 7-bit bars everyone recognizes — hashes, an arrow head, pipes and stars — for serial consoles and LANG=C.',\n    tags: ['ascii', 'classic', 'hash', 'retro'], size: { w: 56, h: 7 }, minSize: { w: 24, h: 1 }, loop: 8, glyphs: 'ascii', color: 'mono',\n    setup(ctx) {\n      const th = ctx.theme;\n      const sims = [0, 1, 2, 3].map((i) => transfer(ctx.rng, { total: 1, seconds: 5 + i * 0.5 }));\n      const styles = [\n        (n, w) => '#'.repeat(n) + '-'.repeat(w - n),\n        (n, w) => (n >= w ? '='.repeat(w) : '='.repeat(Math.max(0, n - 1)) + (n ? '>' : '') + ' '.repeat(w - n)),\n        (n, w) => '|'.repeat(n) + ' '.repeat(w - n),\n        (n, w) => '*'.repeat(n) + '.'.repeat(w - n),\n      ];\n      const wrap = [['[', ']'], ['[', ']'], ['[', ']'], ['(', ')']];\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width;\n        const { local } = phase(t, 8, 1);\n        const { count, step } = rowsFor(ctx.height, 4, 1);\n        for (let r = 0; r < count; r++) {\n          const y = r * step, f = sims[r].at(local).frac;\n          const w = Math.max(4, W - 9);\n          const n = Math.round(f * w);\n          b.text(1, y, wrap[r][0] + styles[r](n, w) + wrap[r][1], r === 1 ? th.pos : th.ink);\n          b.text(W - 5, y, pctText(f), th.muted);\n        }\n      };\n    },\n  },\n  {\n    id: 'progress-thin-line', title: 'Thin Line', category: C, order: 70, kind: 'progress',\n    description: 'The heavy-line bar modern Python and Rust tools use — a bright run, a half-cap head and a dim remainder, with elapsed time.',\n    tags: ['line', 'rich', 'pip', 'minimal'], size: { w: 64, h: 5 }, minSize: { w: 24, h: 1 }, loop: 8, color: '256',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const rows = [\n        { name: 'Downloading', c: th.crit, sim: transfer(ctx.rng, { total: 1, seconds: 5.5 }) },\n        { name: 'Building', c: th.accent, sim: transfer(ctx.rng, { total: 1, seconds: 6.2 }) },\n        { name: 'Publishing', c: th.pos, sim: transfer(ctx.rng, { total: 1, seconds: 6.8 }) },\n      ];\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width;\n        const { local } = phase(t, 8, 1);\n        const { count, step } = rowsFor(ctx.height, 3, 1);\n        const lw = W >= 44 ? 12 : 0;\n        const tail = W >= 34 ? 13 : 5;\n        const bw = Math.max(4, W - 2 - lw - tail);\n        rows.slice(0, count).forEach((row, r) => {\n          const y = r * step, s = row.sim.at(local);\n          if (lw) b.text(1, y, row.name, th.ink);\n          const half = s.frac * bw, n = Math.floor(half);\n          for (let i = 0; i < bw; i++) {\n            const lit = i < n || s.finished;\n            const head = !s.finished && i === n && half - n >= 0.5;\n            const ch = g.unicode ? (head ? '╸' : '━') : (lit || head ? '=' : '-');\n            b.set(1 + lw + i, y, ch, lit || head ? (s.finished ? th.pos : row.c) : th.line);\n          }\n          const tx = 1 + lw + bw + 1;\n          b.text(tx, y, pctText(s.frac).trim().padStart(4), th.ink);\n          if (tail > 5) b.text(tx + 5, y, clock(s.elapsed), th.dim);\n        });\n      };\n    },\n  },\n  {\n    id: 'progress-vertical', title: 'Vertical Fill', category: C, order: 80, kind: 'progress',\n    description: 'One column per worker filling bottom-up in eighth blocks — parallel shards, partitions or cores at a glance.',\n    tags: ['vertical', 'columns', 'workers', 'parallel'], size: { w: 48, h: 10 }, minSize: { w: 16, h: 4 }, loop: 9, color: '256',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const N = 8;\n      const sims = Array.from({ length: N }, (_, i) => transfer(ctx.rng, { total: 1, seconds: 4.4 + ctx.rng() * 2.6, burst: 0.7 }));\n      const LOWER = ['', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];\n      const colorFor = (f) => mix(th.info, th.pos, f);\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local } = phase(t, 9, 1);\n        const cols = Math.max(1, Math.min(N, Math.floor((W - 1) / 4)));\n        const cw = Math.max(1, Math.floor((W - 1) / cols) - 1);\n        const hh = Math.max(1, H - 2);\n        for (let k = 0; k < cols; k++) {\n          const f = sims[k].at(local).frac;\n          const x = 1 + k * (cw + 1);\n          const e = Math.round(f * hh * 8), full = e >> 3, rem = e & 7;\n          for (let r = 0; r < hh; r++) {\n            const y = hh - 1 - r;\n            const c = colorFor(r / Math.max(1, hh - 1));\n            for (let dx = 0; dx < cw; dx++) {\n              if (r < full) b.set(x + dx, y, g.unicode ? '█' : '#', f >= 1 ? th.pos : c);\n              else if (r === full && rem) b.set(x + dx, y, g.unicode ? LOWER[rem] : (rem > 4 ? '#' : '_'), c);\n              else b.set(x + dx, y, g.unicode ? '░' : '.', th.line);\n            }\n          }\n          b.text(x, H - 2, `${Math.floor(f * 100)}`.padStart(Math.min(3, cw)).slice(-cw), f >= 1 ? th.pos : th.ink);\n          b.text(x, H - 1, `w${k + 1}`.slice(0, cw), th.muted);\n        }\n      };\n    },\n  },\n  {\n    id: 'progress-shaded', title: 'Shaded Edge', category: C, order: 90, kind: 'progress',\n    description: 'Full blocks with a ▓▒░ shaded leading edge over a light-shade track — the bar that feels like a DOS installer.',\n    tags: ['shade', 'dos', 'retro', 'installer'], size: { w: 56, h: 3 }, minSize: { w: 20, h: 1 }, loop: 8, color: '16',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const sims = [transfer(ctx.rng, { total: 1, seconds: 6 }), transfer(ctx.rng, { total: 1, seconds: 5.2 })];\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width;\n        const { local } = phase(t, 8, 1);\n        const { count, step } = rowsFor(ctx.height, 2, 1);\n        for (let r = 0; r < count; r++) {\n          const y = r * step, f = sims[r].at(local).frac;\n          const bw = Math.max(4, W - 8);\n          const n = f * bw;\n          for (let i = 0; i < bw; i++) {\n            const d = n - i;\n            let ch, c;\n            if (d >= 1) { ch = g.unicode ? '█' : '#'; c = r ? th.info : th.accent; }\n            else if (d > 0.66) { ch = g.unicode ? '▓' : '#'; c = r ? th.info : th.accent; }\n            else if (d > 0.33) { ch = g.unicode ? '▒' : '+'; c = r ? th.info : th.accent; }\n            else if (d > 0) { ch = g.unicode ? '░' : '-'; c = r ? th.info : th.accent; }\n            else { ch = g.unicode ? '░' : '.'; c = th.line; }\n            b.set(1 + i, y, ch, c);\n          }\n          b.text(W - 5, y, pctText(f), th.ink, undefined, f >= 1 ? ATTR.BOLD : 0);\n        }\n      };\n    },\n  },\n];\n",
  "prism-term/src/facets/progress/counters.js": "// Counters & odometers — numbers that roll, tick, light up and fill rings.\nimport { ATTR } from '../../buffer.js';\nimport { Canvas } from '../../canvas.js';\nimport { mix, gradient } from '../../color.js';\nimport { sparkline, progressBar, box } from '../../widgets/index.js';\nimport { truncate, stringWidth } from '../../width.js';\nimport { phase, transfer, glyphs, clamp01, commas, clock } from './_sim.js';\nimport { lift } from '../charts/_data.js';\nimport { FONT47, FONT35, width35, SEG7, SEG7_RECTS, seg7Ascii, ring, ringCells } from './_fonts.js';\n\nconst C = 'Counters & Odometers';\nconst pctInt = (f) => Math.floor(clamp01(f) * 100);\n/** 1834 → \"1.8k\", 0.62 → \"0.6\". */\nconst rateText = (r) => (r >= 1000 ? `${(r / 1000).toFixed(1)}k` : r >= 10 ? `${Math.round(r)}` : r.toFixed(1));\nconst ASCII_SPARK = ' .:-=+*#';\nfunction asciiSpark(b, x, y, w, data, color) {\n  const pts = data.slice(-w), max = Math.max(1e-9, ...pts);\n  pts.forEach((v, i) => b.set(x + i, y, ASCII_SPARK[Math.min(7, Math.round((v / max) * 7))], color));\n}\n\n/** Mechanical wheel positions: wheel i turns only during the last unit before it carries. */\nfunction wheels(v, n) {\n  const out = [];\n  for (let i = 0; i < n; i++) {\n    const p = 10 ** i;\n    out.push(i === 0 ? v % 10 : (Math.floor(v / p) + Math.max(0, (v % p) - (p - 1))) % 10);\n  }\n  return out;\n}\n\nexport default [\n  // ── odometer ─────────────────────────────────────────────────────────\n  {\n    id: 'progress-odometer', title: 'Rolling Odometer', category: C, order: 10, kind: 'progress',\n    description: 'A mechanical counter: each drum rolls to its next digit only while the drum to its right passes 9, shaded like a cylinder.',\n    tags: ['odometer', 'counter', 'rolling', 'mechanical'], size: { w: 48, h: 7 }, minSize: { w: 24, h: 3 }, loop: 10, color: '256',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const base = 1283947, N = 7, PITCH = 9;\n      const sim = transfer(ctx.rng, { total: 168, seconds: 10, burst: 0.9, warmup: 0.02 });\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local } = phase(t, 10, 1);\n        const s = sim.at(local);\n        const v = base + s.done;\n        const pos = wheels(v, N);\n        const order = Array.from({ length: N }, (_, k) => N - 1 - k);   // left → right = most → least significant\n        const comma = (i) => i > 0 && i % 3 === 0;\n        const big = g.unicode && H >= 4 && W >= 46;\n        const top = big ? (H >= 6 ? 1 : 0) : H >= 5 ? 1 : 0;\n        if (H >= 5) {\n          b.text(1, 0, 'requests served', th.muted);\n          const r = `${rateText(s.smooth)}/s`;\n          b.text(W - 1 - r.length, 0, r, th.accent);\n        }\n        if (big) {\n          const drumW = 6, cols = N * drumW + 2;\n          const x0 = Math.floor((W - cols) / 2);\n          const cv = new Canvas(cols, 4, { mode: 'half' });\n          let x = 0;\n          for (const i of order) {\n            const off = Math.round(pos[i] * PITCH);\n            for (let py = 0; py < 8; py++) {\n              const k = 1 - Math.abs(py - 3.5) / 3.5;          // cylinder shading\n              const Y = (off + py) % (PITCH * 10);\n              const d = Math.floor(Y / PITCH), row = Y - d * PITCH;\n              const glyph = FONT47[d];\n              for (let px = 0; px < drumW; px++) {\n                const edge = px === 0 || px === drumW - 1;\n                const face = mix(th.bg, th.panel2, edge ? 0.35 : 0.55 + 0.3 * k);\n                const lit = row < 7 && px >= 1 && px <= 4 && glyph[row][px - 1] === '#';\n                cv.set(x + px, py, lit ? mix(face, i === 0 ? th.accent : th.ink, 0.55 + 0.45 * k) : face);\n              }\n            }\n            x += drumW;\n            if (comma(i)) { cv.set(x, 7, th.muted); cv.set(x, 6, th.muted); x += 1; }\n          }\n          cv.paint(b, x0, top);\n        } else {\n          // Text drums: the digit above is the one that just went by, below is the next to roll in.\n          const cols = N * 2 + 2 * 2;\n          let x = Math.max(0, Math.floor((W - cols) / 2));\n          const y = H >= 3 ? top : 0;\n          for (const i of order) {\n            const cur = Math.round(pos[i]) % 10;\n            if (H >= 3) b.text(x, y, String((cur + 9) % 10), th.dim);\n            b.text(x, y + (H >= 3 ? 1 : 0), String(cur), i === 0 ? th.accent : th.ink, undefined, ATTR.BOLD);\n            if (H >= 3) b.text(x, y + 2, String((cur + 1) % 10), th.dim);\n            x += 2;\n            if (comma(i)) { b.text(x - 1, y + (H >= 3 ? 1 : 0), ',', th.muted); x += 2; }\n          }\n        }\n        if (H >= 6 || (!big && H >= 5)) {\n          const line = `+${Math.floor(s.done)} this run  ${g.dot}  avg ${(s.elapsed > 0 ? s.done / s.elapsed : 0).toFixed(1)}/s`;\n          b.text(1, H - 1, truncate(line, W - 2, g.ell), th.muted);\n        }\n      };\n    },\n  },\n\n  // ── seven-segment ────────────────────────────────────────────────────\n  {\n    id: 'progress-seven-segment', title: 'Seven-Segment Counter', category: C, order: 20, kind: 'progress',\n    description: 'A big LED counter with unlit ghost segments, ticking up in bursts and blinking when the batch completes. Classic _|_ in ASCII.',\n    tags: ['seven segment', 'led', 'counter', 'retro'], size: { w: 44, h: 8 }, minSize: { w: 20, h: 3 }, loop: 10, color: '256',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const total = 4096, secs = 7.6;\n      const sim = transfer(ctx.rng, { total, seconds: secs, burst: 0.8, stalls: 2 });\n      const led = mix(th.neg, 0xff2a1a, 0.5), ghost = mix(th.bg, led, 0.13);\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local } = phase(t, 10, 1);\n        const s = sim.at(local);\n        const n = Math.floor(s.done);\n        const after = local - secs;\n        const dark = s.finished && after < 1.8 && Math.floor(after * 3.4) % 2 === 1;   // blink on completion\n        const digits = String(n).padStart(4, ' ');\n        const big = g.unicode && H >= 6 && W >= 32;\n        const top = H >= 5 ? 1 : 0;\n        if (H >= 5) {\n          b.text(1, 0, 'BATCH 7', th.dim, undefined, ATTR.BOLD);\n          const tag = s.finished ? 'DONE' : 'RUN';\n          b.text(W - 1 - tag.length, 0, tag, s.finished ? th.pos : th.warn, undefined, ATTR.BOLD);\n        }\n        if (big) {\n          const cv = new Canvas(30, 5, { mode: 'half' });\n          [...digits].forEach((ch, k) => {\n            const m = ch === ' ' ? 0 : SEG7[+ch];\n            SEG7_RECTS.forEach(([x, y, w, h], bit) => {\n              const on = !dark && (m >> bit) & 1;\n              for (let dy = 0; dy < h; dy++) for (let dx = 0; dx < w; dx++) cv.set(k * 8 + x + dx, y + dy, on ? led : ghost);\n            });\n          });\n          const x0 = Math.max(0, Math.floor((W - 30) / 2) - 4);\n          cv.paint(b, x0, top);\n          if (W >= x0 + 30 + 9) { b.text(x0 + 31, top + 3, 'items', th.muted); b.text(x0 + 31, top + 4, `/${commas(total)}`, th.dim); }\n        } else {\n          const x0 = Math.max(0, Math.floor((W - 16) / 2));\n          [...digits].forEach((ch, k) => {\n            const art = ch === ' ' ? ['   ', '   ', '   '] : seg7Ascii(+ch);\n            art.forEach((r, y) => { if (top + y < H) b.text(x0 + k * 4, top + y, r, dark ? th.dim : led); });\n          });\n        }\n        if (H >= 5) {\n          const msg = s.finished ? `${g.ok} batch 7 complete ${g.dot} ${commas(total)} items in ${secs.toFixed(1)}s`\n            : `${commas(n)} of ${commas(total)} ${g.dot} ${Math.round(s.smooth)}/s ${g.dot} eta ${clock(s.eta, { short: true })}`;\n          b.text(1, H - 1, truncate(msg, W - 2, g.ell), s.finished ? th.pos : th.muted);\n        }\n      };\n    },\n  },\n\n  // ── big percent ──────────────────────────────────────────────────────\n  {\n    id: 'progress-big-percent', title: 'Big Percentage', category: C, order: 30, kind: 'progress',\n    description: 'A banner-sized percentage with an extruded shadow over a slim bar — the number you can read from across the room.',\n    tags: ['percent', 'banner', 'big', 'figlet'], size: { w: 48, h: 9 }, minSize: { w: 20, h: 2 }, loop: 10, color: '256',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const docs = 24960, secs = 7.4;\n      const sim = transfer(ctx.rng, { total: docs, seconds: secs, burst: 0.6 });\n      const ramp = gradient([th.info, th.accent, th.crit]);\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local } = phase(t, 10, 1);\n        const s = sim.at(local);\n        const p = s.finished ? 100 : pctInt(s.frac);\n        const text = `${p}%`;\n        const caption = s.finished ? `${g.ok} Index rebuilt ${g.dot} ${commas(docs)} docs in ${secs.toFixed(1)}s`\n          : `Reindexing ${g.dot} ${commas(s.done)}/${commas(docs)} docs ${g.dot} eta ${clock(s.eta, { short: true })}`;\n        const color = (u) => (s.finished ? th.pos : ramp(u));\n        const shadow = mix(th.bg, s.finished ? th.pos : th.accent, 0.16);\n        // Unicode: 4×7 glyphs on a half-block canvas, two columns per pixel, 1-pixel drop shadow.\n        const pwU = text.length * 10 - 1;\n        const pwA = width35(text) * 2 + 1;\n        const bigU = g.unicode && H >= 7 && W >= pwU + 2, bigA = !g.unicode && H >= 8 && W >= pwA + 2;\n        if (bigU || bigA) {\n          const top = H >= 9 ? 1 : 0;\n          if (bigU) {\n            const cv = new Canvas(pwU, 4, { mode: 'half' });\n            for (const pass of [0, 1]) {\n              [...text].forEach((ch, k) => FONT47[ch].forEach((row, y) => [...row].forEach((on, x) => {\n                if (on !== '#') return;\n                for (const dx of [0, 1]) {\n                  const X = k * 10 + x * 2 + dx;\n                  if (pass === 0) cv.set(X + 1, y + 1, shadow);\n                  else cv.set(X, y, color(X / pwU));\n                }\n              })));\n            }\n            cv.paint(b, Math.floor((W - pwU) / 2), top);\n          } else {\n            const x0 = Math.floor((W - pwA) / 2);\n            for (const pass of [0, 1]) {\n              let cx = 0;\n              for (const ch of text) {\n                const gl = FONT35[ch];\n                gl.forEach((row, y) => [...row].forEach((on, x) => {\n                  if (on !== '#') return;\n                  for (const dx of [0, 1]) {\n                    const X = x0 + (cx + x) * 2 + dx;\n                    if (pass === 0) b.set(X + 1, top + y + 1, ':', shadow);\n                    else b.set(X, top + y, '#', color((X - x0) / pwA));\n                  }\n                }));\n                cx += gl[0].length + 1;\n              }\n            }\n          }\n          const by = Math.min(H - 2, top + (bigU ? 5 : 7));\n          progressBar(b, 2, by, W - 4, s.frac, { theme: th, color: s.finished ? th.pos : th.accent, ascii: !g.unicode });\n          b.text(1, H - 1, truncate(caption, W - 2, g.ell), s.finished ? th.pos : th.muted);\n        } else {\n          const label = `${String(p).padStart(3)}%`;\n          b.text(1, 0, label, s.finished ? th.pos : th.ink, undefined, ATTR.BOLD);\n          b.text(6, 0, truncate(caption, Math.max(0, W - 7), g.ell), s.finished ? th.pos : th.muted);\n          if (H >= 2) progressBar(b, 1, 1, W - 2, s.frac, { theme: th, color: s.finished ? th.pos : th.accent, ascii: !g.unicode });\n        }\n      };\n    },\n  },\n\n  // ── counter tiles ────────────────────────────────────────────────────\n  {\n    id: 'progress-counter-tiles', title: 'Live Counter Tiles', category: C, order: 40, kind: 'progress',\n    description: 'Four running totals ticking at their own rates; digits that just changed flash, with a rate sparkline under each.',\n    tags: ['counters', 'kpi', 'tiles', 'sparkline', 'live'], size: { w: 64, h: 10 }, minSize: { w: 36, h: 2 }, loop: 10, color: '256',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const tiles = [\n        { label: 'requests', base: 48213904, per: 1850, c: th.accent },\n        { label: 'rows written', base: 912440, per: 420, c: th.info },\n        { label: 'jobs done', base: 18442, per: 6, c: th.pos },\n        { label: 'errors 5xx', base: 1204, per: 0.7, c: th.neg },\n      ].map((tl) => ({ ...tl, sim: transfer(ctx.rng, { total: tl.per * 10, seconds: 10, burst: 0.9, warmup: 0.02 }) }));\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local } = phase(t, 10, 1);\n        const draw = (tl, x, y, w, h) => {\n          const s = tl.sim.at(local);\n          const now = commas(tl.base + s.done), was = commas(tl.base + tl.sim.at(Math.max(0, local - 0.3)).done);\n          const r = `+${rateText(s.smooth)}/s`;\n          if (h >= 3) {\n            b.text(x, y, truncate(tl.label, w - r.length - 1, g.ell), th.muted);\n            b.text(x + w - r.length, y, r, tl.c);\n            const nx = x;\n            [...now].forEach((ch, i) => {\n              const changed = was.length !== now.length || was[i] !== ch;\n              b.set(nx + i, y + 1, ch, changed ? lift(th, tl.c, 0.35) : th.ink, undefined, ATTR.BOLD);\n            });\n            const data = Array.from({ length: w }, (_, k) => tl.sim.at(Math.max(0, local - (w - 1 - k) * 0.2)).rate);\n            if (g.unicode) sparkline(b, x, y + 2, w, data, { theme: th, color: tl.c, min: 0 });\n            else asciiSpark(b, x, y + 2, w, data, tl.c);\n          } else {\n            b.text(x, y, truncate(tl.label, 12, g.ell), th.muted);\n            [...now].forEach((ch, i) => b.set(x + 13 + i, y, ch, was[i] !== ch ? lift(th, tl.c, 0.35) : th.ink));\n            if (w >= 13 + now.length + r.length + 1) b.text(x + w - r.length, y, r, tl.c);\n          }\n        };\n        if (H >= 10 && W >= 40) {\n          const tw = Math.floor(W / 2), th2 = Math.floor(H / 2);\n          tiles.forEach((tl, i) => {\n            const x = (i % 2) * tw, y = Math.floor(i / 2) * th2;\n            const bw = tw - (i % 2 ? 0 : 1);\n            box(b, x, y, bw, th2, { theme: th, style: g.unicode ? 'round' : 'ascii', color: th.line });\n            draw(tl, x + 2, y + 1, bw - 4, th2 - 2);\n          });\n        } else {\n          tiles.slice(0, H).forEach((tl, i) => draw(tl, 1, i, W - 2, 1));\n        }\n      };\n    },\n  },\n\n  // ── percentage rings ─────────────────────────────────────────────────\n  {\n    id: 'progress-ring', title: 'Progress Rings', category: C, order: 50, kind: 'progress',\n    description: 'Braille rings filling clockwise around a centered percentage — upload, transcode and verify, finishing one after another.',\n    tags: ['ring', 'donut', 'circular', 'braille'], size: { w: 60, h: 10 }, minSize: { w: 14, h: 6 }, loop: 10, glyphs: 'braille', color: '256',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const jobs = [\n        { name: 'upload', unit: 'GB', total: 4.2, secs: 6.2, c: [th.info, th.accent] },\n        { name: 'transcode', unit: 'frames', total: 1820, secs: 7.1, c: [th.accent, th.crit] },\n        { name: 'verify', unit: 'files', total: 312, secs: 8.0, c: [th.warn, th.pos] },\n      ].map((j) => ({ ...j, ramp: gradient(j.c), sim: transfer(ctx.rng, { total: j.total, seconds: j.secs, burst: 0.5 }) }));\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local } = phase(t, 10, 1);\n        const rows = Math.max(5, H >= 8 ? H - 2 : H - 1);\n        const cols = rows * 2;\n        const n = Math.max(1, Math.min(jobs.length, Math.floor(W / (cols + 2))));\n        const slot = Math.floor(W / n);\n        jobs.slice(0, n).forEach((j, k) => {\n          const s = j.sim.at(local);\n          const x = k * slot + Math.floor((slot - cols) / 2);\n          const done = s.finished;\n          if (g.unicode) {\n            const cv = new Canvas(cols, rows, { mode: 'braille', threshold: 0 });\n            const cx = cv.width / 2, cy = cv.height / 2;\n            ring(cv, { cx, cy, rx: cx - 0.5, ry: cy - 0.5, thick: 2.6, frac: done ? 1 : s.frac, color: (u) => (done ? th.pos : j.ramp(u)), track: th.line });\n            cv.paint(b, x, 0);\n          } else {\n            ringCells(cols, rows, done ? 1 : s.frac, (cx, cy, lit) => b.set(x + cx, cy, lit ? '#' : '.', lit ? (done ? th.pos : j.c[1]) : th.line));\n          }\n          const mid = Math.floor(rows / 2);\n          const label = done ? g.ok : `${pctInt(s.frac)}%`;\n          b.text(x + Math.floor((cols - stringWidth(label)) / 2), mid, label, done ? th.pos : th.ink, undefined, ATTR.BOLD);\n          const amount = j.unit === 'GB' ? `${s.done.toFixed(1)}/${j.total} GB` : `${commas(s.done)}/${commas(j.total)}`;\n          // The amount sits inside the ring when it fits; ASCII rings are fatter at the sides, so it moves under the name.\n          const inside = g.unicode && rows >= 6 && amount.length <= cols - 6;\n          if (inside) b.text(x + Math.floor((cols - amount.length) / 2), mid + 1, amount, th.muted);\n          const name = truncate(inside || amount.length + j.name.length + 1 > slot - 1 ? j.name : `${j.name} ${amount}`, slot - 1, g.ell);\n          if (H > rows) b.text(k * slot + Math.floor((slot - stringWidth(name)) / 2), rows + (H - rows > 1 ? 1 : 0), name, done ? th.pos : th.muted);\n        });\n      };\n    },\n  },\n\n  // ── concentric rings ─────────────────────────────────────────────────\n  {\n    id: 'progress-concentric-rings', title: 'Concentric Rings', category: C, order: 60, kind: 'progress',\n    description: 'Three nested rings for the three phases of a rolling deploy, each with a legend row giving its count, so color is never the only cue.',\n    tags: ['rings', 'activity', 'nested', 'rollout', 'braille'], size: { w: 52, h: 11 }, minSize: { w: 24, h: 3 }, loop: 11, glyphs: 'braille', color: '256',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const sets = [\n        { name: 'nodes drained', total: 24, secs: 5.0, c: mix(th.neg, th.crit, 0.3), mark: g.unicode ? '●' : '#' },\n        { name: 'images pulled', total: 9, secs: 6.8, c: th.pos, mark: g.unicode ? '◆' : '=' },\n        { name: 'health checks', total: 60, secs: 8.4, c: th.info, mark: g.unicode ? '▲' : '+' },\n      ].map((r) => ({ ...r, sim: transfer(ctx.rng, { total: r.total, seconds: r.secs, burst: 0.4, stalls: 1 }) }));\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local } = phase(t, 11, 1);\n        const big = H >= 7 && W >= 40;\n        const legendX = big ? H * 2 + 3 : 1;\n        if (big) {\n          const rows = H, cols = H * 2;\n          if (g.unicode) {\n            const cv = new Canvas(cols, rows, { mode: 'braille', threshold: 0 });\n            const cx = cv.width / 2, cy = cv.height / 2;\n            sets.forEach((r, i) => {\n              const s = r.sim.at(local);\n              const rad = cx - 0.5 - i * 4.2;\n              ring(cv, { cx, cy, rx: rad, ry: rad, thick: 2.8, frac: s.frac, color: (u) => lift(th, r.c, u > s.frac - 0.03 ? 0.35 : 0), track: mix(th.bg, r.c, 0.35) });\n            });\n            cv.paint(b, 1, 0);\n          } else {\n            sets.forEach((r, i) => {\n              const s = r.sim.at(local), inset = i * 2;\n              ringCells(cols - inset * 4, rows - inset * 2, s.frac, (x, y, lit) => b.set(1 + inset * 2 + x, inset + y, lit ? r.mark : '.', lit ? r.c : th.line));\n            });\n          }\n        }\n        const y0 = big ? Math.max(0, Math.floor(H / 2) - 2) : 0;\n        sets.forEach((r, i) => {\n          const s = r.sim.at(local), y = y0 + i * (big ? 2 : 1);\n          if (y >= H) return;\n          const n = Math.floor(s.done);\n          b.text(legendX, y, r.mark, r.c);\n          const txt = `${r.name}`;\n          const val = s.finished ? `${r.total}/${r.total} ${g.ok}` : `${n}/${r.total}`;\n          const room = W - legendX - 2 - val.length - 1;\n          b.text(legendX + 2, y, truncate(txt, Math.max(0, room), g.ell), s.finished ? th.muted : th.ink);\n          b.text(W - 1 - val.length, y, val, s.finished ? th.pos : r.c);\n        });\n        if (big && H >= 9) {\n          const all = sets.every((r) => r.sim.at(local).finished);\n          b.text(legendX, y0 + 6, truncate(all ? `${g.ok} rollout 0.9.2 complete` : `rolling out 0.9.2`, W - legendX - 1, g.ell), all ? th.pos : th.dim);\n        }\n      };\n    },\n  },\n];\n",
  "prism-term/src/facets/progress/pipelines.js": "// Steps & pipelines — multi-part work as the real tools print it.\n//\n// Every number agrees with every other: bytes done never exceed totals, counts\n// match the lines on screen, percentages are floor(done/total), and finished\n// states hold until the loop restarts.\nimport { ATTR } from '../../buffer.js';\nimport { rgb } from '../../color.js';\nimport { stringWidth, truncate } from '../../width.js';\nimport { phase, transfer, glyphs, bytes, clock, commas, clamp01 } from './_sim.js';\nimport { segs, visible } from './_log.js';\n\nconst C = 'Steps & Pipelines';\nconst pct = (f) => `${String(Math.floor(clamp01(f) * 100)).padStart(3)}%`;\nconst hex = (rng, n) => Array.from({ length: n }, () => '0123456789abcdef'[Math.floor(rng() * 16)]).join('');\nconst spinAt = (t, u) => (u ? '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' : '-\\\\|/')[Math.floor(t * 12) % (u ? 10 : 4)];\n/** Docker / go-units HumanSize: decimal units, 4 significant digits (\"5.243MB\"). */\nconst human = (n) => {\n  const u = ['B', 'kB', 'MB', 'GB'];\n  let i = 0;\n  while (n >= 1000 && i < u.length - 1) { n /= 1000; i++; }\n  return `${Number(n.toPrecision(4))}${u[i]}`;\n};\n\nexport default [\n  // ── parallel downloads ───────────────────────────────────────────────\n  {\n    id: 'progress-multi-bar', title: 'Parallel Downloads', category: C, order: 10, kind: 'progress',\n    description: 'Six downloads sharing one link, each with its own bar and rate, plus a total line whose bytes, rate and ETA add up.',\n    tags: ['multi', 'parallel', 'downloads', 'total'], size: { w: 76, h: 8 }, minSize: { w: 36, h: 3 }, loop: 10, color: '256',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const files = [\n        ['base-image.tar.zst', 48.2], ['models/encoder.bin', 31.6], ['assets.pak', 12.9],\n        ['locale-pack.tar', 6.4], ['docs.zip', 3.1], ['checksums.txt', 0.004],\n      ].map(([name, mib], i) => {\n        const total = mib * 1048576, start = i * 0.35, secs = 0.3 + mib / 7 + ctx.rng() * 0.8;\n        return { name, total, start, secs, sim: transfer(ctx.rng, { total, seconds: secs, burst: 0.6 }) };\n      });\n      const grand = files.reduce((n, f) => n + f.total, 0);\n      const lastEnd = files.reduce((m, f) => Math.max(m, f.start + f.secs), 0);\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local } = phase(t, 10, 1);\n        const gap = H >= 5 ? 1 : 0;\n        const rows = Math.max(1, Math.min(files.length, H - 1 - gap));\n        const nameW = W >= 60 ? 20 : 12;\n        const tail = W >= 64 ? 15 : 5;\n        const bw = Math.max(6, W - 3 - nameW - tail);\n        let doneAll = 0, rateAll = 0, allDone = true;\n        files.forEach((f, i) => {\n          const s = f.sim.at(Math.max(0, local - f.start));\n          doneAll += s.done;\n          if (!s.finished) allDone = false;\n          if (!s.finished && local >= f.start) rateAll += s.smooth;\n          if (i >= rows) return;\n          b.text(1, i, truncate(f.name, nameW - 1, g.ell), s.finished ? th.muted : th.ink);\n          const n = s.frac * bw, full = Math.floor(n);\n          for (let k = 0; k < bw; k++) {\n            const lit = k < full || s.finished;\n            const head = !s.finished && k === full && n - full >= 0.5;\n            b.set(1 + nameW + k, i, g.unicode ? (head ? '╸' : '━') : (lit || head ? '=' : '-'), s.finished ? th.pos : lit || head ? th.accent : th.line);\n          }\n          const tx = 2 + nameW + bw;\n          if (s.finished) b.text(tx, i, tail > 5 ? `${g.ok} ${bytes(f.total, { digits: 1 })}` : ` ${g.ok}`, th.pos);\n          else if (local < f.start) b.text(tx, i, tail > 5 ? 'queued' : ' ...', th.dim);\n          else b.text(tx, i, tail > 5 ? `${pct(s.frac)} ${bytes(s.smooth, { digits: 1 })}/s`.slice(0, tail) : pct(s.frac), th.muted);\n        });\n        if (H >= 2) {\n          const y = Math.min(H - 1, rows + gap);\n          const eta = rateAll > 0 ? (grand - doneAll) / rateAll : 0;\n          const wide = W >= 56;\n          const doneTxt = wide ? `${bytes(allDone ? grand : doneAll, { digits: 1 })} / ${bytes(grand, { digits: 1 })}` : `${((allDone ? grand : doneAll) / 1048576).toFixed(1)}/${(grand / 1048576).toFixed(1)} MiB`;\n          segs(b, 1, y, W - 2, [\n            ['total ', th.ink, ATTR.BOLD],\n            [doneTxt, th.ink],\n            [` ${wide ? ' ' : ''}${allDone ? '100%' : pct(doneAll / grand).trim()}`, th.accent],\n            allDone ? [wide ? `  ${g.ok} ${files.length} files in ${lastEnd.toFixed(1)}s` : ` ${g.ok}`, th.pos]\n              : [wide ? `  ${bytes(rateAll, { digits: 1 })}/s  eta ${clock(eta, { short: true })}` : ` eta ${clock(eta, { short: true })}`, th.muted],\n          ], g.ell);\n        }\n      };\n    },\n  },\n\n  // ── docker pull ──────────────────────────────────────────────────────\n  {\n    id: 'progress-docker-pull', title: 'docker pull', category: C, order: 20, kind: 'progress',\n    description: 'Layers going Waiting → Downloading → Extracting → Pull complete, three at a time; like docker, bars only show past 110 columns.',\n    tags: ['docker', 'container', 'layers', 'pull'], size: { w: 112, h: 11 }, minSize: { w: 44, h: 4 }, loop: 14, glyphs: 'ascii', color: '16',\n    setup(ctx) {\n      const th = ctx.theme;\n      const layers = [29.13, 0, 41.02, 9.81, 3.27, 0.82, 0.12].map((mb) => ({ id: hex(ctx.rng, 12), cached: mb === 0, total: mb * 1e6 }));\n      // At most three concurrent downloads; extraction is strictly in layer order.\n      const slots = [0.5, 0.5, 0.5];\n      for (const L of layers) {\n        if (L.cached) continue;\n        const s = slots.indexOf(Math.min(...slots));\n        L.dlStart = slots[s];\n        L.dlSecs = 0.6 + L.total / 7.5e6;\n        L.dlEnd = L.dlStart + L.dlSecs;\n        L.sim = transfer(ctx.rng, { total: L.total, seconds: L.dlSecs, burst: 0.5 });\n        slots[s] = L.dlEnd + 0.1;\n      }\n      let prevEx = 0;\n      for (const L of layers) {\n        if (L.cached) continue;\n        L.exStart = Math.max(L.dlEnd + 0.3, prevEx);\n        L.exSecs = 0.35 + L.total / 22e6;\n        prevEx = L.exStart + L.exSecs;\n      }\n      const finished = prevEx + 0.2;\n      const digest = hex(ctx.rng, 64);\n      const image = 'registry.example.net/prism/demo:1.4.2';\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local } = phase(t, 14, 1);\n        // jsonmessage only draws its 50-column bar when the terminal is wider than 110.\n        const bar = (f) => {\n          if (W <= 110) return '';\n          const p = Math.min(50, Math.floor(f * 100) >> 1);\n          return `[${'='.repeat(p)}>${' '.repeat(Math.max(0, 50 - p))}] `;\n        };\n        const nums = (cur, tot) => `${human(cur).padStart(8)}/${human(tot)}`;\n        const rows = [[['1.4.2: Pulling from prism/demo', th.ink]]];\n        for (const L of layers) {\n          const head = [`${L.id}: `, th.ink];\n          if (L.cached) rows.push([head, ['Already exists', th.muted]]);\n          else if (local < 0.25) rows.push([head, ['Pulling fs layer', th.muted]]);\n          else if (local < L.dlStart) rows.push([head, ['Waiting', th.dim]]);\n          else if (local < L.dlEnd) {\n            const s = L.sim.at(local - L.dlStart);\n            rows.push([head, ['Downloading ', th.info], [bar(s.frac) + nums(s.done, L.total), th.info]]);\n          } else if (local < L.exStart) rows.push([head, [local < L.dlEnd + 0.15 ? 'Verifying Checksum' : 'Download complete', th.muted]]);\n          else if (local < L.exStart + L.exSecs) {\n            const f = (local - L.exStart) / L.exSecs;\n            rows.push([head, ['Extracting ', th.warn], [bar(f) + nums(f * L.total, L.total), th.warn]]);\n          } else rows.push([head, ['Pull complete', th.pos]]);\n        }\n        if (local >= finished) {\n          rows.push([['Digest: sha256:', th.muted], [digest, th.dim]]);\n          rows.push([['Status: Downloaded newer image for ', th.ink], [image, th.ink, ATTR.BOLD]]);\n          rows.push([[image, th.muted]]);\n        }\n        rows.slice(-H).forEach((r, i) => segs(b, 0, i, W, r, '.'));\n      };\n    },\n  },\n\n  // ── cargo build ──────────────────────────────────────────────────────\n  {\n    id: 'progress-cargo-build', title: 'cargo build', category: C, order: 30, kind: 'progress',\n    description: 'Green \"Compiling\" lines scrolling above cargo\\'s pinned Building bar; the count and the crates in flight match the log.',\n    tags: ['cargo', 'rust', 'build', 'compile'], size: { w: 80, h: 10 }, minSize: { w: 40, h: 3 }, loop: 12, glyphs: 'ascii', color: '16',\n    setup(ctx) {\n      const th = ctx.theme;\n      const names = ['proc-macro2 v1.0.89', 'unicode-ident v1.0.13', 'libc v0.2.161', 'cfg-if v1.0.0', 'autocfg v1.4.0', 'once_cell v1.20.2',\n        'quote v1.0.37', 'syn v2.0.85', 'serde v1.0.214', 'serde_derive v1.0.214', 'memchr v2.7.4', 'itoa v1.0.11', 'ryu v1.0.18',\n        'bytes v1.8.0', 'pin-project-lite v0.2.15', 'smallvec v1.13.2', 'scopeguard v1.2.0', 'lock_api v0.4.12', 'parking_lot_core v0.9.10',\n        'parking_lot v0.12.3', 'log v0.4.22', 'tracing-core v0.1.32', 'tracing v0.1.40', 'mio v1.0.2', 'socket2 v0.5.7', 'tokio-macros v2.4.0',\n        'tokio v1.41.0', 'futures-core v0.3.31', 'futures-util v0.3.31', 'serde_json v1.0.132', 'http v1.1.0', 'httparse v1.9.5',\n        'http-body v1.0.1', 'hyper v1.5.0', 'tower-service v0.3.3', 'tower v0.5.1', 'axum-core v0.4.5', 'axum v0.7.7', 'clap_lex v0.7.2',\n        'clap_builder v4.5.20', 'clap v4.5.20', 'prism-core v0.9.2 (/srv/build/prism/core)', 'prism-cli v0.9.2 (/srv/build/prism/cli)'];\n      const heavy = /^(syn |serde_derive|tokio |serde_json|hyper |axum |clap_builder|prism-)/;\n      const crates = names.map((name, i) => {\n        const start = 0.3 + i * 0.19;\n        return { name, short: name.split(' ')[0], start, end: start + (heavy.test(name) ? 0.75 : 0.3) + ctx.rng() * 0.2 };\n      });\n      const total = crates.length;\n      const run = crates.reduce((m, c) => Math.max(m, c.end), 0);\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local } = phase(t, 12, 1);\n        const finished = local >= run;\n        const started = crates.filter((c) => c.start <= local);\n        const shown = started.slice(-(H - 1)).slice(0, H - 1);\n        shown.forEach((c, i) => segs(b, 0, i, W, [['   Compiling', th.pos, ATTR.BOLD], [` ${c.name}`, th.ink]], '...'));\n        if (finished) {\n          segs(b, 0, Math.min(H - 1, shown.length), W, [['    Finished', th.pos, ATTR.BOLD], [` \\`release\\` profile [optimized] target(s) in ${run.toFixed(2)}s`, th.ink]], '...');\n          return;\n        }\n        const n = crates.filter((c) => c.end <= local).length;\n        const inflight = started.filter((c) => c.end > local).map((c) => c.short).join(', ');\n        const barW = Math.max(8, Math.min(27, W - 40));\n        const filled = Math.floor((n / total) * barW);\n        const barStr = filled > 0 ? '='.repeat(filled - 1) + '>' + ' '.repeat(barW - filled) : ' '.repeat(barW);\n        // Like cargo, the bar is the line under the newest log line, not pinned to the screen bottom.\n        segs(b, 0, Math.min(H - 1, shown.length), W, [['    Building', th.info, ATTR.BOLD], [` [${barStr}] ${n}/${total}: `, th.ink], [inflight, th.ink]], '...');\n      };\n    },\n  },\n\n  // ── CI pipeline ──────────────────────────────────────────────────────\n  {\n    id: 'progress-ci-pipeline', title: 'CI Pipeline', category: C, order: 40, kind: 'progress',\n    description: 'lint → build → test → deploy as connected stages with timings; on alternate runs a test fails, is retried, then passes.',\n    tags: ['ci', 'pipeline', 'stages', 'deploy', 'failure', 'retry'], size: { w: 78, h: 7 }, minSize: { w: 40, h: 3 }, loop: 14, color: '16',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const stages = [{ name: 'lint', secs: 1.2 }, { name: 'build', secs: 2.6 }, { name: 'test', secs: 3.0, cases: 580 }, { name: 'deploy', secs: 2.0 }];\n      const plans = [false, true].map((flaky) => {\n        const plan = [];\n        let at = 0.3;\n        for (const s of stages) {\n          if (s.cases && flaky) { plan.push({ s, start: at, end: at + 1.6, fail: true }); at += 2.5; }\n          plan.push({ s, start: at, end: at + s.secs });\n          at += s.secs + 0.15;\n        }\n        return plan;\n      });\n      const bx = g.unicode ? { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│', arrow: '▶' } : { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|', arrow: '>' };\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local, k } = phase(t, 14, 1);\n        const flaky = k % 2 === 1, plan = plans[k % 2];\n        const state = stages.map((s) => {\n          const runs = plan.filter((p) => p.s === s);\n          const cur = runs.find((p) => local >= p.start && local < p.end);\n          const ended = runs.filter((p) => local >= p.end);\n          const last = ended[ended.length - 1];\n          const retried = runs.length > 1;\n          if (cur) return { s, status: 'running', p: (local - cur.start) / (cur.end - cur.start), attempt: runs.indexOf(cur) + 1, retried };\n          if (last) return { s, status: last.fail ? 'failed' : 'passed', secs: last.end - last.start, retried };\n          return { s, status: 'pending' };\n        });\n        const icon = { passed: [g.ok, th.pos], failed: [g.fail, th.neg], running: [spinAt(t, g.unicode), th.info], pending: [g.hollow, th.dim] };\n        const sub = (st) => {\n          if (st.status === 'running') return st.s.cases ? `${Math.floor(st.p * st.s.cases)}/${st.s.cases}` : `${(st.p * st.s.secs).toFixed(1)}s`;\n          if (st.status === 'passed') return `${st.secs.toFixed(1)}s${st.retried ? ' retry' : ''}`;\n          if (st.status === 'failed') return '3 failed';\n          return 'pending';\n        };\n        const tall = H >= 5;\n        if (tall) {\n          const boxW = Math.max(9, Math.min(17, Math.floor((W - 9) / 4)));\n          const conn = Math.max(1, Math.floor((W - boxW * 4) / 3));\n          state.forEach((st, i) => {\n            const x = i * (boxW + conn);\n            const [ic, c] = icon[st.status];\n            const bc = st.status === 'pending' ? th.line : c;\n            b.text(x, 0, bx.tl + bx.h.repeat(boxW - 2) + bx.tr, bc);\n            for (const y of [1, 2]) { b.text(x, y, bx.v, bc); b.text(x + boxW - 1, y, bx.v, bc); }\n            b.text(x, 3, bx.bl + bx.h.repeat(boxW - 2) + bx.br, bc);\n            b.text(x + 2, 1, truncate(`${ic} ${st.s.name}`, boxW - 3, g.ell), st.status === 'pending' ? th.dim : th.ink, undefined, st.status === 'running' ? ATTR.BOLD : 0);\n            b.text(x + 2, 2, truncate(sub(st), boxW - 3, g.ell), st.status === 'failed' ? th.neg : th.muted);\n            if (i < 3) b.text(x + boxW, 1, bx.h.repeat(Math.max(0, conn - 1)) + bx.arrow, state[i + 1].status === 'pending' ? th.line : th.muted);\n          });\n        } else {\n          const parts = [];\n          state.forEach((st, i) => {\n            const [ic, c] = icon[st.status];\n            parts.push([`${ic} ${st.s.name}`, c]);\n            if (i < 3) parts.push([` ${g.arrow} `, th.dim]);\n          });\n          segs(b, 0, 0, W, parts, g.ell);\n        }\n        const msgY = tall ? (H >= 6 ? 5 : 4) : 1;\n        if (H <= msgY) return;\n        const runIdx = state.findIndex((s) => s.status === 'running');\n        const failed = state.some((s) => s.status === 'failed');\n        let msg;\n        if (state.every((s) => s.status === 'passed')) msg = [[`${g.ok} pipeline #${1842 + k} passed`, th.pos, ATTR.BOLD], [`  ${flaky ? 'after 1 retry' : 'first try'}`, th.muted]];\n        else if (failed) msg = [[`${g.fail} test: 3 of 580 failed`, th.neg, ATTR.BOLD], ['  test_refund_window, test_idempotent_pay, +1 more', th.muted]];\n        else if (runIdx >= 0) msg = [[`${spinAt(t, g.unicode)} ${stages[runIdx].name}`, th.info], [state[runIdx].attempt > 1 ? '  attempt 2 of 2' : '  running', th.muted]];\n        else msg = [['waiting for a runner', th.dim]];\n        segs(b, 0, msgY, W, msg, g.ell);\n        if (H > msgY + 1 && failed) segs(b, 0, msgY + 1, W, [[`${g.arrow} retrying failed job (flaky-test policy: 1 retry)`, th.warn]], g.ell);\n      };\n    },\n  },\n\n  // ── wizard steps ─────────────────────────────────────────────────────\n  {\n    id: 'progress-step-wizard', title: 'Step Wizard', category: C, order: 50, kind: 'progress',\n    description: 'A setup wizard\\'s step indicator — finished steps filled, the current one ringed, the rest hollow — with a caption.',\n    tags: ['steps', 'wizard', 'onboarding', 'stepper'], size: { w: 70, h: 5 }, minSize: { w: 30, h: 2 }, loop: 9, color: '16',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const steps = ['Account', 'Network', 'Storage', 'Review', 'Deploy'];\n      const captions = ['Create the admin account', 'Pick a VPC and subnets', 'Attach a 200 GB volume', 'Check the plan', `Deploying${g.ell}`];\n      const dash = g.unicode ? '—' : '-';\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local } = phase(t, 9, 1);\n        const n = steps.length;\n        const cur = Math.min(n, Math.floor(local / 1.5));\n        const gap = Math.max(4, Math.floor((W - 4) / (n - 1)));\n        const x0 = Math.max(1, Math.floor((W - gap * (n - 1)) / 2));\n        const cy = H >= 4 ? 1 : 0;\n        for (let i = 0; i < n; i++) {\n          const x = x0 + i * gap;\n          const done = i < cur, now = i === cur;\n          if (i < n - 1) b.text(x + 1, cy, (g.unicode ? '━' : '=').repeat(gap - 1), done ? th.pos : th.line);\n          b.text(x, cy, done ? (g.unicode ? '●' : '*') : now ? (g.unicode ? '◉' : '@') : g.hollow, done ? th.pos : now ? th.accent : th.dim, undefined, now ? ATTR.BOLD : 0);\n          if (H >= 2) {\n            const label = gap >= 9 ? steps[i] : String(i + 1);\n            const lx = Math.max(0, Math.min(W - label.length, x - Math.floor(label.length / 2)));\n            b.text(lx, cy + 1, label, done ? th.muted : now ? th.ink : th.dim, undefined, now ? ATTR.BOLD : 0);\n          }\n        }\n        if (H >= 4) {\n          const caption = cur >= n ? `${g.ok} All set ${dash} prism-node-03 is live` : `Step ${cur + 1} of ${n}  ${g.dot}  ${captions[cur]}`;\n          b.text(1, H - 1, truncate(caption, W - 2, g.ell), cur >= n ? th.pos : th.muted);\n        }\n      };\n    },\n  },\n\n  // ── checklist ────────────────────────────────────────────────────────\n  {\n    id: 'progress-checklist', title: 'Runbook Checklist', category: C, order: 60, kind: 'progress',\n    description: 'A maintenance runbook ticking through its steps with timings; on alternate runs the smoke test fails once and is retried.',\n    tags: ['checklist', 'tasks', 'runbook', 'retry'], size: { w: 64, h: 7 }, minSize: { w: 32, h: 3 }, loop: 12, color: '16',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const tasks = [\n        { name: 'Check free disk space', secs: 0.6 },\n        { name: 'Drain prism-api from the load balancer', secs: 1.2 },\n        { name: 'Migrate database', secs: 2.8, rows: 18400 },\n        { name: 'Start prism-api 0.9.2', secs: 1.1 },\n        { name: 'Run smoke tests', secs: 1.4, flaky: true },\n        { name: 'Return to the load balancer', secs: 0.7 },\n      ];\n      const plans = [false, true].map((flaky) => {\n        let at = 0.2;\n        return tasks.map((task) => {\n          const r = { ...task, start: at };\n          if (task.flaky && flaky) { r.failAt = at + 0.9; r.retryStart = at + 1.8; at = r.retryStart + task.secs; }\n          else at += task.secs;\n          r.end = at;\n          at += 0.15;\n          return r;\n        });\n      });\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local, k } = phase(t, 12, 1);\n        const rows = plans[k % 2];\n        const listH = Math.min(rows.length, H);\n        const active = rows.findIndex((r) => local < r.end);\n        const focus = active < 0 ? rows.length - 1 : active;\n        const first = Math.max(0, Math.min(rows.length - listH, focus - listH + 2));\n        rows.slice(first, first + listH).forEach((r, i) => {\n          let ic, c, tail = '', tc = th.dim;\n          if (local >= r.end) { ic = g.ok; c = th.pos; tail = `${(r.end - r.start).toFixed(1)}s${r.failAt ? ' (retried)' : ''}`; }\n          else if (r.failAt && local >= r.failAt && local < r.retryStart) { ic = g.fail; c = th.neg; tail = '1 failed, retrying'; tc = th.neg; }\n          else if (local >= r.start) {\n            ic = spinAt(t, g.unicode); c = th.info; tc = th.muted;\n            const from = r.retryStart && local >= r.retryStart ? r.retryStart : r.start;\n            tail = r.rows ? `${commas(clamp01((local - r.start) / r.secs) * r.rows)}/${commas(r.rows)} rows` : `${(local - from).toFixed(1)}s`;\n          } else { ic = g.hollow; c = th.dim; }\n          // ASCII status marks are two columns wide (\"OK\", \"XX\"), so the name column moves over.\n          const nx = g.unicode ? 3 : 4;\n          b.text(1, i, ic, c);\n          const tw = stringWidth(tail);\n          b.text(nx, i, truncate(r.name, Math.max(4, W - nx - 2 - tw), g.ell), local >= r.start ? th.ink : th.dim);\n          if (tail) b.text(W - 1 - tw, i, tail, tc);\n        });\n        if (H > rows.length) {\n          const done = rows.filter((r) => local >= r.end).length;\n          const all = done === rows.length;\n          const elapsed = all ? rows[rows.length - 1].end - rows[0].start : Math.max(0, local - rows[0].start);\n          b.text(1, H - 1, truncate(`${done}/${rows.length} steps  ${g.dot}  ${all ? 'done in ' : ''}${elapsed.toFixed(1)}s`, W - 2, g.ell), all ? th.pos : th.muted, undefined, all ? ATTR.BOLD : 0);\n        }\n      };\n    },\n  },\n\n  // ── apt ──────────────────────────────────────────────────────────────\n  {\n    id: 'progress-apt', title: 'apt install', category: C, order: 70, kind: 'progress',\n    description: 'apt fetching then unpacking packages while dpkg\\'s progress line holds the bottom row: Progress: [ 64%] [####....].',\n    tags: ['apt', 'dpkg', 'debian', 'ubuntu', 'packages'], size: { w: 80, h: 12 }, minSize: { w: 40, h: 3 }, loop: 13, glyphs: 'ascii', color: '16',\n    setup(ctx) {\n      const th = ctx.theme;\n      const pkgs = [['libprism0', 'amd64', '0.9.2-1', 412], ['prism-cli', 'amd64', '0.9.2-1', 968], ['prism-data', 'all', '0.9.2-1', 3104]];\n      const totalKb = pkgs.reduce((n, p) => n + p[3], 0);\n      const L = (at, text, c = th.ink) => ({ at, parts: [[text, c]] });\n      const script = [\n        L(0, 'Reading package lists... Done'),\n        L(0.3, 'Building dependency tree... Done'),\n        L(0.45, 'Reading state information... Done'),\n        L(0.6, 'The following NEW packages will be installed:'),\n        L(0.62, `  ${pkgs.map((p) => p[0]).join(' ')}`),\n        L(0.75, `0 upgraded, ${pkgs.length} newly installed, 0 to remove and 0 not upgraded.`),\n        L(0.85, `Need to get ${commas(totalKb)} kB of archives.`),\n        L(0.9, 'After this operation, 17.9 MB of additional disk space will be used.'),\n        ...pkgs.map((p, i) => ({ at: 1.1 + i * 0.7, parts: [[`Get:${i + 1} `, th.ink], [`http://pkg.example/apt stable/main amd64 ${p[0]} ${p[1]} ${p[2]}`, th.muted], [` [${commas(p[3])} kB]`, th.ink]] })),\n        L(3.3, `Fetched ${commas(totalKb)} kB in 2s (${commas(totalKb / 2)} kB/s)`),\n      ];\n      const dpkgStart = 3.6;\n      let at = dpkgStart;\n      const steps = [];\n      for (const p of pkgs) {\n        script.push(L(at, `Selecting previously unselected package ${p[0]}:${p[1]}.`));\n        if (p === pkgs[0]) script.push(L(at + 0.07, '(Reading database ... 214783 files and directories currently installed.)'));\n        script.push(L(at + 0.15, `Preparing to unpack .../${p[0]}_${p[2]}_${p[1]}.deb ...`));\n        script.push(L(at + 0.3, `Unpacking ${p[0]}:${p[1]} (${p[2]}) ...`));\n        steps.push(at, at + 0.3);\n        at += 0.9;\n      }\n      for (const p of pkgs) { script.push(L(at, `Setting up ${p[0]}:${p[1]} (${p[2]}) ...`)); steps.push(at); at += 0.6; }\n      script.push(L(at, 'Processing triggers for man-db (2.11.2-2) ...'));\n      steps.push(at);\n      const end = at + 0.6;\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local } = phase(t, 13, 1);\n        const dpkg = local >= dpkgStart && local < end;\n        visible(script, local, dpkg ? H - 1 : H).forEach((l, i) => segs(b, 0, i, W, l.parts, '.'));\n        if (!dpkg) return;\n        const done = steps.filter((s) => s <= local).length;\n        const f = done / (steps.length + 1);\n        const label = `Progress: [${String(Math.floor(f * 100)).padStart(3)}%]`;\n        b.text(0, H - 1, label, th.bg, th.pos);\n        const bw = Math.max(4, W - label.length - 3);\n        const n = Math.floor(f * bw);\n        b.text(label.length + 1, H - 1, `[${'#'.repeat(n)}${'.'.repeat(bw - n)}]`, th.ink);\n      };\n    },\n  },\n\n  // ── pip install ──────────────────────────────────────────────────────\n  {\n    id: 'progress-pip-install', title: 'pip install', category: C, order: 80, kind: 'progress',\n    description: 'pip collecting wheels with its rich bar — pink run, green size, red rate, cyan ETA — then installing and reporting.',\n    tags: ['pip', 'python', 'wheel', 'rich'], size: { w: 80, h: 9 }, minSize: { w: 44, h: 3 }, loop: 11, color: '256',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const wheels = [\n        { req: 'prism-sdk==2.4.0', name: 'prism-sdk', ver: '2.4.0', file: 'prism_sdk-2.4.0-py3-none-any.whl', mb: 5.2 },\n        { req: 'httpx>=0.27 (from prism-sdk==2.4.0)', name: 'httpx', ver: '0.27.2', file: 'httpx-0.27.2-py3-none-any.whl', mb: 0.076, cached: true },\n        { req: 'numpy>=2.0 (from prism-sdk==2.4.0)', name: 'numpy', ver: '2.1.2', file: 'numpy-2.1.2-cp312-cp312-manylinux_2_17_x86_64.whl', mb: 16.3 },\n      ];\n      let at = 0;\n      for (const w of wheels) {\n        w.at = at;\n        w.secs = w.cached ? 0 : 0.8 + w.mb / 5;\n        if (!w.cached) w.sim = transfer(ctx.rng, { total: w.mb * 1e6, seconds: w.secs, burst: 0.4 });\n        at += w.cached ? 0.5 : 0.35 + w.secs + 0.3;\n      }\n      const installAt = at + 0.2, doneAt = installAt + 1.4;\n      // pip's rich theme: rgb(249,38,114) while running, rgb(114,156,31) when finished, gray remainder.\n      const running = rgb(249, 38, 114), fin = rgb(114, 156, 31), rest = rgb(58, 58, 58);\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local } = phase(t, 11, 1);\n        const rows = [];\n        for (const w of wheels) {\n          if (local < w.at) break;\n          rows.push([[`Collecting ${w.req}`, th.ink]]);\n          if (w.cached) { rows.push([[`  Using cached ${w.file} (${Math.round(w.mb * 1000)} kB)`, th.muted]]); continue; }\n          if (local < w.at + 0.35) continue;\n          rows.push([[`  Downloading ${w.file} (${w.mb.toFixed(1)} MB)`, th.muted]]);\n          const s = w.sim.at(local - w.at - 0.35);\n          const bw = Math.max(8, Math.min(40, W - 34));\n          const n = s.frac * bw, full = Math.floor(n), half = !s.finished && n - full >= 0.5;\n          const on = (g.unicode ? '━' : '=').repeat(full) + (half ? (g.unicode ? '╸' : '-') : '');\n          const off = (g.unicode ? '━' : ' ').repeat(Math.max(0, bw - full - (half ? 1 : 0)));\n          const rate = (s.finished ? s.avg : s.smooth) / 1e6;\n          rows.push([['     ', th.ink], [on, s.finished ? fin : running], [off, rest],\n            [` ${(s.done / 1e6).toFixed(1)}/${w.mb.toFixed(1)} MB`, fin], [` ${rate.toFixed(1)} MB/s`, th.neg], [` eta ${s.finished ? '0:00:00' : clock(s.eta)}`, th.info]]);\n        }\n        if (local >= installAt) rows.push([[`Installing collected packages: ${wheels.map((w) => w.name).reverse().join(', ')}`, th.ink]]);\n        if (local >= doneAt) rows.push([[`Successfully installed ${wheels.map((w) => `${w.name}-${w.ver}`).sort().join(' ')}`, th.ink]]);\n        rows.slice(-H).forEach((r, i) => segs(b, 0, i, W, r, g.ell));\n      };\n    },\n  },\n\n  // ── git clone ────────────────────────────────────────────────────────\n  {\n    id: 'progress-git-clone', title: 'git clone', category: C, order: 90, kind: 'progress',\n    description: 'git\\'s counters in order — enumerating, counting, compressing, receiving with size and rate, resolving deltas, updating files.',\n    tags: ['git', 'clone', 'objects', 'deltas'], size: { w: 80, h: 8 }, minSize: { w: 44, h: 3 }, loop: 10, glyphs: 'ascii', color: 'mono',\n    setup(ctx) {\n      const th = ctx.theme;\n      // Counted + pack-reused objects make the total; reused objects come from the counted ones.\n      const objects = 4821, counted = 812, compress = 390, deltas = 2210, files = 1203, mib = 63.47;\n      const recvSecs = 4.4;\n      const recv = transfer(ctx.rng, { total: objects, seconds: recvSecs, burst: 0.5 });\n      const Tenum = 0.4, Tcount = 0.7, Tcomp = 1.3, Trecv = 1.8, Ttotal = Trecv + recvSecs - 0.6, Tres = Trecv + recvSecs + 0.1, Tupd = Tres + 1.1;\n      const counter = (label, f, n, extra = '') => `${label}${String(Math.floor(f * 100)).padStart(3)}% (${Math.floor(f * n)}/${n})${extra}`;\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local } = phase(t, 10, 1);\n        const rows = [\"Cloning into 'prism-demo'...\"];\n        const stage = (at, secs) => clamp01((local - at) / secs);\n        if (local >= Tenum) rows.push(`remote: Enumerating objects: ${objects}, done.`);\n        if (local >= Tcount) rows.push(counter('remote: Counting objects: ', stage(Tcount, 0.5), counted, local >= Tcount + 0.5 ? ', done.' : ''));\n        if (local >= Tcomp) rows.push(counter('remote: Compressing objects: ', stage(Tcomp, 0.4), compress, local >= Tcomp + 0.4 ? ', done.' : ''));\n        if (local >= Ttotal) rows.push(`remote: Total ${objects} (delta ${deltas}), reused 640 (delta 412), pack-reused ${objects - counted}`);\n        if (local >= Trecv) {\n          const s = recv.at(local - Trecv);\n          const rate = (s.finished ? s.avg : s.smooth) / objects * mib;\n          rows.push(counter('Receiving objects: ', s.frac, objects, `, ${(s.frac * mib).toFixed(2)} MiB | ${rate.toFixed(2)} MiB/s${s.finished ? ', done.' : ''}`));\n        }\n        if (local >= Tres) rows.push(counter('Resolving deltas: ', stage(Tres, 1.0), deltas, local >= Tres + 1.0 ? ', done.' : ''));\n        if (local >= Tupd) rows.push(counter('Updating files: ', stage(Tupd, 0.6), files, local >= Tupd + 0.6 ? ', done.' : ''));\n        rows.slice(-H).forEach((r, i) => b.text(0, i, truncate(r, W, '.'), r.startsWith('remote:') ? th.muted : th.ink));\n      };\n    },\n  },\n\n  // ── npm install ──────────────────────────────────────────────────────\n  {\n    id: 'progress-npm-install', title: 'npm install', category: C, order: 100, kind: 'progress',\n    description: 'npm\\'s gauge — a bracketed fill, a spinner and the current reify step — ending in the added, funding and audit summary.',\n    tags: ['npm', 'node', 'gauge', 'javascript'], size: { w: 80, h: 7 }, minSize: { w: 40, h: 2 }, loop: 9, color: '16',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const pkgs = ['tslib', 'esbuild', 'typescript', '@types/node', 'undici', 'zod', 'picocolors', 'semver', 'prism-term'];\n      const run = 6;\n      const ms = pkgs.map(() => Math.round(40 + ctx.rng() * 1600));\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local } = phase(t, 9, 1);\n        const rows = [[['~/prism-web', th.info], [' $ ', th.dim], ['npm install', th.ink]]];\n        if (local >= run) {\n          rows.push([[`added 214 packages, and audited 215 packages in ${run}s`, th.ink]], [['', th.ink]],\n            [['38 packages are looking for funding', th.ink]], [['  run `npm fund` for details', th.muted]], [['', th.ink]],\n            [['found ', th.ink], ['0', th.pos, ATTR.BOLD], [' vulnerabilities', th.ink]]);\n        } else {\n          const f = clamp01(local / run);\n          const i = Math.min(pkgs.length - 1, Math.floor(f * pkgs.length));\n          const stage = f < 0.25 ? `idealTree:${pkgs[i]}: timing idealTree:#root Completed in ${ms[i]}ms`\n            : f < 0.92 ? `reify:${pkgs[i]}: timing reifyNode:node_modules/${pkgs[i]} Completed in ${ms[i]}ms` : 'auditReport: timing auditReport Completed in 412ms';\n          const gw = Math.max(8, Math.min(18, W - 50));\n          const n = Math.floor(f * gw);\n          rows.push([['(', th.dim], ['#'.repeat(n), th.ink], [(g.unicode ? '⠂' : '.').repeat(gw - n), th.dim], [') ', th.dim], [spinAt(t, g.unicode), th.ink], [` ${stage}`, th.muted]]);\n        }\n        rows.slice(-H).forEach((r, y) => segs(b, 0, y, W, r, g.ell));\n      };\n    },\n  },\n];\n",
  "prism-term/src/facets/progress/timers.js": "// Timers & countdowns — time that runs out, runs up, or flips over.\n//\n// Clocks here are internally consistent: \"now\" plus \"time left\" always equals\n// the target, laps sum to the elapsed time, and nothing reads the real clock —\n// every instant is derived from t so frames are reproducible.\nimport { ATTR } from '../../buffer.js';\nimport { Canvas } from '../../canvas.js';\nimport { mix } from '../../color.js';\nimport { truncate, stringWidth } from '../../width.js';\nimport { phase, glyphs, clamp01, commas } from './_sim.js';\nimport { lift } from '../charts/_data.js';\nimport { FONT47, FONT35, layout } from './_fonts.js';\nimport { segs } from './_log.js';\n\nconst C = 'Timers & Countdowns';\nconst p2 = (n) => String(Math.floor(n)).padStart(2, '0');\nconst hms = (s) => `${p2(s / 3600)}:${p2((s % 3600) / 60)}:${p2(s % 60)}`;\nconst spinAt = (t, u) => (u ? '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' : '-\\\\|/')[Math.floor(t * 12) % (u ? 10 : 4)];\n\n/** Paint big text: half-block 4×7 glyphs (two columns per pixel) or 3×5 '#' glyphs in ASCII. Returns rows used. */\nfunction bigText(b, x, y, text, color, unicode, { scale = 2 } = {}) {\n  if (unicode) {\n    const L = layout(text, FONT47, { scale });\n    const cv = new Canvas(L.w, 4, { mode: 'half' });\n    for (const [px, py, k] of L.px) cv.set(px, py, color(px / Math.max(1, L.w - 1), k));\n    cv.paint(b, x, y);\n    return { w: L.w, h: 4 };\n  }\n  const L = layout(text, FONT35, { scale });\n  for (const [px, py, k] of L.px) b.set(x + px, y + py, '#', color(px / Math.max(1, L.w - 1), k));\n  return { w: L.w, h: 5 };\n}\nconst bigWidth = (text, unicode, scale = 2) => layout(text, unicode ? FONT47 : FONT35, { scale }).w;\n\nexport default [\n  // ── maintenance countdown ────────────────────────────────────────────\n  {\n    id: 'progress-countdown', title: 'Maintenance Countdown', category: C, order: 10, kind: 'progress',\n    description: 'Big T-minus digits to a maintenance window, going amber then red while sessions drain, then counting up once it opens.',\n    tags: ['countdown', 'maintenance', 'window', 't-minus'], size: { w: 60, h: 10 }, minSize: { w: 30, h: 2 }, loop: 18, color: '256',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const lead = 12, opensAt = 2 * 3600;                  // window opens 02:00:00 UTC\n      const sessions = 1204;\n      const jitter = Array.from({ length: 64 }, () => ctx.rng());\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local } = phase(t, 18, 1);\n        const open = local >= lead;\n        const left = Math.max(0, lead - local);\n        const now = opensAt - lead + local;\n        const shown = open ? local - lead : Math.ceil(left);\n        const digits = `${p2(shown / 60)}:${p2(shown % 60)}`;\n        const tone = open ? th.pos : left <= 5 ? th.neg : left <= 10 ? th.warn : th.ink;\n        const remain = open ? 0 : Math.max(0, Math.round(sessions * Math.pow(left / lead, 1.7) * (0.96 + 0.08 * jitter[Math.floor(local * 4) % 64])));\n        const title = open ? `${g.bullet} MAINTENANCE IN PROGRESS` : 'Maintenance window opens in';\n        const clockLine = `now ${hms(now)} UTC  ${g.dot}  window 02:00${g.unicode ? '–' : '-'}02:30 UTC`;\n        const status = open ? `${g.ok} failover started ${g.dot} writes paused on prism-db-01`\n          : left <= 5 ? `${g.warn} final notice sent ${g.dot} ${commas(remain)} sessions still open` : `draining sessions ${g.dot} ${commas(remain)} of ${commas(sessions)} open`;\n        const bw = bigWidth(digits, g.unicode);\n        const big = H >= 8 && W >= bw + 2;\n        if (!big) {\n          segs(b, 0, 0, W, [[open ? 'in progress ' : 'opens in ', th.muted], [digits, tone, ATTR.BOLD], [`  ${open ? '' : `${commas(remain)} sessions`}`, th.muted]], g.ell);\n          if (H >= 2) b.text(0, 1, truncate(clockLine, W, g.ell), th.dim);\n          return;\n        }\n        b.text(1, 0, truncate(title, W - 2, g.ell), open ? th.pos : th.muted, undefined, open ? ATTR.BOLD : 0);\n        const r = bigText(b, Math.floor((W - bw) / 2), 1, digits, () => tone, g.unicode);\n        const y = 1 + r.h + 1;\n        if (H > y + 2) {\n          // Drain bar: open sessions as a share of the starting count.\n          const bwid = W - 4, n = Math.round((remain / sessions) * bwid);\n          for (let i = 0; i < bwid; i++) b.set(2 + i, y, g.unicode ? (i < n ? '━' : '─') : i < n ? '=' : '-', i < n ? tone : th.line);\n        }\n        if (H > y + 1) b.text(1, H - 2, truncate(clockLine, W - 2, g.ell), th.dim);\n        b.text(1, H - 1, truncate(status, W - 2, g.ell), open ? th.pos : left <= 5 ? th.neg : th.muted);\n      };\n    },\n  },\n\n  // ── stopwatch ────────────────────────────────────────────────────────\n  {\n    id: 'progress-stopwatch', title: 'Lap Stopwatch', category: C, order: 20, kind: 'progress',\n    description: 'A centisecond stopwatch timing benchmark runs; each lap is logged with its delta to the best, and the best lap is marked.',\n    tags: ['stopwatch', 'laps', 'benchmark', 'timer'], size: { w: 48, h: 11 }, minSize: { w: 28, h: 2 }, loop: 20, color: '256',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const laps = [3.12, 2.87, 3.4, 2.71, 2.95, 3.05].map((v) => +(v + (ctx.rng() - 0.5) * 0.1).toFixed(2));\n      const ends = laps.reduce((a, v) => [...a, +((a[a.length - 1] || 0) + v).toFixed(2)], []);\n      const stopAt = ends[ends.length - 1];\n      const fmt = (s) => `${p2(s / 60)}:${p2(s % 60)}.${p2(Math.floor((s * 100) % 100))}`;\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local } = phase(t, 20, 1);\n        const el = Math.min(local, stopAt);\n        const stopped = local >= stopAt;\n        const doneLaps = ends.filter((e) => e <= el).length;\n        const best = doneLaps ? Math.min(...laps.slice(0, doneLaps)) : null;\n        const worst = doneLaps > 2 ? Math.max(...laps.slice(0, doneLaps)) : null;\n        const state = stopped ? [`${g.unicode ? '■' : '#'} stopped`, th.muted] : [`${g.bullet} running`, th.pos];\n        const head = `bench: cold start ${g.dot} lap ${Math.min(laps.length, doneLaps + 1)}/${laps.length}`;\n        const digits = fmt(el);\n        const bw = bigWidth(digits, g.unicode, 1);\n        const big = H >= 9 && W >= bw + 2;\n        b.text(1, 0, truncate(head, W - 14, g.ell), th.muted);\n        b.text(W - 1 - stringWidth(state[0]), 0, state[0], state[1]);\n        let y = 1;\n        if (big) {\n          const r = bigText(b, Math.floor((W - bw) / 2), 1, digits, () => (stopped ? th.ink : th.accent), g.unicode, { scale: 1 });\n          y = 2 + r.h;\n        } else if (H >= 2) {\n          b.text(1, 1, digits, stopped ? th.ink : th.accent, undefined, ATTR.BOLD);\n          if (doneLaps) b.text(14, 1, truncate(`best ${laps.slice(0, doneLaps).reduce((m, v) => Math.min(m, v)).toFixed(2)}s`, W - 15, g.ell), th.pos);\n          return;\n        }\n        const room = H - y;\n        const list = laps.slice(0, doneLaps).map((v, i) => ({ v, i })).slice(-room);\n        list.forEach(({ v, i }, r) => {\n          const tag = v === best ? (g.unicode ? '★ best' : '* best') : v === worst ? 'slowest' : `+${(v - best).toFixed(2)}`;\n          const tc = v === best ? th.pos : v === worst ? th.warn : th.muted;\n          b.text(2, y + r, `lap ${i + 1}`, th.ink);\n          b.text(10, y + r, fmt(v), th.ink);\n          b.text(10 + 9 + 3, y + r, `${fmt(ends[i])}`, th.dim);\n          if (W >= 42) b.text(W - 1 - stringWidth(tag), y + r, tag, tc, undefined, v === best ? ATTR.BOLD : 0);\n        });\n      };\n    },\n  },\n\n  // ── health-check wait ────────────────────────────────────────────────\n  {\n    id: 'progress-health-wait', title: 'Health-Check Wait', category: C, order: 30, kind: 'progress',\n    description: 'Waiting for a service to report healthy within a 12 s budget: probes back off 1-2-4 s, and every other run it times out and rolls back.',\n    tags: ['timeout', 'health check', 'backoff', 'deploy', 'failure'], size: { w: 72, h: 9 }, minSize: { w: 36, h: 2 }, loop: 15, color: '16',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const budget = 12, probes = [0.5, 1.5, 3.5, 7.5];\n      const ok = ['connect: connection refused', 'HTTP 503 Service Unavailable (starting)', 'HTTP 503 Service Unavailable (migrations pending)', 'HTTP 200 OK'];\n      const bad = ['connect: connection refused', 'HTTP 503 Service Unavailable (starting)', 'HTTP 503 Service Unavailable (migrations pending)', 'HTTP 503 Service Unavailable (migrations pending)'];\n      const ms = probes.map(() => Math.round(4 + ctx.rng() * 30));\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local, k } = phase(t, 15, 1);\n        const fails = k % 2 === 1;\n        const replies = fails ? bad : ok;\n        const healthyAt = fails ? Infinity : probes[3] + ms[3] / 1000;\n        const timedOut = fails && local >= budget;\n        const doneAt = Math.min(healthyAt, budget);\n        const el = Math.min(local, doneAt);\n        const rows = [[['Waiting for ', th.ink], ['prism-api', th.ink, ATTR.BOLD], [' (10.20.0.14:8080/healthz) to become healthy', th.ink]]];\n        probes.forEach((p, i) => {\n          if (local < p) return;\n          const good = replies[i].startsWith('HTTP 200');\n          rows.push([[`  probe ${i + 1}  `, th.dim], [good ? g.ok : g.fail, good ? th.pos : th.neg], [` ${replies[i]}`, good ? th.pos : th.muted], [`  ${ms[i]}ms`, th.dim]]);\n        });\n        if (local >= healthyAt) rows.push([[`${g.ok} healthy after ${healthyAt.toFixed(1)}s`, th.pos, ATTR.BOLD], ['  (4 probes)', th.muted]]);\n        if (timedOut) {\n          rows.push([[`${g.fail} timed out after ${budget}s`, th.neg, ATTR.BOLD], [` ${g.dot} last reply: HTTP 503`, th.muted]]);\n          if (local >= budget + 0.6) rows.push([[`${g.arrow} rolling back to prism-api 0.9.1`, th.warn]]);\n        }\n        const live = local < doneAt;\n        const logRows = live ? H - 1 : H;\n        rows.slice(-logRows).forEach((r, i) => segs(b, 0, i, W, r, g.ell));\n        if (!live) return;\n        // Status line: spinner, time to next probe, and the budget draining.\n        const next = [...probes, 15.5].find((p) => p > local);\n        const left = budget - el;\n        const status = `${spinAt(t, g.unicode)} ${next ? `next probe in ${(next - local).toFixed(1)}s` : 'waiting'}`;\n        b.text(0, H - 1, status, th.info);\n        const bx = Math.max(stringWidth(status) + 2, 26), bw = W - bx - 12;\n        if (bw >= 6) {\n          const n = Math.round((left / budget) * bw);\n          for (let i = 0; i < bw; i++) b.set(bx + i, H - 1, g.unicode ? (i < n ? '█' : '░') : i < n ? '#' : '.', i < n ? (left < 3 ? th.neg : left < 6 ? th.warn : th.accent) : th.line);\n          b.text(bx + bw + 1, H - 1, `${left.toFixed(1)}s left`, th.muted);\n        }\n      };\n    },\n  },\n\n  // ── flip clock ───────────────────────────────────────────────────────\n  {\n    id: 'progress-flip-clock', title: 'Split-Flap Clock', category: C, order: 40, kind: 'progress',\n    description: 'A split-flap clock rolling over midnight on New Year\\'s Eve — every flap falls at once and the date card turns over with them.',\n    tags: ['flip clock', 'split flap', 'clock', 'midnight'], size: { w: 52, h: 7 }, minSize: { w: 20, h: 1 }, loop: 12, color: '256',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const start = 24 * 3600 - 6;                          // 23:59:54\n      const FLIP = 0.32;\n      const face = mix(th.bg, th.panel2, 0.95), hinge = mix(th.bg, th.panel2, 0.45), ink = lift(th, th.ink, 0.3);\n      const lit = (d, x, y) => y >= 1 && y <= 7 && x >= 1 && x <= 4 && FONT47[d][y - 1][x - 1] === '#';\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local } = phase(t, 12, 1);\n        const sec = start + local, whole = Math.floor(sec), f = sec - whole;\n        const at = (s) => { s %= 86400; return `${p2(s / 3600)}${p2((s % 3600) / 60)}${p2(s % 60)}`; };\n        const cur = at(whole), prev = at(whole - 1);\n        const nextDay = whole >= 86400;\n        const date = nextDay ? 'FRI 01 JAN 2027' : 'THU 31 DEC 2026';\n        const big = g.unicode && H >= 5 && W >= 47;\n        if (!big && !g.unicode && H >= 6 && W >= 29) {\n          const txt = `${cur.slice(0, 2)}:${cur.slice(2, 4)}:${cur.slice(4)}`;\n          b.text(Math.floor((W - date.length) / 2), 0, date, nextDay ? th.accent : th.muted, undefined, ATTR.BOLD);\n          bigText(b, Math.floor((W - bigWidth(txt, false, 1)) / 2), 1, txt, () => ink, false, { scale: 1 });\n          return;\n        }\n        if (!big) {\n          const txt = `${cur.slice(0, 2)}:${cur.slice(2, 4)}:${cur.slice(4)}`;\n          b.text(Math.max(0, Math.floor((W - 8) / 2)), H >= 3 ? 1 : 0, txt, ink, undefined, ATTR.BOLD);\n          if (H >= 3) b.text(Math.max(0, Math.floor((W - date.length) / 2)), 0, date, th.muted);\n          return;\n        }\n        const cv = new Canvas(45, 5, { mode: 'half' });\n        let x = 0;\n        for (let i = 0; i < 6; i++) {\n          const n = +cur[i], o = +prev[i];\n          const flipping = n !== o && f < FLIP;\n          const p = flipping ? f / FLIP : 1;\n          for (let py = 0; py < 10; py++) for (let px = 0; px < 6; px++) {\n            let d = n, yy = py, shade = 0;\n            if (flipping) {\n              if (py <= 4) {\n                // Top half: new digit behind; the old top flap folds down toward the hinge.\n                const hgt = p < 0.5 ? 5 * (1 - 2 * p) : 0;\n                if (py >= 5 - hgt) { d = o; yy = Math.round(5 - (5 - py) * (5 / Math.max(0.01, hgt))); shade = 0.35 * p * 2; }\n              } else {\n                // Bottom half: old digit until the new flap swings down over it.\n                const hgt = p >= 0.5 ? 5 * (2 * p - 1) : 0;\n                if (py < 5 + hgt) { yy = Math.round(5 + (py - 5) * (5 / Math.max(0.01, hgt))); shade = 0.7 * (1 - p); } else d = o;\n              }\n            }\n            const base = py === 4 ? hinge : face;\n            const on = yy >= 0 && yy <= 9 && lit(d, px, yy);\n            cv.set(x + px, py, mix(on ? ink : base, th.bg, shade));\n          }\n          x += 6;\n          if (i % 2 === 0) x += 1;\n          else if (i < 5) { cv.set(x + 1, 3, th.muted); cv.set(x + 1, 6, th.muted); x += 3; }\n        }\n        const x0 = Math.floor((W - 45) / 2);\n        if (H >= 6) b.text(Math.floor((W - date.length) / 2), 0, date, nextDay ? th.accent : th.muted, undefined, ATTR.BOLD);\n        cv.paint(b, x0, H >= 6 ? 1 : 0);\n        if (H >= 7) b.text(Math.floor((W - 34) / 2), 6, truncate(`UTC  ${g.dot}  NTP synced, offset +0.4 ms`, W, g.ell), th.dim);\n      };\n    },\n  },\n\n  // ── sand timer (netplan try) ─────────────────────────────────────────\n  {\n    id: 'progress-sand-timer', title: 'netplan try', category: C, order: 50, kind: 'progress',\n    description: 'netplan try\\'s revert countdown beside a braille hourglass that really drains; some runs are confirmed in time, others revert.',\n    tags: ['hourglass', 'netplan', 'revert', 'confirm', 'countdown'], size: { w: 80, h: 10 }, minSize: { w: 40, h: 2 }, loop: 14, glyphs: 'braille', color: '256',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const timeout = 10, acceptAt = 6.3;\n      const CW = 10, CH = 10, PW = CW * 2, PH = CH * 4, cx = PW / 2, cy = PH / 2;\n      const half = (y) => 1 + 7.2 * Math.pow(Math.min(1, Math.abs(y + 0.5 - cy) / (cy - 3)), 0.85);\n      const inside = (x, y) => y >= 3 && y < PH - 3 && Math.abs(x + 0.5 - cx) < half(y) - 0.6;\n      const top = [], bot = [];\n      for (let y = 0; y < PH; y++) for (let x = 0; x < PW; x++) {\n        if (!inside(x, y)) continue;\n        if (y + 0.5 < cy - 1) top.push([x, y]);\n        else if (y + 0.5 > cy + 1) bot.push([x, y]);\n      }\n      // Top bulb drains from its surface, center first (a dip); bottom pile builds a mound.\n      top.sort((a, c) => (a[1] + 0.35 * Math.abs(a[0] + 0.5 - cx)) - (c[1] + 0.35 * Math.abs(c[0] + 0.5 - cx)));\n      bot.sort((a, c) => (c[1] - 0.6 * Math.abs(c[0] + 0.5 - cx)) - (a[1] - 0.6 * Math.abs(a[0] + 0.5 - cx)));\n      const grains = Math.min(top.length, bot.length);\n      const sand = mix(th.warn, 0xffd27a, 0.4);\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local, k } = phase(t, 14, 1);\n        const accept = k % 2 === 0;\n        const stopAt = accept ? acceptAt : timeout;\n        const el = Math.min(local, stopAt);\n        const f = clamp01(el / timeout);\n        const left = Math.ceil(timeout - el);\n        const running = local < stopAt;\n        const moved = Math.round(f * grains);\n        const tx = H >= 6 && W >= 52 ? CW + 3 : 0;\n        if (tx) {\n          if (g.unicode) {\n            const cv = new Canvas(CW, CH, { mode: 'braille', threshold: 0 });\n            for (let x = 1; x < PW - 1; x++) { cv.set(x, 1, th.muted); cv.set(x, PH - 2, th.muted); }\n            for (let y = 3; y < PH - 3; y++) { const h = half(y); cv.set(Math.round(cx - h - 0.5), y, th.line); cv.set(Math.round(cx + h - 0.5), y, th.line); }\n            top.slice(Math.min(grains, moved)).forEach(([x, y]) => cv.set(x, y, sand));\n            bot.slice(0, moved).forEach(([x, y]) => cv.set(x, y, sand));\n            if (running && moved < grains) {\n              const pile = bot[moved] ? bot[moved][1] : PH - 4;\n              for (let y = Math.floor(cy - 1); y < pile; y++) if ((y + Math.floor(t * 20)) % 3) cv.set(Math.floor(cx) - (y % 2), y, sand);\n            }\n            cv.paint(b, 1, Math.max(0, Math.floor((H - CH) / 2)));\n          } else {\n            const art = ['+-----+', ' \\\\   / ', '  \\\\ /  ', '   |   ', '  / \\\\  ', ' /   \\\\ ', '+-----+'];\n            const y0 = Math.max(0, Math.floor((H - 7) / 2));\n            art.forEach((r, i) => b.text(2, y0 + i, r, th.line));\n            const topCells = [[1, 3], [1, 2], [1, 4], [2, 3]];   // drain order: centre of the surface first\n            const botCells = [[5, 3], [5, 2], [5, 4], [4, 3]];\n            const q = Math.round(f * 4);\n            topCells.slice(q).forEach(([r, c]) => b.text(2 + c, y0 + r, ':', sand));\n            botCells.slice(0, q).forEach(([r, c]) => b.text(2 + c, y0 + r, ':', sand));\n            if (running && q < 4) b.text(5, y0 + 3, Math.floor(t * 8) % 2 ? ':' : '.', sand);\n          }\n        }\n        const lines = [\n          [['$ ', th.dim], ['sudo netplan try --timeout 10', th.ink]],\n          [['Do you want to keep these settings?', th.ink]],\n          [['', th.ink]],\n          [['Press ENTER before the timeout to accept the new configuration', th.ink]],\n          [['', th.ink]],\n          [['Changes will revert in ', th.ink], [String(left).padStart(3), left <= 3 ? th.neg : th.warn, ATTR.BOLD], [' seconds', th.ink]],\n        ];\n        if (!running) lines.push(accept ? [['Configuration accepted.', th.pos, ATTR.BOLD]] : [['Reverting.', th.neg, ATTR.BOLD]]);\n        const shown = lines.slice(-H);\n        const y0 = Math.max(0, Math.floor((H - shown.length) / 2));\n        shown.forEach((r, i) => segs(b, tx, y0 + i, W - tx, r, g.ell));\n      };\n    },\n  },\n\n  // ── systemd timers ───────────────────────────────────────────────────\n  {\n    id: 'progress-systemd-timers', title: 'systemctl list-timers', category: C, order: 60, kind: 'progress',\n    description: 'systemctl list-timers with LEFT counting down live; a timer that fires reschedules and re-sorts. Narrow terminals keep NEXT, LEFT, UNIT.',\n    tags: ['systemd', 'timers', 'cron', 'schedule', 'linux'], size: { w: 146, h: 11 }, minSize: { w: 40, h: 3 }, loop: 20, glyphs: 'ascii', color: '16',\n    setup(ctx) {\n      const th = ctx.theme;\n      const now0 = Date.UTC(2026, 9, 13, 13, 59, 50);         // Tue 2026-10-13 13:59:50 UTC\n      const D = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];\n      const stamp = (s) => {\n        const d = new Date(now0 + s * 1000);\n        return `${D[d.getUTCDay()]} ${d.getUTCFullYear()}-${p2(d.getUTCMonth() + 1)}-${p2(d.getUTCDate())} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}:${p2(d.getUTCSeconds())} UTC`;\n      };\n      // systemd's format_timestamp_relative: two most significant units.\n      const rel = (s) => {\n        s = Math.max(0, Math.floor(s));\n        const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;\n        if (s >= 2 * 86400) return `${d} days`;\n        if (s >= 25 * 3600) return `${d} day ${h}h`;\n        if (s >= 6 * 3600) return `${Math.floor(s / 3600)}h`;\n        if (s >= 3600) return `${h}h ${m}min`;\n        if (s >= 300) return `${m}min`;\n        if (s >= 60) return `${m}min ${sec}s`;\n        return `${sec}s`;\n      };\n      const units = [\n        ['prism-healthcheck', 6, 9, 15], ['prism-metrics-flush', 14, 46, 60], ['apt-daily', 11533, 74820, 43200],\n        ['logrotate', 36010, 50390, 86400], ['man-db', 41474, 50558, 86400], ['systemd-tmpfiles-clean', 86211, 429, 86400],\n        ['fstrim', 471133, 132666, 604800],\n      ].map(([name, next, ago, period]) => ({ unit: `${name}.timer`, act: `${name}.service`, next, last: -ago, period }));\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local } = phase(t, 20, 1);\n        const rows = units.map((u) => {\n          let { next, last } = u;\n          while (next <= local) { last = next; next += u.period; }\n          return { ...u, next, last, fired: local - last < 1.2 && last >= 0 };\n        }).sort((a, c) => a.next - c.next);\n        const cells = rows.map((r) => [stamp(r.next), `${rel(Math.ceil(r.next - local))} left`, stamp(r.last), `${rel(local - r.last)} ago`, r.unit, r.act]);\n        const head = ['NEXT', 'LEFT', 'LAST', 'PASSED', 'UNIT', 'ACTIVATES'];\n        const widths = head.map((h, i) => Math.max(h.length, ...cells.map((c) => c[i].length)));\n        // Columns and formats are systemd's; a terminal too narrow for the full table keeps\n        // NEXT, LEFT and UNIT, dropping ACTIVATES, then LAST, then PASSED, then NEXT.\n        const keep = [0, 1, 2, 3, 4, 5];\n        const total = () => keep.reduce((a, i) => a + widths[i], 0) + keep.length - 1;\n        for (const i of [5, 2, 3, 0]) if (total() > W) keep.splice(keep.indexOf(i), 1);\n        const line = (vals, y, color, attr, hl) => {\n          let x = 0;\n          keep.forEach((i) => {\n            if (x >= W) return;\n            const s = truncate(vals[i], Math.min(widths[i], W - x), '...');\n            b.text(x, y, s, i === 1 && hl ? hl : color, undefined, attr);\n            x += widths[i] + 1;\n          });\n        };\n        line(head, 0, th.ink, ATTR.BOLD);\n        const body = Math.min(rows.length, Math.max(0, H - 1));\n        rows.slice(0, body).forEach((r, i) => line(cells[i], 1 + i, r.fired ? th.pos : th.ink, 0, r.next - local < 10 ? th.accent : null));\n        if (H >= rows.length + 4) {\n          b.text(0, rows.length + 2, `${rows.length} timers listed.`, th.ink);\n          b.text(0, rows.length + 3, truncate('Pass --all to see loaded but inactive timers, too.', W, '...'), th.ink);\n        }\n      };\n    },\n  },\n];\n",
  "prism-term/src/facets/progress/transfers.js": "// Transfers & ETA — bytes on the move, printed the way the real tools print\n// them, plus a few views of rate and ETA themselves.\n//\n// Every line is built from a seeded transfer() so size, percent, rate, elapsed\n// and ETA agree; formats follow each tool's source (tqdm's format_meter, wget's\n// bar.c, curl's progress.c max5data/time2str, pv's -pterab, rsync --progress).\nimport { ATTR } from '../../buffer.js';\nimport { Canvas } from '../../canvas.js';\nimport { mix, gradient } from '../../color.js';\nimport { sparkline, gauge } from '../../widgets/index.js';\nimport { truncate, stringWidth } from '../../width.js';\nimport { phase, transfer, glyphs, clamp01, commas, clock } from './_sim.js';\nimport { segs, wrapRows } from './_log.js';\n\nconst C = 'Transfers & ETA';\nconst MiB = 1048576;\nconst p2 = (n) => String(Math.floor(n)).padStart(2, '0');\nconst mmss = (s) => (s >= 3600 ? `${Math.floor(s / 3600)}:${p2((s % 3600) / 60)}:${p2(s % 60)}` : `${p2(s / 60)}:${p2(s % 60)}`);\nconst ASCII_SPARK = ' .:-=+*#';\n\nexport default [\n  // ── tqdm ─────────────────────────────────────────────────────────────\n  {\n    id: 'progress-tqdm', title: 'tqdm', category: C, order: 10, kind: 'progress',\n    description: 'Nested tqdm bars from a training run: epochs outside, batches inside, with elapsed<remaining, it/s and a live loss postfix.',\n    tags: ['tqdm', 'python', 'training', 'ml', 'nested'], size: { w: 80, h: 6 }, minSize: { w: 44, h: 1 }, loop: 13, glyphs: 'ascii', color: '16',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const batches = 250, epochs = 5, prior = 46.2;\n      const eps = [{ n: 3, secs: 5.4, l0: 0.312, l1: 0.241, val: [0.263, 0.907] }, { n: 4, secs: 5.9, l0: 0.244, l1: 0.206, val: [0.238, 0.918] }];\n      let at = 0.2;\n      for (const e of eps) { e.start = at; e.sim = transfer(ctx.rng, { total: batches, seconds: e.secs, burst: 0.35, stalls: 1, alpha: 0.3 }); at += e.secs + 0.5; e.end = e.start + e.secs; }\n      const noise = Array.from({ length: 256 }, () => ctx.rng() - 0.5);\n      const history = ['epoch 1: train_loss=0.512 val_loss=0.448 val_acc=0.861', 'epoch 2: train_loss=0.371 val_loss=0.352 val_acc=0.894'];\n      /** tqdm's format_meter: '{l_bar}{bar}{r_bar}' with the bar filling whatever width is left. */\n      const meter = (desc, n, total, el, rate, postfix, W) => {\n        const pct = `${String(Math.floor((n / total) * 100)).padStart(3)}%`;\n        const rateTxt = rate <= 0 ? '?it/s' : rate >= 1 ? `${rate.toFixed(2)}it/s` : `${(1 / rate).toFixed(2)}s/it`;\n        const rem = rate > 0 ? mmss((total - n) / rate) : '?';\n        const right = `| ${n}/${total} [${mmss(el)}<${rem}, ${rateTxt}${postfix}]`;\n        const left = `${desc}: ${pct}|`;\n        const bw = Math.max(1, W - left.length - right.length - 1);\n        const frac = (n / total) * bw;\n        const full = Math.floor(frac);\n        let bar;\n        if (g.unicode) bar = '█'.repeat(full) + (full < bw ? ' ▏▎▍▌▋▊▉'[Math.floor((frac - full) * 8)] : '') + ' '.repeat(Math.max(0, bw - full - 1));\n        else bar = '#'.repeat(full) + (full < bw ? ' 123456789'[Math.floor((frac - full) * 10)] : '') + ' '.repeat(Math.max(0, bw - full - 1));\n        return { left, bar: bar.slice(0, bw), right };\n      };\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local } = phase(t, 13, 1);\n        const doneEps = eps.filter((e) => local >= e.end);\n        const cur = eps.find((e) => local >= e.start && local < e.end);\n        const logs = [...history, ...doneEps.map((e) => `epoch ${e.n}: train_loss=${e.l1.toFixed(3)} val_loss=${e.val[0].toFixed(3)} val_acc=${e.val[1].toFixed(3)}`)];\n        const nEp = 2 + doneEps.length;\n        const outerEl = prior + local;\n        const outer = meter('training', nEp, epochs, outerEl, nEp / outerEl, '', W);\n        const bars = [outer];\n        if (cur) {\n          const s = cur.sim.at(local - cur.start);\n          const n = Math.floor(s.done);\n          const loss = cur.l0 + (cur.l1 - cur.l0) * s.frac + noise[n % 256] * 0.02;\n          bars.push(meter(`epoch ${cur.n}`, n, batches, s.elapsed, s.smooth, `, loss=${loss.toFixed(3)}`, W));\n        }\n        const logRows = Math.max(0, H - bars.length);\n        logs.slice(-logRows).forEach((l, i) => b.text(0, i, truncate(l, W, g.ell), th.muted));\n        bars.slice(-H).forEach((m, i) => {\n          const y = Math.max(0, H - Math.min(H, bars.length)) + i;\n          segs(b, 0, y, W, [[m.left, th.ink], [m.bar, i === 0 ? th.muted : th.ink], [m.right, th.ink]], g.ell);\n        });\n      };\n    },\n  },\n\n  // ── wget ─────────────────────────────────────────────────────────────\n  {\n    id: 'progress-wget', title: 'wget', category: C, order: 20, kind: 'progress',\n    description: 'wget with its scrolling filename, =====> bar, size, rate and eta; every other run the connection resets and it resumes with +++ for the bytes it had.',\n    tags: ['wget', 'download', 'http', 'resume', 'failure'], size: { w: 80, h: 13 }, minSize: { w: 44, h: 2 }, loop: 11, glyphs: 'ascii', color: '16',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const total = 68157440, name = 'prism-0.9.2.tgz', url = `https://dl.example.com/${name}`;\n      const failByte = 41203712, start = 14 * 3600 + 2 * 60 + 11;\n      const q = g.unicode ? ['‘', '’'] : [\"'\", \"'\"];\n      const full = transfer(ctx.rng, { total, seconds: 7.1, burst: 0.5 });\n      const head = transfer(ctx.rng, { total, seconds: 7.4, burst: 0.5 });\n      let failAt = 0;\n      while (head.at(failAt).done < failByte && failAt < 7.4) failAt += 0.01;\n      const resume = transfer(ctx.rng, { total: total - failByte, seconds: 2.9, burst: 0.5 });\n      const stamp = (s) => { s = start + Math.floor(s); return `2026-10-13 ${p2(s / 3600)}:${p2((s % 3600) / 60)}:${p2(s % 60)}`; };\n      const sz = (n) => `${(n / MiB).toFixed(2)}M`;\n      const rate = (r) => { const m = r / MiB; return m >= 100 ? `${m.toFixed(0)}MB/s` : m >= 10 ? `${m.toFixed(1)}MB/s` : `${m.toFixed(2)}MB/s`; };\n      const eta = (s) => (s >= 60 ? `${Math.floor(s / 60)}m ${Math.round(s % 60)}s` : `${Math.max(1, Math.round(s))}s`);\n      const preamble = (t0, retry) => [\n        [t0, `--${stamp(t0)}--  ${retry ? '(try: 2)  ' : ''}${url}`],\n        ...(retry ? [] : [[t0 + 0.15, 'Resolving dl.example.com (dl.example.com)... 192.0.2.44']]),\n        [t0 + 0.3, 'Connecting to dl.example.com (dl.example.com)|192.0.2.44|:443... connected.'],\n        [t0 + 0.5, `HTTP request sent, awaiting response... ${retry ? '206 Partial Content' : '200 OK'}`],\n        [t0 + 0.55, retry ? `Length: ${total} (65M), ${total - failByte} (26M) remaining [application/gzip]` : `Length: ${total} (65M) [application/gzip]`],\n        [t0 + 0.6, `Saving to: ${q[0]}${name}${q[1]}`],\n        [t0 + 0.6, ''],\n      ];\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local, k } = phase(t, 11, 1);\n        const fails = k % 2 === 1;\n        const nameW = Math.max(8, Math.floor(W / 4));\n        const shownName = (() => {\n          if (name.length <= nameW) return name.padEnd(nameW);\n          const span = name.length - nameW + 3;\n          const off = Math.floor(local / 0.3) % (span + 4);\n          return name.slice(Math.min(off, span - 3), Math.min(off, span - 3) + nameW).padEnd(nameW);\n        })();\n        // The progress line: name, pct, [bar], size, rate, eta-or-elapsed.\n        const bar = (done, have, r, tail, finished) => {\n          const pct = `${String(Math.floor((done / total) * 100)).padStart(3)}%`;\n          const right = `${sz(done).padStart(8)}  ${rate(r).padStart(8)}    ${tail}`;\n          const bw = Math.max(4, W - 1 - nameW - 1 - pct.length - 2 - right.length - 1);\n          const n = Math.min(bw, Math.floor((done / total) * bw)), plus = Math.floor((have / total) * bw);\n          const body = Array.from({ length: bw }, (_, i) => (i < plus ? '+' : i < n - 1 || (finished && i < bw - 1) ? '=' : i === n - 1 || (finished && i === bw - 1) ? '>' : ' ')).join('');\n          return Object.assign([[`${shownName} ${pct}[`, th.ink], [body, th.ink], [`] ${right}`, th.ink]], { noWrap: true });\n        };\n        const rows = [];\n        const add = (txt, c = th.ink) => rows.push([[txt, c]]);\n        for (const [at, txt] of preamble(0, false)) if (local >= at) add(txt, txt.startsWith('--') ? th.ink : th.muted);\n        const t1 = 0.7;\n        if (!fails) {\n          if (local >= t1) {\n            const s = full.at(local - t1);\n            rows.push(bar(s.done, 0, s.finished ? s.avg : s.smooth, s.finished ? `in ${s.elapsed.toFixed(1)}s` : `eta ${eta(s.eta)}`, s.finished));\n            if (s.finished && local >= t1 + 7.15) { add(''); add(`${stamp(t1 + 7.15)} (${rate(s.avg).replace('MB/s', ' MB/s')}) - ${q[0]}${name}${q[1]} saved [${total}/${total}]`, th.pos); }\n          }\n        } else if (local >= t1) {\n          const s = head.at(Math.min(local - t1, failAt));\n          const broke = local - t1 >= failAt;\n          rows.push(bar(s.done, 0, s.smooth, broke ? `in ${failAt.toFixed(1)}s` : `eta ${eta(s.eta)}`, false));\n          if (broke) {\n            add('');\n            add(`${stamp(t1 + failAt)} (${rate(s.done / failAt).replace('MB/s', ' MB/s')}) - Read error at byte ${failByte}/${total} (Connection reset by peer). Retrying.`, th.neg);\n            add('');\n            const t2 = t1 + failAt + 1.0;\n            for (const [at, txt] of preamble(t2, true)) if (local >= at) add(txt, txt.startsWith('--') ? th.ink : th.muted);\n            const t3 = t2 + 0.7;\n            if (local >= t3) {\n              const r = resume.at(local - t3);\n              rows.push(bar(failByte + r.done, failByte, r.finished ? r.avg : r.smooth, r.finished ? `in ${r.elapsed.toFixed(1)}s` : `eta ${eta(r.eta)}`, r.finished));\n              if (r.finished && local >= t3 + 2.95) { add(''); add(`${stamp(t3 + 2.95)} (${rate(r.avg).replace('MB/s', ' MB/s')}) - ${q[0]}${name}${q[1]} saved [${total}/${total}]`, th.pos); }\n            }\n          }\n        }\n        wrapRows(rows, W).slice(-H).forEach((r, i) => segs(b, 0, i, W, r, g.ell));\n      };\n    },\n  },\n\n  // ── curl -# ──────────────────────────────────────────────────────────\n  {\n    id: 'progress-curl-hash', title: 'curl -#', category: C, order: 30, kind: 'progress',\n    description: 'curl\\'s hash-bar mode across the full width with a right-hand percentage; on alternate runs a reset is resumed with -C -.',\n    tags: ['curl', 'download', 'hash', 'resume', 'failure'], size: { w: 80, h: 7 }, minSize: { w: 40, h: 1 }, loop: 13, glyphs: 'ascii', color: '16',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const f1 = transfer(ctx.rng, { total: 1, seconds: 2.6 });\n      const f2 = transfer(ctx.rng, { total: 1, seconds: 6.0 });\n      const failF = 0.613, total2 = 67108864;\n      let failAt = 0;\n      while (f2.at(failAt).frac < failF && failAt < 6) failAt += 0.01;\n      const rest = transfer(ctx.rng, { total: 1 - failF, seconds: 2.4 });\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local, k } = phase(t, 13, 1);\n        const fails = k % 2 === 1;\n        // curl: bar->width = cols - 1; the bar gets width - 7 and is followed by ' %5.1f%%'.\n        const line = (f) => {\n          const bw = Math.max(1, W - 1 - 7);\n          return `${'#'.repeat(Math.floor(f * bw)).padEnd(bw)} ${(f * 100).toFixed(1).padStart(5)}%`;\n        };\n        const rows = [];\n        const cmd = (txt) => rows.push([['$ ', th.dim], [txt, th.ink]]);\n        cmd('curl -# -O https://dl.example.com/prism/assets-0.9.2.tar');\n        if (local >= 0.2) rows.push([[line(f1.at(local - 0.2).frac), th.ink]]);\n        const t2 = 3.2;\n        if (local >= t2) cmd('curl -# -O https://dl.example.com/prism/models-0.9.2.tar');\n        if (local >= t2 + 0.2) {\n          const el = local - t2 - 0.2;\n          if (!fails || el < failAt) rows.push([[line(f2.at(el).frac), th.ink]]);\n          else {\n            rows.push([[line(f2.at(failAt).frac), th.ink]]);\n            rows.push([['curl: (56) Recv failure: Connection reset by peer', th.neg]]);\n            const t3 = failAt + 0.8;\n            if (el >= t3) cmd('curl -# -C - -O https://dl.example.com/prism/models-0.9.2.tar');\n            if (el >= t3 + 0.1) rows.push([[`** Resuming transfer from byte position ${Math.round(total2 * failF)}`, th.muted]]);\n            if (el >= t3 + 0.3) rows.push([[line(failF + rest.at(el - t3 - 0.3).done), th.ink]]);\n          }\n        }\n        rows.slice(-H).forEach((r, i) => segs(b, 0, i, W, r, g.ell));\n      };\n    },\n  },\n\n  // ── curl progress meter ──────────────────────────────────────────────\n  {\n    id: 'progress-curl-meter', title: 'curl progress meter', category: C, order: 40, kind: 'progress',\n    description: 'curl\\'s default meter — % Total, % Received, Average Speed, Time Total/Spent/Left, Current Speed — updating once a second as curl does.',\n    tags: ['curl', 'download', 'meter', 'table'], size: { w: 80, h: 7 }, minSize: { w: 80, h: 3 }, loop: 12, glyphs: 'ascii', color: 'mono',\n    setup(ctx) {\n      const th = ctx.theme;\n      const total = 67108864, secs = 6.8;\n      const sim = transfer(ctx.rng, { total, seconds: secs, burst: 0.5 });\n      // progress.c max5data(): always five characters.\n      const max5 = (n) => {\n        n = Math.floor(n);\n        if (n < 100000) return String(n).padStart(5);\n        if (n < 10000 * 1024) return `${String(Math.floor(n / 1024)).padStart(4)}k`;\n        if (n < 100 * MiB) return `${String(Math.floor(n / MiB)).padStart(2)}.${Math.floor((n % MiB) / (MiB / 10))}M`;\n        return `${String(Math.floor(n / MiB)).padStart(4)}M`;\n      };\n      const time2str = (s) => (s < 0 || !Number.isFinite(s) ? '--:--:--' : `${String(Math.floor(s / 3600)).padStart(2)}:${p2((s % 3600) / 60)}:${p2(s % 60)}`);\n      const pct = (n) => String(Math.floor((n / total) * 100)).padStart(3);\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local } = phase(t, 12, 1);\n        const run = local - 0.3;\n        const done = run >= secs;\n        const s = done ? secs : Math.max(0, Math.floor(run));        // the meter refreshes once per second\n        const got = sim.at(s).done;\n        const avg = s > 0 ? got / s : 0;\n        const cur = s > 0 ? (got - sim.at(Math.max(0, s - 5)).done) / Math.min(5, s) : 0;\n        const tot = avg > 0 ? total / avg : -1;\n        const meter = got <= 0\n          ? `  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0`\n          : `${pct(got)} ${max5(total)}  ${pct(got)} ${max5(got)}    0     0  ${max5(avg)}      0 ${time2str(Math.round(tot))} ${time2str(Math.round(s))} ${done ? '--:--:--' : time2str(Math.round(tot - s))} ${max5(cur)}`;\n        const rows = [\n          [['$ ', th.dim], ['curl -o prism-db-01.dump https://dl.example.com/backups/prism-db-01.dump', th.ink]],\n          [['  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current', th.ink]],\n          [['                                 Dload  Upload   Total   Spent    Left  Speed', th.ink]],\n        ];\n        if (local >= 0.3) rows.push([[meter, th.ink]]);\n        if (local >= secs + 1.0) rows.push([['$ ', th.dim], ['sha256sum -c prism-db-01.dump.sha256', th.ink]]);\n        if (local >= secs + 1.5) rows.push([['prism-db-01.dump: OK', th.pos]]);\n        rows.slice(-H).forEach((r, i) => segs(b, 0, i, W, r, '.'));\n      };\n    },\n  },\n\n  // ── pv ───────────────────────────────────────────────────────────────\n  {\n    id: 'progress-pv', title: 'pv', category: C, order: 50, kind: 'progress',\n    description: 'pv -pterab on a restore stream: bytes, timer, current and average rate, a full-width bar and ETA, with a stall when the far end pushes back.',\n    tags: ['pv', 'pipe', 'throughput', 'restore'], size: { w: 96, h: 4 }, minSize: { w: 66, h: 1 }, loop: 17, glyphs: 'ascii', color: '16',\n    setup(ctx) {\n      const th = ctx.theme;\n      const total = 2.25 * 1024 ** 3, secs = 14;\n      const sim = transfer(ctx.rng, { total, seconds: secs, burst: 0.45, stalls: 1, alpha: 0.25 });\n      // pv sizes: three significant figures and an IEC unit (\"1.21GiB\", \" 512MiB\").\n      const sz = (n) => {\n        const u = ['B', 'KiB', 'MiB', 'GiB'];\n        let i = 0;\n        while (n >= 1000 && i < 3) { n /= 1024; i++; }\n        return `${n >= 100 ? n.toFixed(0).padStart(4) : n >= 10 ? n.toFixed(1) : n.toFixed(2)}${u[i]}`;\n      };\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local } = phase(t, 17, 1);\n        const run = Math.max(0, local - 0.2);\n        const s = sim.at(run);\n        const fin = s.finished;\n        const left = `${sz(s.done)} ${clock(s.elapsed)} [${sz(fin ? s.avg : s.smooth)}/s] [${sz(s.avg)}/s] [`;\n        const right = `] ${String(Math.floor(s.frac * 100)).padStart(3)}% ${fin ? '            ' : `ETA ${clock(s.eta)}`}`;\n        const bw = Math.max(4, W - left.length - right.length - 1);\n        const n = Math.floor(s.frac * bw);\n        const bar = fin ? '='.repeat(bw) : `${'='.repeat(Math.max(0, n - 1))}${n ? '>' : ''}`.padEnd(bw);\n        const rows = [[['$ ', th.dim], [\"pv -pterab backup.tar.zst | ssh restore@10.20.0.31 'zstd -d | tar x -C /srv/restore'\", th.ink]],\n          [[left, th.ink], [bar, fin ? th.pos : th.ink], [right, th.ink]]];\n        if (fin && local >= secs + 0.8) rows.push([['$ ', th.dim]]);\n        rows.slice(-H).forEach((r, i) => segs(b, 0, i, W, r, '.'));\n      };\n    },\n  },\n\n  // ── rsync --progress ─────────────────────────────────────────────────\n  {\n    id: 'progress-rsync', title: 'rsync --progress', category: C, order: 60, kind: 'progress',\n    description: 'rsync -av --progress pushing a tree: per-file bytes, percent, rate and time with xfr#/to-chk counters, then the sent/received summary.',\n    tags: ['rsync', 'backup', 'sync', 'files'], size: { w: 80, h: 12 }, minSize: { w: 64, h: 2 }, loop: 14, glyphs: 'ascii', color: '16',\n    setup(ctx) {\n      const th = ctx.theme;\n      const entries = [\n        ['./'], ['assets/'], ['assets/atlas-0.png', 6815744], ['assets/atlas-1.png', 6291456], ['assets/fonts/'], ['assets/fonts/inter.woff2', 318212],\n        ['assets/sfx.pak', 14680064], ['bin/'], ['bin/prism', 21233664], ['config/'], ['config/prism.toml', 2417], ['docs/'], ['docs/manual.pdf', 3145728],\n      ];\n      const totalSize = entries.reduce((n, e) => n + (e[1] || 0), 0);\n      const nEntries = entries.length;\n      const rate = 6.2 * MiB;\n      let at = 0.6, xfr = 0;\n      const script = [];\n      entries.forEach((e, i) => {\n        script.push({ at, kind: 'name', text: e[0] });\n        at += 0.06;\n        if (e[1] !== undefined) {\n          const secs = Math.max(0.12, e[1] / rate);\n          xfr += 1;\n          script.push({ at, kind: 'file', size: e[1], secs, xfr, chk: nEntries - 1 - i, sim: transfer(ctx.rng, { total: e[1], seconds: secs, burst: 0.4, stalls: 0 }) });\n          at += secs + 0.05;\n        }\n      });\n      const end = at;\n      const sent = totalSize + 12300, received = 465, elapsed = end - 0.3;\n      const rateTxt = (r) => (r >= MiB ? `${(r / MiB).toFixed(2)}MB/s` : `${(r / 1024).toFixed(2)}kB/s`);\n      const time = (s) => `${String(Math.floor(s / 3600)).padStart(4)}:${p2((s % 3600) / 60)}:${p2(s % 60)}`;\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local } = phase(t, 14, 1);\n        const rows = [[['$ ', th.dim], ['rsync -av --progress ./prism/ backup@10.20.0.31:/srv/backup/prism/', th.ink]]];\n        if (local >= 0.4) rows.push([['sending incremental file list', th.ink]]);\n        for (const l of script) {\n          if (local < l.at) break;\n          if (l.kind === 'name') { rows.push([[l.text, th.ink]]); continue; }\n          const s = l.sim.at(local - l.at);\n          const fin = s.finished;\n          const pct = `${String(Math.floor(s.frac * 100)).padStart(3)}%`;\n          const line = `${commas(s.done).padStart(15)} ${pct} ${rateTxt(fin ? l.size / l.secs : s.smooth).padStart(11)} ${time(fin ? l.secs : s.eta)}${fin ? ` (xfr#${l.xfr}, to-chk=${l.chk}/${nEntries})` : ''}`;\n          rows.push([[line, fin ? th.muted : th.info]]);\n        }\n        if (local >= end + 0.1) {\n          rows.push([['', th.ink]]);\n          rows.push([[`sent ${commas(sent)} bytes  received ${commas(received)} bytes  ${((sent + received) / elapsed).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} bytes/sec`, th.ink]]);\n          rows.push([[`total size is ${commas(totalSize)}  speedup is ${(totalSize / (sent + received)).toFixed(2)}`, th.ink]]);\n        }\n        rows.slice(-H).forEach((r, i) => segs(b, 0, i, W, r, '.'));\n      };\n    },\n  },\n\n  // ── speed graph ──────────────────────────────────────────────────────\n  {\n    id: 'progress-speed-graph', title: 'Download Speed Graph', category: C, order: 70, kind: 'progress',\n    description: 'A download drawing its own throughput history as a braille area chart, with a dotted average line, peak, now and ETA.',\n    tags: ['speed', 'graph', 'throughput', 'braille', 'download'], size: { w: 72, h: 10 }, minSize: { w: 32, h: 3 }, loop: 12, glyphs: 'braille', color: '256',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const total = 470 * 1e6, secs = 10;\n      const sim = transfer(ctx.rng, { total, seconds: secs, burst: 0.8, stalls: 2 });\n      let peak = 0;\n      for (let i = 0; i <= 400; i++) peak = Math.max(peak, sim.at((i / 400) * secs).rate);\n      const top = Math.ceil(peak / 1e7) * 1e7;\n      const ramp = gradient([th.info, th.accent, th.crit]);\n      const mb = (r) => `${(r / 1e6).toFixed(1)} MB/s`;\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local } = phase(t, 12, 1);\n        const s = sim.at(local);\n        const head = `prism-dataset-2026q3.tar ${g.dot} ${(s.done / 1e6).toFixed(0)}/${total / 1e6} MB`;\n        const pct = s.finished ? `${g.ok} 100%` : `${Math.floor(s.frac * 100)}%`;\n        b.text(1, 0, truncate(head, W - 8, g.ell), th.ink);\n        b.text(W - 1 - stringWidth(pct), 0, pct, s.finished ? th.pos : th.accent, undefined, ATTR.BOLD);\n        const foot = s.finished ? `done in ${secs.toFixed(1)}s ${g.dot} avg ${mb(s.avg)} ${g.dot} peak ${mb(peak)}`\n          : `now ${mb(s.smooth)} ${g.dot} avg ${mb(s.avg)} ${g.dot} peak ${mb(peak)} ${g.dot} eta ${clock(s.eta, { short: true })}`;\n        if (H >= 3) b.text(1, H - 1, truncate(foot, W - 2, g.ell), th.muted);\n        const ch = H - 2 - (H >= 6 ? 1 : 0);\n        if (ch < 1) return;\n        const lab = W >= 44 ? 9 : 0;\n        const cw = W - 2 - lab;\n        const y0 = 1;\n        if (g.unicode) {\n          const cv = new Canvas(cw, ch, { mode: 'braille', threshold: 0 });\n          const pw = cv.width, ph = cv.height;\n          for (let x = 0; x < pw; x++) {\n            const at = ((x + 0.5) / pw) * secs;\n            if (at > local) break;\n            const r = sim.at(at).rate, h = Math.max(1, Math.round((r / top) * ph));\n            for (let y = ph - h; y < ph; y++) cv.set(x, y, ramp(1 - y / ph));\n          }\n          const ay = ph - 1 - Math.round((s.avg / top) * (ph - 1));\n          if (s.avg > 0) for (let x = 0; x < pw; x += 3) cv.set(x, ay, th.ink);\n          cv.paint(b, 1, y0);\n        } else {\n          for (let x = 0; x < cw; x++) {\n            const at = ((x + 0.5) / cw) * secs;\n            if (at > local) break;\n            const r = sim.at(at).rate, h = (r / top) * ch;\n            for (let y = 0; y < ch; y++) {\n              const fill = h - (ch - 1 - y);\n              if (fill > 0) b.set(1 + x, y0 + y, fill >= 1 ? '#' : fill > 0.5 ? '+' : '.', th.accent);\n            }\n          }\n        }\n        if (lab) {\n          b.text(W - lab, y0, `${(top / 1e6).toFixed(0)} MB/s`.slice(0, lab - 1), th.dim);\n          b.text(W - lab, y0 + ch - 1, '0', th.dim);\n          if (s.avg > 0 && ch >= 3) b.text(W - lab, y0 + Math.min(ch - 2, Math.max(1, Math.round((1 - s.avg / top) * (ch - 1)))), `${g.dot}${g.dot} avg`, th.ink);\n        }\n        if (H >= 6) {\n          const bw = W - 2, n = Math.round(s.frac * bw);\n          for (let i = 0; i < bw; i++) b.set(1 + i, H - 2, g.unicode ? '━' : i < n ? '=' : '-', i < n ? (s.finished ? th.pos : th.accent) : th.line);\n        }\n      };\n    },\n  },\n\n  // ── ETA convergence ──────────────────────────────────────────────────\n  {\n    id: 'progress-eta-converge', title: 'ETA Convergence', category: C, order: 80, kind: 'chart',\n    description: 'Why ETAs jump: the instant-rate estimate (dots) against an EWMA-smoothed one (solid) and the true time left (dashed) across stalls.',\n    tags: ['eta', 'ewma', 'smoothing', 'chart', 'estimate'], size: { w: 72, h: 12 }, minSize: { w: 36, h: 4 }, loop: 11, glyphs: 'braille', color: '256',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const secs = 9;\n      const sim = transfer(ctx.rng, { total: 1, seconds: secs, burst: 0.9, stalls: 2, alpha: 0.015 });\n      const tau = (secs / 480 / 0.015).toFixed(1);   // EWMA time constant in seconds\n      const yMax = secs * 2;\n      const series = (x) => {\n        const s = sim.at(x);\n        return { raw: s.finished ? 0 : (1 - s.done) / Math.max(1e-9, s.rate), ewma: s.eta, truth: Math.max(0, secs - x) };\n      };\n      const cRaw = th.warn, cEw = th.accent, cTr = th.ink;\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local } = phase(t, 11, 1);\n        const now = series(Math.min(local, secs));\n        const legend = [[g.unicode ? '···' : '...', cRaw], [' instant  ', th.muted], [g.unicode ? '━━' : '**', cEw], [` EWMA ${g.unicode ? 'τ' : 'tau'} ${tau}s  `, th.muted], [g.unicode ? '╌╌' : '--', cTr], [' actual', th.muted]];\n        segs(b, 1, 0, W - 2, legend, g.ell);\n        const at60 = series(secs * 0.4);\n        const foot = local >= secs ? `${g.ok} done ${g.dot} at 40% elapsed: EWMA off by ${Math.abs(at60.ewma - at60.truth).toFixed(1)}s, instant by ${Math.abs(at60.raw - at60.truth).toFixed(1)}s`\n          : `left: instant ${clock(Math.min(now.raw, 5999), { short: true })}  ewma ${clock(now.ewma, { short: true })}  actual ${clock(now.truth, { short: true })}`;\n        b.text(1, H - 1, truncate(foot, W - 2, g.ell), th.ink);\n        const ch = H - 2, lab = 5, cw = W - 2 - lab;\n        if (ch < 2) return;\n        b.text(1, 1, `${yMax}s`, th.dim);\n        b.text(1, ch, '0s', th.dim);\n        if (g.unicode) {\n          const cv = new Canvas(cw, ch, { mode: 'braille', threshold: 0 });\n          const pw = cv.width, ph = cv.height;\n          const Y = (v) => Math.round((ph - 1) * (1 - Math.min(1, v / yMax)));\n          for (let x = 0; x < pw; x += 2) cv.set(x, ph - 1, th.line);\n          let prev = null;\n          for (let x = 0; x < pw; x++) {\n            const at = (x / (pw - 1)) * secs;\n            if (at > local) break;\n            const v = series(at);\n            if (x % 6 < 4) cv.set(x, Y(v.truth), cTr);\n            if (v.raw < yMax && x % 2 === 0) cv.set(x, Y(v.raw), cRaw);\n            const y = Y(v.ewma);\n            if (prev !== null) { cv.line(x - 1, prev, x, y, cEw); cv.line(x - 1, prev + 1, x, y + 1, cEw); } else cv.set(x, y, cEw);\n            prev = y;\n          }\n          cv.paint(b, 1 + lab, 1);\n        } else {\n          const Y = (v) => 1 + Math.round((ch - 1) * (1 - Math.min(1, v / yMax)));\n          for (let x = 0; x < cw; x++) {\n            const at = (x / (cw - 1)) * secs;\n            if (at > local) break;\n            const v = series(at);\n            if (x % 2 === 0) b.set(1 + lab + x, Y(v.truth), '-', cTr);\n            if (v.raw < yMax) b.set(1 + lab + x, Y(v.raw), '.', cRaw);\n            b.set(1 + lab + x, Y(v.ewma), '*', cEw);\n          }\n        }\n      };\n    },\n  },\n\n  // ── queue drain ──────────────────────────────────────────────────────\n  {\n    id: 'progress-queue-drain', title: 'Queue Drain', category: C, order: 90, kind: 'progress',\n    description: 'A backlog draining while producers keep adding: consumers autoscale 4 → 8, the net rate sets the ETA, and the depth is charted.',\n    tags: ['queue', 'backlog', 'autoscale', 'workers', 'eta'], size: { w: 64, h: 10 }, minSize: { w: 36, h: 2 }, loop: 13, color: '256',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const dt = 0.05, N = Math.round(13 / dt);\n      const start = 18402, scaleUp = 2.5, scaleDown = 11.5;\n      const depth = new Float64Array(N + 1), inR = new Float64Array(N + 1), outR = new Float64Array(N + 1), workers = new Uint8Array(N + 1);\n      const doneArr = new Float64Array(N + 1);\n      let q = start, done = 0;\n      for (let i = 0; i <= N; i++) {\n        const tt = i * dt;\n        const w = tt < scaleUp ? 4 : tt < scaleDown ? 8 : 2;\n        const p = 410 + 90 * Math.sin(tt * 1.3) + (ctx.rng() - 0.5) * 120;\n        const cap = w * (330 + (ctx.rng() - 0.5) * 60);\n        const c = q > 0 ? cap : Math.min(cap, p);\n        depth[i] = q; inR[i] = p; outR[i] = c; workers[i] = w; doneArr[i] = done;\n        q = Math.max(0, q + (p - c) * dt);\n        done += c * dt;\n      }\n      const at = (tt) => Math.min(N, Math.max(0, Math.round(tt / dt)));\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local } = phase(t, 13, 1);\n        const i = at(local);\n        // Rates averaged over the last second, the way a dashboard would show them.\n        const j = at(local - 1);\n        const avg = (arr) => { let s = 0; for (let k = j; k <= i; k++) s += arr[k]; return s / (i - j + 1); };\n        const pin = avg(inR), pout = avg(outR), net = pout - pin;\n        const d = Math.round(depth[i]);\n        const eta = d > 0 && net > 0 ? d / net : 0;\n        const w = workers[i];\n        const head = `queue thumbnails.render ${g.dot} ${commas(d)} waiting`;\n        b.text(1, 0, truncate(head, W - 2, g.ell), d ? th.ink : th.pos, undefined, ATTR.BOLD);\n        if (H < 3) { b.text(1, 1, truncate(`in ${Math.round(pin)}/s  out ${Math.round(pout)}/s  eta ${clock(eta, { short: true })}`, W - 2, g.ell), th.muted); return; }\n        const bw = W - 2, n = Math.round((d / start) * bw);\n        for (let x = 0; x < bw; x++) b.set(1 + x, 1, g.unicode ? (x < n ? '█' : '░') : x < n ? '#' : '.', x < n ? th.warn : th.line);\n        const slots = (g.unicode ? '■' : '#').repeat(w) + (g.unicode ? '□' : '.').repeat(8 - Math.min(8, w));\n        segs(b, 1, 2, W - 2, [['consumers ', th.muted], [slots, th.info], [`  ${w} running`, th.muted]], g.ell);\n        const chartH = H - 6;\n        if (chartH >= 1) {\n          const len = W - 2;\n          const data = Array.from({ length: len }, (_, k) => depth[at(local - (len - 1 - k) * 0.06)]);\n          if (g.unicode) sparkline(b, 1, 3, W - 2, data.slice(-(W - 2)), { theme: th, height: chartH, min: 0, max: start * 1.05, color: mix(th.warn, th.bg, 0.45) });\n          else data.slice(-(W - 2)).forEach((v, x) => { const h = Math.round((v / (start * 1.05)) * chartH); for (let y = 0; y < h; y++) b.set(1 + x, 3 + chartH - 1 - y, '|', th.warn); });\n        }\n        const y = H - 3;\n        segs(b, 1, y, W - 2, [['in ', th.muted], [`+${Math.round(pin)}/s`, th.ink], ['  out ', th.muted], [`-${Math.round(pout)}/s`, th.ink], ['  net ', th.muted],\n          [net > 0 && d > 0 ? `-${Math.round(net)}/s` : '0/s', th.ink], ['  eta ', th.muted], [d > 0 ? clock(eta, { short: true }) : 'drained', d > 0 ? th.accent : th.pos]], g.ell);\n        b.text(1, y + 1, truncate(`processed ${commas(doneArr[i])} ${g.dot} produced ${commas(doneArr[i] + depth[i] - start)}`, W - 2, g.ell), th.dim);\n        const ev = local >= scaleDown ? [`${g.unicode ? '▼' : 'v'} scaled consumers 8 ${g.arrow} 2 (queue idle 60s)`, th.muted]\n          : local >= scaleUp ? [`${g.unicode ? '▲' : '^'} scaled consumers 4 ${g.arrow} 8 (backlog > 10,000)`, th.info] : [`${g.dot} watching backlog (scale-up threshold 10,000)`, th.dim];\n        b.text(1, y + 2, truncate(ev[0], W - 2, g.ell), ev[1]);\n      };\n    },\n  },\n\n  // ── throughput gauge ─────────────────────────────────────────────────\n  {\n    id: 'progress-throughput', title: 'Throughput Gauge', category: C, order: 100, kind: 'progress',\n    description: 'A needle gauge of link use during a backup stream, beside the running total, average and peak and a rolling rate sparkline.',\n    tags: ['throughput', 'gauge', 'bandwidth', 'network', 'backup'], size: { w: 64, h: 10 }, minSize: { w: 30, h: 2 }, loop: 12, glyphs: 'braille', color: '256',\n    setup(ctx) {\n      const th = ctx.theme, g = glyphs(ctx);\n      const link = 1.25e9, secs = 12, base = 18.4e9;\n      const sim = transfer(ctx.rng, { total: 0.6 * link * secs, seconds: secs, burst: 0.6, stalls: 1, warmup: 0.02, alpha: 0.3 });\n      // Scale the whole run so its fastest instant stays under line rate — a 10GbE link can't burst past 1.25 GB/s.\n      let fastest = 0;\n      for (let i = 0; i <= 480; i++) fastest = Math.max(fastest, sim.at((i / 480) * secs).rate);\n      const k = Math.min(1, (0.97 * link) / fastest);\n      const at = (sec) => { const s = sim.at(sec); return { ...s, done: s.done * k, rate: s.rate * k, smooth: s.smooth * k, avg: s.avg * k }; };\n      let peak = 0;\n      const gb = (n) => `${(n / 1e9).toFixed(2)} GB`;\n      const rateTxt = (r) => (r >= 1e9 ? `${(r / 1e9).toFixed(2)} GB/s` : `${Math.round(r / 1e6)} MB/s`);\n      return (t) => {\n        const b = ctx.buffer, W = ctx.width, H = ctx.height;\n        const { local } = phase(t, 12, 1);\n        const s = at(local);\n        peak = 0;\n        for (let i = 0; i <= 60; i++) peak = Math.max(peak, at((i / 60) * local).smooth);\n        const u = clamp01(s.smooth / link);\n        const gw = H >= 6 && W >= 52 ? Math.min(26, Math.floor(W * 0.42)) : 0;\n        if (gw) {\n          if (g.unicode) gauge(b, 0, 0, gw, H, u, { theme: th, label: 'egress', valueText: rateTxt(s.smooth), thresholds: [[0.75, 'warn'], [0.92, 'neg']], color: th.pos });\n          else {\n            const n = Math.round(u * (gw - 4));\n            b.text(1, 1, rateTxt(s.smooth), th.ink, undefined, ATTR.BOLD);\n            b.text(1, 2, `[${'#'.repeat(n)}${'.'.repeat(gw - 4 - n)}]`, u > 0.92 ? th.neg : u > 0.75 ? th.warn : th.pos);\n            b.text(1, 3, 'egress', th.muted);\n          }\n        }\n        const x = gw ? gw + 2 : 1, w = W - x - 1;\n        const lines = [\n          [['backup stream ', th.muted], [`${g.arrow} 10.20.0.31`, th.ink]],\n          [['sent   ', th.muted], [gb(base + s.done), th.ink, ATTR.BOLD]],\n          [['now    ', th.muted], [`${rateTxt(s.smooth)}  ${Math.round(u * 100)}% of 10GbE`, u > 0.92 ? th.neg : u > 0.75 ? th.warn : th.ink]],\n          [['avg    ', th.muted], [rateTxt(s.avg), th.ink], ['  peak ', th.muted], [rateTxt(peak), th.ink]],\n        ];\n        const rows = gw ? lines : lines.slice(1, 1 + H);\n        rows.forEach((r, i) => { if (i < H) segs(b, x, i, w, r, g.ell); });\n        if (gw && H >= 7) {\n          const data = Array.from({ length: w * 2 }, (_, i) => at(Math.max(0, local - (w * 2 - 1 - i) * 0.1)).rate);\n          if (g.unicode) sparkline(b, x, H - 2, w, data, { theme: th, style: 'braille', height: 2, min: 0, max: link, color: th.info });\n          else data.filter((_, k) => k % 2).forEach((v, k) => b.set(x + k, H - 1, ASCII_SPARK[Math.min(7, Math.round((v / link) * 7))], th.info));\n          b.text(x, H - 3, truncate('last 6s', w, g.ell), th.dim);\n        }\n      };\n    },\n  },\n];\n",
  "prism-term/src/facets/retro/_bbs.js": "// BBS-era helpers: an ANSI-art encoder and block-letter logos.\n//\n// ansify() turns a 1×2-pixel-per-cell image into what an ANSI artist would\n// have drawn in 1993: every cell is one of ' ░▒▓█▀▄' with a foreground from\n// the 16 VGA colors and a background from the 8 low ones (DOS text mode had\n// no bright backgrounds without iCE colors). Mid-tones come out as shade\n// dithers, sharp vertical detail as half blocks.\n\nimport { VGA } from './_kit.js';\nimport { distance2, mix } from '../../color.js';\n\nconst SHADES = [[' ', 0], ['░', 0.25], ['▒', 0.5], ['▓', 0.75]];\nconst LUTS = new Map(); // palette signature → Map(color key → [ch, fg, bg])\n\nfunction shadeLut(pal) {\n  const sig = pal.join(',');\n  let lut = LUTS.get(sig);\n  if (!lut) { lut = new Map(); LUTS.set(sig, lut); }\n  return lut;\n}\n\n/** Best single-cell approximation of `c` with a shade glyph (4-bit-per-channel memo). */\nfunction bestShade(c, pal) {\n  const key = (((c >> 20) & 15) << 8) | (((c >> 12) & 15) << 4) | ((c >> 4) & 15);\n  const lut = shadeLut(pal);\n  let hit = lut.get(key);\n  if (hit) return hit;\n  let best = Infinity;\n  for (let b = 0; b < 8; b++) {\n    for (let f = 0; f < 16; f++) {\n      for (const [ch, k] of SHADES) {\n        if (k === 0 && f !== 0) continue; // ' ' only needs the background\n        const d = distance2(c, mix(pal[b], pal[f], k));\n        if (d < best) { best = d; hit = [ch, k === 0 ? pal[7] : pal[f], pal[b]]; }\n      }\n    }\n    // A full block shows the foreground alone, so bright colors are reachable.\n    for (let f = 8; f < 16; f++) {\n      const d = distance2(c, pal[f]);\n      if (d < best) { best = d; hit = ['█', pal[f], pal[b]]; }\n    }\n  }\n  lut.set(key, hit);\n  return hit;\n}\n\nfunction nearest(c, pal, n) {\n  let best = 0, bd = Infinity;\n  for (let i = 0; i < n; i++) { const d = distance2(c, pal[i]); if (d < bd) { bd = d; best = i; } }\n  return best;\n}\n\n/**\n * Paint `img` (Int32Array, w × 2h pixels, row-major) onto `surface` at (ox, oy)\n * as ANSI art. `pal` is a 16-entry palette (VGA by default).\n */\nexport function ansify(surface, img, w, h, ox = 0, oy = 0, pal = VGA) {\n  for (let cy = 0; cy < h; cy++) {\n    for (let cx = 0; cx < w; cx++) {\n      const top = img[(cy * 2) * w + cx], bot = img[(cy * 2 + 1) * w + cx];\n      if (distance2(top, bot) > 2600) {\n        // Sharp vertical edge inside the cell: a half block says it best.\n        const ft = nearest(top, pal, 16), bb = nearest(bot, pal, 8);\n        const fb = nearest(bot, pal, 16), bt = nearest(top, pal, 8);\n        const e1 = distance2(top, pal[ft]) + distance2(bot, pal[bb]);\n        const e2 = distance2(bot, pal[fb]) + distance2(top, pal[bt]);\n        if (e1 <= e2) surface.set(ox + cx, oy + cy, '▀', pal[ft], pal[bb]);\n        else surface.set(ox + cx, oy + cy, '▄', pal[fb], pal[bt]);\n      } else {\n        const [ch, fg, bg] = bestShade(mix(top, bot, 0.5), pal);\n        surface.set(ox + cx, oy + cy, ch, fg, bg);\n      }\n    }\n  }\n}\n\n/**\n * Block-letter logo: font pixels become full/half blocks with a per-row color\n * ramp and a drop shadow — the \"ANSI logo\" look. Returns { w, h } in cells.\n */\nexport function blockLogo(surface, x0, y0, mask, { sx = 2, ramp, shadow = 0x555555, shade = true } = {}) {\n  const rows = Math.ceil(mask.h / 2);\n  const W = mask.w * sx;\n  const px = (x, y) => (y >= 0 && y < mask.h && x >= 0 && x < mask.w ? mask.bits[y * mask.w + x] : 0);\n  for (let r = 0; r < rows; r++) {\n    const top = r * 2, bot = r * 2 + 1;\n    const cTop = ramp(top / Math.max(1, mask.h - 1)), cBot = ramp(bot / Math.max(1, mask.h - 1));\n    for (let x = 0; x < W; x++) {\n      const mx = Math.floor(x / sx);\n      const a = px(mx, top), b = px(mx, bot);\n      // Shadow first (one cell right, half a row down), only where the letter isn't.\n      const sa = px(Math.floor((x - 1) / sx), top - 1), sb = px(Math.floor((x - 1) / sx), bot - 1);\n      if (!a && !b && (sa || sb)) surface.set(x0 + x, y0 + r, shade ? '░' : '▒', shadow);\n      if (a && b) surface.set(x0 + x, y0 + r, '█', cTop, undefined);\n      else if (a) surface.set(x0 + x, y0 + r, '▀', cTop);\n      else if (b) surface.set(x0 + x, y0 + r, '▄', cBot);\n    }\n  }\n  return { w: W + 1, h: rows };\n}\n\n/** Double-line frame with a title, in BBS colors. */\nexport function frame(surface, x, y, w, h, color, title, titleColor) {\n  if (w < 2 || h < 2) return;\n  surface.text(x, y, '╔' + '═'.repeat(w - 2) + '╗', color);\n  for (let i = 1; i < h - 1; i++) { surface.set(x, y + i, '║', color); surface.set(x + w - 1, y + i, '║', color); }\n  surface.text(x, y + h - 1, '╚' + '═'.repeat(w - 2) + '╝', color);\n  if (title && w > title.length + 6) surface.text(x + 3, y, `╡ ${title} ╞`.replace(/^╡ /, '╡ '), titleColor ?? color);\n}\n",
  "prism-term/src/facets/retro/_kit.js": "// Shared helpers for the retro and fx galleries (underscore → not a facet).\n//\n// Bitmap text as pixel masks (for scrollers and logos drawn on a Canvas), the\n// IBM VGA 16-color palette BBS art was painted in, and small color helpers.\n\nimport { mix, rgb, NONE } from '../../color.js';\nimport { glyph, getFont } from '../../text/fonts.js';\n\nexport const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);\nexport const fract = (v) => v - Math.floor(v);\nexport const lerpc = (a, b, t) => mix(a, b, clamp01(t));\n\n/**\n * Shade the theme background by k (0..1): toward black at Night; by Day toward the\n * theme's hairline tone, so depth reads as a pearl shadow instead of a gray wash.\n */\nexport function shade(theme, k) {\n  return theme.mode === 'light' ? lerpc(theme.bg, theme.line, k * 2.2) : lerpc(theme.bg, 0x000000, k);\n}\n\n/** The color a light-emitting effect rises out of: black at Night, the page by Day. */\nexport const ground = (theme) => (theme.mode === 'light' ? theme.bg : 0x000000);\n\n/** Scale a packed color's brightness (0..1+), clamped. */\nexport function dim(c, k) {\n  if (c === NONE) return NONE;\n  return rgb(((c >> 16) & 255) * k, ((c >> 8) & 255) * k, (c & 255) * k);\n}\n\n// IBM VGA text-mode palette, in DOS attribute order.\nexport const VGA = [\n  0x000000, 0x0000aa, 0x00aa00, 0x00aaaa, 0xaa0000, 0xaa00aa, 0xaa5500, 0xaaaaaa,\n  0x555555, 0x5555ff, 0x55ff55, 0x55ffff, 0xff5555, 0xff55ff, 0xffff55, 0xffffff,\n];\nexport const VGA_NAME = {\n  black: 0, blue: 1, green: 2, cyan: 3, red: 4, magenta: 5, brown: 6, gray: 7,\n  dark: 8, lblue: 9, lgreen: 10, lcyan: 11, lred: 12, lmagenta: 13, yellow: 14, white: 15,\n};\n\n/**\n * VGA palette, or the active theme mapped onto the same 16 slots\n * (`--set colors=theme`) so BBS screens can follow a design system.\n */\nexport function bbsPalette(ctx) {\n  if (ctx.param('colors', 'vga') !== 'theme') return VGA;\n  const t = ctx.theme;\n  const d = (c, k) => dim(c, k);\n  return [\n    t.bg, d(t.info, 0.65), d(t.pos, 0.65), d(t.accent, 0.65), d(t.neg, 0.65), d(t.crit, 0.65), d(t.warn, 0.6), t.muted,\n    t.dim, t.info, t.pos, t.accent, t.neg, t.crit, t.warn, t.ink,\n  ];\n}\n\n/**\n * Rasterize text into a 0/1 mask using a bitmap font.\n * Returns { w, h, bits: Uint8Array, cols: [startX per char] }.\n */\nexport function textMask(text, { font = 'bold', spacing = 1 } = {}) {\n  const f = getFont(font);\n  const glyphs = [...String(text)].map((ch) => (ch === ' ' ? { w: Math.max(2, f.width - 2), h: f.height, bits: null } : glyph(f, ch)));\n  const h = f.height;\n  let w = 0;\n  const cols = [];\n  for (const g of glyphs) { cols.push(w); w += g.w + spacing; }\n  w = Math.max(1, w - spacing);\n  const bits = new Uint8Array(w * h);\n  glyphs.forEach((g, i) => {\n    if (!g.bits) return;\n    const gh = Math.min(h, g.h);\n    for (let y = 0; y < gh; y++) for (let x = 0; x < g.w; x++) if (g.bits[y * g.w + x]) bits[y * w + cols[i] + x] = 1;\n  });\n  return { w, h, bits, cols, glyphs };\n}\n\n/** Sample a mask at (x, y) with integer scaling; out of range → 0. */\nexport function maskAt(m, x, y, sx = 1, sy = sx) {\n  const mx = Math.floor(x / sx), my = Math.floor(y / sy);\n  if (mx < 0 || my < 0 || mx >= m.w || my >= m.h) return 0;\n  return m.bits[my * m.w + mx];\n}\n\n/** Cheap deterministic hash → [0, 1). */\nexport function hash2(x, y, s = 0) {\n  let h = (x * 374761393 + y * 668265263 + s * 2147483647) | 0;\n  h = Math.imul(h ^ (h >>> 13), 1274126177);\n  return ((h ^ (h >>> 16)) >>> 0) / 4294967296;\n}\n\n/**\n * Canvas-pixel scale for bitmap-font text so one font pixel is visually square\n * in any canvas mode and the text stands `targetCells` rows tall.\n */\nexport function fontPx(cv, targetCells, fontH = 7) {\n  const L = Math.max(0.5, (targetCells * 2) / fontH); // visual units (cell widths) per font pixel\n  return { sx: Math.max(1, Math.round(L * cv.sx)), sy: Math.max(1, Math.round((L * cv.sy) / 2)) };\n}\n\n/** A deterministic starfield: [x, y, layer] in canvas pixels. */\nexport function makeStars(rng, w, h, n) {\n  return Array.from({ length: n }, () => [rng() * w, rng() * h, rng()]);\n}\n",
  "prism-term/src/facets/retro/_music.js": "// A tiny deterministic chiptune for the music visualizers to dance to.\n// One 64-row pattern at 125 BPM / speed 6 (0.12 s a row, 7.68 s a loop):\n// drums, a square-wave bass, a pulse lead and hats over Am–F–C–G. Nothing is\n// synthesised to audio — the visualizers just read envelopes, frequencies,\n// band energies and a waveform, all pure functions of t.\nimport { hash2 } from './_kit.js';\n\nexport const BPM = 125;\nexport const ROW = 60 / BPM / 4;\nexport const ROWS = 64;\nexport const LOOP = ROW * ROWS;\nexport const SONG = 'prism_intro.mod';\n\nconst NAMES = ['C-', 'C#', 'D-', 'D#', 'E-', 'F-', 'F#', 'G-', 'G#', 'A-', 'A#', 'B-'];\nexport const noteName = (m) => `${NAMES[m % 12]}${Math.floor(m / 12) - 1}`;\nexport const freq = (m) => 440 * Math.pow(2, (m - 69) / 12);\n\nconst ROOTS = [45, 41, 48, 43];   // A2 F2 C3 G2\nconst LEAD = [\n  76, 0, 72, 0, 69, 72, 76, 79,\n  77, 0, 76, 0, 72, 0, 69, 72,\n  79, 0, 76, 72, 67, 0, 72, 76,\n  74, 0, 71, 0, 79, 77, 74, 71,\n];\nexport const INSTRUMENTS = ['', 'lead-pulse', 'bass-square', 'kick-808', 'snare-noise', 'hat-closed', 'hat-open'];\n\n/** The pattern: PATTERN[row][ch] = { note, inst, eff } or null. Channels: drums, bass, lead, hats. */\nexport const PATTERN = Array.from({ length: ROWS }, (_, r) => {\n  const bar = r >> 4, step = r & 15;\n  const drum = step === 0 || step === 8 || (bar === 3 && step === 10) ? { note: 36, inst: 3, eff: 'C40' }\n    : step === 4 || step === 12 || (bar === 3 && step >= 13) ? { note: 38, inst: 4, eff: bar === 3 && step > 12 ? `C${(0x20 + (step - 12) * 8).toString(16).toUpperCase()}` : 'C38' } : null;\n  const bass = step % 2 === 0 ? { note: ROOTS[bar] + (step % 4 === 2 ? 12 : 0), inst: 2, eff: step % 4 === 2 ? 'C28' : 'C30' } : null;\n  const ln = step % 2 === 0 ? LEAD[bar * 8 + (step >> 1)] : 0;\n  const lead = ln ? { note: ln, inst: 1, eff: step === 0 ? '4A2' : 'C30' } : null;\n  const hat = step % 2 === 1 ? { note: 72, inst: step === 15 ? 6 : 5, eff: step % 4 === 3 ? 'C18' : 'C10' } : null;\n  return [drum, bass, lead, hat];\n});\n\n// For each channel and row, the most recent event at or before that row (wrapping).\nconst LAST = PATTERN[0].map((_, ch) => {\n  const out = new Array(ROWS);\n  let last = null, lastRow = 0;\n  for (let i = 0; i < ROWS * 2; i++) {\n    const r = i % ROWS, ev = PATTERN[r][ch];\n    if (ev) { last = ev; lastRow = i; }\n    if (i >= ROWS) out[r] = { ev: last, back: i - lastRow };\n  }\n  return out;\n});\n\n/** The song's state at time t: row position plus per-voice envelopes and frequencies. */\nexport function songAt(t) {\n  const lt = ((t % LOOP) + LOOP) % LOOP;\n  const pos = lt / ROW, row = Math.floor(pos), frac = pos - row;\n  const voice = (ch) => {\n    const { ev, back } = LAST[ch][row];\n    return { ev, age: (back + frac) * ROW };\n  };\n  const d = voice(0), bs = voice(1), ld = voice(2), ht = voice(3);\n  const isKick = d.ev && d.ev.inst === 3;\n  const vol = (ev) => (ev ? parseInt(ev.eff.slice(1), 16) / 64 : 0);\n  const kick = isKick ? Math.exp(-d.age / 0.11) : 0;\n  const snare = d.ev && !isKick ? Math.exp(-d.age / 0.13) * vol(d.ev) * 1.6 : 0;\n  const bass = (0.35 + 0.65 * Math.exp(-bs.age / 0.12)) * Math.min(1, vol(bs.ev) * 1.4) * (bs.age < ROW * 2 ? 1 : Math.exp(-(bs.age - ROW * 2) / 0.05));\n  const lead = Math.min(1, ld.age / 0.01) * (0.45 + 0.55 * Math.exp(-ld.age / 0.18)) * Math.exp(-ld.age / 1.4);\n  const hat = ht.ev ? Math.exp(-ht.age / (ht.ev.inst === 6 ? 0.12 : 0.035)) * vol(ht.ev) * 2.2 : 0;\n  return {\n    t: lt, row, frac, beat: Math.floor(row / 4), bar: row >> 4,\n    kick, snare: Math.min(1, snare), bass, lead, hat: Math.min(1, hat),\n    kickAge: d.age,\n    bassFreq: bs.ev ? freq(bs.ev.note) : 110,\n    leadFreq: ld.ev ? freq(ld.ev.note) * (1 + 0.006 * Math.sin(ld.age * 34) * Math.min(1, ld.age * 4)) : 440,\n    bassNote: bs.ev ? bs.ev.note : 45, leadNote: ld.ev ? ld.ev.note : 69,\n  };\n}\n\n/** Per-channel levels [drums, bass, lead, hats] in 0..1. */\nexport function levels(s) {\n  return [Math.max(s.kick, s.snare), s.bass * 0.85, s.lead * 0.8, s.hat * 0.9];\n}\n\nconst LO = Math.log2(40), HI = Math.log2(12000);\nconst bump = (fc, f, w) => { const d = (fc - Math.log2(f)) / w; return Math.exp(-d * d); };\n\n/** Band energies (0..1) for `n` log-spaced bands from 40 Hz to 12 kHz. */\nexport function bands(t, n, out = new Float32Array(n)) {\n  const s = songAt(t);\n  for (let i = 0; i < n; i++) {\n    const fc = LO + ((i + 0.5) / n) * (HI - LO);\n    let e = s.kick * (bump(fc, 58, 0.55) + 0.35 * bump(fc, 120, 0.5));\n    for (let h = 1; h <= 7; h += 2) e += (s.bass / h) * bump(fc, s.bassFreq * h, 0.22);\n    for (let h = 1; h <= 6; h++) e += (s.lead * 0.8 / h) * bump(fc, s.leadFreq * h, 0.18);\n    e += s.snare * (0.55 * bump(fc, 220, 0.6) + 0.7 * bump(fc, 2500, 1.6));\n    e += s.hat * 0.8 * bump(fc, 9000, 0.9);\n    const shimmer = 0.06 * hash2(i, Math.floor(t * 30), 7);\n    out[i] = Math.min(1, Math.sqrt(e * 0.9) * 0.92 + shimmer * (0.3 + e));\n  }\n  return out;\n}\n\nconst noise = (t) => hash2(Math.floor(t * 9000), 3, 11) * 2 - 1;\n\n/** A stereo-ish waveform sample at time t: { l, r, mix } in about -1..1. */\nexport function sample(t, s = songAt(t)) {\n  const pb = (t * s.bassFreq) % 1, pl = (t * s.leadFreq) % 1;\n  const bass = (pb < 0.5 ? 1 : -1) * 0.34 * s.bass;\n  const lead = (pl < 0.25 ? 1 : -1) * 0.24 * s.lead;\n  const a = s.kickAge;\n  const kick = Math.sin(2 * Math.PI * (48 * a + 110 * 0.035 * (1 - Math.exp(-a / 0.035)))) * 0.62 * s.kick;\n  const nz = noise(t);\n  const snare = nz * 0.32 * s.snare, hat = nz * 0.14 * s.hat;\n  const l = Math.tanh((bass + kick + snare + lead * 0.6 + hat * 0.4) * 1.2);\n  const r = Math.tanh((bass * 0.6 + kick + snare + lead + hat) * 1.2);\n  return { l, r, mix: (l + r) / 2 };\n}\n",
  "prism-term/src/facets/retro/ansi-landscape.js": "// A moonlit lake painted the way ANSI artists did it: a procedural scene\n// encoded cell by cell into VGA colors and ░▒▓ shading. Moonlit ridges,\n// pine silhouettes, twinkling stars, a shivering reflection, the odd meteor.\nimport { bbsPalette, lerpc, hash2 } from './_kit.js';\nimport { ansify } from './_bbs.js';\n\nexport default {\n  title: 'ANSI Moonlit Lake',\n  gallery: 'retro',\n  category: 'BBS & ANSI Art',\n  kind: 'art',\n  tags: ['ansi', 'landscape', 'moon', 'lake', 'bbs', 'shading', 'night'],\n  order: 40,\n  description: 'A moonlit lake in true ANSI shading — VGA colors and ░▒▓ dithers — with twinkling stars and a rippling reflection.',\n  size: { w: 80, h: 22 },\n  minSize: { w: 30, h: 8 },\n  loop: 10,\n  color: '16',\n  params: { colors: { default: 'vga', options: ['vga', 'theme'], label: 'Colors' } },\n  setup(ctx) {\n    const P = bbsPalette(ctx);\n    const W = ctx.width, H = ctx.height, PH = H * 2;\n    const { fbm } = ctx.noise;\n    const rng = ctx.rng;\n    const img = new Int32Array(W * PH);\n    const isSky = new Uint8Array(W * PH);\n    const shore = Math.floor(PH * 0.6);\n    const moon = { x: W * 0.74, y: PH * 0.16, r: Math.max(2.2, Math.min(W * 0.055, PH * 0.11)) };\n\n    // Far range: one continuous ridge of a few octaves; near hills low and soft.\n    const ph = [rng() * 6.28, rng() * 6.28, rng() * 6.28];\n    const peak = (v) => Math.pow(1 - Math.abs(Math.sin(v)), 1.6);   // ridged: sharp tops, round valleys\n    const ridge = (u) => 0.62 * peak(u * 4.4 + ph[0]) + 0.26 * peak(u * 9.7 + ph[1]) + 0.1 * peak(u * 21 + ph[2]) + 0.03 * fbm(u * 12, 2, 0, 2);\n    const far = new Float32Array(W), near = new Float32Array(W), lit = new Uint8Array(W);\n    for (let x = 0; x < W; x++) {\n      const u = x / W;\n      far[x] = shore - PH * (0.07 + 0.4 * Math.max(0, ridge(u)));\n      near[x] = shore - PH * (0.035 + 0.05 * (0.5 + 0.5 * Math.sin(u * 7.3 + 1.1)) + 0.015 * fbm(u * 6, 5, 0, 2));\n      // Slopes descending toward the moon (on the right) catch the light.\n      lit[x] = ridge(u + 1.5 / W) < ridge(u - 1.5 / W) ? 1 : 0;\n    }\n    const snowLine = shore - PH * 0.36;\n    // Pines along the near hills: narrow triangles of varying height.\n    const pine = new Float32Array(W);\n    for (let x = 0; x < W; x++) {\n      if (hash2(x, 7) > 0.18) continue;\n      const h = PH * (0.05 + hash2(x, 9) * 0.06);\n      for (let dx = -2; dx <= 2; dx++) {\n        const xx = x + dx;\n        if (xx >= 0 && xx < W) pine[xx] = Math.max(pine[xx], h * (1 - Math.abs(dx) / 2.6));\n      }\n    }\n    const stars = Array.from({ length: Math.max(6, Math.round(W * H * 0.035)) }, (_, i) => [Math.floor(hash2(i, 1) * W), Math.floor(hash2(i, 2) * (shore * 0.75)), hash2(i, 3)]);\n    const mid = P[1], horizon = lerpc(P[5], P[13], 0.25);\n    const ridgeBody = lerpc(P[1], P[0], 0.7), ridgeRim = lerpc(P[8], P[7], 0.3);\n\n    function sky(x, y) {\n      const v = y / shore;\n      let c = v < 0.7 ? lerpc(P[0], mid, Math.pow(v / 0.7, 1.8)) : lerpc(mid, horizon, Math.pow((v - 0.7) / 0.3, 1.2));\n      const dx = x + 0.5 - moon.x, dy = y + 0.5 - moon.y, d = Math.sqrt(dx * dx + dy * dy);\n      if (d < moon.r) {\n        c = d < moon.r * 0.82 ? P[15] : P[7];\n        if (hash2(Math.floor(dx * 1.5 + 9), Math.floor(dy * 1.5 + 9), 3) > 0.82 && d < moon.r * 0.7) c = P[7];   // craters\n      } else if (d < moon.r * 2) c = lerpc(c, P[9], (1 - (d - moon.r) / moon.r) * 0.45);\n      return c;\n    }\n    function land(x, y) {\n      if (y >= near[x] - pine[x]) return P[0];\n      if (y >= far[x]) {\n        const depth = y - far[x];\n        if (depth < 1.5 && far[x] < snowLine) return P[7];\n        return lit[x] && depth < 1.2 ? ridgeRim : ridgeBody;\n      }\n      return -1;\n    }\n\n    return (t) => {\n      isSky.fill(0);\n      for (let y = 0; y < PH; y++) for (let x = 0; x < W; x++) {\n        const i = y * W + x;\n        let c;\n        if (y < shore) {\n          c = land(x, y);\n          if (c < 0) { c = sky(x, y); isSky[i] = 1; }\n        } else {\n          // Water: the mirrored scene, displaced by ripples and darkened with depth.\n          const my = 2 * shore - y - 1;\n          const k = (y - shore) / Math.max(1, PH - shore);\n          const ripple = Math.round((Math.sin(y * 1.9 + t * Math.PI) * 0.6 + Math.sin(y * 0.7 - t * Math.PI * 0.6) * 0.5) * k * 2.2);\n          const mx = Math.min(W - 1, Math.max(0, x + ripple));\n          let m = my >= 0 ? land(mx, my) : P[0];\n          if (m < 0) m = sky(mx, Math.max(0, my));\n          c = lerpc(m, P[1], 0.45);\n        }\n        img[i] = c;\n      }\n      ansify(ctx.buffer, img, W, H, 0, 0, P);\n      const b = ctx.buffer;\n      for (const [x, y, k] of stars) {\n        if (!isSky[y * W + x] || !isSky[(y ^ 1) * W + x]) continue;\n        const tw = Math.sin((t * Math.PI * (2 + Math.floor(k * 5))) / 5 + k * 40);\n        if (tw > 0.1) b.set(x, y >> 1, tw > 0.85 ? '*' : k > 0.55 ? '+' : '.', tw > 0.85 ? P[15] : k > 0.3 ? P[7] : P[8]);\n      }\n      // Glitter on the water under the moon.\n      const gx = Math.round(moon.x);\n      for (let row = Math.ceil(shore / 2); row < H; row++) {\n        const spread = 1 + (row - shore / 2) * 0.6;\n        for (let j = 0; j < 3; j++) {\n          const x = Math.round(gx + (hash2(row, j + Math.floor(t * 4) * 3, 5) - 0.5) * 2 * spread);\n          if (x >= 0 && x < W && hash2(row, j, Math.floor(t * 6)) > 0.45) b.set(x, row, hash2(x, row) > 0.5 ? '~' : '-', j ? P[11] : P[15]);\n        }\n      }\n      // A meteor every five seconds.\n      const mt = t % 5;\n      if (mt < 0.8) {\n        const k = Math.floor(t / 5);\n        const sx = hash2(k, 11) * W * 0.5 + W * 0.05, sy = hash2(k, 12) * shore * 0.3;\n        for (let i = 0; i < 7; i++) {\n          const px = Math.round(sx + mt * W * 0.45 - i * 1.6), py = Math.round((sy + mt * shore * 0.5 - i * 0.7) / 2);\n          if (py >= 0 && py < H && px >= 0 && px < W && isSky[py * 2 * W + px]) b.set(px, py, i === 0 ? '*' : i < 3 ? '-' : '·', i < 2 ? P[15] : i < 4 ? P[11] : P[3]);\n        }\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/retro/ansi-logo.js": "// An ANSI group logo in the 1994 style: bevelled block letters encoded as real\n// ANSI art (VGA colors, ░▒▓ shading, half blocks), a light sweep gliding\n// across them, sparkles, and the obligatory scene tagline and credits.\nimport { textMask, maskAt, bbsPalette, lerpc, hash2 } from './_kit.js';\nimport { ansify } from './_bbs.js';\n\nexport default {\n  title: 'ANSI Group Logo',\n  gallery: 'retro',\n  category: 'BBS & ANSI Art',\n  kind: 'art',\n  tags: ['ansi', 'logo', 'bbs', 'acid', 'ice', 'scene', 'shading'],\n  order: 30,\n  description: 'A 1994-style ANSI group logo: bevelled block letters in ░▒▓ shading with a light sweep and sparkles.',\n  size: { w: 78, h: 16 },\n  minSize: { w: 36, h: 8 },\n  loop: 8,\n  color: '16',\n  params: {\n    text: { default: 'PRISM', label: 'Logo' },\n    colors: { default: 'vga', options: ['vga', 'theme'], label: 'Colors' },\n  },\n  setup(ctx) {\n    const P = bbsPalette(ctx);\n    const b = ctx.buffer;\n    const W = ctx.width, H = ctx.height;\n    const mask = textMask(String(ctx.param('text', 'PRISM')).toUpperCase(), { font: 'bold' });\n    const rows = Math.max(4, H - 5);\n    // One font pixel = L cells wide × L half-rows tall, i.e. visually square.\n    const L = Math.max(1, Math.min(Math.floor((W - 2) / mask.w), Math.floor((rows * 2) / mask.h)));\n    const sx = L, sy = L;\n    const lw = Math.min(W, mask.w * sx), lh = Math.ceil((mask.h * sy) / 2);\n    const lx = Math.floor((W - lw) / 2), ly = Math.max(0, Math.floor((H - lh - 3) / 2));\n    const img = new Int32Array(lw * lh * 2);\n    const inside = (x, y) => maskAt(mask, x, y, sx, sy);\n    const base = [P[1], P[9], P[11], P[15]];\n    return (t) => {\n      b.fill(0, 0, W, H, ' ', P[7], P[0]);\n      const sweep = ((t * 0.28) % 1.5) - 0.25;\n      for (let y = 0; y < lh * 2; y++) for (let x = 0; x < lw; x++) {\n        let c = P[0];\n        if (inside(x, y)) {\n          const v = y / (lh * 2);\n          c = lerpc(base[Math.min(3, Math.floor((1 - v) * 3.99))], base[Math.min(3, Math.floor((1 - v) * 3.99) + 1)] ?? base[3], ((1 - v) * 3.99) % 1);\n          if (!inside(x - 1, y - 1)) c = lerpc(c, P[15], 0.55);           // bevel highlight\n          if (!inside(x + 1, y + 1)) c = lerpc(c, P[0], 0.5);            // bevel shadow\n          const d = Math.abs(x / lw - sweep - (y / (lh * 2)) * 0.2);\n          if (d < 0.07) c = lerpc(c, P[15], (1 - d / 0.07) * 0.8);\n        } else if (inside(x - Math.max(1, sx >> 1), y - Math.max(1, sy >> 1))) c = P[8];   // drop shadow\n        img[y * lw + x] = c;\n      }\n      ansify(b, img, lw, lh, lx, ly, P);\n      // Sparkles that bloom and fade around the letters.\n      for (let i = 0; i < 8; i++) {\n        const k = Math.floor(t * 1.3) + i * 7;\n        const x = lx + Math.floor(hash2(k, 1) * lw), y = ly + Math.floor(hash2(k, 2) * lh);\n        const life = (t * 1.3) % 1;\n        b.set(x, y, life < 0.3 ? '·' : life < 0.6 ? '+' : '*', life < 0.6 ? P[15] : P[11]);\n      }\n      const tag = '-[ PRiSM ANSi & ASCii DiViSiON ]-';\n      const credits = 'ans by ckr/prism · 10/94 · 80x25 · iCE-free';\n      if (ly + lh + 1 < H) b.text(Math.max(0, Math.floor((W - tag.length) / 2)), ly + lh + 1, tag.slice(0, W), P[13]);\n      if (ly + lh + 2 < H) b.text(Math.max(0, Math.floor((W - credits.length) / 2)), ly + lh + 2, credits.slice(0, W), P[8]);\n    };\n  },\n};\n",
  "prism-term/src/facets/retro/bbs-login.js": "// A BBS login screen, 1993: block-letter board logo, caller stats in a double\n// frame, and a login that types itself — handle, password, credential check,\n// welcome — then hangs up and does it again. VGA colors (or --set colors=theme).\nimport { textMask, bbsPalette } from './_kit.js';\nimport { blockLogo, frame } from './_bbs.js';\n\nconst HANDLE = 'LIGHTMAN';\nconst SPIN = '|/-\\\\';\n\nexport default {\n  title: 'BBS Login',\n  gallery: 'retro',\n  category: 'BBS & ANSI Art',\n  kind: 'art',\n  tags: ['bbs', 'ansi', 'login', 'dial-up', '1993', 'sysop'],\n  order: 10,\n  description: 'A 1993 BBS login: block-letter board logo, caller stats in a double frame and a login that types itself.',\n  size: { w: 80, h: 24 },\n  minSize: { w: 40, h: 12 },\n  loop: 12,\n  color: '16',\n  params: { colors: { default: 'vga', options: ['vga', 'theme'], label: 'Colors' } },\n  setup(ctx) {\n    const P = bbsPalette(ctx);\n    const b = ctx.buffer;\n    const W = ctx.width, H = ctx.height;\n    const logo = textMask('PRISM', { font: 'bold' });\n    const sx = logo.w * 2 + 2 <= W ? 2 : 1;\n    const showLogo = H >= 18 && logo.w * sx + 2 <= W;\n    const ramp = (v) => (v < 0.2 ? P[15] : v < 0.45 ? P[11] : v < 0.75 ? P[3] : P[9]);\n    return (t) => {\n      const lt = t % 12;\n      b.fill(0, 0, W, H, ' ', P[7], P[0]);\n      // Status bar.\n      const mins = 41 + Math.floor(t / 60);\n      const clock = `${String((23 + Math.floor(mins / 60)) % 24).padStart(2, '0')}:${String(mins % 60).padStart(2, '0')}`;\n      b.fill(0, 0, W, 1, ' ', P[15], P[1]);\n      b.text(1, 0, (W >= 56 ? ' PRISM BBS · Node 3 of 8 · 2400 bps · ANSI ' : ' PRISM BBS · Node 3 · 2400 bps ').slice(0, W - 2), P[15], P[1]);\n      if (W > 56) b.text(W - 7, 0, clock, P[14], P[1]);\n      let y = 2;\n      if (showLogo) {\n        const lx = Math.floor((W - logo.w * sx) / 2);\n        blockLogo(b, lx, y, logo, { sx, ramp, shadow: P[8] });\n        y += 5;\n        const tag = 'THE TERMINAL ART UNDERGROUND · EST. 1991';\n        if (tag.length < W) b.text(Math.floor((W - tag.length) / 2), y, tag, P[6]);\n        y += 2;\n      } else {\n        b.text(2, y, 'P R I S M   B B S', P[11]);\n        y += 2;\n      }\n      // Caller stats.\n      const fw = Math.min(W - 4, 46), fx = Math.floor((W - fw) / 2);\n      const stats = [['Last caller', 'CRASH OVERRIDE'], ['Calls today', '42'], ['Your node', '3 of 8'], ['Software', 'PRISM/BBS 0.42']];\n      const rows = H - y >= 13 ? stats : stats.slice(0, Math.max(0, H - y - 8));\n      if (rows.length) {\n        frame(b, fx, y, fw, rows.length + 2, P[1], 'SYSTEM', P[14]);\n        rows.forEach(([k, v], i) => {\n          const dots = '.'.repeat(Math.max(2, 16 - k.length));\n          b.text(fx + 2, y + 1 + i, `${k} ${dots} `, P[7]);\n          b.text(fx + 2 + k.length + dots.length + 2, y + 1 + i, v.slice(0, fw - k.length - dots.length - 6), P[15]);\n        });\n        y += rows.length + 3;\n      }\n      // The login, scripted on a 12-second loop.\n      const px = Math.max(1, fx);\n      const blink = Math.floor(t * 2.5) % 2 === 0;\n      const typed = (from, text, cps) => text.slice(0, Math.max(0, Math.min(text.length, Math.floor((lt - from) * cps))));\n      const line = (text, color, cursor) => {\n        if (y >= H - 1) return;\n        const end = b.text(px, y, text, color);\n        if (cursor && blink) b.set(end, y, '█', P[7]);\n        y++;\n      };\n      const handle = lt >= 1.4 ? typed(1.4, HANDLE, 6) : '';\n      const ask = W - px >= 46 ? 'Enter your handle (NEW to apply): ' : 'Handle: ';\n      line(ask, P[11], false);\n      if (y - 1 < H - 1) {\n        const end = b.text(px + ask.length, y - 1, handle, P[15]);\n        if (lt < 3.3 && blink) b.set(end, y - 1, '█', P[7]);\n      }\n      if (lt >= 3.3) {\n        const stars = '*'.repeat(typed(3.8, 'xxxxxxxx', 7).length);\n        line(`Password: ${stars}`, P[11], lt < 5.2);\n      }\n      if (lt >= 5.2 && lt < 6.8) line(`Checking credentials... ${SPIN[Math.floor(t * 8) % 4]}`, P[7], false);\n      if (lt >= 6.8) {\n        line(`Welcome back, ${HANDLE}!`, P[10], false);\n        if (lt >= 7.4) line(W - px > 62 ? 'You have 3 new messages and 12 new files since your last call.' : '3 new messages, 12 new files.', P[7], lt > 10.8);\n      }\n      // Footer.\n      if (H > 12) {\n        const foot = '[ESC] hang up   [?] help   SysOp is AVAILABLE';\n        b.text(Math.max(0, Math.floor((W - foot.length) / 2)), H - 1, foot.slice(0, W), P[8]);\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/retro/bbs-menu.js": "// BBS main menu: a shaded header, two columns of hotkey commands with a\n// lightbar walking through them, a who's-online panel and the classic\n// time-left status line.\nimport { bbsPalette } from './_kit.js';\nimport { frame } from './_bbs.js';\n\nconst ITEMS = [\n  ['M', 'Message Bases'], ['F', 'File Areas'], ['D', 'Door Games'], ['C', 'Chat with SysOp'],\n  ['B', 'Bulletins'], ['W', \"Who's Online\"], ['N', 'New Files Scan'], ['U', 'User Settings'],\n  ['L', 'Last Callers'], ['G', 'Goodbye (Log Off)'],\n];\nconst ONLINE = [['1', 'ZERO COOL', 'Playing L.O.R.D.'], ['2', 'ACID BURN', 'Reading msgs'], ['3', 'LIGHTMAN', 'Main menu'], ['4', 'THE PHREAK', 'Downloading']];\n\nexport default {\n  title: 'BBS Main Menu',\n  gallery: 'retro',\n  category: 'BBS & ANSI Art',\n  kind: 'art',\n  tags: ['bbs', 'ansi', 'menu', 'lightbar', 'hotkeys', 'sysop'],\n  order: 20,\n  description: 'A BBS main menu: shaded header, hotkey commands with a walking lightbar, who-is-online and time left.',\n  size: { w: 80, h: 22 },\n  minSize: { w: 40, h: 12 },\n  loop: 10,\n  color: '16',\n  params: { colors: { default: 'vga', options: ['vga', 'theme'], label: 'Colors' } },\n  setup(ctx) {\n    const P = bbsPalette(ctx);\n    const b = ctx.buffer;\n    const W = ctx.width, H = ctx.height;\n    const twoCols = W >= 60;\n    const panel = W >= 72 && H >= 16;\n    return (t) => {\n      b.fill(0, 0, W, H, ' ', P[7], P[0]);\n      // Header: ░▒▓█ MAIN MENU █▓▒░ across the top.\n      const title = ' MAIN MENU ';\n      const side = Math.max(0, Math.floor((W - title.length) / 2));\n      const grad = '░▒▓█';\n      for (let x = 0; x < W; x++) {\n        const d = x < side ? side - x : x - side - title.length;\n        if (x >= side && x < side + title.length) continue;\n        b.set(x, 0, grad[Math.max(0, 3 - Math.min(3, Math.floor(d / Math.max(1, side / 4))))], x % 2 ? P[1] : P[9]);\n      }\n      b.text(side, 0, title, P[15], P[1]);\n      b.text(Math.max(0, Math.floor((W - 37) / 2)), 1, 'PRISM BBS · terminal art since 1991'.slice(0, W), P[8]);\n      // Menu.\n      const menuW = panel ? Math.min(52, W - 28) : W - 2;\n      const colW = twoCols ? Math.floor((menuW - 4) / 2) : menuW - 4;\n      const perCol = twoCols ? Math.ceil(ITEMS.length / 2) : ITEMS.length;\n      const visible = Math.min(perCol, Math.max(1, H - 7));\n      frame(b, 1, 3, menuW, visible + 2, P[1], 'COMMANDS', P[14]);\n      const sel = Math.floor(t / 0.8) % ITEMS.length;\n      ITEMS.forEach(([key, label], i) => {\n        const col = twoCols ? Math.floor(i / perCol) : 0, row = twoCols ? i % perCol : i;\n        if (row >= visible) return;\n        const x = 3 + col * (colW + 1), y = 4 + row;\n        const on = i === sel;\n        if (on) b.fill(x - 1, y, colW, 1, ' ', P[0], P[3]);\n        b.text(x, y, '[', on ? P[0] : P[1], on ? P[3] : undefined);\n        b.text(x + 1, y, key, on ? P[0] : P[14], on ? P[3] : undefined);\n        b.text(x + 2, y, ']', on ? P[0] : P[1], on ? P[3] : undefined);\n        b.text(x + 4, y, label.slice(0, colW - 6), on ? P[0] : P[7], on ? P[3] : undefined);\n      });\n      // Who's online.\n      if (panel) {\n        const px = menuW + 3, pw = W - px - 1;\n        frame(b, px, 3, pw, ONLINE.length * 2 + 2, P[5], 'ONLINE', P[13]);\n        ONLINE.forEach(([n, who, what], i) => {\n          const y = 4 + i * 2;\n          const live = Math.floor(t * 1.5 + i) % 5 !== 0;\n          b.text(px + 2, y, `${n}`, P[8]);\n          b.text(px + 4, y, who.slice(0, pw - 6), i === 2 ? P[11] : P[15]);\n          b.text(px + 4, y + 1, what.slice(0, pw - 6), live ? P[7] : P[8]);\n        });\n      }\n      // Prompt + status line.\n      const py = Math.min(H - 3, 4 + visible + 2);\n      const blink = Math.floor(t * 2.5) % 2 === 0;\n      const end = b.text(2, py, 'Command ', P[11]);\n      const end2 = b.text(end, py, '» ', P[14]);\n      b.text(end2, py, ITEMS[sel][0], P[15]);\n      if (blink) b.set(end2 + 1, py, '█', P[7]);\n      const left = 58 - Math.floor(t / 60);\n      b.fill(0, H - 1, W, 1, ' ', P[0], P[7]);\n      b.text(1, H - 1, `Time left: ${left} min  ·  Node 3  ·  3 new msgs  ·  ↑↓ select  Enter`.slice(0, W - 2), P[0], P[7]);\n    };\n  },\n};\n",
  "prism-term/src/facets/retro/boing-ball.js": "// Homage to the 1984 Amiga Boing demo: a red-and-white checkered sphere spins\n// and bounces across a purple grid room, casting a soft shadow on the wall.\nimport { lerpc } from './_kit.js';\n\nexport default {\n  title: 'Boing Ball',\n  gallery: 'retro',\n  category: 'Intros & Scrollers',\n  kind: 'scene',\n  tags: ['boing', 'amiga', 'ball', 'checker', '1984'],\n  order: 30,\n  description: 'The 1984 Amiga Boing Ball: a spinning checkered sphere bouncing across a grid room with its shadow.',\n  size: { w: 64, h: 24 },\n  minSize: { w: 20, h: 8 },\n  loop: 6,\n  canvas: 'hires',\n  palette: 'prism',\n  params: { speed: { default: 1, min: 0.25, max: 4, label: 'Speed' } },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const speed = ctx.param('speed', 1);\n    const { width: W, height: H, px, aspect } = cv;\n    const th = ctx.theme;\n    const wall = lerpc(th.bg, 0xb0b0b0, 0.12), line = lerpc(th.bg, th.crit, 0.75);\n    const red = 0xe0262b, white = 0xf2f2f2;\n    const VH = H * aspect;                       // height in horizontal-pixel units\n    const R = Math.min(W * 0.17, VH * 0.24);\n    const floor = VH * 0.9;\n    const gridX = Math.max(4, Math.round(W / 14)), gridY = Math.max(3, Math.round(H / 9));\n    return (t) => {\n      const tt = t * speed;\n      // Room: wall grid + floor grid in perspective.\n      const floorRow = Math.floor(floor / aspect);\n      for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {\n        let c = wall;\n        if (y < floorRow) { if (x % gridX === 0 || y % gridY === 0) c = line; }\n        else {\n          const k = (y - floorRow + 1) / (H - floorRow + 1);\n          const fx = (x - W / 2) / (0.6 + k * 1.4) + W / 2;\n          if (Math.abs(fx % gridX) < 0.6 || (y - floorRow) % Math.max(1, Math.round(gridY * 0.5 * k + 1)) === 0) c = line;\n        }\n        px[y * W + x] = c;\n      }\n      // Bounce: horizontal ping-pong, vertical |sin|.\n      const span = W - 2 * R - 2;\n      const ph = (tt * 0.22) % 2;\n      const bx = R + 1 + (ph < 1 ? ph : 2 - ph) * span;\n      const by = floor - R - Math.abs(Math.sin(tt * 2.4)) * (floor - 2 * R - 2);\n      const dir = ph < 1 ? 1 : -1;\n      // Shadow on the wall (offset right) — drawn first.\n      const sx0 = bx + R * 0.45, sy0 = by + R * 0.18;\n      for (let y = Math.floor((sy0 - R) / aspect); y <= Math.ceil((sy0 + R) / aspect); y++) {\n        for (let x = Math.floor(sx0 - R); x <= Math.ceil(sx0 + R); x++) {\n          const dx = x + 0.5 - sx0, dy = (y + 0.5) * aspect - sy0;\n          if (dx * dx + dy * dy < R * R && x >= 0 && x < W && y >= 0 && y < H) px[y * W + x] = lerpc(px[y * W + x], 0x000000, 0.45);\n        }\n      }\n      // The ball: checker in (longitude, latitude), tilted, spinning.\n      const spin = tt * 3 * dir, tilt = 0.35;\n      for (let y = Math.floor((by - R) / aspect); y <= Math.ceil((by + R) / aspect); y++) {\n        for (let x = Math.floor(bx - R); x <= Math.ceil(bx + R); x++) {\n          if (x < 0 || x >= W || y < 0 || y >= H) continue;\n          const nx = (x + 0.5 - bx) / R, ny = ((y + 0.5) * aspect - by) / R;\n          const r2 = nx * nx + ny * ny;\n          if (r2 > 1) continue;\n          const nz = Math.sqrt(1 - r2);\n          // Tilt around z then read longitude/latitude.\n          const tx = nx * Math.cos(tilt) - ny * Math.sin(tilt), ty = nx * Math.sin(tilt) + ny * Math.cos(tilt);\n          const lon = Math.atan2(tx, nz) + spin, lat = Math.asin(Math.max(-1, Math.min(1, ty)));\n          const check = ((Math.floor(lon / (Math.PI / 8)) + Math.floor(lat / (Math.PI / 8))) & 1) === 0;\n          const light = 0.45 + 0.55 * Math.max(0, nz * 0.8 - nx * 0.25 - ny * 0.35);\n          px[y * W + x] = lerpc(0x000000, check ? red : white, Math.min(1, light + 0.15));\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/retro/checker-floor.js": "// The infinite checkerboard floor: a perspective-mapped checker plane racing\n// toward the horizon and slowly banking, under a raster-gradient sunset sky.\n// Distance fog keeps the far squares from shimmering.\nimport { lerpc } from './_kit.js';\n\nexport default {\n  title: 'Checker Floor',\n  gallery: 'retro',\n  category: 'Raster & Copper',\n  kind: 'scene',\n  tags: ['checkerboard', 'floor', 'perspective', 'amiga', 'mode 7'],\n  order: 40,\n  description: 'A perspective checkerboard plane racing to the horizon and banking, under a raster-gradient sky.',\n  size: { w: 80, h: 24 },\n  minSize: { w: 20, h: 8 },\n  loop: 12,\n  canvas: 'field',\n  palette: 'sunset',\n  params: { speed: { default: 1, min: 0.25, max: 4, label: 'Speed' } },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette();\n    const speed = ctx.param('speed', 1);\n    const { width: W, height: H, px, aspect } = cv;\n    const hz = Math.floor(H * 0.45);\n    const a = ctx.theme.accent, b = lerpc(ctx.theme.bg, ctx.theme.ink, 0.9);\n    return (t) => {\n      const tt = t * speed;\n      const bank = Math.sin(tt * 0.35) * 0.18;\n      const cs = Math.cos(bank), sn = Math.sin(bank);\n      const mid = lerpc(a, b, 0.5), haze = pal(0.66);\n      for (let y = 0; y < H; y++) {\n        if (y < hz) {\n          px.fill(pal(0.08 + (y / hz) * 0.62), y * W, (y + 1) * W);\n          continue;\n        }\n        const dy = (y - hz + 0.5) * aspect;\n        const z = (H * aspect * 0.55) / dy;      // depth of this row\n        // Far squares shrink below a pixel: fade their contrast first, then haze to the horizon.\n        const contrast = 1 - Math.min(1, Math.pow(z / 7, 1.3));\n        const hazeK = Math.min(1, Math.pow(z / 14, 1.2));\n        for (let x = 0; x < W; x++) {\n          const sx = (x - W / 2) / (W / 2);\n          let wx = sx * z * 1.8, wz = z * 1.2 + tt * 4;\n          [wx, wz] = [wx * cs - wz * sn, wx * sn + wz * cs];\n          const check = ((Math.floor(wx) + Math.floor(wz)) & 1) === 0;\n          px[y * W + x] = lerpc(lerpc(mid, check ? a : b, contrast), haze, hazeK);\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/retro/copper-bars.js": "// Amiga copper bars: glossy horizontal bars swing past each other in depth,\n// each a vertical gradient with a white-hot center line — the copper chip's\n// party trick, now in a terminal.\nimport { lerpc, shade, ground } from './_kit.js';\n\nexport default {\n  title: 'Copper Bars',\n  gallery: 'retro',\n  category: 'Raster & Copper',\n  kind: 'scene',\n  tags: ['copper', 'raster bars', 'amiga', 'demoscene', 'gradient'],\n  order: 10,\n  description: 'Glossy raster bars swinging through each other in depth, each a gradient with a white-hot core.',\n  size: { w: 80, h: 20 },\n  minSize: { w: 20, h: 6 },\n  loop: 10,\n  canvas: 'field',\n  palette: 'prism',\n  params: {\n    bars: { default: 7, min: 2, max: 14, label: 'Bars' },\n    speed: { default: 1, min: 0.25, max: 4, label: 'Speed' },\n  },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette();\n    const n = Math.round(ctx.param('bars', 7));\n    const speed = ctx.param('speed', 1);\n    const { width: W, height: H, px } = cv;\n    const r = Math.max(2, H / 14);\n    const bars = Array.from({ length: n }, (_, i) => ({ phase: (i / n) * Math.PI * 2, color: pal(i / n) }));\n    const bg = ctx.theme.bg, base = ground(ctx.theme);\n    return (t) => {\n      const tt = t * speed;\n      for (let y = 0; y < H; y++) px.fill(shade(ctx.theme, 0.35 * Math.abs(y / H - 0.5) * 2), y * W, (y + 1) * W);\n      const live = bars.map((b) => {\n        const a = tt * 1.4 + b.phase;\n        return { y: H / 2 + Math.sin(a) * (H / 2 - r - 1) * 0.92, z: Math.cos(a), color: b.color };\n      }).sort((a, b) => a.z - b.z);\n      for (const b of live) {\n        const rr = r * (0.75 + 0.35 * (b.z + 1) / 2);\n        const depth = 0.55 + 0.45 * (b.z + 1) / 2;\n        for (let y = Math.max(0, Math.floor(b.y - rr)); y <= Math.min(H - 1, Math.ceil(b.y + rr)); y++) {\n          const d = Math.abs(y + 0.5 - b.y) / rr;\n          if (d > 1) continue;\n          const k = 1 - d;\n          let c = lerpc(base, b.color, Math.min(1, k * 1.6) * depth);\n          if (k > 0.72) c = lerpc(c, 0xffffff, (k - 0.72) / 0.28 * 0.85 * depth);\n          px.fill(c, y * W, (y + 1) * W);\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/retro/cracktro.js": "// A complete cracktro screen: chrome group logo with a shine sweep, raster bars\n// pumping in the middle, a greetings sine scroller along the bottom, and a\n// parallax starfield behind it all. Everything a 1989 floppy needed.\nimport { textMask, maskAt, fontPx, makeStars, lerpc, dim } from './_kit.js';\n\nconst GREETS = 'CRACKED AND TRAINED BY THE PRISM CREW   *   GREETINGS TO ALL TERMINAL SCENERS   *   CALL OUR BOARD AT 300-2400 BAUD   *   ';\n\nexport default {\n  title: 'Cracktro',\n  gallery: 'retro',\n  category: 'Intros & Scrollers',\n  kind: 'scene',\n  tags: ['cracktro', 'intro', 'logo', 'scroller', 'raster bars', 'demoscene'],\n  order: 5,\n  description: 'Full cracktro: chrome logo with shine sweep, pumping raster bars, greetings scroller and starfield.',\n  size: { w: 80, h: 24 },\n  minSize: { w: 36, h: 12 },\n  loop: 14,\n  canvas: 'field',\n  palette: 'prism',\n  params: {\n    logo: { default: 'PRISM', label: 'Logo' },\n    text: { default: GREETS, label: 'Scroller' },\n    speed: { default: 1, min: 0.25, max: 4, label: 'Speed' },\n  },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette();\n    const speed = ctx.param('speed', 1);\n    const { width: W, height: H, px, aspect } = cv;\n    const logo = textMask(String(ctx.param('logo', 'PRISM')).toUpperCase(), { font: 'bold' });\n    const greets = textMask(String(ctx.param('text', GREETS)).toUpperCase(), { font: 'standard' });\n    // Logo: as large as fits in the top ~40%.\n    let L = fontPx(cv, Math.max(3, ctx.height * 0.3), 7);\n    if (logo.w * L.sx > W * 0.92) {\n      const sx = Math.max(1, Math.floor((W * 0.92) / logo.w));\n      L = { sx, sy: Math.max(1, Math.round((L.sy * sx) / L.sx)) };\n    }\n    const lw = logo.w * L.sx, lh = logo.h * L.sy;\n    const lx = Math.round((W - lw) / 2), ly = Math.max(1, Math.round(H * 0.06));\n    const G = fontPx(cv, Math.max(1.5, ctx.height * 0.1), 7);\n    const gh = greets.h * G.sy, gw = greets.w * G.sx;\n    const stars = makeStars(ctx.rng, W, H, Math.round(W * H * 0.015));\n    const bg = ctx.theme.bg;\n    const chrome = [0x2a3446, 0x8fa3c4, 0xf4f7ff, 0x6c7f9f, 0x1b2230, 0x9fb4d6, 0xe8eef9];\n    const metal = (v) => {\n      const i = Math.min(chrome.length - 2, Math.floor(v * (chrome.length - 1)));\n      return lerpc(chrome[i], chrome[i + 1], v * (chrome.length - 1) - i);\n    };\n    const barsTop = ly + lh + Math.round(H * 0.05), barsBot = H - gh - Math.round(H * 0.12);\n    return (t) => {\n      const tt = t * speed;\n      px.fill(bg);\n      for (const s of stars) {\n        const x = ((s[0] - tt * (4 + 30 * s[2] * s[2])) % W + W) % W;\n        cv.set(x, s[1], lerpc(bg, 0xffffff, 0.2 + 0.7 * s[2]));\n      }\n      // Raster bars between logo and scroller.\n      if (barsBot > barsTop + 2) {\n        const mid = (barsTop + barsBot) / 2, span = (barsBot - barsTop) / 2;\n        for (let i = 0; i < 4; i++) {\n          const y0 = mid + Math.sin(tt * 2.2 + i * 0.9) * span * 0.75;\n          const r = Math.max(1.5, span * 0.28);\n          const c = pal(i / 4 + tt * 0.05);\n          for (let y = Math.floor(y0 - r); y <= Math.ceil(y0 + r); y++) {\n            if (y < barsTop || y >= barsBot) continue;\n            const k = 1 - Math.abs(y + 0.5 - y0) / r;\n            if (k <= 0) continue;\n            const col = k > 0.75 ? lerpc(c, 0xffffff, (k - 0.75) * 3) : lerpc(0x000000, c, k * 1.4);\n            px.fill(col, y * W, (y + 1) * W);\n          }\n        }\n      }\n      // Logo with a diagonal shine sweep.\n      const sweep = ((tt * 0.35) % 1.6) - 0.3;\n      for (let y = 0; y < lh; y++) for (let x = 0; x < lw; x++) {\n        if (!maskAt(logo, x, y, L.sx, L.sy)) continue;\n        const v = y / lh;\n        let c = metal(v);\n        const d = Math.abs((x / lw) - v * 0.25 * aspect - sweep);\n        if (d < 0.06) c = lerpc(c, 0xffffff, 1 - d / 0.06);\n        cv.set(lx + x + 1, ly + y + 1, dim(bg, 0.3));\n        cv.set(lx + x, ly + y, c);\n      }\n      // Greetings sine scroller.\n      const period = gw + W;\n      const scroll = tt * 30 * G.sx;\n      const gy = H - gh - Math.max(1, Math.round(H * 0.06));\n      for (let x = 0; x < W; x++) {\n        const u = ((x - W + scroll) % period + period) % period;\n        if (u >= gw) continue;\n        const yOff = Math.round(gy + Math.sin(u * 0.09 / G.sx + tt * 3) * Math.max(1, H * 0.035));\n        for (let k = 0; k < gh; k++) {\n          if (!maskAt(greets, u, k, G.sx, G.sy)) continue;\n          cv.set(x, yOff + k, pal((x / W) * 0.6 + tt * 0.2));\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/retro/dot-tunnel.js": "// Dot tunnel: rings of dots flying at the camera along a snaking path, each\n// ring twisted a little further than the last. Near dots are big and bright,\n// far ones fade into the dark — braille makes the dots razor sharp.\nimport { lerpc } from './_kit.js';\n\nexport default {\n  title: 'Dot Tunnel',\n  gallery: 'retro',\n  category: 'Intros & Scrollers',\n  kind: 'scene',\n  tags: ['tunnel', 'dots', 'demoscene', '3d', 'braille'],\n  order: 50,\n  description: 'Rings of dots racing at the camera along a snaking path, twisting ring by ring, fading with depth.',\n  size: { w: 70, h: 24 },\n  minSize: { w: 20, h: 8 },\n  loop: 10,\n  canvas: 'line',\n  palette: 'prism',\n  params: { speed: { default: 1, min: 0.25, max: 4, label: 'Speed' } },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette();\n    const speed = ctx.param('speed', 1);\n    const { width: W, height: H, aspect } = cv;\n    const VH = H * aspect;\n    const RINGS = 30, DOTS = 28, DEPTH = 18;\n    const f = Math.min(W, VH) * 0.62;\n    const bg = ctx.theme.bg;\n    const path = (z, t) => [Math.sin(z * 0.23 + t * 0.9) * 1.1, Math.cos(z * 0.17 + t * 0.7) * 0.8];\n    return (t) => {\n      const tt = t * speed;\n      cv.clear();\n      const travel = tt * 5;\n      const [camX, camY] = path(travel, tt);\n      for (let r = RINGS - 1; r >= 0; r--) {\n        const zWorld = Math.floor(travel) + r + 1;\n        const z = zWorld - travel;                 // distance from camera\n        if (z <= 0.15) continue;\n        const [ox, oy] = path(zWorld, tt);\n        const fade = Math.pow(1 - Math.min(1, z / DEPTH), 0.55);\n        const c = lerpc(bg, lerpc(pal((zWorld * 0.045) % 1), 0xffffff, 0.15 * fade), 0.2 + 0.8 * fade);\n        const twist = zWorld * 0.35 + tt * 0.8;\n        for (let d = 0; d < DOTS; d++) {\n          const a = (d / DOTS) * Math.PI * 2 + twist;\n          const x = W / 2 + ((ox - camX + Math.cos(a) * 1.6) * f) / z;\n          const y = (VH / 2 + ((oy - camY + Math.sin(a) * 1.6) * f) / z) / aspect;\n          cv.set(x, y, c);\n          if (z < 2.2) { cv.set(x + 1, y, c); cv.set(x, y + 1, c); cv.set(x + 1, y + 1, c); }\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/retro/dycp-scroller.js": "// DYCP — \"Different Y Character Position\", the C64 scroller where every\n// letter bobs on its own sine phase. This one bounces off a mirrored water line\n// that ripples its reflection.\nimport { textMask, maskAt, fontPx, lerpc, dim, shade } from './_kit.js';\n\nconst MESSAGE = 'DIFFERENT Y CHARACTER POSITION   ';\n\nexport default {\n  title: 'DYCP Scroller',\n  gallery: 'retro',\n  category: 'Intros & Scrollers',\n  kind: 'scene',\n  tags: ['dycp', 'scroller', 'c64', 'reflection', 'demoscene'],\n  order: 20,\n  description: 'C64-style DYCP scroller: every letter bobs on its own sine, mirrored in a rippling water line.',\n  size: { w: 80, h: 20 },\n  minSize: { w: 30, h: 8 },\n  loop: 10,\n  canvas: 'field',\n  palette: 'candy',\n  params: {\n    text: { default: MESSAGE, label: 'Message' },\n    speed: { default: 1, min: 0.25, max: 4, label: 'Speed' },\n  },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette();\n    const speed = ctx.param('speed', 1);\n    const text = String(ctx.param('text', MESSAGE)).toUpperCase();\n    const { width: W, height: H, px } = cv;\n    const water = Math.round(H * 0.68);\n    const S = fontPx(cv, Math.max(2, ctx.height * 0.22), 7);\n    const chars = [...text].map((ch) => textMask(ch === ' ' ? ' ' : ch, { font: 'bold' }));\n    const adv = chars.map((m) => (m.w + 1) * S.sx);\n    const total = adv.reduce((a, b) => a + b, 0);\n    const ch = 7 * S.sy;\n    const amp = Math.max(1, (water - ch) * 0.42);\n    const bg = ctx.theme.bg;\n    const day = ctx.theme.mode === 'light';\n    const sky0 = shade(ctx.theme, 0.25), sky1 = lerpc(bg, pal(0.3), 0.18);\n    const sea = day ? lerpc(bg, pal(0.55), 0.3) : dim(pal(0.55), 0.22);\n    return (t) => {\n      const tt = t * speed;\n      for (let y = 0; y < H; y++) {\n        const c = y < water ? lerpc(sky0, sky1, y / water) : lerpc(sea, bg, (y - water) / (H - water));\n        px.fill(c, y * W, (y + 1) * W);\n      }\n      let x0 = W - ((tt * 24 * S.sx) % (total + W));\n      for (let i = 0; i < chars.length * 2; i++) {\n        const k = i % chars.length;\n        const m = chars[k];\n        const gx = Math.round(x0);\n        x0 += adv[k];\n        if (gx > W || gx + adv[k] < 0 || text[k] === ' ') continue;\n        const gy = Math.round(water - ch - 1 - Math.abs(Math.sin(tt * 2.6 + i * 0.55)) * amp);\n        const color = pal((k / chars.length + tt * 0.1) % 1);\n        for (let y = 0; y < ch; y++) for (let x = 0; x < m.w * S.sx; x++) {\n          if (!maskAt(m, x, y, S.sx, S.sy)) continue;\n          // The top row catches the light: white at Night; by Day it's the color itself over a deeper body.\n          const c = day ? (y < S.sy ? color : lerpc(color, ctx.theme.ink, 0.2)) : y < S.sy ? lerpc(color, 0xffffff, 0.5) : color;\n          cv.set(gx + x, gy + y, c);\n          // Reflection below the water line, rippled and darker.\n          const ry = water + (water - (gy + y)) - 1;\n          if (ry < H) cv.set(gx + x + Math.round(Math.sin(ry * 0.9 + tt * 4) * 1.2), ry, lerpc(c, bg, 0.6));\n        }\n      }\n      for (let x = 0; x < W; x++) px[water * W + x] = lerpc(pal(0.6), 0xffffff, 0.3 + 0.2 * Math.sin(x * 0.4 + tt * 3));\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/retro/kefrens-bars.js": "// Kefrens bars (a.k.a. Alcatraz bars): one short shaded bar is redrawn on every\n// scanline at a wandering x, and each line keeps what the line above drew —\n// so the bars stack into a twisting staircase of cylinders. Pure 1990 magic.\nimport { lerpc } from './_kit.js';\n\nexport default {\n  title: 'Kefrens Bars',\n  gallery: 'retro',\n  category: 'Raster & Copper',\n  kind: 'scene',\n  tags: ['kefrens', 'alcatraz', 'raster', 'amiga', 'demoscene'],\n  order: 20,\n  description: 'One shaded bar per scanline that never gets erased, stacking into a twisting staircase of cylinders.',\n  size: { w: 80, h: 22 },\n  minSize: { w: 24, h: 8 },\n  loop: 12,\n  canvas: 'field',\n  palette: 'prism',\n  params: { speed: { default: 1, min: 0.25, max: 4, label: 'Speed' } },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette();\n    const speed = ctx.param('speed', 1);\n    const { width: W, height: H, px } = cv;\n    const bw = Math.max(3, Math.round(W / 14));\n    const row = new Int32Array(W);\n    const bg = ctx.theme.bg;\n    return (t) => {\n      const tt = t * speed;\n      row.fill(bg);\n      for (let y = 0; y < H; y++) {\n        const x0 = Math.round(W / 2 - bw / 2\n          + Math.sin(y * 0.11 + tt * 1.9) * W * 0.22\n          + Math.sin(y * 0.047 - tt * 1.3) * W * 0.16);\n        const c = pal((y / H) * 0.85 + tt * 0.07);\n        for (let k = 0; k < bw; k++) {\n          const x = x0 + k;\n          if (x < 0 || x >= W) continue;\n          const u = (k + 0.5) / bw * 2 - 1;          // -1..1 across the bar\n          const shade = Math.sqrt(1 - u * u);       // cylinder\n          let col = lerpc(0x000000, c, 0.25 + 0.75 * shade);\n          if (shade > 0.9) col = lerpc(col, 0xffffff, (shade - 0.9) * 6);\n          row[x] = col;\n        }\n        px.set(row, y * W);\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/retro/modem-connect.js": "// Dialing in, 1992: the modem init string, the dial, RINGING, CONNECT 2400,\n// then the board's welcome screen painting itself cell by cell at line speed —\n// cursor racing ahead of the paint — until NO CARRIER sends you back to start.\nimport { CellBuffer } from '../../buffer.js';\nimport { textMask, bbsPalette } from './_kit.js';\nimport { blockLogo, frame } from './_bbs.js';\n\nconst SCRIPT = [\n  [0.0, 'ATZ'], [0.6, 'OK'], [1.0, 'ATDT 555-0142'], [2.4, 'RINGING'], [3.4, 'RINGING'], [4.2, 'CONNECT 2400/ARQ/V42BIS'],\n];\nconst PAINT_AT = 5.0, PAINT = 3.4, HOLD = 2.6;\n\nfunction welcome(W, H, P) {\n  const s = new CellBuffer(W, H);\n  s.clear(P[0]);\n  const logo = textMask('PRISM', { font: 'bold' });\n  const sx = logo.w * 2 + 2 <= W ? 2 : 1;\n  let y = 1;\n  if (H >= 14 && logo.w * sx <= W) {\n    blockLogo(s, Math.floor((W - logo.w * sx) / 2), y, logo, { sx, ramp: (v) => (v < 0.25 ? P[15] : v < 0.5 ? P[14] : v < 0.8 ? P[6] : P[4]), shadow: P[8] });\n    y += 5;\n  }\n  const lines = [['P R I S M   B B S', P[11]], ['300 · 1200 · 2400 bps · 24 hours · 2 nodes', P[7]], ['', 0], ['Home of the terminal art underground', P[6]]];\n  for (const [text, c] of lines) { if (y < H - 4 && text) s.text(Math.max(0, Math.floor((W - text.length) / 2)), y, text, c); y++; }\n  const fw = Math.min(W - 2, 44), fx = Math.floor((W - fw) / 2);\n  if (y + 4 <= H) {\n    frame(s, fx, y, fw, 4, P[2], 'NEWS', P[10]);\n    s.text(fx + 2, y + 1, 'New door game: TradeWars 2002 on node 2'.slice(0, fw - 4), P[7]);\n    s.text(fx + 2, y + 2, 'Upload ratio waived this weekend'.slice(0, fw - 4), P[7]);\n  }\n  return s;\n}\n\nexport default {\n  title: 'Modem Connect',\n  gallery: 'retro',\n  category: 'BBS & ANSI Art',\n  kind: 'art',\n  tags: ['modem', 'dial-up', 'bbs', 'ansi', 'connect', 'hayes', '2400 baud'],\n  order: 50,\n  description: 'Dialing a BBS: ATZ, ATDT, RINGING, CONNECT 2400 — then the welcome screen paints in at line speed.',\n  size: { w: 80, h: 22 },\n  minSize: { w: 40, h: 10 },\n  loop: 12,\n  color: '16',\n  params: { colors: { default: 'vga', options: ['vga', 'theme'], label: 'Colors' } },\n  setup(ctx) {\n    const P = bbsPalette(ctx);\n    const b = ctx.buffer;\n    const W = ctx.width, H = ctx.height;\n    const screen = welcome(W, H - 1, P);\n    const total = W * (H - 1);\n    const paintDur = PAINT, CPS = total / PAINT;\n    const loop = PAINT_AT + PAINT + HOLD + 1;\n    const statusBar = (lt, online) => {\n      const secs = online ? Math.floor(lt - 4.2) : 0;\n      const clock = `${String(Math.floor(secs / 60)).padStart(2, '0')}:${String(secs % 60).padStart(2, '0')}`;\n      b.fill(0, H - 1, W, 1, ' ', P[0], P[3]);\n      const parts = ['PRiSMTERM 3.51', 'Alt-Z Help', 'ANSI-BBS', '2400 N81', 'FDX', online ? `Online ${clock}` : 'Offline'];\n      let x = 1;\n      for (const [i, p] of parts.entries()) {\n        if (x + p.length >= W) break;\n        x = b.text(x, H - 1, p, i === parts.length - 1 && online ? P[15] : P[0], P[3]);\n        if (i < parts.length - 1 && x + 3 < W) x = b.text(x, H - 1, ' │ ', P[1], P[3]);\n      }\n    };\n    const dialBox = (lt) => {\n      const bw = Math.min(W - 4, 36), bh = 6;\n      if (H < bh + 4) return;\n      const bx = Math.floor((W - bw) / 2), by = Math.max(SCRIPT.length + 1, Math.floor((H - bh) / 2));\n      b.fill(bx, by, bw, bh, ' ', P[15], P[1]);\n      frame(b, bx, by, bw, bh, P[11], 'Dialing', P[15]);\n      const rows = [['System', 'PRISM BBS'], ['Number', '555-0142'], ['Elapsed', `${Math.floor((lt - 1) * 3)}s`], ['Status', lt < 2.4 ? 'Dialing' : lt < 4.2 ? 'Ringing' : 'CONNECT']];\n      rows.forEach(([k, v], i) => {\n        b.text(bx + 2, by + 1 + i, `${k}:`.padEnd(9), P[7], P[1]);\n        b.text(bx + 11, by + 1 + i, v.slice(0, bw - 13), i === 3 && lt >= 4.2 ? P[10] : P[14], P[1]);\n      });\n    };\n    return (t) => {\n      const lt = t % loop;\n      b.fill(0, 0, W, H, ' ', P[7], P[0]);\n      const blink = Math.floor(t * 3) % 2 === 0;\n      if (lt < PAINT_AT) {\n        let y = 0;\n        for (const [at, text] of SCRIPT) {\n          if (lt < at || y >= H - 1) break;\n          const isCmd = text.startsWith('AT');\n          const shown = isCmd ? text.slice(0, Math.floor((lt - at) * 14)) : text;\n          const end = b.text(0, y, shown, isCmd ? P[15] : text.startsWith('CONNECT') ? P[10] : P[7]);\n          if (isCmd && shown.length < text.length && blink) b.set(end, y, '█', P[7]);\n          y++;\n        }\n        if (lt >= 1 && lt < PAINT_AT - 0.3) dialBox(lt);\n        statusBar(lt, lt >= 4.2);\n        return;\n      }\n      // Paint the welcome screen in transmission order, cursor at the head.\n      const n = Math.min(total, Math.floor((lt - PAINT_AT) * CPS));\n      for (let i = 0; i < n; i++) {\n        const x = i % W, y = (i / W) | 0;\n        const c = screen.get(x, y);\n        b.set(x, y, c.ch, c.fg, c.bg, c.attr);\n      }\n      if (n < total) b.set(n % W, (n / W) | 0, '█', P[7]);\n      if (lt > PAINT_AT + paintDur + HOLD) {\n        b.fill(0, H - 2, W, 1, ' ', P[7], P[0]);\n        b.text(0, H - 2, 'NO CARRIER', P[12]);\n      }\n      statusBar(lt, lt <= PAINT_AT + paintDur + HOLD);\n    };\n  },\n};\n",
  "prism-term/src/facets/retro/neon-skyline.js": "// A neon city at night drifting past in three parallax layers: hazy far\n// towers, mid-rise blocks full of lit windows, near buildings trimmed in neon\n// with flickering signs — searchlights sweeping the clouds, traffic streaming\n// along the waterfront, and the whole thing mirrored in a rippling harbour.\nimport { lerpc, hash2 } from './_kit.js';\n\nconst LOOP = 40;\nconst add = (a, b, k) => {\n  const r = Math.min(255, ((a >> 16) & 255) + ((b >> 16) & 255) * k);\n  const g = Math.min(255, ((a >> 8) & 255) + ((b >> 8) & 255) * k);\n  const bl = Math.min(255, (a & 255) + (b & 255) * k);\n  return (r << 16) | (g << 8) | bl;\n};\n\nexport default {\n  title: 'Neon Skyline',\n  gallery: 'retro',\n  category: 'Outrun',\n  kind: 'scene',\n  tags: ['city', 'skyline', 'neon', 'night', 'parallax', 'reflection', 'synthwave', 'cyberpunk'],\n  order: 20,\n  description: 'A neon city drifting past in parallax: lit windows, neon trim, searchlights, waterfront traffic and a rippling harbour.',\n  size: { w: 80, h: 22 },\n  minSize: { w: 24, h: 8 },\n  loop: LOOP,\n  canvas: 'field',\n  palette: 'synthwave',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette();\n    const { width: PW, height: PH, px, aspect } = cv;\n    const waterY = Math.floor(PH * 0.72);\n    const road = waterY - Math.max(2, Math.round(PH * 0.03));\n    const pitch = Math.max(2, Math.round(PH / 24));                 // window grid in pixels\n    const neon = [0xff3fd0, 0x3ff0ff, 0xffe45a, pal(0.62), 0x7a5cff];\n    const scale = Math.max(1, PW / 80);\n    // Each layer is a strip of buildings exactly one loop's travel long, so the drift repeats seamlessly.\n    const LAYERS = [\n      { speed: PW / 20, hMin: 0.22, hMax: 0.6, wMin: 4, wMax: 10, body: lerpc(pal(0.1), pal(0.24), 0.5), lit: 0.16, haze: 0.45 },\n      { speed: PW / 10, hMin: 0.16, hMax: 0.48, wMin: 5, wMax: 12, body: lerpc(pal(0.07), pal(0.16), 0.5), lit: 0.42, haze: 0.18 },\n      { speed: PW / 5, hMin: 0.08, hMax: 0.36, wMin: 8, wMax: 17, body: lerpc(pal(0.02), 0x000000, 0.5), lit: 0.3, haze: 0, near: true },\n    ].map((L, li) => {\n      const len = Math.round(L.speed * LOOP);\n      const owner = new Int32Array(len), local = new Int32Array(len), bs = [];\n      let x = 0, j = 0;\n      while (x < len) {\n        let w = Math.round((L.wMin + hash2(j, li, 1) * (L.wMax - L.wMin)) * scale);\n        if (len - x - w < L.wMin * scale) w = len - x;\n        const top = waterY - Math.round(PH * (L.hMin + Math.pow(hash2(j, li, 2), 1.3) * (L.hMax - L.hMin)));\n        bs.push({ w, top, trim: L.near && hash2(j, li, 3) < 0.5, sign: L.near && hash2(j, li, 7) < 0.4, c: neon[Math.floor(hash2(j, li, 4) * neon.length)], mast: hash2(j, li, 5) < 0.3, tone: hash2(j, li, 6) });\n        for (let i = 0; i < w && x + i < len; i++) { owner[x + i] = j; local[x + i] = i; }\n        x += w; j++;\n      }\n      return { ...L, len, owner, local, bs };\n    });\n    const skyTop = pal(0.01), skyMid = pal(0.14), skyLow = pal(0.42);\n    const stars = Array.from({ length: Math.round(PW * PH * 0.005) }, (_, i) => [Math.floor(hash2(i, 9) * PW), Math.floor(hash2(i, 8) * waterY * 0.45), hash2(i, 7)]);\n    const above = new Int32Array(PW * waterY);\n    const warm = 0xffd38a, cool = 0xa8ecff;\n    const beams = [{ x: 0.28, ph: 0, sp: 0.31 }, { x: 0.71, ph: 2.1, sp: 0.23 }];\n\n    return (t) => {\n      const lt = t % LOOP;\n      for (let y = 0; y < waterY; y++) {\n        const v = y / waterY;\n        above.fill(v < 0.5 ? lerpc(skyTop, skyMid, v / 0.5) : lerpc(skyMid, skyLow, Math.pow((v - 0.5) / 0.5, 1.3)), y * PW, (y + 1) * PW);\n      }\n      for (const [x, y, k] of stars) if (Math.sin((t * Math.PI * (1 + Math.floor(k * 3))) / 10 + k * 30) > -0.2) above[y * PW + x] = lerpc(skyMid, 0xffffff, 0.45 + k * 0.4);\n      // Searchlights: soft additive cones swinging slowly across the sky.\n      for (const bm of beams) {\n        const ang = Math.sin((lt * Math.PI * 2 * 2) / LOOP + bm.ph) * 0.55;    // two sweeps per loop\n        const ox = bm.x * PW, oy = waterY, dx = Math.sin(ang), dy = -Math.cos(ang);\n        for (let y = 0; y < waterY; y++) for (let x = 0; x < PW; x++) {\n          const vx = x + 0.5 - ox, vy = (y + 0.5 - oy) * aspect;\n          const along = vx * dx + vy * dy;\n          if (along <= 0) continue;\n          const across = Math.abs(vx * dy - vy * dx), width = 0.6 + along * 0.09;\n          if (across < width) above[y * PW + x] = add(above[y * PW + x], pal(0.7), 0.32 * (1 - across / width) * Math.max(0, 1 - along / (PW * 0.9)));\n        }\n      }\n      for (const L of LAYERS) {\n        const scroll = lt * L.speed;\n        for (let x = 0; x < PW; x++) {\n          const wx = Math.floor(x + scroll) % L.len, j = L.owner[wx], lx = L.local[wx], bd = L.bs[j];\n          for (let y = Math.max(0, bd.top); y < waterY; y++) {\n            let c = L.haze ? lerpc(L.body, skyLow, L.haze * (0.4 + 0.6 * ((y - bd.top) / (waterY - bd.top)))) : L.body;\n            const wy = y - bd.top - 1, edge = lx === 0 || lx === bd.w - 1;\n            if (!edge && wy > 0 && lx % pitch === 1 && wy % pitch === 1) {\n              const cxI = (lx / pitch) | 0, cyI = (wy / pitch) | 0;\n              const on = hash2(j * 31 + cxI, cyI, Math.floor(lt / 8 + hash2(j, cxI, cyI) * 5)) < L.lit;\n              if (on) c = lerpc(bd.tone < 0.65 ? warm : cool, c, L.haze * 0.9);\n            }\n            if (bd.sign && lx >= 2 && lx < 2 + pitch && wy > pitch && wy < pitch * 7) {\n              const flick = hash2(j, Math.floor(t * 12), 5) < 0.05 ? 0.2 : 1;\n              c = lerpc(L.body, wy % (pitch * 2) < pitch ? bd.c : lerpc(bd.c, 0xffffff, 0.4), flick);\n            }\n            above[y * PW + x] = c;\n          }\n          // Neon trim along the roofline and down the edges of some near buildings.\n          if (bd.trim && bd.top >= 0) {\n            above[bd.top * PW + x] = bd.c;\n            if (lx === 0 || lx === bd.w - 1) for (let y = bd.top; y < Math.min(road, bd.top + pitch * 4); y++) above[y * PW + x] = lerpc(bd.c, L.body, (y - bd.top) / (pitch * 4));\n          }\n          if (bd.mast && lx === bd.w >> 1) {\n            const mh = Math.max(2, pitch * 2);\n            for (let y = Math.max(0, bd.top - mh); y < bd.top; y++) above[y * PW + x] = L.body;\n            if ((lt + j * 0.37) % 2 < 0.5 && bd.top - mh - 1 >= 0) above[(bd.top - mh - 1) * PW + x] = lerpc(L.body, 0xff3040, L.haze ? 0.55 : 1);\n          }\n        }\n      }\n      // Waterfront road: a dark deck with headlights streaming right and tail-lights left.\n      for (let y = road; y < waterY; y++) above.fill(0x0a0612, y * PW, (y + 1) * PW);\n      for (let i = 0; i < Math.ceil(PW / 14); i++) {\n        const xr = Math.floor((hash2(i, 1) * PW + lt * PW * 0.25) % PW), xl = Math.floor((((hash2(i, 2) * PW - lt * PW * 0.2) % PW) + PW) % PW);\n        above[road * PW + xr] = 0xfff4d0;\n        if (road + 1 < waterY) above[(road + 1) * PW + xl] = 0xff3a3a;\n      }\n      for (let x = (PW >> 3) % 7; x < PW; x += 7) above[(road - 1) * PW + x] = lerpc(warm, above[(road - 1) * PW + x], 0.3);\n      px.set(above);\n      // Harbour: the city upside down, rippled sideways, lights stretching into streaks.\n      const water = lerpc(skyTop, 0x000000, 0.3);\n      for (let y = waterY; y < PH; y++) {\n        const d = y - waterY, k = d / Math.max(1, PH - waterY);\n        const my = waterY - 1 - Math.floor(d * 0.8);\n        const off = Math.round(Math.sin(d * 1.3 + (t * Math.PI * 2 * 8) / LOOP) * (0.5 + k * 2) + Math.sin(d * 0.5 - (t * Math.PI * 2 * 5) / LOOP) * k * 1.4);\n        for (let x = 0; x < PW; x++) {\n          const sx = Math.min(PW - 1, Math.max(0, x + off));\n          const src = my >= 0 ? above[my * PW + sx] : skyTop;\n          px[y * PW + x] = lerpc(src, water, 0.38 + k * 0.3);\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/retro/oscilloscope.js": "// A phosphor oscilloscope on the chiptune: graticule, a trace triggered on the\n// bass so it stands still, and persistence — the last few sweeps fading out\n// behind the beam. `--set mode=xy` plots bass against lead as Lissajous figures.\nimport { lerpc } from './_kit.js';\nimport { songAt } from './_music.js';\n\nconst PHOSPHOR = { green: 0x40ff80, amber: 0xffb020, blue: 0x60c8ff };\n\nexport default {\n  title: 'Oscilloscope',\n  gallery: 'retro',\n  category: 'Chiptune Visuals',\n  kind: 'scene',\n  tags: ['oscilloscope', 'scope', 'waveform', 'phosphor', 'lissajous', 'audio', 'chiptune'],\n  order: 30,\n  description: 'A phosphor oscilloscope on the chiptune: graticule, a bass-triggered trace with persistence, and an XY Lissajous mode.',\n  size: { w: 64, h: 18 },\n  minSize: { w: 20, h: 6 },\n  loop: 7.68,\n  canvas: 'line',\n  params: {\n    mode: { default: 'yt', options: ['yt', 'xy'], label: 'Mode' },\n    phosphor: { default: 'green', options: ['green', 'amber', 'blue', 'theme'], label: 'Phosphor' },\n  },\n  setup(ctx) {\n    const { theme } = ctx;\n    const b = ctx.buffer;\n    const W = ctx.width, H = ctx.height;\n    const cv = ctx.canvas('line');\n    const PW = cv.width, PHt = cv.height;\n    const xy = ctx.param('mode', 'yt') === 'xy';\n    const ph = ctx.param('phosphor', 'green');\n    const beam = ph === 'theme' ? theme.accent : PHOSPHOR[ph] ?? PHOSPHOR.green;\n    // Phosphor glows on dark CRT glass, even in daylight; the 'theme' phosphor draws\n    // in the theme's own ink on the page instead (with a firmer persistence by Day).\n    const day = theme.mode === 'light';\n    const glass = day && ph !== 'theme' ? 0x060b08 : theme.bg;\n    const K = (k) => (day && ph === 'theme' ? 1 - (1 - k) * (1 - k) : k);\n    const grid = lerpc(glass, beam, K(0.2)), axis = lerpc(glass, beam, K(0.32));\n    const trails = [1, 0.5, 0.26, 0.12].map((k) => lerpc(glass, beam, K(k)));\n    const readout = H >= 10 && W >= 36;\n    const cx = PW / 2, cy = PHt / 2;\n    const amp = PHt * 0.42;\n    function graticule() {\n      const dx = PW / 10, dy = PHt / 8;\n      for (let i = 0; i <= 10; i++) {\n        const x = Math.min(PW - 1, Math.round(i * dx));\n        for (let y = 0; y < PHt; y += 3) cv.set(x, y, i === 5 ? axis : grid);\n      }\n      for (let j = 0; j <= 8; j++) {\n        const y = Math.min(PHt - 1, Math.round(j * dy));\n        for (let x = 0; x < PW; x += 3) cv.set(x, y, j === 4 ? axis : grid);\n      }\n      // Minor ticks along the center axes.\n      for (let i = 0; i <= 50; i++) { const x = Math.round((i * PW) / 50); cv.set(x, Math.round(cy) - 1, axis); cv.set(x, Math.round(cy) + 1, axis); }\n      for (let j = 0; j <= 40; j++) { const y = Math.round((j * PHt) / 40); cv.set(Math.round(cx) - 1, y, axis); cv.set(Math.round(cx) + 1, y, axis); }\n    }\n    // The scope looks at the tonal voices through a gentle low-pass: rounded\n    // squares and pulses plus the kick's thump; the noise channels stay out.\n    const soft = (ph, duty) => Math.tanh(4 * (Math.sin(2 * Math.PI * ph) + (duty - 0.5) * 2)) * 0.95;\n    function sweepYT(t, color) {\n      // Trigger on the bass so the picture holds still: two bass periods per screen.\n      const s = songAt(t), fb = s.bassFreq;\n      const start = Math.floor(t * fb) / fb, span = 2 / fb;\n      let px = 0, py = cy;\n      for (let x = 0; x < PW; x++) {\n        const tau = start + (x / PW) * span, a = s.kickAge + (tau - start);\n        const v = 0.42 * s.bass * soft(tau * fb, 0.5) + 0.28 * s.lead * soft(tau * s.leadFreq, 0.3) + 0.45 * s.kick * Math.sin(2 * Math.PI * 52 * a);\n        const y = cy - v * amp;\n        if (x) cv.line(px, py, x, y, color);\n        px = x; py = y;\n      }\n    }\n    // XY: the bass/lead interval picks a small-integer frequency ratio, so the\n    // figure changes shape with the harmony and closes on itself.\n    const RATIO = [[1, 1], [9, 8], [9, 8], [6, 5], [5, 4], [4, 3], [7, 5], [3, 2], [8, 5], [5, 3], [7, 4], [2, 1]];\n    function sweepXY(t, color) {\n      const s = songAt(t);\n      const [p, q] = RATIO[(((s.leadNote - s.bassNote) % 12) + 12) % 12];\n      const ax = (0.55 + 0.35 * s.bass) * (1 + s.kick * 0.18), ay = (0.5 + 0.4 * s.lead) * (1 + s.kick * 0.18);\n      const phase = t * 0.9, N = Math.max(200, PW * 4);\n      let px = 0, py = 0;\n      for (let i = 0; i <= N; i++) {\n        const u = (i / N) * 2 * Math.PI;\n        const x = cx + Math.sin(q * u + phase) * ax * cx * 0.92;\n        const y = cy - Math.sin(p * u) * ay * amp * 1.05;\n        if (i) cv.line(px, py, x, y, color);\n        px = x; py = y;\n      }\n    }\n    return (t) => {\n      b.fill(0, 0, W, H, ' ', theme.ink, glass);\n      cv.clear();\n      graticule();\n      // Persistence: older sweeps first, dimmer, so the newest sits on top.\n      for (let k = trails.length - 1; k >= 0; k--) (xy ? sweepXY : sweepYT)(t - k / 30, trails[k]);\n      cv.paint(b);\n      if (readout) {\n        const s = songAt(t);\n        const dim = lerpc(glass, beam, K(0.6));\n        b.text(1, 0, xy ? 'X: BASS  Y: LEAD' : 'CH1 0.5V/div', dim);\n        b.text(W - 12, 0, xy ? '    XY MODE' : 'TRIG ↑ BASS', dim);\n        b.text(1, H - 1, xy ? `${RATIO[(((s.leadNote - s.bassNote) % 12) + 12) % 12].join(':')}  ${s.bassFreq.toFixed(1)} Hz` : `${s.bassFreq.toFixed(1)} Hz`, dim);\n        if (!xy) b.text(W - 11, H - 1, `${((2 / s.bassFreq) * 100).toFixed(2)}ms/div`.padStart(10), dim);\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/retro/outrun-road.js": "// Arcade-racer pseudo-3D: a road built row by row from the horizon down, with\n// curvature integrated per scanline so it sweeps left and right, alternating\n// rumble strips and lane dashes, palm silhouettes scaling past, the sunset\n// sliding against the curves, a red convertible leaning in — and the HUD.\nimport { lerpc, hash2 } from './_kit.js';\n\nconst CAR = [\n  '......rrrrrrrrrrrrrrrr......',\n  '.....rkkkkkkkkkkkkkkkkr.....',\n  '....rkkhkkkkkkkkkkkkkkkr....',\n  '..rrrrrrrrrrrrrrrrrrrrrrrr..',\n  '.RRRRRRRRRRRRRRRRRRRRRRRRRR.',\n  'RRLLLLRRRRRRRRRRRRRRRRLLLLRR',\n  'RRLLLLRRggggggggggggRRLLLLRR',\n  'RRRRRRRRggggggggggggRRRRRRRR',\n  'ddssssddddddddddddddddssssdd',\n  'kkkkkk................kkkkkk',\n];\nconst SEG = 7, LOOP = 12, SPEED = 60;            // world units; one loop covers 720 units\nconst TAU = Math.PI * 2, LEN = SPEED * LOOP;\nconst curve = (z) => Math.sin((TAU * z) / LEN) * 0.9 + Math.sin((TAU * 3 * z) / LEN + 1) * 0.45;\nconst curveInt = (z) => (-Math.cos((TAU * z) / LEN) * 0.9 * LEN) / TAU - (Math.cos((TAU * 3 * z) / LEN + 1) * 0.45 * LEN) / (3 * TAU);\n\nexport default {\n  title: 'Outrun Road',\n  gallery: 'retro',\n  category: 'Outrun',\n  kind: 'scene',\n  tags: ['outrun', 'racer', 'pseudo-3d', 'road', 'palms', 'sunset', 'arcade', '1986'],\n  order: 10,\n  description: 'Arcade pseudo-3D racing: a curving road with rumble strips, palm silhouettes, a sunset and a red convertible.',\n  size: { w: 80, h: 24 },\n  minSize: { w: 24, h: 8 },\n  loop: LOOP,\n  canvas: 'field',\n  palette: 'sunset',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette();\n    const { theme } = ctx;\n    const b = ctx.buffer;\n    const { width: PW, height: PH, px, aspect } = cv;\n    const hy = Math.floor(PH * 0.46);\n    const rows = PH - hy;\n    const zNear = 3, zFar = 260;\n    const halfW = (p) => p * PW * 0.4;\n    const fogC = pal(0.62);\n    const roadA = 0x5c5866, roadB = 0x56525f, grassA = 0x1f7a3c, grassB = 0x1a6a34;\n    const sil = lerpc(pal(0.05), 0x000000, 0.3);\n    const sunR = Math.min(PW * 0.16, hy * aspect * 0.8);\n    // Mountains: a ridge wider than the screen so it can slide with the curves.\n    const MW = PW * 3, ridge = new Float32Array(MW);\n    for (let x = 0; x < MW; x++) {\n      const u = x / MW;\n      ridge[x] = hy - PH * (0.04 + 0.09 * Math.pow(Math.abs(Math.sin(u * TAU * 3 + 1.3)), 0.7) * (0.6 + 0.4 * Math.sin(u * TAU * 7)));\n    }\n    const wx = new Float32Array(rows);            // road centre per row, in road half-widths\n    const hud = PW >= 40 && ctx.height >= 12;\n    const carW0 = CAR[0].length, carH0 = CAR.length;\n    const carScale = Math.max(PW * 0.3, 8) / carW0;\n    const carColors = { r: 0xd81e2c, R: 0xf0283a, k: 0x1a1020, h: 0x6a6aa8, L: 0xffb040, g: 0x2a1418, d: 0x301018, s: 0x8a8a96 };\n\n    return (t) => {\n      const lt = t % LOOP, camZ = lt * SPEED;\n      const shift = (curveInt(camZ) * 0.04 * PW) / 10;\n      // Sky and sun.\n      for (let y = 0; y < hy; y++) {\n        const c = pal(0.04 + (y / hy) * 0.62);\n        px.fill(c, y * PW, (y + 1) * PW);\n      }\n      const sunX = PW / 2 - shift * 0.6, sunY = hy - sunR * 0.35 / aspect;\n      for (let y = Math.max(0, Math.floor(sunY - sunR / aspect)); y < hy; y++) for (let x = Math.max(0, Math.floor(sunX - sunR)); x < Math.min(PW, sunX + sunR); x++) {\n        const dx = x + 0.5 - sunX, dy = (y + 0.5 - sunY) * aspect;\n        if (dx * dx + dy * dy < sunR * sunR) px[y * PW + x] = lerpc(pal(0.98), pal(0.78), (y - (sunY - sunR / aspect)) / (2 * sunR / aspect));\n      }\n      for (let x = 0; x < PW; x++) {\n        const rx = Math.floor((((x + shift) % MW) + MW) % MW);\n        for (let y = Math.max(0, Math.floor(ridge[rx])); y < hy; y++) px[y * PW + x] = lerpc(sil, fogC, 0.35);\n      }\n      // Road: walk from the bottom (near) up to the horizon, integrating curvature.\n      let x = 0, dx = 0, zPrev = zNear;\n      for (let r = rows - 1; r >= 0; r--) {\n        const p = (r + 1) / rows, z = zNear / p;\n        if (z > zFar) { wx[r] = x; continue; }\n        const dz = z - zPrev; zPrev = z;\n        dx += curve(camZ + z) * 0.0011 * dz;\n        x += dx * dz;\n        wx[r] = x;\n      }\n      for (let r = 0; r < rows; r++) {\n        const y = hy + r, p = (r + 1) / rows, z = zNear / p;\n        const hw = halfW(p), cx = PW / 2 + wx[r] * hw;\n        const seg = Math.floor((camZ + z) / SEG) & 1;\n        const fog = Math.min(1, Math.pow(z / zFar, 0.7));\n        const grass = lerpc(seg ? grassA : grassB, fogC, fog), road = lerpc(seg ? roadA : roadB, fogC, fog);\n        const rumble = lerpc(seg ? 0xf0f0f0 : 0xd02030, fogC, fog), lane = lerpc(0xf0f0f0, road, fog);\n        const rw = hw * 0.12, lw = hw * 0.035;\n        for (let xx = 0; xx < PW; xx++) {\n          const d = Math.abs(xx + 0.5 - cx);\n          let c = grass;\n          if (d < hw) c = road;\n          else if (d < hw + rw) c = rumble;\n          if (seg && d < hw && Math.abs(d - hw / 3) < lw) c = lane;\n          px[y * PW + xx] = c;\n        }\n      }\n      // Palms, far to near: trunk and drooping fronds, silhouetted against the sunset.\n      const GAP = 10, first = Math.ceil((camZ + zNear + 1) / GAP), last = Math.floor((camZ + zFar) / GAP);\n      for (let k = last; k >= first; k--) {\n        const z = k * GAP - camZ, p = zNear / z;\n        const r = Math.min(rows - 1, Math.max(0, Math.round(p * rows) - 1));\n        const side = k & 1 ? 1 : -1, hw = halfW(p);\n        const bx = PW / 2 + (wx[r] + side * (1.4 + hash2(k, 3) * 0.35)) * hw, by = hy + r;\n        const fog = Math.min(1, Math.pow(z / zFar, 0.7)), c = lerpc(sil, fogC, fog * 0.9);\n        const hgt = (hw * 2.1) / aspect, lean = side * hw * 0.22;\n        const th = Math.max(1, hw * 0.06);\n        for (let i = 0; i <= 40; i++) {\n          const f = i / 40, tx = bx + lean * f * f, ty = by - hgt * f;\n          for (let w = 0; w < th; w++) cv.set(tx + w - th / 2, ty, c);\n        }\n        const topX = bx + lean, topY = by - hgt;\n        for (let a = 0; a < 8; a++) {\n          // Fronds arc up and out, then droop; each a little different.\n          const ang = -Math.PI + (a / 7) * Math.PI, len = hw * (0.7 + 0.2 * hash2(k, a));\n          for (let i = 0; i <= 30; i++) {\n            const f = i / 30, fx = topX + Math.cos(ang) * len * f, fy = topY + (Math.sin(ang) * len * f * 0.55 + len * f * f * 0.7) / aspect;\n            cv.set(fx, fy, c);\n            if (f < 0.7) cv.set(fx, fy + 1, c);\n          }\n        }\n      }\n      // The car: bobbing slightly, leaning into the curve.\n      const lean = curve(camZ + 20) * PW * 0.03;\n      const cw = carW0 * carScale, ch = (carH0 * carScale) / aspect;\n      const cx0 = PW / 2 - cw / 2 + lean, cy0 = PH - ch - Math.max(1, PH * 0.03) - (Math.floor(lt * 8) % 2 ? 0 : 0.6);\n      for (let y = 0; y < ch; y++) for (let xx = 0; xx < cw; xx++) {\n        const k = CAR[Math.min(carH0 - 1, Math.floor((y / ch) * carH0))][Math.min(carW0 - 1, Math.floor((xx / cw) * carW0))];\n        if (k !== '.') cv.set(cx0 + xx, cy0 + y, k === 'L' && Math.floor(lt * 2) % 4 === 0 ? 0xff5a3a : carColors[k]);\n      }\n      cv.paint(b);\n      if (hud) {\n        const time = 60 - Math.floor(lt * 2.5);\n        const score = String(1250000 + Math.floor(camZ * 37) * 10).padStart(8, '0');\n        const kmh = 286 + Math.round(Math.sin(lt * 1.3) * 6);\n        b.text(1, 0, 'TIME', theme.warn);\n        b.text(6, 0, String(time), 0xffffff);\n        b.text(Math.floor(ctx.width / 2) - 7, 0, `SCORE ${score}`, 0x7cf0ff);\n        const sp = `${kmh} km/h`;\n        b.text(ctx.width - sp.length - 1, ctx.height - 1, sp, 0xffe14a);\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/retro/rotozoomer.js": "// Rotozoomer: one small tile — a checker with a prism emblem — rotated and\n// zoomed across the whole screen every frame. The texture-mapping trick that\n// showed what a 7 MHz CPU could do.\nimport { lerpc, shade } from './_kit.js';\n\nconst T = 32;\nfunction makeTile(pal, theme) {\n  const tile = new Int32Array(T * T);\n  for (let y = 0; y < T; y++) for (let x = 0; x < T; x++) {\n    const check = ((x >> 3) + (y >> 3)) & 1;\n    let c = check ? lerpc(theme.bg, theme.accent, 0.42) : shade(theme, 0.25);\n    // Prism emblem: a triangle with a bright edge, centered in the tile.\n    const u = (x - T / 2 + 0.5) / (T / 2), v = (y - T / 2 + 0.5) / (T / 2);\n    const inTri = v > -0.62 && v < 0.55 && Math.abs(u) < (v + 0.62) * 0.62;\n    const edge = inTri && (v > 0.43 || Math.abs(u) > (v + 0.62) * 0.62 - 0.16);\n    if (inTri) c = edge ? 0xffffff : pal(((u + 1) * 0.5 + 0.1) % 1);\n    tile[y * T + x] = c;\n  }\n  return tile;\n}\n\nexport default {\n  title: 'Rotozoomer',\n  gallery: 'retro',\n  category: 'Intros & Scrollers',\n  kind: 'scene',\n  tags: ['rotozoom', 'texture mapping', 'demoscene', 'rotation', 'zoom'],\n  order: 60,\n  description: 'One small emblem tile rotated and zoomed across the whole screen every frame — classic texture mapping.',\n  size: { w: 70, h: 22 },\n  minSize: { w: 16, h: 6 },\n  loop: 12,\n  canvas: 'field',\n  palette: 'prism',\n  params: { speed: { default: 1, min: 0.25, max: 4, label: 'Speed' } },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette();\n    const speed = ctx.param('speed', 1);\n    const { width: W, height: H, px, aspect } = cv;\n    const tile = makeTile(pal, ctx.theme);\n    return (t) => {\n      const tt = t * speed;\n      const a = tt * 0.6, zoom = 1.1 + Math.sin(tt * 0.45) * 0.75;\n      const ca = Math.cos(a) * zoom * (T / Math.min(W, H * aspect)) * 1.6, sa = Math.sin(a) * zoom * (T / Math.min(W, H * aspect)) * 1.6;\n      const ox = tt * 9, oy = tt * 5;\n      for (let y = 0, i = 0; y < H; y++) {\n        const vy = (y - H / 2) * aspect;\n        for (let x = 0; x < W; x++, i++) {\n          const vx = x - W / 2;\n          const u = Math.floor(vx * ca - vy * sa + ox), v = Math.floor(vx * sa + vy * ca + oy);\n          px[i] = tile[(v & (T - 1)) * T + (u & (T - 1))];\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/retro/sine-scroller.js": "// The classic sine scroller: a big bitmap-font message rides a sine wave from\n// right to left, each scanline tinted by a cycling copper gradient, over a\n// parallax starfield. Drop shadow included, as the law requires.\nimport { textMask, maskAt, fontPx, makeStars, dim, lerpc } from './_kit.js';\n\nconst MESSAGE = 'PRISM TERMINAL FACETS   *   SINE SCROLLERS NEVER DIE   *   GREETINGS TO EVERY SYSOP STILL RUNNING A NODE   *   ';\n\nexport default {\n  title: 'Sine Scroller',\n  gallery: 'retro',\n  category: 'Intros & Scrollers',\n  kind: 'scene',\n  tags: ['scroller', 'sine', 'cracktro', 'demoscene', 'bitmap font'],\n  order: 10,\n  description: 'Big bitmap-font message riding a sine wave, copper-tinted per scanline, over a parallax starfield.',\n  size: { w: 80, h: 20 },\n  minSize: { w: 30, h: 8 },\n  loop: 12,\n  canvas: 'field',\n  palette: 'prism',\n  params: {\n    speed: { default: 1, min: 0.25, max: 4, label: 'Speed' },\n    text: { default: MESSAGE, label: 'Message' },\n  },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette();\n    const speed = ctx.param('speed', 1);\n    const m = textMask(String(ctx.param('text', MESSAGE)).toUpperCase(), { font: 'bold' });\n    const { width: W, height: H, px } = cv;\n    const { sx, sy } = fontPx(cv, Math.max(3, ctx.height * 0.34), 7);\n    const tw = m.w * sx, th = m.h * sy;\n    const amp = Math.max(0, (H - th) / 2 - 1);\n    const stars = makeStars(ctx.rng, W, H, Math.round(W * H * 0.012));\n    const bg0 = ctx.theme.bg, bg1 = lerpc(ctx.theme.bg, pal(0.65), 0.12);\n    const shadow = dim(ctx.theme.bg, 0.4);\n    return (t) => {\n      const tt = t * speed;\n      for (let y = 0; y < H; y++) px.fill(lerpc(bg0, bg1, y / H), y * W, (y + 1) * W);\n      for (const s of stars) {\n        const v = 0.25 + s[2] * 0.75;\n        const x = ((s[0] - tt * (6 + 40 * s[2] * s[2])) % W + W) % W;\n        cv.set(x, s[1], lerpc(bg0, ctx.theme.ink, v));\n      }\n      const scroll = tt * 26 * sx;\n      const period = tw + W;\n      for (let x = 0; x < W; x++) {\n        const u = ((x - W + scroll) % period + period) % period; // text-space column\n        if (u >= tw) continue;\n        const yOff = Math.round(H / 2 - th / 2 + Math.sin(u * 0.045 / sx * 2 + tt * 2.1) * amp);\n        for (let k = 0; k < th; k++) {\n          if (!maskAt(m, u, k, sx, sy)) continue;\n          const y = yOff + k;\n          const c = pal(((y / H) * 0.9 + tt * 0.12) % 1);\n          cv.set(x + 1, y + 1, shadow);\n          cv.set(x, y, k < sy ? lerpc(c, 0xffffff, 0.45) : c);\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/retro/spectrum-analyzer.js": "// A 1998 media-player spectrum analyser: log-spaced bars in eighth-block\n// resolution, green-to-red by height, falling bars and peak caps that drop\n// with gravity — dancing to the shared chiptune. A dim floor reflection when tall.\nimport { lerpc } from './_kit.js';\nimport { bands } from './_music.js';\n\nconst EIGHTHS = ' ▁▂▃▄▅▆▇█';\nconst K = 26, STEP = 1 / 30;\n\nexport default {\n  title: 'Spectrum Analyzer',\n  gallery: 'retro',\n  category: 'Chiptune Visuals',\n  kind: 'scene',\n  tags: ['spectrum', 'analyzer', 'fft', 'eq', 'audio', 'bars', 'chiptune', 'winamp'],\n  order: 20,\n  description: 'A media-player spectrum analyser: log-spaced bars in eighth-blocks, falling peak caps and a floor reflection.',\n  size: { w: 64, h: 16 },\n  minSize: { w: 20, h: 6 },\n  loop: 7.68,\n  params: { style: { default: 'classic', options: ['classic', 'palette'], label: 'Colors' } },\n  setup(ctx) {\n    const { theme } = ctx;\n    const b = ctx.buffer;\n    const W = ctx.width, H = ctx.height;\n    const pal = ctx.palette('prism');\n    const classic = ctx.param('style', 'classic') !== 'palette';\n    const barW = W >= 48 ? 2 : 1, pitch = barW + 1;\n    const n = Math.max(4, Math.floor((W - 1) / pitch));\n    const x0 = Math.floor((W - (n * pitch - 1)) / 2);\n    const labels = H >= 10;\n    const reflect = H >= 12 ? Math.max(2, Math.round(H * 0.18)) : 0;\n    const rows = H - (labels ? 1 : 0) - reflect;\n    const hist = Array.from({ length: K }, () => new Float32Array(n));\n    const color = (f) => (!classic ? pal(f) : f < 0.55 ? theme.pos : f < 0.8 ? lerpc(theme.pos, theme.warn, (f - 0.55) / 0.25) : lerpc(theme.warn, theme.neg, (f - 0.8) / 0.2));\n    const rowColor = Array.from({ length: rows }, (_, r) => color((r + 0.5) / rows));\n    const freqs = [[60, '60'], [250, '250'], [1000, '1k'], [4000, '4k'], [10000, '10k']];\n    return (t) => {\n      b.fill(0, 0, W, H, ' ', theme.ink, theme.bg);\n      for (let k = 0; k < K; k++) bands(t - k * STEP, n, hist[k]);\n      for (let i = 0; i < n; i++) {\n        // Bars fall at a fixed rate; caps hold briefly then fall with gravity.\n        let bar = 0, peak = 0;\n        for (let k = 0; k < K; k++) {\n          const v = hist[k][i], age = k * STEP;\n          bar = Math.max(bar, v - age * 2.2);\n          peak = Math.max(peak, v - Math.max(0, age - 0.2) ** 2 * 3.2);\n        }\n        const h = Math.max(0, bar) * rows, ph = Math.max(h / rows, peak) * rows;\n        const x = x0 + i * pitch;\n        for (let r = 0; r < rows; r++) {\n          const y = rows - 1 - r, fill = Math.min(1, Math.max(0, h - r));\n          if (fill <= 0) break;\n          const ch = EIGHTHS[Math.max(1, Math.round(fill * 8))];\n          for (let dx = 0; dx < barW; dx++) b.set(x + dx, y, ch, rowColor[r]);\n        }\n        // The cap sits on the cell above the bar's top when there's room.\n        const pr = Math.min(rows - 1, Math.floor(ph));\n        if (ph > 0.05 && pr >= Math.ceil(h)) for (let dx = 0; dx < barW; dx++) b.set(x + dx, rows - 1 - pr, '▔', lerpc(rowColor[pr], theme.ink, 0.5));\n        // Floor reflection: dim, upside down, fading away.\n        for (let r = 0; r < reflect; r++) {\n          const fill = Math.min(1, Math.max(0, h - r));\n          if (fill <= 0) break;\n          const fade = 0.28 * (1 - r / reflect);\n          for (let dx = 0; dx < barW; dx++) b.set(x + dx, rows + r, fill > 0.5 ? '█' : '▀', lerpc(theme.bg, rowColor[r], fade));\n        }\n      }\n      if (labels) {\n        const y = H - 1;\n        const lo = Math.log2(40), hi = Math.log2(12000);\n        for (const [f, s] of freqs) {\n          const x = Math.round(x0 + ((Math.log2(f) - lo) / (hi - lo)) * (n * pitch - 1) - s.length / 2);\n          if (x >= 0 && x + s.length <= W) b.text(x, y, s, theme.muted);\n        }\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/retro/starfield.js": "// Warp-speed starfield: stars fly at the camera in 3D, drawn as braille\n// streaks whose length tracks the current speed. Every ten seconds the ship\n// surges to warp; color temperature runs from ember (far) to blue-white (near).\nexport default {\n  title: 'Starfield',\n  gallery: 'retro',\n  category: 'Intros & Scrollers',\n  kind: 'scene',\n  tags: ['starfield', 'warp', 'space', 'demoscene'],\n  order: 10,\n  description: 'Warp-speed 3D starfield — braille motion streaks, periodic warp surges, depth-based color temperature.',\n  canvas: 'line',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const { width: W, height: H, aspect } = cv;\n    const rand = ctx.rng;\n    const temp = ctx.palette(['#ff5a2e', '#ffab5e', '#fff0d8', '#d6e4ff', '#8db4ff']);\n    // Deep space stays dark by Day: starlight is light, not ink.\n    const sky = ctx.theme.mode === 'light' ? 0x06070b : -1;\n    const speedK = ctx.param('speed', 1);\n    const Hv = H * aspect;\n    const k = 0.5 * Math.max(W, Hv);\n    const ax = (W / 2 / k) * 1.2, ay = (Hv / 2 / k) * 1.2;\n    const N = Math.max(60, Math.min(1800, Math.round(ctx.width * ctx.height * 0.16)));\n    const X = new Float32Array(N), Y = new Float32Array(N), Z = new Float32Array(N), T = new Float32Array(N);\n    const spawn = (i, z) => {\n      X[i] = (rand() * 2 - 1) * ax;\n      Y[i] = (rand() * 2 - 1) * ay;\n      Z[i] = z;\n      T[i] = rand();\n    };\n    for (let i = 0; i < N; i++) spawn(i, 0.04 + rand() * 0.96);\n    const cx = W / 2, cy = H / 2;\n\n    // Brightness-ramped line: dim at the tail, full at the head.\n    const streak = (x0, y0, x1, y1, c) => {\n      const n = Math.min(240, Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0)))));\n      const r = (c >> 16) & 255, g = (c >> 8) & 255, b = c & 255;\n      for (let s = 0; s <= n; s++) {\n        const u = s / n, k2 = 0.18 + 0.82 * u * u;\n        cv.set(x0 + (x1 - x0) * u, y0 + (y1 - y0) * u, ((r * k2) << 16) | ((g * k2) << 8) | ((b * k2) | 0));\n      }\n    };\n\n    return (t, dt) => {\n      // Surge envelope: cruise, then a 5 s warp bump every 10 s.\n      const u = (t % 10) / 10;\n      const bump = u < 0.5 ? Math.pow(Math.sin(Math.PI * u * 2), 2) : 0;\n      const speed = (0.22 + 1.9 * bump) * speedK;\n      const tail = 0.06 + 0.1 * bump;\n      const roll = 0.35 * Math.sin(t * 0.11) + 0.25 * bump * Math.sin(t * 0.7);\n      const cr = Math.cos(roll), sr = Math.sin(roll);\n      cv.clear();\n      for (let i = 0; i < N; i++) {\n        Z[i] -= speed * dt;\n        if (Z[i] <= 0.03) { spawn(i, 1); continue; }\n        const x = X[i] * cr - Y[i] * sr, y = X[i] * sr + Y[i] * cr;\n        const z = Z[i], zt = Math.min(1.05, z + speed * tail + 0.004);\n        const hx = cx + (x / z) * k, hy = cy + ((y / z) * k) / aspect;\n        if (hx < -2 || hy < -2 || hx > W + 2 || hy > H + 2) { spawn(i, 1); continue; }\n        const tx = cx + (x / zt) * k, ty = cy + ((y / zt) * k) / aspect;\n        const near = 1 - z;\n        const c = temp(Math.min(1, near * 0.85 + T[i] * 0.3));\n        const bright = Math.min(1, 0.42 + 0.8 * near * near + 0.25 * bump);\n        const r = ((c >> 16) & 255) * bright, g = ((c >> 8) & 255) * bright, b = (c & 255) * bright;\n        const col = (r << 16) | (g << 8) | (b | 0);\n        streak(tx, ty, hx, hy, col);\n        // Close stars get a fat 2×2 head so they read as bodies, not just lines.\n        if (z < 0.3) { cv.set(hx + 1, hy, col); cv.set(hx, hy + 1, col); cv.set(hx + 1, hy + 1, col); }\n      }\n      if (sky >= 0) ctx.buffer.fill(0, 0, ctx.width, ctx.height, ' ', ctx.theme.ink, sky);\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/retro/synthwave.js": "// Outrun flyover: a striped, gradient sun sinking behind a neon-rimmed\n// mountain ridge, starry dusk sky, and a perspective grid racing toward the\n// camera. Grid lines are anti-aliased analytically (world-space distance\n// converted to pixels per row), so they stay crisp without moiré at the horizon.\nexport default {\n  title: 'Synthwave',\n  gallery: 'retro',\n  category: 'Outrun',\n  kind: 'scene',\n  tags: ['synthwave', 'outrun', 'retrowave', 'grid', 'sun'],\n  order: 40,\n  description: 'Outrun flyover — striped gradient sun, neon-rimmed mountains and an anti-aliased perspective grid.',\n  canvas: 'field',\n  palette: 'synthwave',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette();\n    const speed = ctx.param('speed', 1);\n    const rand = ctx.rng;\n    const { fbm } = ctx.noise;\n    const { width: W, height: H, px, aspect } = cv;\n    const hz = Math.floor(H * 0.6);           // horizon row\n    const S = Math.min(W, H * aspect);\n    const sunR = S * 0.3, sunX = W / 2, sunY = hz * aspect - sunR * 0.62; // sun center in visual units\n    const camH = 1, focal = W * 0.55;\n    const gridC = pal(0.62), gridC2 = pal(0.5), rimC = pal(0.7);\n    const sky = new Int32Array(W * hz);\n    const skyTop = pal(0.02), skyLow = pal(0.45);\n    for (let y = 0; y < hz; y++) {\n      const k = Math.pow(y / hz, 1.6);\n      sky.fill(lerpc(skyTop, skyLow, k * 0.85), y * W, (y + 1) * W);\n    }\n    const stars = Array.from({ length: Math.floor(W * hz * 0.01) }, () => [Math.floor(rand() * W), Math.floor(rand() * hz * 0.7), rand()]);\n    const ridge = new Float32Array(W), ridge2 = new Float32Array(W);\n    // Ridged noise for sharp peaks; the near range is low in the middle so the sun shows.\n    for (let x = 0; x < W; x++) {\n      const u = x / W, side = 0.25 + 0.75 * Math.min(1, Math.abs(u - 0.5) * 2.4);\n      const p = Math.max(0, 1 - Math.abs(fbm(u * 3.4, 1.7, 0, 3)) * 1.7);\n      const q = Math.max(0, 1 - Math.abs(fbm(u * 7.5, 8.2, 0, 3)) * 1.7);\n      ridge[x] = hz - H * (0.012 + 0.2 * side * p * p);\n      ridge2[x] = hz - H * (0.02 + 0.13 * q * q * (0.35 + 0.65 * side));\n    }\n\n    return (t) => {\n      const zoff = t * 2.2 * speed;\n      const xoff = Math.sin(t * 0.25) * 1.2;\n      for (let y = 0, i = 0; y < H; y++) {\n        const vy = (y + 0.5) * aspect;\n        for (let x = 0; x < W; x++, i++) {\n          if (y < hz) {\n            let c = sky[i];\n            // Sun: vertical gradient, with horizontal gaps that widen toward its base and scroll down.\n            const dx = x + 0.5 - sunX, dy = vy - sunY;\n            const d = Math.sqrt(dx * dx + dy * dy);\n            if (d < sunR) {\n              const v = (dy / sunR + 1) / 2;       // 0 top → 1 bottom\n              const band = v > 0.42 ? ((v * 6.5 - t * 0.35) % 1 + 1) % 1 : 1;\n              const gap = (v - 0.42) * 1.35;\n              if (band > gap) c = pal(0.98 - v * 0.42);\n            } else if (d < sunR * 1.5) {\n              c = lerpc(c, pal(0.75), Math.pow(1 - (d - sunR) / (sunR * 0.5), 2) * 0.35);\n            }\n            if (y >= ridge2[x]) c = lerpc(0x1a0630, rimC, y - ridge2[x] < 1 ? 0.55 : 0.04);\n            if (y >= ridge[x]) c = lerpc(0x0c0218, rimC, y - ridge[x] < 1 ? 0.95 : 0.02);\n            px[i] = c;\n          } else {\n            const dyp = y - hz + 0.5;\n            const z = (camH * focal) / dyp;           // world depth of this row\n            const wx = ((x + 0.5 - W / 2) * z) / focal + xoff;\n            const fx = wx - Math.round(wx), fz = (z + zoff) - Math.round(z + zoff);\n            const pxPerX = focal / z, pxPerZ = (camH * focal) / (z * z);\n            // Coverage of a ~0.9px line, faded out as lines converge below ~3px spacing (no moiré).\n            const lx = Math.max(0, 1 - Math.abs(fx) * pxPerX / 0.9) * Math.min(1, Math.max(0, (pxPerX - 2.5) / 6));\n            const lz = Math.max(0, 1 - Math.abs(fz) * pxPerZ / 0.9) * Math.min(1, Math.max(0, (pxPerZ - 1.5) / 4));\n            const fog = Math.exp(-z / 18);\n            const near = Math.min(1, dyp / (H - hz));\n            const base = lerpc(0x0a0016, 0x1d0638, near);\n            let c = lerpc(base, gridC2, Math.min(1, lz * fog * 1.15));\n            c = lerpc(c, gridC, Math.min(1, lx * fog * 1.2));\n            // Horizon haze glow.\n            const sx = (x + 0.5 - sunX) / (sunR * 1.3);\n            c = lerpc(c, pal(0.74), Math.pow(Math.max(0, 1 - dyp / (H * 0.12)), 1.5) * (0.3 + 0.45 * Math.exp(-sx * sx)));\n            px[i] = c;\n          }\n        }\n      }\n      for (const [sx, sy, p] of stars) {\n        const tw = 0.4 + 0.6 * Math.abs(Math.sin(t * (0.8 + p * 2) + p * 10));\n        const dx = sx + 0.5 - sunX, dy = (sy + 0.5) * aspect - sunY;\n        if (sy < ridge2[sx] && dx * dx + dy * dy > sunR * sunR * 1.6) px[sy * W + sx] = lerpc(px[sy * W + sx], 0xffffff, tw * 0.8);\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n\nfunction lerpc(a, b, t) {\n  const r = ((a >> 16) & 255) + ((((b >> 16) & 255) - ((a >> 16) & 255)) * t);\n  const g = ((a >> 8) & 255) + ((((b >> 8) & 255) - ((a >> 8) & 255)) * t);\n  const bl = (a & 255) + (((b & 255) - (a & 255)) * t);\n  return (r << 16) | (g << 8) | (bl | 0);\n}\n",
  "prism-term/src/facets/retro/tracker-pattern.js": "// A 4-channel tracker playing the shared chiptune: hex row numbers, note /\n// instrument / effect columns, the play row locked in the middle while the\n// pattern scrolls under it, per-channel VU strips and an instrument list.\nimport { lerpc } from './_kit.js';\nimport { PATTERN, ROWS, ROW, BPM, SONG, INSTRUMENTS, songAt, levels, noteName } from './_music.js';\n\nconst CHANNELS = ['DRUMS', 'BASS', 'LEAD', 'HATS'];\nconst BAR = ' ▏▎▍▌▋▊▉█';\nconst hex = (n) => n.toString(16).toUpperCase().padStart(2, '0');\n\nexport default {\n  title: 'Tracker Pattern',\n  gallery: 'retro',\n  category: 'Chiptune Visuals',\n  kind: 'scene',\n  tags: ['tracker', 'mod', 'protracker', 'pattern', 'chiptune', 'music', 'amiga'],\n  order: 40,\n  description: 'A 4-channel tracker playing a chiptune: hex rows, note/instrument/effect columns and a locked play row with VUs.',\n  size: { w: 76, h: 22 },\n  minSize: { w: 40, h: 10 },\n  loop: ROW * ROWS,\n  setup(ctx) {\n    const { theme } = ctx;\n    const b = ctx.buffer;\n    const W = ctx.width, H = ctx.height;\n    const side = W >= 70 ? 22 : 0;\n    const avail = W - side - 3;\n    const full = avail >= 4 * 11;\n    const cw = full ? 11 : Math.max(9, Math.floor(avail / 4));\n    const body = Math.max(3, H - 4);\n    const bar = lerpc(theme.bg, theme.accent, 0.38);\n    const beatRow = lerpc(theme.bg, theme.panel, 0.7);\n    const cell = (ev) => {\n      if (!ev) return full ? ['---', ' --', ' ---'] : ['---', '--', '---'];\n      const inst = String(ev.inst).padStart(2, '0');\n      return full ? [noteName(ev.note), ` ${inst}`, ` ${ev.eff}`] : [noteName(ev.note), inst, ev.eff];\n    };\n    return (t) => {\n      b.fill(0, 0, W, H, ' ', theme.ink, theme.bg);\n      const s = songAt(t), lv = levels(s);\n      // Title bar.\n      b.fill(0, 0, W, 1, ' ', theme.bg, theme.panel);\n      const secs = s.t;\n      const clock = `${String(Math.floor(secs / 60)).padStart(2, '0')}:${secs.toFixed(2).padStart(5, '0')}`;\n      const left = ` ▶ PRiSMTRACKER · ${SONG}`;\n      const right = W >= 60 ? `POS 00 PAT 00  BPM ${BPM} SPD 06  ${clock} ` : `${clock} `;\n      b.text(0, 0, left.slice(0, W - right.length - 1), theme.ink, theme.panel);\n      b.text(W - right.length, 0, right, theme.muted, theme.panel);\n      // Channel headers with VU strips.\n      for (let c = 0; c < 4; c++) {\n        const x = 3 + c * cw;\n        const name = `${c + 1} ${CHANNELS[c]}`.slice(0, cw - 2);\n        b.text(x + 1, 1, name, theme.muted);\n        const n = (cw - 2) * lv[c];\n        for (let i = 0; i < cw - 2; i++) {\n          const f = Math.min(1, Math.max(0, n - i));\n          b.set(x + 1 + i, 2, f > 0 ? BAR[Math.round(f * 8)] : ' ', i / (cw - 2) < 0.7 ? theme.pos : theme.warn);\n        }\n      }\n      // Pattern body: play row fixed in the middle.\n      const mid = 3 + Math.floor(body / 2);\n      for (let y = 3; y < 3 + body; y++) {\n        const r = s.row + (y - mid);\n        if (r < 0 || r >= ROWS) continue;\n        const play = y === mid;\n        const bg = play ? bar : r % 16 === 0 ? beatRow : undefined;\n        if (bg !== undefined) b.fill(0, y, 3 + 4 * cw, 1, ' ', theme.ink, bg);\n        b.text(0, y, hex(r), play ? theme.ink : r % 4 === 0 ? theme.warn : theme.muted, bg);\n        for (let c = 0; c < 4; c++) {\n          const x = 3 + c * cw;\n          b.set(x - 1, y, '│', lerpc(theme.bg, theme.line, 0.9), bg);\n          const ev = PATTERN[r][c];\n          const [nt, ins, ef] = cell(ev);\n          const fresh = play && ev && s.frac < 0.5;\n          let cx = b.text(x, y, nt, ev ? (fresh ? 0xffffff : theme.ink) : theme.dim, bg);\n          cx = b.text(cx, y, ins, ev ? theme.info : theme.dim, bg);\n          b.text(cx, y, ef, ev ? theme.accent2 ?? theme.warn : theme.dim, bg);\n        }\n      }\n      b.text(3 + 4 * cw - 1, mid, '◀', theme.accent, bar);\n      // Instrument list, with the ones just struck lit up.\n      if (side) {\n        const sx = W - side + 1;\n        b.text(sx, 1, 'INSTRUMENTS', theme.muted);\n        const hot = new Set(PATTERN[s.row].filter(Boolean).map((e) => e.inst));\n        for (let i = 1; i < INSTRUMENTS.length && 2 + i < H - 1; i++) {\n          const on = hot.has(i) && s.frac < 0.6;\n          b.text(sx, 2 + i, hex(i), on ? theme.bg : theme.info, on ? theme.accent : undefined);\n          b.text(sx + 3, 2 + i, INSTRUMENTS[i].slice(0, side - 5), on ? theme.ink : theme.muted);\n        }\n        const y0 = 4 + INSTRUMENTS.length;\n        if (y0 + 3 < H) {\n          b.text(sx, y0, 'BAR', theme.muted);\n          b.text(sx + 4, y0, `${s.bar + 1}/4  beat ${(s.beat % 4) + 1}`, theme.ink);\n          const chord = ['Am', 'F', 'C', 'G'][s.bar];\n          b.text(sx, y0 + 1, 'KEY', theme.muted);\n          b.text(sx + 4, y0 + 1, `A minor · ${chord}`, theme.ink);\n        }\n      }\n      // Footer hint.\n      b.text(0, H - 1, (W >= 60 ? ' F1 help  F5 play  F8 stop  ←→ channel  ↑↓ row  Space edit' : ' F5 play  F8 stop  Space edit').slice(0, W), theme.dim);\n    };\n  },\n};\n",
  "prism-term/src/facets/retro/twister.js": "// The Amiga twister: a square column whose rotation angle varies with height,\n// drawn scanline by scanline as the visible faces of a spinning prism. Each\n// face keeps its own color; brightness follows its apparent width.\nimport { lerpc, makeStars } from './_kit.js';\n\nexport default {\n  title: 'Twister',\n  gallery: 'retro',\n  category: 'Raster & Copper',\n  kind: 'scene',\n  tags: ['twister', 'amiga', 'raster', 'demoscene', 'column'],\n  order: 30,\n  description: 'A square column twisting along its height, drawn per scanline from its visible faces — Amiga classic.',\n  size: { w: 60, h: 24 },\n  minSize: { w: 16, h: 8 },\n  loop: 10,\n  canvas: 'field',\n  palette: 'prism',\n  params: {\n    twist: { default: 1, min: 0, max: 3, label: 'Twist' },\n    speed: { default: 1, min: 0.25, max: 4, label: 'Speed' },\n  },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette();\n    const twist = ctx.param('twist', 1);\n    const speed = ctx.param('speed', 1);\n    const { width: W, height: H, px } = cv;\n    const R = Math.min(W * 0.22, H * cv.aspect * 0.5);\n    const faces = [pal(0.02), pal(0.27), pal(0.52), pal(0.77)];\n    const stars = makeStars(ctx.rng, W, H, Math.round(W * H * 0.01));\n    const bg = ctx.theme.bg;\n    return (t) => {\n      const tt = t * speed;\n      px.fill(bg);\n      for (const s of stars) cv.set(s[0], (s[1] + tt * (2 + 6 * s[2])) % H, lerpc(bg, ctx.theme.muted, 0.3 + s[2] * 0.6));\n      for (let y = 0; y < H; y++) {\n        const cx = W / 2 + Math.sin(y * 0.03 + tt * 0.9) * W * 0.12;\n        const a = tt * 1.6 + Math.sin(y / H * 3.1 + tt * 0.8) * 2.2 * twist;\n        for (let f = 0; f < 4; f++) {\n          const x1 = cx + Math.sin(a + (f * Math.PI) / 2) * R;\n          const x2 = cx + Math.sin(a + ((f + 1) * Math.PI) / 2) * R;\n          if (x2 <= x1) continue;\n          const light = 0.35 + 0.65 * ((x2 - x1) / (R * Math.SQRT2));\n          for (let x = Math.max(0, Math.floor(x1)); x < Math.min(W, Math.ceil(x2)); x++) {\n            const u = (x - x1) / (x2 - x1);\n            const edge = u < 0.08 || u > 0.92 ? 0.7 : 1;\n            px[y * W + x] = lerpc(0x000000, faces[f], Math.min(1, light) * edge);\n          }\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/retro/vector-balls.js": "// Vector balls: 64 shaded spheres arranged in 3D, morphing cube → sphere →\n// torus → helix while the formation tumbles. Painter's-sorted, Phong-lit,\n// sized by perspective — the Amiga demo effect that made people buy Amigas.\nimport { lerpc } from './_kit.js';\nimport { rotate } from '../../math3d.js';\nimport { easing } from '../../easing.js';\n\nconst N = 64;\nfunction shapes() {\n  const cube = [], sphere = [], torus = [], helix = [];\n  for (let i = 0; i < N; i++) {\n    cube.push([(i % 4) / 1.5 - 1, (Math.floor(i / 4) % 4) / 1.5 - 1, Math.floor(i / 16) / 1.5 - 1]);\n    const y = 1 - (i + 0.5) / N * 2, r = Math.sqrt(1 - y * y), th = i * 2.399963;\n    sphere.push([Math.cos(th) * r * 1.15, y * 1.15, Math.sin(th) * r * 1.15]);\n    const a = (i % 16) / 16 * Math.PI * 2, b = Math.floor(i / 16) / 4 * Math.PI * 2;\n    torus.push([(1 + 0.42 * Math.cos(b)) * Math.cos(a), 0.42 * Math.sin(b), (1 + 0.42 * Math.cos(b)) * Math.sin(a)]);\n    const k = i / N, s = i % 2 ? Math.PI : 0;\n    helix.push([Math.cos(k * Math.PI * 6 + s) * 0.7, k * 2.4 - 1.2, Math.sin(k * Math.PI * 6 + s) * 0.7]);\n  }\n  return [cube, sphere, torus, helix];\n}\n\nexport default {\n  title: 'Vector Balls',\n  gallery: 'retro',\n  category: 'Intros & Scrollers',\n  kind: 'scene',\n  tags: ['vector balls', 'amiga', '3d', 'morph', 'demoscene', 'spheres'],\n  order: 40,\n  description: '64 Phong-lit spheres morphing cube → sphere → torus → helix while the formation tumbles in 3D.',\n  size: { w: 70, h: 24 },\n  minSize: { w: 24, h: 8 },\n  loop: 16,\n  canvas: 'field',\n  palette: 'prism',\n  params: { speed: { default: 1, min: 0.25, max: 4, label: 'Speed' } },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const pal = ctx.palette();\n    const speed = ctx.param('speed', 1);\n    const { width: W, height: H, px, aspect } = cv;\n    const forms = shapes();\n    const VH = H * aspect, S = Math.min(W, VH) * 0.36, D = 4;\n    const ballR = Math.max(1.2, Math.min(W, VH) * 0.045);\n    const L = [-0.45, -0.6, 0.66];\n    const bg0 = ctx.theme.bg, bg1 = lerpc(ctx.theme.bg, pal(0.7), 0.15);\n    const colors = Array.from({ length: N }, (_, i) => pal(i / N));\n    return (t) => {\n      const tt = t * speed;\n      for (let y = 0; y < H; y++) px.fill(lerpc(bg0, bg1, y / H), y * W, (y + 1) * W);\n      const seg = (tt / 4) % forms.length, i0 = Math.floor(seg), k = easing.inOutCubic(Math.min(1, (seg - i0) * 1.6));\n      const A = forms[i0], B = forms[(i0 + 1) % forms.length];\n      const pts = [];\n      for (let i = 0; i < N; i++) {\n        const p = [A[i][0] + (B[i][0] - A[i][0]) * k, A[i][1] + (B[i][1] - A[i][1]) * k, A[i][2] + (B[i][2] - A[i][2]) * k];\n        const [x, y, z] = rotate(p, tt * 0.7, tt * 0.9, tt * 0.31);\n        const zz = z + D;\n        pts.push({ x: W / 2 + (x * S * D) / zz, y: (VH / 2 + (y * S * D) / zz) / aspect, r: (ballR * D) / zz, z: zz, c: colors[i] });\n      }\n      pts.sort((a, b) => b.z - a.z);\n      for (const b of pts) {\n        const ry = b.r / aspect;\n        for (let y = Math.floor(b.y - ry); y <= Math.ceil(b.y + ry); y++) {\n          if (y < 0 || y >= H) continue;\n          for (let x = Math.floor(b.x - b.r); x <= Math.ceil(b.x + b.r); x++) {\n            if (x < 0 || x >= W) continue;\n            const nx = (x + 0.5 - b.x) / b.r, ny = ((y + 0.5) - b.y) * aspect / b.r;\n            const r2 = nx * nx + ny * ny;\n            if (r2 > 1) continue;\n            const nz = Math.sqrt(1 - r2);\n            const diff = Math.max(0, nx * L[0] + ny * L[1] + nz * L[2]);\n            const spec = Math.pow(Math.max(0, 2 * diff * nz - L[2]), 18);\n            const fog = Math.min(1, (b.z - D + 1.3) / 2.6);\n            let c = lerpc(0x000000, b.c, 0.18 + 0.82 * diff);\n            c = lerpc(c, 0xffffff, spec * 0.9);\n            px[y * W + x] = lerpc(c, bg0, fog * 0.45);\n          }\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/retro/vu-meters.js": "// Twin analog VU meters on a backlit amber face, needles riding a little\n// chiptune with real VU ballistics (300 ms integration), a red zone past 0 dB,\n// peak lamps, and fast LED ladders with peak-hold underneath.\nimport { lerpc } from './_kit.js';\nimport { songAt } from './_music.js';\n\nconst SCALE = [[-20, 0], [-10, 0.28], [-7, 0.4], [-5, 0.5], [-3, 0.62], [-2, 0.69], [-1, 0.76], [0, 0.83], [1, 0.89], [2, 0.95], [3, 1]];\nconst pos = (db) => {\n  if (db <= SCALE[0][0]) return 0;\n  for (let i = 1; i < SCALE.length; i++) if (db <= SCALE[i][0]) {\n    const [d0, p0] = SCALE[i - 1], [d1, p1] = SCALE[i];\n    return p0 + ((db - d0) / (d1 - d0)) * (p1 - p0);\n  }\n  return 1;\n};\nconst toDb = (v) => 20 * Math.log10(Math.max(1e-3, v)) + 3;\nconst lum = (c) => ((c >> 16) & 255) * 0.3 + ((c >> 8) & 255) * 0.59 + (c & 255) * 0.11;\nconst clamp = (v) => Math.min(1, Math.max(0, v));\n\nfunction stereo(t) {\n  const s = songAt(t), d = Math.max(s.kick, s.snare);\n  return [clamp(0.5 * d + 0.34 * s.bass + 0.22 * s.lead + 0.06 * s.hat), clamp(0.5 * d + 0.22 * s.bass + 0.34 * s.lead + 0.18 * s.hat)];\n}\n\nexport default {\n  title: 'VU Meters',\n  gallery: 'retro',\n  category: 'Chiptune Visuals',\n  kind: 'scene',\n  tags: ['vu', 'meter', 'audio', 'needle', 'led', 'chiptune', 'hifi'],\n  order: 10,\n  description: 'Twin analog VU meters on a backlit face — needles with real ballistics, a red zone, peak lamps and LED ladders.',\n  size: { w: 64, h: 18 },\n  minSize: { w: 24, h: 8 },\n  loop: 7.68,\n  canvas: 'line',\n  setup(ctx) {\n    const { theme } = ctx;\n    const b = ctx.buffer;\n    const W = ctx.width, H = ctx.height;\n    const cv = ctx.canvas('line');\n    const ladders = H >= 10 ? 2 : 0;\n    const dark = lum(theme.bg) < lum(theme.ink) ? theme.bg : theme.ink;\n    const face = lerpc(theme.panel, theme.warn, 0.62), faceHi = lerpc(face, 0xffffff, 0.35);\n    const red = lerpc(theme.neg, dark, 0.15);\n    const mw = Math.floor(W / 2), mh = H - ladders;\n    // Geometry in cell units; a cell is about twice as tall as it is wide.\n    // Pivot hides at the bottom of the face; the arc's radius is set by the face\n    // height and the sweep narrows to fit the width.\n    const top = mh >= 12 ? 2.6 : 1.6;\n    const R = Math.max(3, (mh - 0.6 - top) * 2);\n    const SPAN = Math.min((50 * Math.PI) / 180, Math.asin(Math.min(1, (mw - 4) / (2 * R * 1.05))));\n    const labels = mw >= 26 ? [-20, -10, -5, -3, 0, 3] : mw >= 16 ? [-20, -5, 0, 3] : [-20, 0];\n    const meters = [0, 1].map((i) => ({ x0: i * mw, cx: i * mw + mw / 2, cy: mh - 0.6, name: i ? 'R' : 'L' }));\n    const at = (m, p, r) => {\n      const a = -SPAN + p * 2 * SPAN;\n      return [(m.cx + r * Math.sin(a)) * cv.sx, (m.cy - (r * Math.cos(a)) / 2) * cv.sy];\n    };\n    // VU: 300 ms integration. PPM ladders: instant attack, ~1.2 s fall. Both pure in t.\n    const vu = (t) => {\n      let l = 0, r = 0, w = 0;\n      for (let k = 0; k < 12; k++) {\n        const [a, c] = stereo(t - k * 0.025), wk = Math.exp(-k / 4);\n        l += a * wk; r += c * wk; w += wk;\n      }\n      return [l / w, r / w];\n    };\n    const ppm = (t) => {\n      const out = [0, 0], hold = [0, 0];\n      for (let k = 0; k < 40; k++) {\n        const v = stereo(t - k * 0.03), f = Math.exp(-(k * 0.03) / 0.4);\n        for (let c = 0; c < 2; c++) { out[c] = Math.max(out[c], v[c] * f); hold[c] = Math.max(hold[c], v[c]); }\n      }\n      return [out, hold];\n    };\n    return (t) => {\n      b.fill(0, 0, W, H, ' ', theme.ink, theme.bg);\n      cv.clear();\n      const lv = vu(t);\n      meters.forEach((m, i) => {\n        // Backlit face: brighter toward the middle.\n        for (let y = 0; y < mh; y++) for (let x = m.x0; x < m.x0 + mw - (i ? 0 : 1); x++) {\n          const dx = (x + 0.5 - m.cx) / (mw / 2), dy = (y + 0.5 - mh * 0.55) / (mh / 2);\n          b.set(x, y, ' ', dark, lerpc(face, faceHi, clamp(1 - Math.sqrt(dx * dx + dy * dy * 0.6))));\n        }\n        // Scale arc, red zone and ticks.\n        for (let s = 0; s <= 200; s++) {\n          const p = s / 200;\n          for (const k of p > 0.83 ? [0.84, 0.87, 0.9] : [0.86, 0.875]) {\n            const [x, y] = at(m, p, R * k);\n            cv.set(x, y, p > 0.83 ? red : dark);\n          }\n        }\n        for (const [db, p] of SCALE) {\n          const major = labels.includes(db);\n          const [x0, y0] = at(m, p, R * (major ? 0.78 : 0.82)), [x1, y1] = at(m, p, R * 0.94);\n          cv.line(x0, y0, x1, y1, db > 0 ? red : dark);\n        }\n        // Needle.\n        const p = pos(toDb(lv[i]));\n        const [nx0, ny0] = at(m, p, R * 0.06), [nx1, ny1] = at(m, p, R * 1.0);\n        cv.line(nx0, ny0, nx1, ny1, dark);\n        cv.line(nx0 + 1, ny0, nx1 + 1, ny1, dark);\n        // The pivot cover the needle disappears under.\n        cv.ellipse(m.cx * cv.sx, m.cy * cv.sy, R * 0.14 * cv.sx, R * 0.07 * cv.sy, dark, true);\n      });\n      cv.paint(b);\n      meters.forEach((m, i) => {\n        // Labels sit just outside the arc; the bg is whatever the face set.\n        for (const db of labels) {\n          const s = db > 0 ? `+${db}` : String(db);\n          const [x, y] = at(m, pos(db), R * 1.13);\n          const cx = Math.min(m.x0 + mw - 1 - s.length, Math.max(m.x0, Math.round(x / cv.sx - s.length / 2))), cy = Math.floor(y / cv.sy);\n          if (cy >= 0) b.text(cx, cy, s, db > 0 ? red : dark);\n        }\n        const vy = Math.round(m.cy - R * 0.2);\n        if (vy > 0 && vy < mh - 1) b.text(Math.round(m.cx - 1), vy, 'VU', dark);\n        b.text(m.x0 + 1, mh - 1, m.name, dark);\n        const peak = toDb(stereo(t)[i]) > 1.5;\n        if (mw >= 12) b.text(m.x0 + mw - (i ? 3 : 4), 0, 'PK', peak ? 0xffffff : lerpc(face, dark, 0.3), peak ? theme.neg : undefined);\n      });\n      if (!ladders) return;\n      const [level, hold] = ppm(t);\n      const segs = Math.max(4, W - 8);\n      for (let c = 0; c < 2; c++) {\n        const y = mh + c;\n        b.text(0, y, c ? 'R' : 'L', theme.muted);\n        const lit = Math.round(pos(toDb(level[c])) * segs), hi = Math.round(pos(toDb(hold[c])) * segs) - 1;\n        for (let s = 0; s < segs; s++) {\n          const f = s / segs;\n          const col = f < 0.62 ? theme.pos : f < 0.83 ? theme.warn : theme.neg;\n          const on = s < lit || s === hi;\n          b.set(2 + s, y, '▌', on ? col : lerpc(theme.bg, col, 0.2));\n        }\n        const db = Math.round(toDb(level[c]));\n        b.text(W - 5, y, `${db > 0 ? '+' : ''}${Math.max(-20, db)}`.padStart(4), theme.muted);\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/sim/boids.js": "// Boids: Reynolds flocking — separation, alignment, cohesion — plus soft\n// walls and a wandering predator that hunts the flock's center while every\n// boid within sight scatters. Boids are little oriented arrowheads colored by\n// heading, dragging short fading wakes; the predator is larger and red.\nimport { NONE } from '../../color.js';\nimport { fadeMark } from '../../palettes.js';\n\nexport default {\n  title: 'Boids',\n  gallery: 'sim',\n  category: 'Life & Flocking',\n  kind: 'scene',\n  tags: ['boids', 'flocking', 'birds', 'predator'],\n  order: 30,\n  description: 'Reynolds flocking (separation, alignment, cohesion) with a hunting predator — oriented arrowheads with fading wakes.',\n  canvas: 'line',\n  palette: 'prism',\n  setup(ctx) {\n    const trail = ctx.canvas();\n    const view = ctx.canvas();\n    const { width: W, height: H, aspect } = view;\n    const rand = ctx.rng;\n    const pal = ctx.palette();\n    const th = ctx.theme;\n    const speedK = ctx.param('speed', 1);\n    const Hv = H * aspect;\n    const S = Math.min(W, Hv);\n    const N = Math.max(24, Math.min(240, Math.round((ctx.width * ctx.height) / 14)));\n    const maxV = S * 0.34 * speedK, minV = maxV * 0.45;\n    const rN = S * 0.11, rS = rN * 0.55, rP = S * 0.24, margin = S * 0.08;\n    const X = new Float32Array(N), Y = new Float32Array(N), VX = new Float32Array(N), VY = new Float32Array(N);\n    for (let i = 0; i < N; i++) {\n      X[i] = W * (0.2 + rand() * 0.6); Y[i] = Hv * (0.2 + rand() * 0.6);\n      const a = rand() * Math.PI * 2;\n      VX[i] = Math.cos(a) * maxV * 0.7; VY[i] = Math.sin(a) * maxV * 0.7;\n    }\n    const pred = { x: W * 0.1, y: Hv * 0.5, vx: maxV * 0.6, vy: 0 };\n    const size = view.mode === 'braille' ? 3.4 : view.mode === 'ascii' || view.mode === 'half' ? 1.6 : 2.6;\n    const bg = ctx.bg === NONE ? 0 : ctx.bg;\n    const bgR = (bg >> 16) & 255, bgG = (bg >> 8) & 255, bgB = bg & 255;\n    const tp = trail.px;\n    const fadeTrail = (k) => {\n      for (let i = 0; i < tp.length; i++) {\n        const c = tp[i];\n        if (c === NONE) continue;\n        const r = bgR + (((c >> 16) & 255) - bgR) * k, g = bgG + (((c >> 8) & 255) - bgG) * k, b = bgB + ((c & 255) - bgB) * k;\n        tp[i] = Math.abs(r - bgR) + Math.abs(g - bgG) + Math.abs(b - bgB) < 14 ? NONE : (r << 16) | (g << 8) | (b | 0);\n      }\n    };\n    const arrow = (x, y, vx, vy, len, c) => {\n      const v = Math.hypot(vx, vy) || 1, dx = vx / v, dy = vy / v;\n      const nose = [x + dx * len, (y + dy * len) / aspect];\n      const l = [x - dx * len * 0.6 - dy * len * 0.55, (y - dy * len * 0.6 + dx * len * 0.55) / aspect];\n      const r = [x - dx * len * 0.6 + dy * len * 0.55, (y - dy * len * 0.6 - dx * len * 0.55) / aspect];\n      const tail = [x - dx * len * 0.25, (y - dy * len * 0.25) / aspect];\n      view.polygon([nose, l, tail, r], c);\n      view.line(nose[0], nose[1], tail[0], tail[1], c);\n    };\n    const steer = (vx, vy, tx, ty, w) => [vx + tx * w, vy + ty * w];\n\n    return (t, dt) => {\n      fadeTrail(Math.exp(-dt * 3.2));\n      // Predator: drift toward the flock's centroid with a noisy wander.\n      let mx = 0, my = 0;\n      for (let i = 0; i < N; i++) { mx += X[i]; my += Y[i]; }\n      mx /= N; my /= N;\n      const wa = ctx.noise.noise2(t * 0.25, 3.7) * Math.PI * 2;\n      let pax = (mx - pred.x) * 0.9 + Math.cos(wa) * S * 0.6, pay = (my - pred.y) * 0.9 + Math.sin(wa) * S * 0.6;\n      pred.vx += pax * dt; pred.vy += pay * dt;\n      const pv = Math.hypot(pred.vx, pred.vy), pmax = maxV * 0.82;\n      if (pv > pmax) { pred.vx *= pmax / pv; pred.vy *= pmax / pv; }\n      const pox = pred.x, poy = pred.y;\n      pred.x += pred.vx * dt; pred.y += pred.vy * dt;\n      pred.x = Math.min(W - 1, Math.max(0, pred.x)); pred.y = Math.min(Hv - 1, Math.max(0, pred.y));\n      if (pred.x <= 0 || pred.x >= W - 1) pred.vx *= -1;\n      if (pred.y <= 0 || pred.y >= Hv - 1) pred.vy *= -1;\n\n      for (let i = 0; i < N; i++) {\n        let sx = 0, sy = 0, ax = 0, ay = 0, cx = 0, cy = 0, n = 0;\n        const xi = X[i], yi = Y[i];\n        for (let j = 0; j < N; j++) {\n          if (j === i) continue;\n          const dx = X[j] - xi, dy = Y[j] - yi;\n          const d2 = dx * dx + dy * dy;\n          if (d2 > rN * rN) continue;\n          n++;\n          ax += VX[j]; ay += VY[j]; cx += X[j]; cy += Y[j];\n          if (d2 < rS * rS) { const d = Math.sqrt(d2) || 0.01; sx -= dx / d * (rS - d); sy -= dy / d * (rS - d); }\n        }\n        let vx = VX[i], vy = VY[i];\n        if (n) {\n          [vx, vy] = steer(vx, vy, ax / n - vx, ay / n - vy, 1.1 * dt);\n          [vx, vy] = steer(vx, vy, cx / n - xi, cy / n - yi, 0.55 * dt);\n        }\n        [vx, vy] = steer(vx, vy, sx, sy, 7 * dt);\n        const pdx = xi - pred.x, pdy = yi - pred.y, pd = Math.hypot(pdx, pdy);\n        if (pd < rP) [vx, vy] = steer(vx, vy, (pdx / (pd || 1)) * maxV, (pdy / (pd || 1)) * maxV, 4 * (1 - pd / rP) * dt);\n        // Soft walls.\n        if (xi < margin) vx += maxV * 2.5 * dt; else if (xi > W - margin) vx -= maxV * 2.5 * dt;\n        if (yi < margin) vy += maxV * 2.5 * dt; else if (yi > Hv - margin) vy -= maxV * 2.5 * dt;\n        const v = Math.hypot(vx, vy) || 1;\n        const cl = v > maxV ? maxV / v : v < minV ? minV / v : 1;\n        VX[i] = vx * cl; VY[i] = vy * cl;\n      }\n\n      view.clear();\n      for (let i = 0; i < N; i++) {\n        const ox = X[i], oy = Y[i];\n        X[i] = Math.min(W - 0.5, Math.max(0, X[i] + VX[i] * dt));\n        Y[i] = Math.min(Hv - 0.5, Math.max(0, Y[i] + VY[i] * dt));\n        const hue = Math.atan2(VY[i], VX[i]) / (Math.PI * 2) + 0.5;\n        trail.line(ox, oy / aspect, X[i], Y[i] / aspect, fadeMark(th, pal(hue), 0.45));\n      }\n      trail.line(pox, poy / aspect, pred.x, pred.y / aspect, fadeMark(th, th.neg, 0.6));\n      view.px.set(tp);\n      for (let i = 0; i < N; i++) arrow(X[i], Y[i], VX[i], VY[i], size, pal(Math.atan2(VY[i], VX[i]) / (Math.PI * 2) + 0.5));\n      arrow(pred.x, pred.y, pred.vx, pred.vy, size * 2.1, th.neg);\n      view.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/sim/bouncing-balls.js": "// Billiard-ball physics under gravity: balls of different sizes (mass goes\n// with area) collide elastically with each other and the walls, integrated at\n// a fixed 240 Hz so it never tunnels or explodes. Every few seconds the floor\n// \"pops\" and throws whatever is resting on it back into the air. Lit spheres,\n// soft floor shadows that tighten as a ball lands, and a flash on hard hits.\nimport { sphere, blend } from '../objects/_raster.js';\nimport { NONE } from '../../color.js';\n\nconst HZ = 240, KICK = 4.5;\n\nexport default {\n  title: 'Bouncing Balls',\n  gallery: 'sim',\n  category: 'Physics',\n  kind: 'scene',\n  tags: ['physics', 'collision', 'elastic', 'balls', 'gravity', 'billiards'],\n  order: 10,\n  description: 'Balls of different masses colliding elastically under gravity at a fixed 240 Hz step, with shadows and impact flashes.',\n  size: { w: 60, h: 22 },\n  minSize: { w: 20, h: 7 },\n  loop: KICK * 4,\n  canvas: 'field',\n  palette: 'prism',\n  params: { balls: { default: 9, min: 2, max: 24, label: 'Balls' } },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const W = cv.width, H = cv.height, asp = cv.aspect, th = ctx.theme, rand = ctx.rng;\n    const HP = H * asp; // world height in horizontal-pixel units\n    const pal = ctx.palette();\n    const n = Math.round(ctx.param('balls', 9));\n    const unit = Math.min(W, HP);\n    const G = HP * 2.2, FLOOR = HP - 1;\n    const balls = [];\n    for (let i = 0; i < n; i++) {\n      const r = unit * (0.05 + rand() * 0.05);\n      let x, y, tries = 0;\n      do { x = r + rand() * (W - 2 * r); y = r + rand() * (FLOOR * 0.6 - r); tries++; } while (tries < 40 && balls.some((b) => Math.hypot(b.x - x, b.y - y) < b.r + r + 0.5));\n      balls.push({ x, y, vx: (rand() - 0.5) * W * 0.9, vy: (rand() - 0.5) * HP * 0.4, r, m: r * r, c: pal(i / n), flash: 0 });\n    }\n    let acc = 0, kicks = 0;\n\n    const step = (h) => {\n      for (const b of balls) {\n        b.vy += G * h;\n        b.x += b.vx * h; b.y += b.vy * h;\n        if (b.x < b.r) { b.x = b.r; b.vx = Math.abs(b.vx) * 0.95; }\n        if (b.x > W - b.r) { b.x = W - b.r; b.vx = -Math.abs(b.vx) * 0.95; }\n        if (b.y < b.r) { b.y = b.r; b.vy = Math.abs(b.vy) * 0.95; }\n        if (b.y > FLOOR - b.r) {\n          if (b.vy > HP * 0.9) b.flash = Math.max(b.flash, Math.min(1, b.vy / (HP * 3)));\n          b.y = FLOOR - b.r; b.vy = -Math.abs(b.vy) * 0.86; b.vx *= 0.995;\n        }\n      }\n      for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) {\n        const a = balls[i], b = balls[j];\n        const dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy), min = a.r + b.r;\n        if (d >= min || d === 0) continue;\n        const nx = dx / d, ny = dy / d;\n        // Push apart in proportion to inverse mass, then exchange impulse along the normal.\n        const over = min - d, ia = 1 / a.m, ib = 1 / b.m, sum = ia + ib;\n        a.x -= nx * over * (ia / sum); a.y -= ny * over * (ia / sum);\n        b.x += nx * over * (ib / sum); b.y += ny * over * (ib / sum);\n        const vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;\n        if (vn >= 0) continue;\n        const jimp = (-(1 + 0.97) * vn) / sum;\n        a.vx -= jimp * ia * nx; a.vy -= jimp * ia * ny;\n        b.vx += jimp * ib * nx; b.vy += jimp * ib * ny;\n        const hit = Math.min(1, -vn / (unit * 2.5));\n        if (hit > 0.25) { a.flash = Math.max(a.flash, hit); b.flash = Math.max(b.flash, hit); }\n      }\n    };\n\n    return (t, dt) => {\n      // The floor pops every KICK seconds, flinging resting balls back up.\n      while (t >= (kicks + 1) * KICK) {\n        kicks++;\n        for (const b of balls) if (b.y > FLOOR - b.r - unit * 0.2) { b.vy = -HP * (1.6 + rand() * 0.9); b.vx += (rand() - 0.5) * W * 0.6; }\n      }\n      acc += Math.min(0.25, Math.max(0, dt)) * HZ;\n      for (; acc >= 1; acc--) step(1 / HZ);\n      for (const b of balls) b.flash *= Math.pow(0.02, Math.min(0.25, dt));\n\n      cv.clear();\n      const floorRow = Math.floor(FLOOR / asp);\n      for (let x = 0; x < W; x++) cv.set(x, floorRow, blend(th.bg, th.line, 0.9));\n      // Shadows: wider and fainter the higher the ball.\n      for (const b of balls) {\n        const hgt = Math.max(0, FLOOR - b.r - b.y) / HP;\n        const sw = b.r * (1 + hgt * 1.5), k = 0.45 * (1 - Math.min(0.85, hgt * 1.3));\n        for (let x = Math.floor(b.x - sw); x <= Math.ceil(b.x + sw); x++) {\n          const f = 1 - Math.abs(x + 0.5 - b.x) / sw;\n          if (f <= 0) continue;\n          const y = floorRow - 1, old = cv.get(x, y);\n          cv.set(x, y, blend(old === NONE ? th.bg : old, 0x000000, k * f));\n        }\n      }\n      // Balls, far-to-near isn't needed in 2D; small ones last so they stay visible.\n      const order = balls.slice().sort((a, b) => b.r - a.r);\n      for (const b of order) {\n        sphere(cv, null, b.x, b.y / asp, b.r, 0, (nx, ny, nz) => {\n          const dif = Math.max(0, -nx * 0.45 - ny * 0.62 - nz * 0.64);\n          const spec = Math.pow(Math.max(0, -nx * 0.3 - ny * 0.5 - nz * 0.81), 30);\n          let c = blend(blend(0x000000, b.c, 0.3 + 0.7 * dif), 0xffffff, spec * 0.85);\n          if (b.flash > 0.02) c = blend(c, 0xffffff, b.flash * 0.6 * (1 + nz));\n          return c;\n        });\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/sim/brians-brain.js": "// Brian's Brain: a three-state automaton (ready → firing → refractory → ready)\n// where a ready cell fires when exactly two neighbors are firing. Almost any\n// seed boils into a sea of colliding \"spaceships\". Firing cells burn white-hot,\n// refractory cells glow in the accent, and a short afterglow traces where the\n// ships have been. Re-seeds itself if activity ever dies down.\nimport { NONE, mix } from '../../color.js';\nimport { fadeMark } from '../../palettes.js';\n\nexport default {\n  title: \"Brian's Brain\",\n  gallery: 'sim',\n  category: 'Cellular Automata',\n  kind: 'scene',\n  tags: ['brians brain', 'cellular automata', 'three-state', 'spaceships', 'chaos'],\n  order: 40,\n  description: \"Brian's Brain: a three-state automaton where nearly any seed boils into colliding spaceships with glowing wakes.\",\n  size: { w: 60, h: 22 },\n  minSize: { w: 20, h: 7 },\n  loop: 10,\n  canvas: 'field',\n  palette: 'theme',\n  params: { speed: { default: 1, min: 0.25, max: 4, label: 'Speed' } },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const W = cv.width, H = cv.height, px = cv.px, th = ctx.theme, rand = ctx.rng;\n    const gps = 12 * ctx.param('speed', 1);\n    const N = W * H;\n    let cur = new Uint8Array(N), nxt = new Uint8Array(N); // 0 ready, 1 firing, 2 refractory\n    const glow = new Float32Array(N);\n    const xm = new Int32Array(W), xp = new Int32Array(W);\n    for (let x = 0; x < W; x++) { xm[x] = (x - 1 + W) % W; xp[x] = (x + 1) % W; }\n    const seed = () => {\n      // A few dense blobs rather than uniform noise: they burst outward as ship swarms.\n      for (let k = 0; k < 3 + Math.floor(rand() * 3); k++) {\n        const cx = rand() * W, cy = rand() * H, r = Math.max(3, Math.min(W, H) * (0.12 + rand() * 0.1));\n        for (let y = Math.floor(cy - r); y <= cy + r; y++) for (let x = Math.floor(cx - r); x <= cx + r; x++) {\n          if (Math.hypot(x - cx, y - cy) > r || rand() > 0.35) continue;\n          cur[(((y % H) + H) % H) * W + (((x % W) + W) % W)] = 1;\n        }\n      }\n    };\n    seed();\n    // Firing cells are the hottest ink: white light at Night, deep ink by Day.\n    const fire = th.mode === 'light' ? mix(th.ink, th.info, 0.3) : mix(0xffffff, th.info, 0.15);\n    const refr = mix(th.bg, th.accent, 0.75), trail = th.info;\n    let acc = 0;\n    const step = () => {\n      let live = 0;\n      for (let y = 0; y < H; y++) {\n        const r0 = ((y - 1 + H) % H) * W, r1 = y * W, r2 = ((y + 1) % H) * W;\n        for (let x = 0; x < W; x++) {\n          const i = r1 + x, s = cur[i];\n          if (s === 1) { nxt[i] = 2; continue; }\n          if (s === 2) { nxt[i] = 0; continue; }\n          const a = xm[x], b = xp[x];\n          const n = (cur[r0 + a] === 1) + (cur[r0 + x] === 1) + (cur[r0 + b] === 1) + (cur[r1 + a] === 1) + (cur[r1 + b] === 1) + (cur[r2 + a] === 1) + (cur[r2 + x] === 1) + (cur[r2 + b] === 1);\n          if (n === 2) { nxt[i] = 1; live++; } else nxt[i] = 0;\n        }\n      }\n      [cur, nxt] = [nxt, cur];\n      for (let i = 0; i < N; i++) glow[i] = cur[i] === 1 ? 1 : glow[i] * 0.72;\n      if (live < N * 0.004) seed();\n    };\n\n    return (t, dt) => {\n      acc += Math.min(0.25, Math.max(0, dt)) * gps;\n      for (let k = 0; acc >= 1 && k < 6; k++, acc--) step();\n      if (acc > 6) acc = 0;\n      for (let i = 0; i < N; i++) {\n        const s = cur[i];\n        if (s === 1) px[i] = fire;\n        else if (s === 2) px[i] = refr;\n        else px[i] = glow[i] > 0.06 ? fadeMark(th, trail, glow[i] * 0.45) : NONE;\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/sim/double-pendulum.js": "// Sensitive dependence, made visible: three double pendulums released from\n// starting angles a thousandth of a radian apart, integrated with RK4 at\n// 480 Hz. For a few seconds they move as one; then the trails split and they\n// go their own chaotic ways. Each run restarts from the same three starts.\nimport { mix } from '../../color.js';\nimport { fadeMark } from '../../palettes.js';\n\nconst RUN = 20, HZ = 480, G = 9.81;\nconst TRAIL = 260; // trail samples kept per pendulum (recorded at 60 Hz)\n\nfunction deriv(s) {\n  const [t1, w1, t2, w2] = s;\n  const d = t1 - t2, den = 3 - Math.cos(2 * d); // m1 = m2 = 1, L1 = L2 = 1\n  const a1 = (-G * 3 * Math.sin(t1) - G * Math.sin(t1 - 2 * t2) - 2 * Math.sin(d) * (w2 * w2 + w1 * w1 * Math.cos(d))) / den;\n  const a2 = (2 * Math.sin(d) * (w1 * w1 * 2 + G * 2 * Math.cos(t1) + w2 * w2 * Math.cos(d))) / den;\n  return [w1, a1, w2, a2];\n}\nfunction rk4(s, h) {\n  const k1 = deriv(s);\n  const k2 = deriv(s.map((v, i) => v + (h / 2) * k1[i]));\n  const k3 = deriv(s.map((v, i) => v + (h / 2) * k2[i]));\n  const k4 = deriv(s.map((v, i) => v + h * k3[i]));\n  return s.map((v, i) => v + (h / 6) * (k1[i] + 2 * k2[i] + 2 * k3[i] + k4[i]));\n}\n\nexport default {\n  title: 'Double Pendulum',\n  gallery: 'sim',\n  category: 'Physics',\n  kind: 'scene',\n  tags: ['pendulum', 'chaos', 'rk4', 'butterfly effect', 'trails', 'braille'],\n  order: 20,\n  description: 'Three double pendulums started a thousandth of a radian apart — together at first, then chaos splits their trails.',\n  size: { w: 48, h: 24 },\n  minSize: { w: 16, h: 8 },\n  loop: RUN,\n  canvas: 'line',\n  palette: 'prism',\n  params: { pendulums: { default: 3, min: 1, max: 6, label: 'Pendulums' } },\n  setup(ctx) {\n    const cv = ctx.canvas('line');\n    const W = cv.width, H = cv.height, asp = cv.aspect, th = ctx.theme;\n    const pal = ctx.palette();\n    const count = Math.round(ctx.param('pendulums', 3));\n    const L = Math.min(W / 2, (H * asp) / 2) * 0.47; // arm length in pixels\n    const ox = W / 2, oy = (H * asp) / 2;\n    const cols = Array.from({ length: count }, (_, i) => mix(pal(i / count), th.ink, 0.15));\n    const fresh = () => Array.from({ length: count }, (_, i) => ({ s: [2.3 + i * 0.001, 0, 2.6, 0], trail: [] }));\n    let ps = fresh(), run = 0, acc = 0, rec = 0;\n    const tip = (s) => {\n      const x1 = ox + Math.sin(s[0]) * L, y1 = oy + Math.cos(s[0]) * L;\n      return [x1, y1, x1 + Math.sin(s[2]) * L, y1 + Math.cos(s[2]) * L];\n    };\n\n    return (t, dt) => {\n      const r = Math.floor(t / RUN);\n      if (r !== run) { run = r; ps = fresh(); acc = 0; }\n      acc += Math.min(0.25, Math.max(0, dt)) * HZ;\n      for (; acc >= 1; acc--) {\n        for (const p of ps) p.s = rk4(p.s, 1 / HZ);\n        if (++rec % (HZ / 60) === 0) for (const p of ps) { p.trail.push(tip(p.s).slice(2)); if (p.trail.length > TRAIL) p.trail.shift(); }\n      }\n      cv.clear();\n      // Trails, oldest (faintest) first; later pendulums drawn over earlier ones.\n      ps.forEach((p, k) => {\n        const tr = p.trail;\n        for (let i = 1; i < tr.length; i++) {\n          const f = i / tr.length;\n          cv.line(tr[i - 1][0], tr[i - 1][1] / asp, tr[i][0], tr[i][1] / asp, fadeMark(th, cols[k], 0.2 + 0.8 * Math.pow(f, 1.4)));\n        }\n      });\n      // Arms and bobs.\n      ps.forEach((p, k) => {\n        const [x1, y1, x2, y2] = tip(p.s);\n        cv.line(ox, oy / asp, x1, y1 / asp, th.muted);\n        cv.line(x1, y1 / asp, x2, y2 / asp, th.muted);\n        cv.circle(x1, y1 / asp, Math.max(1, L * 0.06), th.ink, true);\n        cv.circle(x2, y2 / asp, Math.max(1, L * 0.08), cols[k], true);\n      });\n      cv.circle(ox, oy / asp, Math.max(1, L * 0.04), th.ink, true);\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/sim/falling-sand.js": "// A falling-sand automaton like the old sand-art toys: three spouts sweep back\n// and forth pouring grains whose hue drifts slowly, so the pile builds up in\n// colored strata over sloped ledges. Grains fall, then slide diagonally when\n// blocked (never squeezing between two diagonal walls). Every cycle a trapdoor\n// opens, the whole pile drains out through the floor, and pouring starts again.\nimport { NONE, mix } from '../../color.js';\n\nconst CYCLE = 24, DRAIN = 6;\n\nexport default {\n  title: 'Falling Sand',\n  gallery: 'sim',\n  category: 'Particles',\n  kind: 'scene',\n  tags: ['sand', 'falling sand', 'automaton', 'granular', 'pile', 'sand art'],\n  order: 20,\n  description: 'Sweeping spouts pour color-drifting grains that pile into strata over ledges, then drain through a trapdoor.',\n  size: { w: 60, h: 22 },\n  minSize: { w: 20, h: 8 },\n  loop: CYCLE,\n  canvas: 'field',\n  palette: 'prism',\n  params: { rate: { default: 1, min: 0.2, max: 3, label: 'Pour rate' } },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const W = cv.width, H = cv.height, px = cv.px, th = ctx.theme;\n    const rand = ctx.rng, pal = ctx.palette();\n    const rate = ctx.param('rate', 1);\n    const EMPTY = 0, SAND = 1, WALL = 2;\n    const grid = new Uint8Array(W * H), col = new Int32Array(W * H);\n    const wallC = th.muted, wallTop = th.ink;\n    // Ledges: sloped shelves alternating sides, each stopping short of the far wall.\n    const ledge = (x0, y0, x1, y1) => {\n      const n = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0));\n      for (let s = 0; s <= n; s++) {\n        const x = Math.round(x0 + ((x1 - x0) * s) / n), y = Math.round(y0 + ((y1 - y0) * s) / n);\n        if (x >= 0 && x < W && y >= 0 && y < H) grid[y * W + x] = WALL;\n      }\n    };\n    const build = () => {\n      grid.fill(EMPTY);\n      const rows = H >= 24 ? 3 : H >= 14 ? 2 : 1;\n      for (let k = 0; k < rows; k++) {\n        const y = Math.round(H * (rows === 1 ? 0.5 : 0.3 + (0.42 * k) / (rows - 1)));\n        const left = k % 2 === 0, len = Math.round(W * (0.38 + rand() * 0.08)), drop = Math.max(1, Math.round(len * 0.22));\n        if (left) ledge(Math.round(W * 0.06), y - drop, Math.round(W * 0.06) + len, y);\n        else ledge(W - 1 - Math.round(W * 0.06), y - drop, W - 1 - Math.round(W * 0.06) - len, y);\n      }\n    };\n    build();\n    const spouts = [0.2, 0.5, 0.8].map((f, i) => ({ f, ph: rand() * 6.28, sp: 0.25 + rand() * 0.2, hue: i / 3 }));\n    let acc = 0, steps = 0, lastCycle = 0;\n\n    const step = (t, draining) => {\n      // Bottom-up so a grain moves at most once per step; alternate the scan\n      // direction per row and per step so piles don't lean one way.\n      const gate0 = Math.floor(W * 0.4), gate1 = Math.ceil(W * 0.6);\n      if (draining) for (let x = gate0; x < gate1; x++) { const i = (H - 1) * W + x; if (grid[i] === SAND) grid[i] = EMPTY; }\n      for (let y = H - 2; y >= 0; y--) {\n        const rtl = (y + steps) & 1;\n        for (let k = 0; k < W; k++) {\n          const x = rtl ? W - 1 - k : k, i = y * W + x;\n          if (grid[i] !== SAND) continue;\n          const d = i + W;\n          if (grid[d] === EMPTY) { grid[d] = SAND; col[d] = col[i]; grid[i] = EMPTY; continue; }\n          const first = rand() < 0.5 ? -1 : 1;\n          for (const s of [first, -first]) {\n            const nx = x + s;\n            if (nx < 0 || nx >= W) continue;\n            if (grid[d + s] === EMPTY && grid[i + s] === EMPTY) { grid[d + s] = SAND; col[d + s] = col[i]; grid[i] = EMPTY; break; }\n          }\n        }\n      }\n      if (!draining) {\n        for (const s of spouts) {\n          const x = Math.round((s.f + Math.sin(t * s.sp + s.ph) * 0.12) * (W - 1));\n          if (rand() > 0.55 * rate || x < 0 || x >= W || grid[x] !== EMPTY) continue;\n          grid[x] = SAND;\n          const hue = (s.hue + t * 0.021) % 1;\n          col[x] = mix(pal(hue + (rand() - 0.5) * 0.03), 0xd9b77a, 0.3 + rand() * 0.12);\n        }\n      }\n      steps++;\n    };\n\n    return (t, dt) => {\n      const c = Math.floor(t / CYCLE);\n      if (c !== lastCycle) { lastCycle = c; build(); }\n      const local = t - c * CYCLE, draining = local > CYCLE - DRAIN;\n      acc += dt * 60;\n      for (let k = 0; acc >= 1 && k < 10; k++, acc--) step(t, draining);\n      if (acc > 10) acc = 0;\n      for (let i = 0; i < W * H; i++) {\n        const g = grid[i];\n        if (g === EMPTY) px[i] = NONE;\n        else if (g === WALL) px[i] = i >= W && grid[i - W] !== WALL ? wallTop : wallC;\n        else {\n          // Surface grains catch the light; buried ones sit a touch darker.\n          const top = i < W || grid[i - W] === EMPTY;\n          const c0 = col[i];\n          const k = top ? 1.12 : 0.92;\n          px[i] = (Math.min(255, ((c0 >> 16) & 255) * k) << 16) | (Math.min(255, ((c0 >> 8) & 255) * k) << 8) | Math.min(255, (c0 & 255) * k);\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/sim/fireworks.js": "// Fireworks: rockets climb on sparkling trails and burst into one of four shell\n// types — peony (sphere), ring, willow, crackle — whose sparks fly under\n// gravity and drag, cool from white-hot to ember, and leave glowing trails\n// (additive plotting on a persistent, fading canvas). The lower band of the\n// sky is mirrored into a rippling lake.\nimport { mix, NONE } from '../../color.js';\n\nconst WHITE = 0xffffff, EMBER = 0x6a1606, GOLD = 0xffc35a;\n\nexport default {\n  title: 'Fireworks',\n  gallery: 'sim',\n  category: 'Particles',\n  kind: 'scene',\n  tags: ['fireworks', 'particles', 'celebration', 'glow'],\n  order: 30,\n  description: 'Rockets and four shell types (peony, ring, willow, crackle) with gravity, drag, additive glow trails and a lake reflection.',\n  canvas: 'line',\n  palette: 'prism',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const { width: W, height: H, aspect, px } = cv;\n    const rand = ctx.rng;\n    const pal = ctx.palette();\n    const reflect = ctx.param('reflect', true) && H >= 16;\n    const Hv = H * aspect; // vertical extent in horizontal-pixel units (keeps bursts round)\n    const S = Math.min(W, Hv);\n    const g = S * 0.42;\n    const groundRow = reflect ? Math.floor(H * 0.84) : H;\n    const ground = groundRow * aspect;\n    const maxSparks = Math.max(400, Math.min(6000, Math.round(W * H * 0.08)));\n    const rockets = [], sparks = [];\n    let next = 0.2;\n\n    const addLine = (x0, y0, x1, y1, c) => {\n      const n = Math.min(64, Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0)))));\n      for (let s = 1; s <= n; s++) cv.add(x0 + ((x1 - x0) * s) / n, y0 + ((y1 - y0) * s) / n, c);\n    };\n    const dim = (c, k) => ((((c >> 16) & 255) * k) << 16) | ((((c >> 8) & 255) * k) << 8) | (((c & 255) * k) | 0);\n    const spark = (o) => { if (sparks.length < maxSparks) sparks.push(o); };\n\n    const launch = () => {\n      const x = W * (0.12 + 0.76 * rand());\n      const apex = Hv * (0.1 + 0.32 * rand());\n      rockets.push({\n        x, y: ground, vx: (rand() - 0.5) * S * 0.12,\n        vy: -Math.sqrt(2 * g * (ground - apex)),\n        color: pal(rand()), type: rand(),\n      });\n    };\n\n    const explode = (r) => {\n      const c2 = rand() < 0.3 ? pal(rand()) : r.color;\n      const base = { x: r.x, y: r.y, crackle: false, gf: 1 };\n      if (r.type < 0.38) { // peony: sphere projected to the screen\n        const n = 110 + Math.floor(rand() * 70), s = S * (0.7 + rand() * 0.3);\n        for (let i = 0; i < n; i++) {\n          const z = rand() * 2 - 1, a = rand() * Math.PI * 2, q = Math.sqrt(1 - z * z);\n          const k = s * (0.92 + rand() * 0.16);\n          const life = 1.2 + rand() * 0.6;\n          spark({ ...base, vx: Math.cos(a) * q * k, vy: Math.sin(a) * q * k, life, max: life, drag: 1.7, color: i % 2 ? r.color : c2 });\n        }\n      } else if (r.type < 0.6) { // ring, tilted and rotated\n        const n = 48 + Math.floor(rand() * 18), s = S * (0.72 + rand() * 0.2);\n        const tilt = 0.3 + rand() * 0.7, rot = rand() * Math.PI;\n        for (let i = 0; i < n; i++) {\n          const a = (i / n) * Math.PI * 2, ex = Math.cos(a), ey = Math.sin(a) * tilt;\n          const vx = (ex * Math.cos(rot) - ey * Math.sin(rot)) * s, vy = (ex * Math.sin(rot) + ey * Math.cos(rot)) * s;\n          spark({ ...base, vx, vy, life: 1.4, max: 1.4, drag: 1.5, color: r.color });\n        }\n        for (let i = 0; i < 18; i++) { // a small core\n          const a = rand() * Math.PI * 2, k = s * 0.25 * rand();\n          spark({ ...base, vx: Math.cos(a) * k, vy: Math.sin(a) * k, life: 0.9, max: 0.9, drag: 2, color: c2 });\n        }\n      } else if (r.type < 0.8) { // willow: slow, heavy drag, long golden fall\n        const n = 110 + Math.floor(rand() * 40), s = S * (0.55 + rand() * 0.15);\n        for (let i = 0; i < n; i++) {\n          const a = rand() * Math.PI * 2, k = s * Math.sqrt(rand());\n          const life = 2.6 + rand() * 1.2;\n          spark({ ...base, vx: Math.cos(a) * k, vy: Math.sin(a) * k - s * 0.1, life, max: life, drag: 2.6, gf: 0.55, color: GOLD, trail: [] });\n        }\n      } else { // crackle: a sphere whose sparks split into flickering white glitter\n        const n = 80 + Math.floor(rand() * 30), s = S * (0.65 + rand() * 0.2);\n        for (let i = 0; i < n; i++) {\n          const z = rand() * 2 - 1, a = rand() * Math.PI * 2, q = Math.sqrt(1 - z * z);\n          const life = 0.8 + rand() * 0.4;\n          spark({ ...base, vx: Math.cos(a) * q * s, vy: Math.sin(a) * q * s, life, max: life, drag: 1.8, color: r.color, crackle: true });\n        }\n      }\n    };\n\n    // Fireworks need a night sky, even by Day.\n    const sky = ctx.theme.mode === 'light' ? 0x080a14 : NONE;\n    return (t, dt) => {\n      if (sky !== NONE) ctx.buffer.fill(0, 0, ctx.width, ctx.height, ' ', ctx.theme.ink, sky);\n      cv.fade(Math.exp(-dt * 11));\n      // Clear the lake band; it is rebuilt from the sky every frame.\n      if (reflect) px.fill(NONE, groundRow * W);\n\n      if (t >= next) {\n        launch();\n        if (rand() < 0.18) { launch(); launch(); }\n        next = t + 0.35 + rand() * 1.1;\n      }\n\n      for (let i = rockets.length - 1; i >= 0; i--) {\n        const r = rockets[i];\n        const ox = r.x, oy = r.y;\n        r.vy += g * dt; r.x += r.vx * dt; r.y += r.vy * dt;\n        addLine(ox, oy / aspect, r.x, r.y / aspect, 0xffd9a0);\n        if (rand() < 0.6) spark({ x: r.x, y: r.y, vx: (rand() - 0.5) * S * 0.05, vy: S * 0.05 * rand(), life: 0.35, max: 0.35, drag: 3, gf: 0.3, color: 0xffa94d, crackle: false });\n        if (r.vy > -S * 0.04) { explode(r); rockets.splice(i, 1); }\n      }\n\n      for (let i = sparks.length - 1; i >= 0; i--) {\n        const p = sparks[i];\n        const ox = p.x, oy = p.y;\n        const d = Math.exp(-p.drag * dt);\n        p.vx *= d; p.vy = p.vy * d + g * p.gf * dt;\n        p.x += p.vx * dt; p.y += p.vy * dt;\n        p.life -= dt;\n        if (p.life <= 0 || p.y > ground || p.x < -4 || p.x > W + 4) {\n          if (p.crackle && p.life <= 0) {\n            for (let k = 0; k < 4; k++) {\n              const a = rand() * Math.PI * 2, s = S * 0.08 * rand();\n              spark({ x: p.x, y: p.y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, life: 0.3, max: 0.3, drag: 4, gf: 0.2, color: WHITE, crackle: false, flicker: true });\n            }\n          }\n          sparks.splice(i, 1);\n          continue;\n        }\n        if (p.flicker && rand() < 0.45) continue;\n        const age = 1 - p.life / p.max;\n        let c = age < 0.12 ? mix(WHITE, p.color, age / 0.12) : age > 0.65 ? mix(p.color, EMBER, (age - 0.65) / 0.35) : p.color;\n        c = dim(c, Math.pow(p.life / p.max, 0.6) * 0.85 + 0.15);\n        addLine(ox, oy / aspect, p.x, p.y / aspect, c);\n        if (p.trail) {\n          // Willow sparks drag a long fading tail of their own recent positions.\n          p.trail.push(p.x, p.y);\n          if (p.trail.length > 28) p.trail.splice(0, 2);\n          const n = p.trail.length / 2;\n          for (let k = 1; k < n; k++) {\n            const tk = dim(c, (k / n) * 0.55);\n            addLine(p.trail[2 * k - 2], p.trail[2 * k - 1] / aspect, p.trail[2 * k], p.trail[2 * k + 1] / aspect, tk);\n          }\n        }\n      }\n\n      if (reflect) {\n        // Mirror the whole sky into the lake band, squashed vertically and rippled.\n        // Each lake row samples a slab of sky rows and keeps the brightest pixel\n        // so single sparks survive the squash.\n        const rows = H - groundRow, step = groundRow / rows;\n        for (let y = groundRow; y < H; y++) {\n          const d = (y - groundRow + 0.5) / rows;\n          const s0 = Math.max(0, Math.floor(groundRow - (d + 0.5 / rows) * groundRow));\n          const s1 = Math.min(groundRow - 1, Math.ceil(s0 + step));\n          const k = 0.42 * (1 - d * 0.55);\n          const shift = Math.round(Math.sin(y * 1.9 + t * 2.6) * (1 + d * 2.5));\n          for (let x = 0; x < W; x++) {\n            const sx = (x + shift + W) % W;\n            let best = NONE, bl = -1;\n            for (let sy = s0; sy <= s1; sy++) {\n              const c = px[sy * W + sx];\n              if (c === NONE) continue;\n              const l = ((c >> 16) & 255) + ((c >> 8) & 255) + (c & 255);\n              if (l > bl) { bl = l; best = c; }\n            }\n            px[y * W + x] = best === NONE ? NONE : dim(best, k);\n          }\n        }\n        // A faint dashed shoreline.\n        const shore = mix(ctx.theme.line, ctx.theme.muted, 0.5);\n        for (let x = 0; x < W; x += 3) if (px[groundRow * W + x] === NONE) px[groundRow * W + x] = shore;\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/sim/flowfield.js": "// Flow field: particles drift through a slowly evolving curl-noise field (the\n// curl of a Perlin potential is divergence-free, so streams swirl without\n// collapsing into sinks), painting silky braille trails on a persistent canvas\n// that fades toward the background. Hue follows heading, brightness speed.\nimport { NONE } from '../../color.js';\nexport default {\n  title: 'Flow Field',\n  gallery: 'sim',\n  category: 'Particles',\n  kind: 'scene',\n  tags: ['flow field', 'particles', 'noise', 'trails'],\n  order: 50,\n  description: 'Particles advected through an evolving curl-noise flow field, leaving fading silk trails colored by heading.',\n  canvas: 'line',\n  palette: 'prism',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const { width: W, height: H, aspect } = cv;\n    const rand = ctx.rng;\n    const pal = ctx.palette();\n    const { noise3 } = ctx.noise;\n    const speedK = ctx.param('speed', 1);\n    const Hv = H * aspect;\n    const sc = 2.6 / Math.max(W, Hv);\n    const spd = Math.min(W, Hv) * 0.16 * speedK;\n    const N = Math.max(150, Math.min(2600, Math.round(W * H * 0.012)));\n    const X = new Float32Array(N), Y = new Float32Array(N), A = new Float32Array(N), L = new Float32Array(N);\n    const spawn = (i) => { X[i] = rand() * W; Y[i] = rand() * Hv; A[i] = 0; L[i] = 2 + rand() * 5; };\n    for (let i = 0; i < N; i++) { spawn(i); A[i] = rand() * L[i]; }\n\n    const bg = ctx.bg === NONE ? 0 : ctx.bg;\n    const bgR = (bg >> 16) & 255, bgG = (bg >> 8) & 255, bgB = bg & 255;\n    const px = cv.px;\n    // Fade toward the background color (not black) so old trails never read as\n    // dark smudges; pixels that reach the background become transparent again.\n    const fade = (k) => {\n      for (let i = 0; i < px.length; i++) {\n        const c = px[i];\n        if (c === NONE) continue;\n        const r = bgR + (((c >> 16) & 255) - bgR) * k, g = bgG + (((c >> 8) & 255) - bgG) * k, b = bgB + ((c & 255) - bgB) * k;\n        px[i] = Math.abs(r - bgR) + Math.abs(g - bgG) + Math.abs(b - bgB) < 12 ? NONE : (r << 16) | (g << 8) | (b | 0);\n      }\n    };\n    // Ink on paper needs longer trails than light on glass to read.\n    const decay = ctx.theme.mode === 'light' ? 0.7 : 1.5;\n    const e = 0.75; // finite-difference step, in noise space units ÷ sc\n    return (t, dt) => {\n      fade(Math.exp(-dt * decay));\n      const z = t * 0.07;\n      for (let i = 0; i < N; i++) {\n        const x0 = X[i], y0 = Y[i];\n        const nx = x0 * sc, ny = y0 * sc;\n        // Curl of the potential ψ: v = (∂ψ/∂y, −∂ψ/∂x).\n        const dpy = noise3(nx, ny + e * sc, z) - noise3(nx, ny - e * sc, z);\n        const dpx = noise3(nx + e * sc, ny, z) - noise3(nx - e * sc, ny, z);\n        let vx = dpy / (2 * e * sc), vy = -dpx / (2 * e * sc);\n        const m = Math.hypot(vx, vy) || 1e-6;\n        const sp = Math.min(1.6, 0.35 + m * 0.6);\n        vx = (vx / m) * sp; vy = (vy / m) * sp;\n        X[i] += vx * spd * dt;\n        Y[i] += vy * spd * dt;\n        A[i] += dt;\n        // Fade in on spawn and out at end of life so respawns never pop.\n        const life = Math.min(1, A[i] / 0.6, (L[i] - A[i]) / 0.6);\n        if (life <= 0 || X[i] < 0 || X[i] >= W || Y[i] < 0 || Y[i] >= Hv) { spawn(i); continue; }\n        const hue = Math.atan2(vy, vx) / (Math.PI * 2) + 0.5;\n        const c = pal(hue + t * 0.02);\n        const k = Math.min(1, 0.45 + 0.4 * sp) * life;\n        const r = bgR + (((c >> 16) & 255) - bgR) * k, g = bgG + (((c >> 8) & 255) - bgG) * k, bl = bgB + ((c & 255) - bgB) * k;\n        cv.line(x0, y0 / aspect, X[i], Y[i] / aspect, (r << 16) | (g << 8) | (bl | 0));\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/sim/galaxy.js": "// A rotating barred-free spiral galaxy built on the density-wave trick: each\n// star orbits on an ellipse whose tilt grows with radius, and inner orbits run\n// faster (differential rotation). Where neighboring ellipses crowd together\n// two spiral arms emerge — and, unlike real winding, they never wrap up.\n// The glow is a pre-baked texture of that same star density plus a hot core,\n// sampled per pixel through the view transform; stars are added on top.\nimport { mix, NONE } from '../../color.js';\n\nconst EXT = 1.15; // texture half-extent in galaxy units\n\nexport default {\n  title: 'Spiral Galaxy',\n  gallery: 'sim',\n  category: 'Particles',\n  kind: 'scene',\n  tags: ['galaxy', 'spiral', 'stars', 'space'],\n  order: 40,\n  description: 'Density-wave spiral galaxy — thousands of stars on tilted elliptical orbits with differential rotation, glowing core and dust.',\n  canvas: 'field',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const { width: W, height: H, aspect, px } = cv;\n    const rand = ctx.rng;\n    const speed = ctx.param('speed', 1);\n    const starCol = ctx.palette(['#ffcf9a', '#fff1dd', '#dfe7ff', '#9fb9ff', '#7f9cff']);\n    const Hv = H * aspect;\n    const incl = 0.62, tilt = -0.3, twist = 4.2;\n    const Rg = Math.min(W * 0.47, (Hv * 0.47) / (incl + 0.25 * Math.abs(Math.sin(tilt))));\n    const cx = W / 2, cy = H / 2;\n\n    const ecc = (r) => 1 - 0.55 * Math.sqrt(Math.sin(Math.PI * Math.min(1, r)));\n    const orbit = (r, th) => {\n      const a = r, b = r * ecc(r), ph = r * twist;\n      const c = Math.cos(th), s = Math.sin(th), cp = Math.cos(ph), sp = Math.sin(ph);\n      return [a * c * cp - b * s * sp, a * c * sp + b * s * cp];\n    };\n    // 75% disk (uniform by area, so the arms carry the mass) + 25% exponential bulge.\n    const sampleR = () => (rand() < 0.75 ? Math.sqrt(rand()) : Math.min(1, -Math.log(1 - rand() * 0.98) * 0.12));\n\n    // ── bake the glow texture from the orbit density ──\n    const G = 160;\n    const dens = new Float32Array(G * G);\n    for (let n = 0; n < 70000; n++) {\n      const [x, y] = orbit(sampleR(), rand() * Math.PI * 2);\n      const ix = Math.floor(((x / EXT) * 0.5 + 0.5) * G), iy = Math.floor(((y / EXT) * 0.5 + 0.5) * G);\n      if (ix >= 0 && iy >= 0 && ix < G && iy < G) dens[iy * G + ix]++;\n    }\n    const tmp = new Float32Array(G * G);\n    for (let pass = 0; pass < 3; pass++) {\n      for (let y = 0; y < G; y++) for (let x = 0; x < G; x++) {\n        let s = 0;\n        for (let k = -2; k <= 2; k++) s += dens[y * G + Math.min(G - 1, Math.max(0, x + k))];\n        tmp[y * G + x] = s / 5;\n      }\n      for (let y = 0; y < G; y++) for (let x = 0; x < G; x++) {\n        let s = 0;\n        for (let k = -2; k <= 2; k++) s += tmp[Math.min(G - 1, Math.max(0, y + k)) * G + x];\n        dens[y * G + x] = s / 5;\n      }\n    }\n    // Normalize against the disk (not the core spike) so the arms keep contrast.\n    let ref = 0;\n    for (let y = 0; y < G; y++) for (let x = 0; x < G; x++) {\n      const gx = ((x + 0.5) / G * 2 - 1) * EXT, gy = ((y + 0.5) / G * 2 - 1) * EXT;\n      if (Math.hypot(gx, gy) > 0.3) ref = Math.max(ref, dens[y * G + x]);\n    }\n    const tex = new Int32Array(G * G);\n    // A galaxy is a night-sky object: on light themes it keeps its own deep-space\n    // backdrop (additive starlight on pearl would wash out to white).\n    const sky = ctx.theme.mode === 'light' ? 0x06070b : NONE;\n    const core = 0xffe6b8, disk = 0x8aa6ff, arm = 0xc9a7ff, bg = sky !== NONE ? sky : ctx.bg < 0 ? 0 : ctx.bg;\n    for (let y = 0; y < G; y++) for (let x = 0; x < G; x++) {\n      const gx = ((x + 0.5) / G * 2 - 1) * EXT, gy = ((y + 0.5) / G * 2 - 1) * EXT, r = Math.hypot(gx, gy);\n      const d = Math.min(1, dens[y * G + x] / (ref || 1));\n      const dust = 0.7 + 0.3 * ctx.noise.fbm(gx * 5, gy * 5, 0.5, 3);\n      const kc = Math.exp(-((r / 0.06) ** 2)) * 1.0 + Math.exp(-r / 0.1) * 0.35;\n      const kd = Math.pow(d, 1.6) * 0.42 * dust * Math.min(1, r / 0.12);\n      const R = ((bg >> 16) & 255) + ((core >> 16) & 255) * kc + ((mix(disk, arm, d) >> 16) & 255) * kd;\n      const Gc = ((bg >> 8) & 255) + ((core >> 8) & 255) * kc + ((mix(disk, arm, d) >> 8) & 255) * kd;\n      const B = (bg & 255) + (core & 255) * kc + (mix(disk, arm, d) & 255) * kd;\n      tex[y * G + x] = kc + kd < 0.012 ? sky : (Math.min(255, R) << 16) | (Math.min(255, Gc) << 8) | Math.min(255, B);\n    }\n\n    // ── stars ──\n    const NS = Math.max(600, Math.min(9000, Math.round(W * H * 0.22)));\n    const SR = new Float32Array(NS), ST = new Float32Array(NS), SW = new Float32Array(NS), SC = new Int32Array(NS);\n    for (let i = 0; i < NS; i++) {\n      const r = sampleR();\n      SR[i] = r;\n      ST[i] = rand() * Math.PI * 2;\n      SW[i] = (0.55 / Math.pow(0.12 + r, 0.8)) * (0.9 + rand() * 0.2);\n      // Bulge stars overlap heavily under additive blending, so keep them faint.\n      const b = (0.1 + 0.9 * Math.pow(rand(), 3.5)) * (0.3 + 0.7 * Math.min(1, r / 0.35));\n      const c = rand() < 0.035 && r > 0.25 ? 0xff7fb0 : starCol(Math.min(1, r * 0.9 + (rand() - 0.5) * 0.35));\n      SC[i] = (((c >> 16) & 255) * b << 16) | (((c >> 8) & 255) * b << 8) | (((c & 255) * b) | 0);\n    }\n    const FIELD = Math.round(W * H * 0.0025);\n    const field = Array.from({ length: FIELD }, () => [rand() * W, rand() * H, mix(bg, 0xcfd8ff, 0.12 + rand() * 0.3)]);\n\n    const ct = Math.cos(tilt), st = Math.sin(tilt);\n    return (t) => {\n      const rot = t * 0.04 * speed, cr = Math.cos(rot), sr = Math.sin(rot);\n      // Glow: inverse-map each pixel into galaxy space and sample the texture.\n      for (let y = 0, i = 0; y < H; y++) {\n        const y3 = ((y + 0.5 - cy) * aspect) / Rg;\n        for (let x = 0; x < W; x++, i++) {\n          const x3 = (x + 0.5 - cx) / Rg;\n          const x1 = x3 * ct + y3 * st, y1 = (-x3 * st + y3 * ct) / incl;\n          const gx = x1 * cr + y1 * sr, gy = -x1 * sr + y1 * cr;\n          const ix = Math.floor(((gx / EXT) * 0.5 + 0.5) * G), iy = Math.floor(((gy / EXT) * 0.5 + 0.5) * G);\n          px[i] = ix >= 0 && iy >= 0 && ix < G && iy < G ? tex[iy * G + ix] : sky;\n        }\n      }\n      for (const [x, y, c] of field) cv.add(x, y, c);\n      // Stars: advance along their ellipse, rotate with the disk, incline, tilt.\n      for (let i = 0; i < NS; i++) {\n        const [gx, gy] = orbit(SR[i], ST[i] + SW[i] * t * speed);\n        const x1 = gx * cr - gy * sr, y1 = (gx * sr + gy * cr) * incl;\n        const x3 = x1 * ct - y1 * st, y3 = x1 * st + y1 * ct;\n        cv.add(cx + x3 * Rg, cy + (y3 * Rg) / aspect, SC[i]);\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/sim/harmonograph.js": "// A rotary harmonograph: two damped pendulums per axis (near-integer frequency\n// ratios with a little detune) trace a decaying Lissajous figure. Each cycle a\n// pen draws the curve in, the figure breathes as its phases drift, then it\n// fades and a fresh set of pendulums takes over. Color flows along the path.\nimport { mix, NONE } from '../../color.js';\nimport { rng, easing, smoothstep } from '../../easing.js';\n\nconst RATIOS = [[2, 3], [3, 4], [1, 2], [3, 5], [4, 5], [2, 5], [1, 3], [5, 6], [1, 1], [3, 2]];\n\nexport default {\n  title: 'Harmonograph',\n  gallery: 'sim',\n  category: 'Physics',\n  kind: 'scene',\n  tags: ['harmonograph', 'lissajous', 'pendulum', 'curves'],\n  order: 20,\n  description: 'Damped rotary harmonograph — a pen traces evolving Lissajous figures in braille, re-tuning its pendulums every cycle.',\n  canvas: 'line',\n  palette: 'prism',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const { width: W, height: H, aspect } = cv;\n    const pal = ctx.palette();\n    const C = ctx.param('cycle', 14);\n    const Hv = H * aspect;\n    const cx = W / 2, cy = H / 2, amp = Math.min(W, Hv) * 0.47, ampX = Math.min(W * 0.47, amp * 1.3);\n    const CYCLES = 55, PER = 44; // pendulum swings drawn per figure, samples per swing\n    const N = CYCLES * PER;\n    const bg = ctx.bg === NONE ? 0 : ctx.bg;\n\n    // Pendulum set for cycle k: x and y run at a near-integer ratio a:b, and\n    // each axis sums two slightly detuned pendulums so the figure beats/rotates.\n    const tune = (k) => {\n      const r = rng((ctx.seed * 7919 + k * 104729) >>> 0);\n      const [a, b] = RATIOS[Math.floor(r() * RATIOS.length)];\n      const fx = a + (r() - 0.5) * 0.01, fy = b + (r() - 0.5) * 0.01;\n      const det = () => (r() < 0.5 ? -1 : 1) * (0.004 + r() * 0.012) * Math.max(a, b);\n      const S = (CYCLES * Math.PI * 2) / Math.max(a, b);\n      const damp = () => (Math.log(12) / S) * (0.6 + r() * 0.7);\n      const A = [0.45 + r() * 0.3, 0, 0.45 + r() * 0.3, 0];\n      A[1] = 1 - A[0]; A[3] = 1 - A[2];\n      return {\n        S,\n        f: [fx, fx + det(), fy, fy + det()],\n        p: [r() * 6.28, r() * 6.28, r() * 6.28, r() * 6.28],\n        d: [damp(), damp(), damp(), damp()],\n        A,\n        rot: (r() - 0.5) * (1.2 / S),\n        hue: r(),\n      };\n    };\n    let cached = -1, P = null;\n\n    return (t) => {\n      const k = Math.floor(t / C), u = (t - k * C) / C;\n      if (k !== cached) { P = tune(k); cached = k; }\n      const progress = easing.inOutSine(Math.min(1, u / 0.55));\n      const alpha = 1 - smoothstep(0.86, 1, u);\n      const drift = (t - k * C) * 0.08;\n      const { S, f, p, d, A, rot, hue } = P;\n      cv.clear();\n      const n = Math.max(2, Math.floor(N * progress));\n      let px0 = 0, py0 = 0;\n      for (let i = 0; i <= n; i++) {\n        const s = (i / N) * S;\n        let x = A[0] * Math.sin(f[0] * s + p[0] + drift) * Math.exp(-d[0] * s) + A[1] * Math.sin(f[1] * s + p[1]) * Math.exp(-d[1] * s);\n        let y = A[2] * Math.sin(f[2] * s + p[2] - drift * 0.7) * Math.exp(-d[2] * s) + A[3] * Math.sin(f[3] * s + p[3]) * Math.exp(-d[3] * s);\n        const a = rot * s, ca = Math.cos(a), sa = Math.sin(a);\n        [x, y] = [x * ca - y * sa, x * sa + y * ca];\n        const X = cx + x * ampX, Y = cy + (y * amp) / aspect;\n        if (i > 0) {\n          const along = i / N;\n          const c = pal(hue + along * 0.5 + t * 0.015);\n          cv.line(px0, py0, X, Y, mix(bg, c, alpha * (0.45 + 0.55 * (1 - along * 0.6))));\n        }\n        px0 = X; py0 = Y;\n      }\n      if (u < 0.55) {\n        // The pen: a bright nib at the drawing point.\n        const c = mix(pal(hue + (n / N) * 0.5 + t * 0.015), 0xffffff, 0.6);\n        cv.ellipse(px0, py0, 1.5 * cv.sx / 2, (1.5 * cv.sx / 2) / aspect, c, true);\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/sim/langtons-ant.js": "// Langton's ant at braille resolution: on a white cell turn right, on a black\n// cell turn left, flip the cell, step. Two ants share the grid and the cells\n// they flip carry their color. The step rate ramps up through each run so\n// the famous transition — ten thousand steps of chaos, then the endless\n// diagonal \"highway\" — arrives inside the loop. A step counter ticks below.\nimport { NONE } from '../../color.js';\n\nconst RUN = 30;\nconst DIRS = [[0, -1], [1, 0], [0, 1], [-1, 0]];\n\nexport default {\n  title: \"Langton's Ant\",\n  gallery: 'sim',\n  category: 'Cellular Automata',\n  kind: 'scene',\n  tags: ['langton', 'ant', 'turmite', 'cellular automata', 'emergence', 'braille'],\n  order: 20,\n  description: \"Two Langton's ants at braille resolution, accelerating through ten thousand steps of chaos into the highway.\",\n  size: { w: 60, h: 22 },\n  minSize: { w: 20, h: 7 },\n  loop: RUN,\n  canvas: 'line',\n  palette: 'prism',\n  params: { ants: { default: 2, min: 1, max: 4, label: 'Ants' } },\n  setup(ctx) {\n    const label = ctx.height >= 10;\n    const cv = ctx.canvas('line', { rows: label ? ctx.height - 1 : ctx.height });\n    const W = cv.width, H = cv.height, px = cv.px, th = ctx.theme;\n    const pal = ctx.palette();\n    const nAnts = Math.round(ctx.param('ants', 2));\n    const cell = new Uint8Array(W * H), owner = new Uint8Array(W * H);\n    const cols = Array.from({ length: nAnts }, (_, i) => pal(0.55 + i * 0.27));\n    let ants = [], steps = 0, run = -1, acc = 0;\n    const reset = () => {\n      cell.fill(0);\n      ants = Array.from({ length: nAnts }, (_, i) => ({\n        x: Math.floor(W * (nAnts === 1 ? 0.5 : 0.3 + (0.4 * i) / (nAnts - 1))),\n        y: Math.floor(H * (0.5 + (i % 2 ? 0.12 : -0.12) * (nAnts > 1 ? 1 : 0))),\n        d: i % 4,\n      }));\n      steps = 0;\n    };\n    const step = () => {\n      for (let k = 0; k < ants.length; k++) {\n        const a = ants[k], i = a.y * W + a.x;\n        a.d = (a.d + (cell[i] ? 3 : 1)) & 3; // white (0) → right, black (1) → left\n        cell[i] ^= 1; owner[i] = k;\n        a.x = (a.x + DIRS[a.d][0] + W) % W; a.y = (a.y + DIRS[a.d][1] + H) % H;\n      }\n      steps++;\n    };\n\n    return (t, dt) => {\n      const r = Math.floor(t / RUN);\n      if (r !== run) { run = r; reset(); acc = 0; }\n      // Steps per second ramp from 150 to ~2400 across the run.\n      const local = t - r * RUN, rate = 150 + 2250 * Math.pow(local / RUN, 1.5);\n      acc += Math.min(0.25, Math.max(0, dt)) * rate;\n      for (; acc >= 1; acc--) step();\n      for (let i = 0; i < W * H; i++) px[i] = cell[i] ? cols[owner[i]] : NONE;\n      for (const a of ants) px[a.y * W + a.x] = th.ink;\n      cv.paint(ctx.buffer);\n      if (label) {\n        const txt = `step ${steps.toLocaleString('en-US')}`;\n        ctx.buffer.text(1, ctx.height - 1, txt.slice(0, ctx.width - 2), th.muted);\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/sim/life.js": "// Conway's Game of Life on a toroidal grid at canvas-pixel resolution (8 cells\n// per terminal cell in braille). Cells are colored by age — newborns white-hot,\n// survivors cooling through the theme's accents — and recently dead cells leave\n// a ghost glow in block modes. A rolling state hash detects stagnation and\n// short cycles, which trigger fresh soup; gliders are fired in periodically.\n\nimport { NONE } from '../../color.js';\n\nconst GLIDER = [[1, 0], [2, 1], [0, 2], [1, 2], [2, 2]];\nconst LWSS = [[1, 0], [4, 0], [0, 1], [0, 2], [4, 2], [0, 3], [1, 3], [2, 3], [3, 3]];\n\nexport default {\n  title: 'Game of Life',\n  gallery: 'sim',\n  category: 'Cellular Automata',\n  kind: 'scene',\n  tags: ['game of life', 'conway', 'cellular automata', 'braille'],\n  order: 10,\n  description: \"Conway's Life at braille resolution — age-colored cells, ghost trails, auto-reseeding on stagnation, injected gliders.\",\n  canvas: 'line',\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const { width: W, height: H, px } = cv;\n    const rand = ctx.rng;\n    const th = ctx.theme;\n    const gps = 14 * ctx.param('speed', 1);\n    // Newborn cells flash the brightest ink the theme has: white light at Night, ink by Day.\n    const ageCol = ctx.palette([th.mode === 'light' ? th.ink : '#ffffff', th.accent, th.info, th.pos, th.crit]);\n    const AGES = 90;\n    const lut = new Int32Array(AGES);\n    for (let a = 0; a < AGES; a++) lut[a] = ageCol(Math.pow(a / (AGES - 1), 0.6));\n    const ghosts = cv.mode !== 'braille' && cv.mode !== 'ascii';\n    const N = W * H;\n    let cur = new Uint8Array(N), nxt = new Uint8Array(N);\n    const age = new Uint16Array(N);\n    const heat = new Float32Array(N);\n    const xm = new Int32Array(W), xp = new Int32Array(W);\n    for (let x = 0; x < W; x++) { xm[x] = (x - 1 + W) % W; xp[x] = (x + 1) % W; }\n\n    const soup = (x0, y0, w, h, density) => {\n      for (let y = y0; y < y0 + h; y++)\n        for (let x = x0; x < x0 + w; x++) {\n          const i = (((y % H) + H) % H) * W + (((x % W) + W) % W);\n          if (rand() < density) { cur[i] = 1; age[i] = 0; }\n        }\n    };\n    const stamp = (shape, ox, oy, fx, fy) => {\n      const sw = Math.max(...shape.map((p) => p[0])), sh = Math.max(...shape.map((p) => p[1]));\n      for (const [x, y] of shape) {\n        const X = ((ox + (fx ? sw - x : x)) % W + W) % W, Y = ((oy + (fy ? sh - y : y)) % H + H) % H;\n        cur[Y * W + X] = 1; age[Y * W + X] = 0;\n      }\n    };\n    const reseed = () => {\n      const n = 2 + Math.floor(rand() * 3), s = Math.max(6, Math.floor(Math.min(W, H) * 0.35));\n      for (let k = 0; k < n; k++) soup(Math.floor(rand() * W), Math.floor(rand() * H), s, s, 0.38);\n    };\n    soup(0, 0, W, H, 0.27);\n\n    const recent = new Map(); // state hash → generation it was seen\n    let gen = 0, stuck = 0, acc = 0, nextShip = 2 + rand() * 3, pop = 0;\n\n    const step = () => {\n      let h = 0x811c9dc5, p = 0;\n      for (let y = 0; y < H; y++) {\n        const r0 = ((y - 1 + H) % H) * W, r1 = y * W, r2 = ((y + 1) % H) * W;\n        for (let x = 0; x < W; x++) {\n          const a = xm[x], b = xp[x];\n          const n = cur[r0 + a] + cur[r0 + x] + cur[r0 + b] + cur[r1 + a] + cur[r1 + b] + cur[r2 + a] + cur[r2 + x] + cur[r2 + b];\n          const i = r1 + x, alive = cur[i];\n          const v = n === 3 || (alive && n === 2) ? 1 : 0;\n          nxt[i] = v;\n          if (v) {\n            age[i] = alive ? Math.min(65535, age[i] + 1) : 0;\n            p++;\n            h = Math.imul(h ^ i, 16777619);\n          } else if (alive) heat[i] = 1;\n          else if (heat[i] > 0) heat[i] = heat[i] < 0.03 ? 0 : heat[i] * 0.78;\n        }\n      }\n      [cur, nxt] = [nxt, cur];\n      gen++;\n      pop = p;\n      h = (h ^ p) >>> 0;\n      // Periodic (≤ 60 generations) or near-empty → stir in fresh soup.\n      if (recent.has(h) && gen - recent.get(h) <= 60) stuck++;\n      else stuck = Math.max(0, stuck - 1);\n      recent.set(h, gen);\n      if (recent.size > 120) recent.delete(recent.keys().next().value);\n      if (stuck > 24 || pop < N * 0.015) { reseed(); stuck = 0; recent.clear(); }\n    };\n\n    return (t, dt) => {\n      acc += dt * gps;\n      for (let k = 0; acc >= 1 && k < 4; k++, acc--) step();\n      if (acc > 4) acc = 0;\n      if (t >= nextShip) {\n        const shape = rand() < 0.7 ? GLIDER : LWSS;\n        stamp(shape, Math.floor(rand() * W), Math.floor(rand() * H), rand() < 0.5, rand() < 0.5);\n        nextShip = t + 2.5 + rand() * 3.5;\n      }\n      const ghostCol = th.crit, bg = ctx.bg < 0 ? 0 : ctx.bg;\n      for (let i = 0; i < N; i++) {\n        if (cur[i]) px[i] = lut[age[i] < AGES ? age[i] : AGES - 1];\n        else if (ghosts && heat[i] > 0.04) {\n          const k = heat[i] * 0.4;\n          const r = ((bg >> 16) & 255) + ((((ghostCol >> 16) & 255) - ((bg >> 16) & 255)) * k);\n          const g = ((bg >> 8) & 255) + ((((ghostCol >> 8) & 255) - ((bg >> 8) & 255)) * k);\n          const b = (bg & 255) + (((ghostCol & 255) - (bg & 255)) * k);\n          px[i] = (r << 16) | (g << 8) | (b | 0);\n        } else px[i] = NONE;\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/sim/n-body.js": "// The figure-eight three-body choreography (Chenciner & Montgomery, 2000):\n// three equal masses chasing each other round one figure-eight, integrated\n// with velocity Verlet. A ring of massless dust starts in orbit around the\n// trio and gets slung about chaotically by it — some captured, some flung\n// clear — so the stable dance and the chaos sit side by side.\nimport { mix } from '../../color.js';\nimport { fadeMark } from '../../palettes.js';\n\nconst H_STEP = 0.0025, RATE = 1; // integrator step (sim units), sim seconds per second\nconst RUN = 30, DUST = 14;\nconst FIG8 = [\n  [-0.97000436, 0.24308753, 0.466203685, 0.43236573],\n  [0.97000436, -0.24308753, 0.466203685, 0.43236573],\n  [0, 0, -0.93240737, -0.86473146],\n];\n\nexport default {\n  title: 'N-Body Orbits',\n  gallery: 'sim',\n  category: 'Physics',\n  kind: 'scene',\n  tags: ['n-body', 'gravity', 'orbits', 'three-body', 'figure eight', 'chaos', 'braille'],\n  order: 30,\n  description: 'The figure-eight three-body choreography, with a ring of dust that the dancing trio flings about chaotically.',\n  size: { w: 60, h: 22 },\n  minSize: { w: 20, h: 7 },\n  loop: RUN,\n  canvas: 'line',\n  palette: 'prism',\n  params: { dust: { default: DUST, min: 0, max: 40, label: 'Dust particles' } },\n  setup(ctx) {\n    const label = ctx.height >= 12;\n    const cv = ctx.canvas('line', { rows: label ? ctx.height - 1 : ctx.height });\n    const W = cv.width, Hc = cv.height, asp = cv.aspect, th = ctx.theme, rand = ctx.rng;\n    const pal = ctx.palette();\n    const nDust = Math.round(ctx.param('dust', DUST));\n    const scale = Math.min((W / 2) / 1.6, ((Hc * asp) / 2) / 0.85);\n    const cx = W / 2, cy = (Hc * asp) / 2;\n    const bodyCol = [pal(0), pal(0.36), pal(0.66)];\n    const dustCol = mix(th.muted, th.ink, 0.3);\n    let B, D, all, run = -1, acc = 0;\n    const trails = [[], [], []];\n    const reset = () => {\n      B = FIG8.map(([x, y, vx, vy]) => ({ x, y, vx, vy, ax: 0, ay: 0 }));\n      D = Array.from({ length: nDust }, (_, i) => {\n        const a = (i / Math.max(1, nDust)) * Math.PI * 2 + rand() * 0.2, r = 1.35 + rand() * 0.45, v = Math.sqrt(3 / r) * (0.92 + rand() * 0.1);\n        return { x: Math.cos(a) * r, y: Math.sin(a) * r, vx: -Math.sin(a) * v, vy: Math.cos(a) * v, ax: 0, ay: 0, tr: [] };\n      });\n      all = [...B, ...D];\n      for (const tr of trails) tr.length = 0;\n      accel();\n    };\n    const accel = () => {\n      for (const p of B) { p.ax = 0; p.ay = 0; }\n      for (let i = 0; i < 3; i++) for (let j = i + 1; j < 3; j++) {\n        const a = B[i], b = B[j], dx = b.x - a.x, dy = b.y - a.y, r2 = dx * dx + dy * dy + 1e-6, f = 1 / (r2 * Math.sqrt(r2));\n        a.ax += dx * f; a.ay += dy * f; b.ax -= dx * f; b.ay -= dy * f;\n      }\n      for (const p of D) {\n        p.ax = 0; p.ay = 0;\n        for (const b of B) { const dx = b.x - p.x, dy = b.y - p.y, r2 = dx * dx + dy * dy + 0.004, f = 1 / (r2 * Math.sqrt(r2)); p.ax += dx * f; p.ay += dy * f; }\n      }\n    };\n    const step = (h) => {\n      for (const p of all) { p.vx += p.ax * h * 0.5; p.vy += p.ay * h * 0.5; p.x += p.vx * h; p.y += p.vy * h; }\n      accel();\n      for (const p of all) { p.vx += p.ax * h * 0.5; p.vy += p.ay * h * 0.5; }\n    };\n    const P = (x, y) => [cx + x * scale, (cy + y * scale) / asp];\n    let rec = 0;\n\n    return (t, dt) => {\n      const r = Math.floor(t / RUN);\n      if (r !== run) { run = r; reset(); acc = 0; }\n      acc += Math.min(0.25, Math.max(0, dt)) * RATE / H_STEP;\n      for (; acc >= 1; acc--) {\n        step(H_STEP);\n        if (++rec % 8 === 0) {\n          B.forEach((b, i) => { trails[i].push([b.x, b.y]); if (trails[i].length > 120) trails[i].shift(); });\n          for (const d of D) { d.tr.push([d.x, d.y]); if (d.tr.length > 40) d.tr.shift(); }\n        }\n      }\n      cv.clear();\n      for (const d of D) {\n        for (let i = 1; i < d.tr.length; i++) {\n          const [x0, y0] = P(...d.tr[i - 1]), [x1, y1] = P(...d.tr[i]);\n          if (Math.abs(x1 - x0) < W / 3) cv.line(x0, y0, x1, y1, fadeMark(th, dustCol, 0.15 + 0.5 * (i / d.tr.length)));\n        }\n        const [x, y] = P(d.x, d.y);\n        cv.set(x, y, dustCol);\n      }\n      trails.forEach((tr, k) => {\n        for (let i = 1; i < tr.length; i++) {\n          const [x0, y0] = P(...tr[i - 1]), [x1, y1] = P(...tr[i]);\n          cv.line(x0, y0, x1, y1, fadeMark(th, bodyCol[k], 0.2 + 0.8 * (i / tr.length)));\n        }\n      });\n      B.forEach((b, k) => { const [x, y] = P(b.x, b.y); cv.circle(x, y, Math.max(1.2, scale * 0.09), bodyCol[k], true); });\n      cv.paint(ctx.buffer);\n      if (label) ctx.buffer.text(1, ctx.height - 1, 'figure-eight choreography · 3 equal masses'.slice(0, ctx.width - 2), th.muted);\n    };\n  },\n};\n",
  "prism-term/src/facets/sim/rain.js": "// Night rain over a city: three parallax layers of slanted braille rain (far\n// drops slow, short and dim; near drops fast and long), splashes on the street\n// and rooftops, lit windows that come and go, and lightning — a branching bolt\n// whose double-flicker flash floods the sky and throws the skyline into\n// silhouette.\nimport { mix, NONE } from '../../color.js';\n\nconst FLASH = 0xdfe8ff, BOLT = 0xf4f7ff;\nconst WINDOW = [0xffc46b, 0xffd99a, 0xffb347, 0x9fd3ff, 0xfff0c8];\n\nexport default {\n  title: 'Night Rain',\n  gallery: 'sim',\n  category: 'Weather',\n  kind: 'scene',\n  tags: ['rain', 'city', 'lightning', 'night'],\n  order: 60,\n  description: 'Parallax rain over a city skyline — street and rooftop splashes, flickering windows, and lightning that silhouettes the city.',\n  canvas: 'line',\n  setup(ctx) {\n    const th = ctx.theme;\n    const rand = ctx.rng;\n    const sky = ctx.canvas('field');\n    const rain = ctx.canvas('line');\n    const { width: W, height: H, aspect } = rain;\n    const SW = sky.width, SH = sky.height;\n    const intensity = ctx.param('intensity', 1);\n    const base = ctx.bg === NONE ? 0x0b0f18 : ctx.bg;\n    const skyTop = mix(base, 0x000000, 0.35), skyLow = mix(base, th.info, 0.16);\n\n    // ── skyline (in sky-canvas pixels) ──\n    const roof = new Int32Array(SW).fill(SH);\n    const buildings = [];\n    for (let x = -2; x < SW;) {\n      const w = Math.max(3, Math.round(SW * (0.03 + rand() * 0.06)));\n      const h = Math.round(SH * (0.14 + Math.pow(rand(), 1.6) * 0.42));\n      const top = SH - h;\n      buildings.push({ x, w, top, spire: rand() < 0.18, tone: 0.45 + rand() * 0.25 });\n      for (let k = Math.max(0, x); k < Math.min(SW, x + w); k++) roof[k] = top;\n      x += w + (rand() < 0.3 ? 1 : 0);\n    }\n    for (const b of buildings) if (b.spire) {\n      const sx = b.x + (b.w >> 1), sh = Math.max(2, Math.round(SH * 0.06));\n      for (let y = b.top - sh; y < b.top; y++) if (sx >= 0 && sx < SW) roof[sx] = Math.min(roof[sx], y);\n    }\n    const windows = [];\n    for (const b of buildings)\n      for (let y = b.top + 2; y < SH - 2; y += 2)\n        for (let x = b.x + 1; x < b.x + b.w - 1; x += 2)\n          if (x >= 0 && x < SW && rand() < 0.42) windows.push({ x, y, c: WINDOW[Math.floor(rand() * WINDOW.length)], phase: rand() * 100, rate: 0.05 + rand() * 0.2 });\n    const silhouette = mix(base, 0x000000, 0.55);\n\n    // Rain-canvas roof height at a rain-canvas x.\n    const roofAt = (x) => (roof[Math.max(0, Math.min(SW - 1, Math.floor((x / W) * SW)))] / SH) * H;\n\n    // ── rain layers ──\n    const area = (ctx.width * ctx.height) / 3000 * (rain.mode === \"braille\" ? 1 : 0.3);\n    const LAYERS = [\n      { n: 360, v: 0.85, len: 2, k: 0.3, roof: false },\n      { n: 210, v: 1.35, len: 4, k: 0.52, roof: true },\n      { n: 90, v: 2.2, len: 7, k: 0.9, roof: false },\n    ];\n    const drops = [];\n    for (let li = 0; li < LAYERS.length; li++) {\n      const L = LAYERS[li];\n      const n = Math.max(8, Math.round(L.n * area * intensity));\n      for (let i = 0; i < n; i++) drops.push({ li, x: rand() * W * 1.3 - W * 0.15, y: rand() * H, s: 0.85 + rand() * 0.3 });\n    }\n    const splashes = [];\n    const rainCol = mix(th.info, 0xc8d6ff, 0.5);\n\n    let nextStrike = 2.5 + rand() * 2, strikeAt = -10, bolt = [];\n    const makeBolt = () => {\n      const segs = [];\n      const grow = (x, y, dir, depth, w) => {\n        const target = roofAt(x) - 1;\n        while (y < target) {\n          const ny = y + 2 + rand() * 4, nx = x + dir * (rand() * 2) + (rand() - 0.5) * 5;\n          segs.push([x, y, nx, ny, w]);\n          if (depth < 2 && rand() < 0.12) grow(nx, ny, rand() < 0.5 ? -1 : 1, depth + 1, w * 0.5);\n          x = nx; y = ny;\n          if (depth > 0 && rand() < 0.06) return;\n        }\n      };\n      grow(W * (0.15 + rand() * 0.7), 0, rand() < 0.5 ? -0.5 : 0.5, 0, 1);\n      return segs;\n    };\n\n    return (t, dt) => {\n      // Lightning envelope: a sharp flash and two decaying re-strikes.\n      if (t >= nextStrike) { strikeAt = t; bolt = makeBolt(); nextStrike = t + 5 + rand() * 6; }\n      const u = t - strikeAt;\n      const f = u < 0 || u > 1.2 ? 0 : Math.min(1, Math.exp(-u * 9) + (u > 0.16 ? 0.7 * Math.exp(-(u - 0.16) * 7) : 0) + (u > 0.42 ? 0.35 * Math.exp(-(u - 0.42) * 10) : 0));\n      const wind = 0.22 + 0.14 * Math.sin(t * 0.17) + 0.06 * Math.sin(t * 0.71);\n\n      // Sky: a vertical gradient on cell backgrounds, lit by the flash.\n      const buf = ctx.buffer;\n      for (let y = 0; y < ctx.height; y++) {\n        const c = mix(mix(skyTop, skyLow, Math.pow(y / Math.max(1, ctx.height - 1), 1.5)), FLASH, f * 0.8);\n        buf.fill(0, y, ctx.width, 1, ' ', undefined, c);\n      }\n\n      // Skyline and windows.\n      sky.clear();\n      for (const b of buildings) {\n        const c = mix(silhouette, 0x000000, f * 0.6);\n        for (let y = b.top; y < SH; y++) for (let x = Math.max(0, b.x); x < Math.min(SW, b.x + b.w); x++) sky.px[y * SW + x] = c;\n      }\n      const spire = mix(silhouette, 0x000000, f * 0.6);\n      for (let x = 0; x < SW; x++) for (let y = roof[x]; y < SH; y++) if (sky.px[y * SW + x] === NONE) sky.px[y * SW + x] = spire;\n      for (const w of windows) {\n        const on = Math.sin(w.phase + t * w.rate) > -0.35;\n        if (on) sky.px[w.y * SW + w.x] = mix(w.c, 0x000000, 0.15 + 0.25 * (0.5 + 0.5 * Math.sin(w.phase * 3 + t * 0.7)));\n      }\n      sky.paint(buf);\n\n      // Rain, splashes, bolt.\n      rain.clear();\n      const lit = 1 + f * 0.9;\n      for (const d of drops) {\n        const L = LAYERS[d.li];\n        const v = L.v * H * d.s;\n        d.y += v * dt;\n        d.x += v * wind * dt;\n        const floor = L.roof ? roofAt(d.x) : H - 1;\n        if (d.y >= floor) {\n          if (d.li === 2 || (L.roof && d.y < H - 1)) {\n            const n = d.li === 2 ? 3 : 1;\n            for (let k = 0; k < n; k++) splashes.push({ x: d.x, y: floor - 1, vx: (rand() - 0.5) * H * 0.35, vy: -H * (0.25 + rand() * 0.3), life: 0.22 + rand() * 0.12, k: L.k });\n          }\n          d.y = -rand() * H * 0.2;\n          d.x = rand() * W * 1.3 - W * 0.3;\n          continue;\n        }\n        const len = L.len * Math.max(1, H / 120) * (0.8 + 0.4 * d.s);\n        const k = Math.min(1, L.k * lit);\n        const c = mix(base, rainCol, k);\n        rain.line(d.x - len * wind, (d.y - len) / aspect, d.x, d.y / aspect, c);\n      }\n      for (let i = splashes.length - 1; i >= 0; i--) {\n        const s = splashes[i];\n        s.vy += H * 2.4 * dt; s.x += s.vx * dt; s.y += s.vy * dt; s.life -= dt;\n        if (s.life <= 0) { splashes.splice(i, 1); continue; }\n        rain.set(s.x, s.y / aspect, mix(base, rainCol, Math.min(1, s.k * lit)));\n      }\n      if (f > 0.2) {\n        const c = mix(rainCol, BOLT, Math.min(1, f * 1.2));\n        for (const [x0, y0, x1, y1, w] of bolt) if (w >= 0.5 || f > 0.5) rain.line(x0, y0 / aspect, x1, y1 / aspect, c);\n      }\n      rain.paint(buf);\n    };\n  },\n};\n",
  "prism-term/src/facets/sim/reaction-diffusion.js": "// Gray-Scott reaction-diffusion: two chemicals, U fed in and V feeding on it\n// (U + 2V → 3V), diffusing at different rates. The feed and kill rates drift\n// slowly between the \"mitosis\", \"coral\" and \"maze\" regimes, so spots divide,\n// then grow into coral, then wind into labyrinths. V is shaded as a lit relief\n// so the patterns read like living tissue.\nimport { NONE, mix } from '../../color.js';\n\n// [feed, kill] presets (Karl Sims' parameterisation: Du = 1, Dv = 0.5, dt = 1),\n// with diffusion scaled by DS so features stay a few cells wide at terminal size.\nconst DS = 0.32;\nconst REGIMES = [[0.0367, 0.0649], [0.0545, 0.062], [0.029, 0.057]];\nconst HOLD = 14;\n\nexport default {\n  title: 'Reaction-Diffusion',\n  gallery: 'sim',\n  category: 'Life & Flocking',\n  kind: 'scene',\n  tags: ['gray-scott', 'reaction diffusion', 'turing', 'morphogenesis', 'pattern'],\n  order: 20,\n  description: 'Gray-Scott reaction-diffusion drifting from dividing spots to coral to mazes, shaded as a lit living relief.',\n  size: { w: 60, h: 22 },\n  minSize: { w: 20, h: 7 },\n  loop: HOLD * REGIMES.length,\n  canvas: 'field',\n  palette: 'aurora',\n  params: { speed: { default: 1, min: 0.25, max: 3, label: 'Speed' } },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const W = cv.width, H = cv.height, px = cv.px, th = ctx.theme, rand = ctx.rng;\n    const pal = ctx.palette();\n    const rate = 260 * ctx.param('speed', 1); // solver steps per second\n    const N = W * H;\n    let U = new Float32Array(N).fill(1), V = new Float32Array(N);\n    let U2 = new Float32Array(N), V2 = new Float32Array(N);\n    const xm = new Int32Array(W), xp = new Int32Array(W);\n    for (let x = 0; x < W; x++) { xm[x] = (x - 1 + W) % W; xp[x] = (x + 1) % W; }\n    const seed = (count) => {\n      for (let k = 0; k < count; k++) {\n        const cx = Math.floor(rand() * W), cy = Math.floor(rand() * H), r = 1;\n        for (let y = cy - r; y <= cy + r; y++) for (let x = cx - r; x <= cx + r; x++) {\n          const i = ((y + H) % H) * W + ((x + W) % W);\n          U[i] = 0.5; V[i] = 0.25 + rand() * 0.1;\n        }\n      }\n    };\n    seed(Math.max(4, Math.round(N / 120)));\n    let acc = 0;\n    const step = (F, K) => {\n      for (let y = 0; y < H; y++) {\n        const r0 = ((y - 1 + H) % H) * W, r1 = y * W, r2 = ((y + 1) % H) * W;\n        for (let x = 0; x < W; x++) {\n          const a = xm[x], b = xp[x], i = r1 + x;\n          // 9-point Laplacian (0.2 edges, 0.05 corners, -1 center).\n          const lu = 0.2 * (U[r0 + x] + U[r2 + x] + U[r1 + a] + U[r1 + b]) + 0.05 * (U[r0 + a] + U[r0 + b] + U[r2 + a] + U[r2 + b]) - U[i];\n          const lv = 0.2 * (V[r0 + x] + V[r2 + x] + V[r1 + a] + V[r1 + b]) + 0.05 * (V[r0 + a] + V[r0 + b] + V[r2 + a] + V[r2 + b]) - V[i];\n          const u = U[i], v = V[i], uvv = u * v * v;\n          U2[i] = u + (DS * lu - uvv + F * (1 - u));\n          V2[i] = v + (0.5 * DS * lv + uvv - (K + F) * v);\n        }\n      }\n      [U, U2] = [U2, U]; [V, V2] = [V2, V];\n    };\n\n    return (t, dt) => {\n      // Parameters ease from one regime to the next over the last third of each hold.\n      const cyc = ((t / HOLD) % REGIMES.length + REGIMES.length) % REGIMES.length;\n      const k = Math.floor(cyc), f = cyc - k, m = f < 0.66 ? 0 : (f - 0.66) / 0.34;\n      const s = m * m * (3 - 2 * m), A = REGIMES[k], B = REGIMES[(k + 1) % REGIMES.length];\n      const F = A[0] + (B[0] - A[0]) * s, K = A[1] + (B[1] - A[1]) * s;\n      acc += Math.min(0.25, Math.max(0, dt)) * rate;\n      let n = 0;\n      for (; acc >= 1 && n < 24; acc--, n++) step(F, K);\n      if (acc > 24) acc = 0;\n      // Keep it alive: if V has nearly vanished, seed fresh droplets.\n      if (n) { let sum = 0; for (let i = 0; i < N; i += 7) sum += V[i]; if (sum < N * 0.0005) seed(6); }\n      for (let y = 0; y < H; y++) {\n        const r0 = ((y - 1 + H) % H) * W, r2 = ((y + 1) % H) * W;\n        for (let x = 0; x < W; x++) {\n          const i = y * W + x, v = V[i];\n          if (v < 0.04) { px[i] = NONE; continue; }\n          // Relief lighting from the V gradient (light from the upper left).\n          const gx = V[y * W + xp[x]] - V[y * W + xm[x]], gy = V[r2 + x] - V[r0 + x];\n          const light = Math.max(0, Math.min(1.4, 0.8 + (gx + gy) * 3));\n          const c = pal(Math.min(1, 0.35 + v * 1.9));\n          const lit = mix(mix(0x000000, c, Math.min(1, light)), 0xffffff, Math.max(0, light - 1) * 0.6);\n          px[i] = mix(th.bg, lit, Math.min(1, (v - 0.04) / 0.12));\n        }\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/sim/slime-mold.js": "// Physarum slime mold: thousands of agents each sense the trail ahead-left,\n// ahead and ahead-right, steer toward the strongest scent, step, and deposit.\n// The trail diffuses and evaporates. Food sources keep leaking scent, so the\n// swarm self-organizes into a transport network linking them — the same trick\n// the real organism uses to rebuild a railway map out of oat flakes.\nimport { NONE, mix } from '../../color.js';\n\nconst SA = Math.PI / 4, RA = Math.PI / 4, SO = 9; // sensor angle, rotation angle, sensor offset (px)\n\nexport default {\n  title: 'Slime Mold',\n  gallery: 'sim',\n  category: 'Life & Flocking',\n  kind: 'scene',\n  tags: ['physarum', 'slime mold', 'agents', 'network', 'emergence', 'trails'],\n  order: 30,\n  description: 'Physarum agents sensing and laying scent trails self-organize into a transport network linking food sources.',\n  size: { w: 60, h: 22 },\n  minSize: { w: 20, h: 7 },\n  loop: 20,\n  canvas: 'line',\n  palette: 'toxic',\n  params: { agents: { default: 1, min: 0.2, max: 3, label: 'Agent density' } },\n  setup(ctx) {\n    const cv = ctx.canvas('line');\n    const W = cv.width, H = cv.height, px = cv.px, th = ctx.theme, rand = ctx.rng;\n    const pal = ctx.palette();\n    const N = W * H;\n    const count = Math.min(9000, Math.round(N * 0.13 * ctx.param('agents', 1)));\n    let trail = new Float32Array(N), tmp = new Float32Array(N);\n    const occ = new Uint8Array(N); // one agent per pixel (Jones' exclusion rule)\n    const ax = new Float32Array(count), ay = new Float32Array(count), ah = new Float32Array(count);\n    // Scattered start, random headings: the network condenses out of the noise.\n    for (let i = 0; i < count; i++) {\n      let x, y, tries = 0;\n      do { x = rand() * W; y = rand() * H; tries++; } while (occ[(y | 0) * W + (x | 0)] && tries < 8);\n      ax[i] = x; ay[i] = y; ah[i] = rand() * Math.PI * 2; occ[(y | 0) * W + (x | 0)] = 1;\n    }\n    const food = Array.from({ length: Math.max(3, Math.min(7, Math.round(N / 1500))) }, () => [3 + rand() * (W - 6), 3 + rand() * (H - 6)]);\n    const sense = (x, y) => (x < 0 || y < 0 || x >= W || y >= H ? -1 : trail[(y | 0) * W + (x | 0)]);\n    let acc = 0, peak = 1;\n    const step = () => {\n      for (const [fx, fy] of food) for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) trail[((fy | 0) + dy) * W + (fx | 0) + dx] += 6;\n      for (let i = 0; i < count; i++) {\n        const h = ah[i], x = ax[i], y = ay[i];\n        // Sensory stage: steer toward the strongest of three sensors.\n        const f = sense(x + Math.cos(h) * SO, y + Math.sin(h) * SO);\n        const l = sense(x + Math.cos(h - SA) * SO, y + Math.sin(h - SA) * SO);\n        const r = sense(x + Math.cos(h + SA) * SO, y + Math.sin(h + SA) * SO);\n        let nh = h;\n        if (f >= l && f >= r) { /* straight on */ } else if (f < l && f < r) nh += (rand() < 0.5 ? -1 : 1) * RA; else if (l > r) nh -= RA; else nh += RA;\n        // Motor stage: move only into a free pixel inside the dish; otherwise pick a new heading.\n        const nx = x + Math.cos(nh), ny = y + Math.sin(nh);\n        const j = (ny | 0) * W + (nx | 0), o = (y | 0) * W + (x | 0);\n        if (nx < 0 || ny < 0 || nx >= W || ny >= H || (j !== o && occ[j])) { ah[i] = rand() * Math.PI * 2; continue; }\n        occ[o] = 0; occ[j] = 1;\n        ax[i] = nx; ay[i] = ny; ah[i] = nh > Math.PI * 2 ? nh - Math.PI * 2 : nh < 0 ? nh + Math.PI * 2 : nh;\n        trail[j] += 5;\n      }\n      // Diffuse (3×3 mean, closed edges) and evaporate.\n      let mx = 0;\n      for (let y = 0; y < H; y++) {\n        const y0 = y > 0 ? y - 1 : y, y1 = y < H - 1 ? y + 1 : y;\n        for (let x = 0; x < W; x++) {\n          const a = x > 0 ? x - 1 : x, b = x < W - 1 ? x + 1 : x;\n          const s = trail[y0 * W + a] + trail[y0 * W + x] + trail[y0 * W + b] + trail[y * W + a] + trail[y * W + x] + trail[y * W + b] + trail[y1 * W + a] + trail[y1 * W + x] + trail[y1 * W + b];\n          const v = (s / 9) * 0.9;\n          tmp[y * W + x] = v;\n          if (v > mx) mx = v;\n        }\n      }\n      [trail, tmp] = [tmp, trail];\n      peak = peak * 0.9 + mx * 0.1;\n    };\n\n    return (t, dt) => {\n      acc += Math.min(0.25, Math.max(0, dt)) * 30;\n      for (let k = 0; acc >= 1 && k < 6; k++, acc--) step();\n      if (acc > 6) acc = 0;\n      // Braille is on/off per dot: threshold the scent against its running peak.\n      const thr = peak * 0.18;\n      for (let i = 0; i < N; i++) {\n        const v = trail[i];\n        px[i] = v < thr ? NONE : pal(0.45 + 0.55 * Math.min(1, (v - thr) / (peak * 0.6)));\n      }\n      for (const [fx, fy] of food) {\n        cv.circle(fx, fy, 1.5, mix(th.warn, 0xffffff, 0.3 + 0.2 * Math.sin(t * 3 + fx)), true);\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/sim/snow.js": "// Snow over a cabin. Three parallax layers of flakes sway down through a gusty\n// wind; the near layer actually lands, building drifts on the ground and a\n// cap on the roof, and the drifts slump downwind once they get too steep.\n// The cabin's window glows (and spills warm light onto the snow), the chimney\n// smokes, and dark pines stand behind it all.\nimport { NONE, mix as blend, luminance } from '../../color.js';\n\nexport default {\n  title: 'Snow over a Cabin',\n  gallery: 'sim',\n  category: 'Weather',\n  kind: 'scene',\n  tags: ['snow', 'winter', 'cabin', 'drifts', 'weather', 'particles', 'night'],\n  order: 20,\n  description: 'Parallax snowfall in a gusty wind that settles into drifts and a roof cap, around a cabin with a glowing window.',\n  size: { w: 64, h: 22 },\n  minSize: { w: 24, h: 8 },\n  loop: 30,\n  canvas: 'field',\n  palette: 'ice',\n  params: { density: { default: 1, min: 0.2, max: 3, label: 'Snowfall' } },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const W = cv.width, H = cv.height, asp = cv.aspect, px = cv.px, th = ctx.theme;\n    const rand = ctx.rng, noise = ctx.noise;\n    const dense = ctx.param('density', 1);\n    const night = luminance(th.bg) < 0.5;\n    const skyTop = blend(th.bg, night ? 0x10204a : 0x9fb4cc, 0.45);\n    const snowC = night ? 0xe8f0ff : 0xffffff, snowShade = night ? 0x7f93b8 : 0xb8c6d8;\n    // Terrain: gentle hills, plus a far ridge silhouette.\n    const ground = new Float32Array(W), ridge = new Float32Array(W);\n    for (let x = 0; x < W; x++) {\n      ground[x] = H * 0.8 + Math.sin(x * 0.07 + 1) * H * 0.04 + noise.noise2(x * 0.05, 3.1) * H * 0.03;\n      ridge[x] = H * 0.52 + noise.fbm(x * 0.03, 7.7, 0, 3) * H * 0.18;\n    }\n    const snow = new Float32Array(W).fill(1); // settled depth (pixels)\n    const CAP = H * 0.12;\n    // Cabin.\n    const cw = Math.max(8, Math.round(W * 0.2)), cx0 = Math.round(W * 0.56), cx1 = cx0 + cw;\n    const floorY = Math.round(Math.max(...Array.from({ length: cw }, (_, i) => ground[Math.min(W - 1, cx0 + i)])));\n    const wallH = Math.max(4, Math.round(H * 0.16)), roofH = Math.max(3, Math.round(cw * 0.32 / asp * 1.2));\n    const eave = 2;\n    const roofAt = (x) => floorY - wallH - roofH * (1 - Math.abs((x - (cx0 + cx1) / 2) / (cw / 2 + eave)));\n    const roofSnow = new Float32Array(W);\n    const chimX = cx0 + Math.round(cw * 0.72), chimTop = Math.round(roofAt(chimX)) - Math.max(2, Math.round(roofH * 0.4));\n    // Pines.\n    const pines = Array.from({ length: Math.max(2, Math.round(W / 22)) }, (_, i) => {\n      const x = Math.round(W * (0.08 + i * 0.3 + rand() * 0.08));\n      return { x, h: H * (0.32 + rand() * 0.14), w: W * 0.05 + rand() * W * 0.02 };\n    }).filter((p) => p.x + p.w < cx0 - 2 || p.x - p.w > cx1 + 2);\n    // Flakes in three parallax layers; only the near layer settles.\n    const flakes = Array.from({ length: Math.round(((W * H) / 26) * dense) }, () => {\n      const z = [0.35, 0.65, 1][Math.floor(rand() * 3)];\n      return { x: rand() * W, y: rand() * H, z, ph: rand() * 6.28, sw: 0.5 + rand() * 1.5 };\n    });\n    const smoke = Array.from({ length: 22 }, (_, i) => ({ age: i / 22, ph: rand() * 6.28 }));\n    const surface = (x) => {\n      const xi = Math.max(0, Math.min(W - 1, x | 0));\n      if (xi >= cx0 - eave && xi <= cx1 + eave) return roofAt(xi) - roofSnow[xi];\n      return ground[xi] - snow[xi];\n    };\n\n    let last = 0;\n    return (t, dt) => {\n      dt = Math.min(0.1, Math.max(0, dt));\n      const wind = 0.9 * Math.sin(t * 0.13) + 0.6 * noise.noise2(t * 0.3, 0.5);\n      // Flakes.\n      for (const f of flakes) {\n        f.y += dt * (4 + 7 * f.z) * (H / 44);\n        f.x += dt * (wind * 6 * f.z + Math.sin(t * f.sw + f.ph) * 1.4);\n        if (f.x < 0) f.x += W; else if (f.x >= W) f.x -= W;\n        const xi = f.x | 0;\n        if (f.z === 1 && f.y >= surface(f.x)) {\n          if (xi >= cx0 - eave && xi <= cx1 + eave) { if (roofSnow[xi] < CAP * 0.45) roofSnow[xi] += 0.35; }\n          else if (snow[xi] < CAP + (xi % 7) * 0.2) snow[xi] += 0.35;\n          f.y = -rand() * 4; f.x = rand() * W;\n        } else if (f.y > H) { f.y = -rand() * 4; f.x = rand() * W; }\n      }\n      // Drifts slump when steep, a little more readily downwind.\n      if (t - last > 0.05) {\n        last = t;\n        for (let x = 1; x < W - 1; x++) {\n          for (const s of [-1, 1]) {\n            const h0 = ground[x] - snow[x], h1 = ground[x + s] - snow[x + s];\n            const lim = s * wind > 0 ? 0.9 : 1.6;\n            if (h1 - h0 > lim && snow[x] > 0.3) { snow[x] -= 0.12; snow[x + s] += 0.12; }\n          }\n        }\n        for (let x = 1; x < W - 1; x++) snow[x] += 0.03 * (snow[x - 1] + snow[x + 1] - 2 * snow[x]);\n        for (let x = cx0 - eave + 1; x < cx1 + eave; x++) roofSnow[x] = Math.min(roofSnow[x], (roofSnow[x - 1] + roofSnow[x + 1]) / 2 + 0.6);\n      }\n      // Draw: sky, ridge, pines, ground + snow, cabin, smoke, flakes.\n      for (let y = 0; y < H; y++) {\n        const sky = blend(skyTop, th.bg, y / (H * 0.8));\n        for (let x = 0; x < W; x++) px[y * W + x] = y > ridge[x] ? blend(sky, night ? 0x1a2236 : 0xc9d3de, 0.6) : sky;\n      }\n      const flick = 0.85 + 0.15 * noise.noise2(t * 3, 9.1);\n      const winX0 = cx0 + Math.round(cw * 0.2), winX1 = winX0 + Math.max(2, Math.round(cw * 0.25));\n      const winY0 = floorY - wallH + Math.round(wallH * 0.25), winY1 = winY0 + Math.max(1, Math.round(wallH * 0.4));\n      for (const p of pines) {\n        const base = Math.round(ground[Math.max(0, Math.min(W - 1, p.x))]);\n        for (let y = Math.round(base - p.h); y < base; y++) {\n          const f = (y - (base - p.h)) / p.h, tier = (f * 3) % 1;\n          const half = p.w * (0.25 + 0.75 * f) * (0.55 + 0.45 * tier);\n          for (let x = Math.round(p.x - half); x <= Math.round(p.x + half); x++) {\n            if (x < 0 || x >= W || y < 0) continue;\n            const edge = tier < 0.18 && Math.abs(x - p.x) > half * 0.3;\n            px[y * W + x] = edge ? blend(snowShade, snowC, 0.4) : blend(0x0d2a1c, 0x1f5a3a, 0.4 + 0.3 * ((x - p.x) / (half + 1)));\n          }\n        }\n      }\n      for (let x = 0; x < W; x++) {\n        const g = ground[x], top = g - snow[x];\n        const slope = (snow[Math.min(W - 1, x + 1)] - snow[Math.max(0, x - 1)]) * 0.5;\n        for (let y = Math.max(0, Math.floor(top)); y < H; y++) {\n          const i = y * W + x;\n          // Fresh snow on top, older snowpack below it, a little bluer with depth.\n          let c = y < g ? blend(snowShade, snowC, Math.min(1, 0.68 + slope * 0.4 + (y - top) * 0.04))\n            : blend(snowShade, snowC, Math.max(0.15, 0.55 - (y - g) * 0.05));\n          const glowD = Math.hypot((x - (winX0 + winX1) / 2) / (cw * 0.6), (y - floorY) / 3);\n          if (glowD < 1) c = blend(c, 0xffc46b, (1 - glowD) * 0.35 * flick);\n          // Anti-alias the surface: the top pixel is only partly covered.\n          px[i] = y === Math.floor(top) ? blend(px[i], c, 1 - (top - y)) : c;\n        }\n      }\n      // Cabin: walls, window, door, chimney, roof and its snow cap.\n      for (let y = floorY - wallH; y < floorY; y++) for (let x = cx0; x < cx1; x++) {\n        if (y < 0 || y >= H) continue;\n        let c = blend(0x3a2414, 0x6a4326, (y & 1) ? 0.55 : 0.75); // log courses\n        if (x >= winX0 && x < winX1 && y >= winY0 && y < winY1) c = blend(0xffb347, 0xffe7a3, flick * 0.7);\n        const dx0 = cx1 - Math.round(cw * 0.32);\n        if (x >= dx0 && x < dx0 + Math.max(2, Math.round(cw * 0.18)) && y >= floorY - Math.round(wallH * 0.75)) c = 0x2a180c;\n        px[y * W + x] = c;\n      }\n      for (let y = chimTop; y < roofAt(chimX); y++) for (let x = chimX; x < chimX + 2; x++) if (y >= 0 && x < W) px[y * W + x] = 0x5a3a2a;\n      for (let x = cx0 - eave; x <= cx1 + eave; x++) {\n        if (x < 0 || x >= W) continue;\n        const r = roofAt(x);\n        for (let y = Math.max(0, Math.floor(r - roofSnow[x])); y < Math.min(H, floorY - wallH + 1); y++) {\n          if (y < r) px[y * W + x] = blend(snowShade, snowC, 0.8);\n          else if (y < r + 1.5) px[y * W + x] = 0x2b1a12;\n        }\n      }\n      for (const s of smoke) {\n        const a = (s.age + t * 0.12) % 1;\n        const x = chimX + 1 + a * 9 * (0.6 + wind * 0.5) + Math.sin(a * 6 + s.ph) * 1.2, y = chimTop - a * H * 0.45;\n        const k = (1 - a) * 0.5;\n        for (const [ox, oy] of [[0, 0], [1, 0], [0, -1]]) {\n          const xx = Math.round(x + ox), yy = Math.round(y + oy);\n          if (xx < 0 || yy < 0 || xx >= W || yy >= H) continue;\n          px[yy * W + xx] = blend(px[yy * W + xx], night ? 0x8a93a6 : 0x8f99a6, k);\n        }\n      }\n      for (const f of flakes) {\n        const xx = f.x | 0, yy = f.y | 0;\n        if (yy < 0 || yy >= H || f.y > surface(f.x)) continue; // hidden by the ground in front\n        const i = yy * W + xx;\n        px[i] = blend(px[i] === NONE ? th.bg : px[i], snowC, 0.35 + 0.65 * f.z);\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/sim/string-waves.js": "// A six-string harp played in an arpeggio. Each string is a real 1-D wave\n// equation (finite differences, fixed ends, light damping and a touch of\n// stiffness) plucked into a triangle and left to ring, so you see the kink\n// split into two traveling waves, reflect off the bridges and settle into\n// the fundamental. Lower strings swing slower and wider; ringing strings glow,\n// and a dotted envelope traces each string's recent swing — the blur your eye\n// sees on a real vibrating string, which single frames would otherwise miss.\nimport { mix } from '../../color.js';\nimport { fadeMark } from '../../palettes.js';\n\nconst STRINGS = 6, N = 72, HZ = 900;\nconst PLUCK = 0.5; // seconds between plucks\nconst PATTERN = [0, 2, 4, 5, 3, 1, 2, 4, 3, 5, 1, 0];\n\nexport default {\n  title: 'Plucked Strings',\n  gallery: 'sim',\n  category: 'Physics',\n  kind: 'scene',\n  tags: ['wave equation', 'strings', 'harp', 'standing waves', 'pluck', 'braille'],\n  order: 40,\n  description: 'A six-string harp plucked in an arpeggio: real wave-equation strings that split, reflect and ring down.',\n  size: { w: 60, h: 20 },\n  minSize: { w: 20, h: 7 },\n  loop: PATTERN.length * PLUCK,\n  canvas: 'line',\n  palette: 'prism',\n  params: {},\n  setup(ctx) {\n    const cv = ctx.canvas('line');\n    const W = cv.width, H = cv.height, th = ctx.theme;\n    const pal = ctx.palette();\n    // Wave speed per string (Courant number ≤ 1), lowest string first.\n    const c = Array.from({ length: STRINGS }, (_, k) => 0.42 + k * 0.11);\n    const y = Array.from({ length: STRINGS }, () => new Float32Array(N));\n    const yp = Array.from({ length: STRINGS }, () => new Float32Array(N));\n    const yn = new Float32Array(N);\n    const energy = new Float32Array(STRINGS);\n    const env = Array.from({ length: STRINGS }, () => new Float32Array(N));\n    const cols = Array.from({ length: STRINGS }, (_, k) => pal(k / STRINGS));\n    const x0 = Math.max(2, W * 0.06), x1 = W - 1 - Math.max(2, W * 0.06);\n    const gap = H / STRINGS;\n    let acc = 0, plucked = -1;\n    const pluck = (k, at, amp) => {\n      const p = Math.round(N * at);\n      for (let i = 0; i < N; i++) y[k][i] = yp[k][i] = i <= p ? (amp * i) / p : (amp * (N - 1 - i)) / (N - 1 - p);\n    };\n    const step = () => {\n      for (let k = 0; k < STRINGS; k++) {\n        const Y = y[k], P = yp[k], c2 = c[k] * c[k];\n        let e = 0;\n        for (let i = 1; i < N - 1; i++) {\n          const lap = Y[i - 1] - 2 * Y[i] + Y[i + 1];\n          const stiff = i > 1 && i < N - 2 ? Y[i - 2] - 4 * Y[i - 1] + 6 * Y[i] - 4 * Y[i + 1] + Y[i + 2] : 0;\n          yn[i] = (2 * Y[i] - P[i] * 0.9993 + c2 * lap - 0.004 * stiff) / 1.0007;\n          e += Math.abs(yn[i]);\n        }\n        yn[0] = yn[N - 1] = 0;\n        P.set(Y); Y.set(yn);\n        const E = env[k];\n        for (let i = 0; i < N; i++) { const a = yn[i] < 0 ? -yn[i] : yn[i]; E[i] = a > E[i] ? a : E[i] * 0.9988; }\n        energy[k] = e / N;\n      }\n    };\n\n    return (t, dt) => {\n      const n = Math.floor(t / PLUCK);\n      if (n !== plucked) {\n        plucked = n;\n        const k = PATTERN[((n % PATTERN.length) + PATTERN.length) % PATTERN.length];\n        pluck(k, 0.18 + (n % 3) * 0.07, 1 - k * 0.05);\n      }\n      acc += Math.min(0.25, Math.max(0, dt)) * HZ;\n      for (let s = 0; acc >= 1 && s < 240; s++, acc--) step();\n      if (acc > 240) acc = 0;\n      cv.clear();\n      for (let k = 0; k < STRINGS; k++) {\n        const base = gap * (k + 0.5), amp = gap * 0.9;\n        const glow = Math.min(1, energy[k] * 4);\n        const col = mix(mix(th.bg, th.muted, 0.85), cols[k], glow);\n        const haze = fadeMark(th, cols[k], 0.25 + 0.45 * glow);\n        const Y = y[k], E = env[k];\n        let px = x0, py = base;\n        for (let s = 1; s <= Math.ceil(x1 - x0); s++) {\n          const f = s / (x1 - x0), u = f * (N - 1), i = Math.min(N - 2, Math.floor(u)), fr = u - i;\n          const v = Y[i] * (1 - fr) + Y[i + 1] * fr;\n          const nx = x0 + s, ny = base + v * amp;\n          // Envelope edges, every other pixel so they read as a blur, not a second string.\n          const e = (E[i] * (1 - fr) + E[i + 1] * fr) * amp;\n          if (e > 1.2 && (s & 1) === 0) { cv.set(nx, base - e, haze); cv.set(nx, base + e, haze); }\n          cv.line(px, py, nx, ny, col);\n          px = nx; py = ny;\n        }\n        // Pegs and bridge pins.\n        cv.set(x0 - 1, base, th.ink); cv.set(x0 - 2, base, th.ink);\n        cv.set(x1 + 1, base, th.ink);\n      }\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/sim/wireworld.js": "// Wireworld on a generated circuit board. Clock loops (cut-corner rings, each\n// carrying one electron, period = ring length) fire pulses down tapped traces\n// that a router lays out PCB-style — straight runs, diagonal corners, fan-out\n// branches — keeping every trace clear of every other. Rules: head → tail,\n// tail → copper, copper → head when one or two neighbors are heads.\nimport { NONE, mix } from '../../color.js';\n\nconst EMPTY = 0, WIRE = 1, HEAD = 2, TAIL = 3;\nconst D4 = [[1, 0], [0, 1], [-1, 0], [0, -1]];\n\nexport default {\n  title: 'Wireworld',\n  gallery: 'sim',\n  category: 'Cellular Automata',\n  kind: 'scene',\n  tags: ['wireworld', 'cellular automata', 'circuit', 'electrons', 'pcb', 'clock'],\n  order: 30,\n  description: 'Wireworld on a generated circuit board: clock loops fire electrons down routed traces that fan out across it.',\n  size: { w: 60, h: 22 },\n  minSize: { w: 20, h: 7 },\n  loop: 12,\n  canvas: 'field',\n  palette: 'theme',\n  params: { speed: { default: 1, min: 0.25, max: 4, label: 'Speed' } },\n  setup(ctx) {\n    const cv = ctx.canvas();\n    const W = cv.width, H = cv.height, px = cv.px, th = ctx.theme, rand = ctx.rng;\n    const gps = 14 * ctx.param('speed', 1);\n    let cur = new Uint8Array(W * H), nxt = new Uint8Array(W * H);\n    const taken = new Uint8Array(W * H); // occupied or reserved by a clock\n    const inside = (x, y) => x >= 1 && y >= 1 && x < W - 1 && y < H - 1;\n    const at = (x, y) => y * W + x;\n    // A cell may be laid if all its neighbors are empty except the `allow` cells.\n    const clear = (x, y, allow) => {\n      if (!inside(x, y) || taken[at(x, y)]) return false;\n      for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {\n        if (!dx && !dy) continue;\n        const X = x + dx, Y = y + dy;\n        if (X < 0 || Y < 0 || X >= W || Y >= H) continue;\n        if (taken[at(X, Y)] && !allow.includes(at(X, Y))) return false;\n      }\n      return true;\n    };\n    const lay = (x, y) => { cur[at(x, y)] = WIRE; taken[at(x, y)] = 1; };\n    const ends = []; // trace ends, drawn as vias\n\n    // Route a trace from (x, y) heading d; returns cells laid.\n    const route = (x, y, d, prev, budget, depth) => {\n      const trail = [...prev];\n      let run = 2 + Math.floor(rand() * 4), extra = [];\n      for (let n = 0; n < budget; n++) {\n        let moved = false;\n        // Keep going straight for the rest of the run, turning only when forced;\n        // after a run, prefer a turn. (A trunk just past a branch must go straight.)\n        const side = rand() < 0.5 ? 1 : 3, other = 4 - side;\n        const options = run > 0 ? (extra.length ? [0] : [0, side, other]) : [side, 0, other];\n        for (const turn of options) {\n          let nx, ny, nd = (d + turn) & 3;\n          if (turn) { nx = x + D4[d][0] + D4[nd][0]; ny = y + D4[d][1] + D4[nd][1]; } // diagonal corner\n          else { nx = x + D4[d][0]; ny = y + D4[d][1]; }\n          if (!clear(nx, ny, [...trail.slice(-2), ...extra])) continue;\n          lay(nx, ny); trail.push(at(nx, ny)); x = nx; y = ny; d = nd; moved = true; extra = [];\n          run = turn ? 2 + Math.floor(rand() * 5) : run - 1;\n          break;\n        }\n        if (!moved) break;\n        // Occasionally fan out a branch at right angles.\n        if (depth < 3 && n > 2 && rand() < 0.1) {\n          const bd = (d + (rand() < 0.5 ? 1 : 3)) & 3, bx = x + D4[bd][0], by = y + D4[bd][1];\n          const allow = trail.slice(-3);\n          // The branch's first cell sits diagonal to the trunk's next cell, so let the trunk pass it.\n          if (clear(bx, by, allow)) { lay(bx, by); extra = [at(bx, by)]; run = Math.max(run, 1); route(bx, by, bd, [...allow, at(bx, by)], Math.floor(budget * 0.5), depth + 1); }\n        }\n      }\n      if (trail.length) ends.push([x, y]);\n      return trail;\n    };\n\n    // Clocks: cut-corner rings with one electron and a tap from a side.\n    const clocks = Math.max(1, Math.min(7, Math.round((W * H) / 700)));\n    for (let c = 0; c < clocks; c++) {\n      for (let tries = 0; tries < 30; tries++) {\n        // Small boards get small clocks and a one-cell margin.\n        const m = W < 30 || H < 14 ? 1 : 2;\n        const w = (m === 1 ? 4 : 5) + Math.floor(rand() * 5), h = (m === 1 ? 2 : 3) + Math.floor(rand() * 3);\n        const x0 = m + Math.floor(rand() * Math.max(1, W - w - 2 * m)), y0 = m + Math.floor(rand() * Math.max(1, H - h - 2 * m));\n        let ok = x0 + w < W - m && y0 + h < H - m;\n        for (let y = y0 - 1; ok && y <= y0 + h + 1; y++) for (let x = x0 - 1; x <= x0 + w + 1; x++) if (taken[at(x, y)]) { ok = false; break; }\n        if (!ok) continue;\n        const ring = [];\n        for (let x = x0 + 1; x < x0 + w; x++) ring.push([x, y0]);\n        for (let y = y0 + 1; y < y0 + h; y++) ring.push([x0 + w, y]);\n        for (let x = x0 + w - 1; x > x0; x--) ring.push([x, y0 + h]);\n        for (let y = y0 + h - 1; y > y0; y--) ring.push([x0, y]);\n        for (const [x, y] of ring) lay(x, y);\n        for (let y = y0; y <= y0 + h; y++) for (let x = x0; x <= x0 + w; x++) taken[at(x, y)] = 1; // keep the interior clear\n        cur[at(...ring[1])] = HEAD; cur[at(...ring[0])] = TAIL;\n        // Tap out of the middle of the right or left side.\n        const right = x0 + w < W * 0.6, ym = y0 + (h >> 1);\n        const tx = right ? x0 + w + 1 : x0 - 1, d = right ? 0 : 2;\n        const allow = [at(right ? x0 + w : x0, ym - 1), at(right ? x0 + w : x0, ym), at(right ? x0 + w : x0, ym + 1)];\n        if (clear(tx, ym, allow)) { lay(tx, ym); route(tx, ym, d, [...allow, at(tx, ym)], W + H, 0); }\n        break;\n      }\n    }\n\n    const copper = mix(th.bg, 0xc8873e, 0.45), via = mix(th.bg, 0xe8c27a, 0.8), headC = mix(0x9fe8ff, th.ink, 0.2), tailC = 0xff7a3c;\n    let acc = 0;\n    const step = () => {\n      for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {\n        const i = y * W + x, s = cur[i];\n        if (s === EMPTY) { nxt[i] = EMPTY; continue; }\n        if (s === HEAD) { nxt[i] = TAIL; continue; }\n        if (s === TAIL) { nxt[i] = WIRE; continue; }\n        let n = 0;\n        for (let dy = -1; dy <= 1; dy++) {\n          const Y = y + dy;\n          if (Y < 0 || Y >= H) continue;\n          for (let dx = -1; dx <= 1; dx++) {\n            const X = x + dx;\n            if (X >= 0 && X < W && cur[Y * W + X] === HEAD) n++;\n          }\n        }\n        nxt[i] = n === 1 || n === 2 ? HEAD : WIRE;\n      }\n      [cur, nxt] = [nxt, cur];\n    };\n\n    return (t, dt) => {\n      acc += Math.min(0.25, Math.max(0, dt)) * gps;\n      for (let k = 0; acc >= 1 && k < 8; k++, acc--) step();\n      if (acc > 8) acc = 0;\n      for (let i = 0; i < W * H; i++) {\n        const s = cur[i];\n        px[i] = s === EMPTY ? NONE : s === WIRE ? copper : s === HEAD ? headC : tailC;\n      }\n      // Vias where traces end (drawing only — not part of the automaton).\n      for (const [x, y] of ends) if (cur[at(x, y)] === WIRE) px[at(x, y)] = via;\n      cv.paint(ctx.buffer);\n    };\n  },\n};\n",
  "prism-term/src/facets/status/_logs.js": "// Deterministic, plausible log traffic for the status gallery: line i is a pure\n// function of (seed, i), so a scrolling log is reproducible and seekable.\n\nimport { rng } from '../../easing.js';\n\nconst HOSTS = ['web-01', 'web-02', 'api-gw-01', 'auth-01', 'checkout-01', 'checkout-02', 'payments-01', 'pg-orders-01'];\nconst SVCS = ['nginx', 'envoy', 'auth', 'checkout', 'payments', 'orders', 'postgres', 'systemd', 'kernel', 'sshd', 'cron'];\n\nconst TEMPLATES = [\n  ['info', 'nginx', (r) => `GET /api/v2/catalog?page=${r.int(1, 40)} 200 ${r.int(8, 90)}ms`],\n  ['info', 'envoy', (r) => `upstream checkout → 10.20.2.${r.int(30, 32)}:8085 ok rtt=${r.int(2, 14)}ms`],\n  ['info', 'auth', (r) => `session issued user=u${r.int(1000, 9999)} mfa=${r.pick(['totp', 'webauthn', 'push'])}`],\n  ['info', 'checkout', (r) => `order ord-${r.int(800000, 899999)} placed total=$${(r() * 180 + 12).toFixed(2)}`],\n  ['debug', 'orders', (r) => `kafka produce topic=orders.v1 partition=${r.int(0, 11)} offset=${r.int(100000, 999999)}`],\n  ['info', 'postgres', (r) => `checkpoint complete: wrote ${r.int(200, 4000)} buffers (${(r() * 4).toFixed(1)}%)`],\n  ['info', 'systemd', () => 'Started Daily apt download activities.'],\n  ['info', 'sshd', (r) => `Accepted publickey for deploy from 192.0.2.${r.int(10, 200)} port ${r.int(40000, 60000)} ssh2`],\n  ['info', 'cron', () => '(root) CMD (/usr/local/bin/backup --incremental)'],\n  ['warn', 'checkout', (r) => `pool at ${r.int(82, 96)}% (${r.int(16, 19)}/20 connections), ${r.int(1, 12)} waiting`],\n  ['warn', 'nginx', (r) => `upstream response time ${(r() * 2 + 1).toFixed(2)}s exceeds 1s for /checkout`],\n  ['warn', 'kernel', (r) => `TCP: request_sock_TCP: Possible SYN flooding on port 443. Sending cookies.`],\n  ['warn', 'payments', () => 'retrying authorize (attempt 2/3): upstream timeout'],\n  ['error', 'checkout', () => 'pool exhausted: 20/20 connections in use — timed out after 2000ms'],\n  ['error', 'payments', () => 'TLS handshake with payproc.example.net:443 failed: certificate expired'],\n  ['error', 'auth', () => \"TypeError: Cannot read properties of undefined (reading 'tenantId')\"],\n  ['crit', 'postgres', () => 'PANIC: could not write to file \"pg_wal/xlogtemp.2241\": No space left on device'],\n];\n\nconst WEIGHTS = TEMPLATES.map(([lvl]) => ({ debug: 4, info: 10, warn: 3, error: 1.4, crit: 0.4 })[lvl]);\nconst TOTAL = WEIGHTS.reduce((a, b) => a + b, 0);\n\n/** The i-th log event: { i, ts (seconds), level, svc, host, pid, msg, fields }. */\nexport function logLine(seed, i) {\n  const r = rng(((seed * 2654435761) ^ (i * 40503)) >>> 0);\n  let pick = r() * TOTAL, k = 0;\n  while (pick > WEIGHTS[k]) { pick -= WEIGHTS[k]; k++; }\n  const [level, svc, msgFn] = TEMPLATES[k];\n  const host = svc === 'postgres' ? 'pg-orders-01' : r.pick(HOSTS);\n  return { i, level, svc, host, pid: 1000 + ((i * 37) % 8000), msg: msgFn(r), fields: { latency_ms: r.int(2, 900), trace: (r() * 0xffffffff >>> 0).toString(16).padStart(8, '0') } };\n}\n\n/** Lines visible at time t at `rate` lines/s: the last n, newest last. */\nexport function tail(seed, t, rate, n) {\n  const last = Math.floor(t * rate) + n;\n  const out = [];\n  for (let i = Math.max(0, last - n + 1); i <= last; i++) out.push(logLine(seed, i));\n  return out;\n}\n\n/** Wall-clock style timestamp for a line index (fictional fixed day). */\nexport function stamp(i, rate, fmt = 'syslog') {\n  const secs = 14 * 3600 + 2 * 60 + Math.floor(i / rate);\n  const hh = String(Math.floor(secs / 3600) % 24).padStart(2, '0'), mm = String(Math.floor(secs / 60) % 60).padStart(2, '0'), ss = String(secs % 60).padStart(2, '0');\n  const ms = String(Math.floor(((i % rate) / rate) * 1000)).padStart(3, '0');\n  if (fmt === 'iso') return `2026-10-13T${hh}:${mm}:${ss}.${ms}Z`;\n  return `Oct 13 ${hh}:${mm}:${ss}`;\n}\n\nexport const LEVEL_KEYS = ['debug', 'info', 'warn', 'error', 'crit'];\nexport const levelColor = (th, l) => ({ debug: th.dim, info: th.info, warn: th.warn, error: th.neg, crit: th.crit })[l] ?? th.ink;\n",
  "prism-term/src/facets/status/alerts.js": "// Toasts & Alerts: a sliding toast stack, alert banners, an incident banner,\n// an on-call page with an ack countdown, and a deploy-succeeded celebration.\nimport { mix, scale } from '../../color.js';\nimport { glyphs, put, putR, clamp, smooth, safe, onFill, ATTR } from '../charts/_data.js';\n\nconst TOASTS = [\n  ['pos', 'Deploy finished', 'checkout v2.14.2 is live on 12/12 hosts'],\n  ['info', 'Backup complete', 'pg-orders snapshot 41.2 GB in 3m 12s'],\n  ['warn', 'High latency', 'search p95 at 812 ms (SLO 500 ms)'],\n  ['neg', 'Job failed', 'nightly-reindex exited 137 (OOM killed)'],\n  ['info', 'Scaled out', 'catalog 6 → 9 replicas (cpu 78%)'],\n  ['pos', 'Certificate renewed', 'payments client cert valid 365 days'],\n];\n\nconst toastStack = {\n  id: 'status-toast-stack',\n  title: 'Toast Stack',\n  category: 'Toasts & Alerts',\n  description: 'Notifications slide in from the right, stack, push older ones down and dismiss themselves with a draining timer line.',\n  tags: ['toast', 'notification', 'snackbar', 'stack', 'slide in'],\n  kind: 'widget',\n  size: { w: 50, h: 15 },\n  minSize: { w: 30, h: 5 },\n  loop: 13.2,\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    const EVERY = 2.2, LIFE = 6.4, TW = Math.min(W, 44), TH = 4;\n    const icon = { pos: g.check, info: g.info, warn: g.warn, neg: g.cross };\n    return (t) => {\n      const n = Math.floor(t / EVERY);\n      const live = [];\n      for (let i = n; i >= 0 && i > n - 5; i--) {\n        const age = t - i * EVERY;\n        if (age < 0 || age > LIFE) continue;\n        live.push({ i, age });\n      }\n      let y = 0;\n      for (const { i, age } of live) {\n        if (y + TH > H) break;\n        const [tone, title, msg] = TOASTS[i % TOASTS.length];\n        const c = th[tone];\n        const inK = smooth(clamp(age / 0.45, 0, 1)), outK = smooth(clamp((LIFE - age) / 0.45, 0, 1));\n        const x = W - TW + Math.round((1 - Math.min(inK, outK)) * (TW + 2));\n        const bg = mix(th.bg, th.panel, 0.9);\n        for (let yy = 0; yy < TH - 1; yy++) for (let xx = 0; xx < TW; xx++) b.set(x + xx, y + yy, ' ', undefined, bg);\n        for (let yy = 0; yy < TH - 1; yy++) b.set(x, y + yy, g.u ? '▌' : '|', c, bg);\n        b.set(x + 2, y, icon[tone], c, bg, ATTR.BOLD);\n        b.text(x + 4, y, title, th.ink, bg, ATTR.BOLD);\n        b.text(x + TW - 2, y, g.u ? '×' : 'x', th.dim, bg);\n        put(b, x + 4, y + 1, msg, TW - 6, th.muted, bg);\n        const left = clamp(1 - age / LIFE, 0, 1);\n        const lw = Math.round(left * (TW - 1));\n        for (let k = 1; k < TW; k++) b.set(x + k, y + 2, g.u ? '━' : '-', k <= lw ? scale(c, 0.85) : mix(bg, th.line, 0.5), bg);\n        y += TH;\n      }\n    };\n  },\n};\n\nconst BANNERS = [\n  ['info', 'Heads up', 'pg-orders maintenance tonight 02:00–02:30 UTC.'],\n  ['pos', 'Resolved', 'Checkout latency is back to normal.'],\n  ['warn', 'Degraded', 'Search is slower than usual (p95 1.8 s).'],\n  ['neg', 'Outage', 'Payments failing in eu-west-1 — team engaged.'],\n];\n\nconst alertBanners = {\n  id: 'status-alert-banners',\n  title: 'Alert Banners',\n  category: 'Toasts & Alerts',\n  description: 'Info, success, warning and error banners with accent rails, icons and dismiss marks; the newest one pulses for attention.',\n  tags: ['alert', 'banner', 'callout', 'severity', 'message'],\n  kind: 'widget',\n  size: { w: 72, h: 8 },\n  minSize: { w: 30, h: 4 },\n  loop: 8,\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    const icon = { pos: g.check, info: g.info, warn: g.warn, neg: g.cross };\n    const step = H >= 8 ? 2 : 1;\n    const rows = Math.min(BANNERS.length, Math.floor(H / step));\n    return (t) => {\n      const hot = Math.floor(t / 2) % rows;\n      for (let r = 0; r < rows; r++) {\n        const [tone, title, msg] = BANNERS[r];\n        const c = th[tone], y = r * step;\n        const pulse = r === hot ? 0.5 + 0.5 * Math.sin(t * 6) : 0;\n        const bg = mix(th.bg, c, 0.16 + 0.1 * pulse);\n        for (let x = 0; x < W; x++) b.set(x, y, ' ', undefined, bg);\n        b.set(0, y, g.u ? '▌' : '|', c, bg);\n        b.set(2, y, icon[tone], c, bg, ATTR.BOLD);\n        const x = b.text(4, y, title, c, bg, ATTR.BOLD);\n        put(b, x + 2, y, msg, W - x - 6, th.ink, bg);\n        b.text(W - 2, y, g.u ? '×' : 'x', th.muted, bg);\n      }\n    };\n  },\n};\n\nconst incident = {\n  id: 'status-incident-banner',\n  title: 'Incident Banner',\n  category: 'Toasts & Alerts',\n  description: 'A pulsing SEV1 banner with a live elapsed timer, affected-service chips, the incident commander and the bridge link.',\n  tags: ['incident', 'sev1', 'outage', 'banner', 'on-call'],\n  kind: 'widget',\n  size: { w: 72, h: 5 },\n  minSize: { w: 34, h: 2 },\n  loop: 10,\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    const svcs = ['checkout', 'payments', 'orders'];\n    return (t) => {\n      const pulse = 0.5 + 0.5 * Math.sin(t * 4.5);\n      const bg = mix(th.bg, th.neg, 0.22 + 0.18 * pulse);\n      const rows = Math.min(H, 3);\n      for (let y = 0; y < rows; y++) for (let x = 0; x < W; x++) b.set(x, y, ' ', undefined, y === 0 ? bg : mix(th.bg, th.neg, 0.1));\n      const secs = 754 + Math.floor(t);\n      const timer = `${String(Math.floor(secs / 60)).padStart(2, '0')}:${String(secs % 60).padStart(2, '0')}`;\n      let x = b.text(1, 0, ` SEV1 `, onFill(th, th.neg), th.neg, ATTR.BOLD);\n      x = b.text(x + 1, 0, `${g.dot} INCIDENT  Checkout failing for some customers`, th.ink, bg, ATTR.BOLD);\n      putR(b, W - 1, 0, `elapsed ${timer}`, th.ink, bg, ATTR.BOLD);\n      if (rows >= 2) {\n        let cx = b.text(1, 1, 'affected', th.muted, mix(th.bg, th.neg, 0.1));\n        for (const s of svcs) cx = b.text(cx + 1, 1, ` ${s} `, th.ink, mix(th.bg, th.neg, 0.35));\n        if (W >= 60) putR(b, W - 1, 1, 'IC: @riley · comms: @sam', th.muted, mix(th.bg, th.neg, 0.1));\n      }\n      if (rows >= 3) {\n        put(b, 1, 2, `bridge: https://status.example/inc/2471 · updates every 15 min · next in ${String(15 - (Math.floor(t / 4) % 15)).padStart(2)} min`, W - 2, th.dim, mix(th.bg, th.neg, 0.1));\n      }\n      if (H >= 5) {\n        const updates = ['14:02 identified: connection pool exhaustion after v2.14.1', '14:09 mitigating: pool resized 20 → 40, recovering'];\n        updates.forEach((u, i) => { if (3 + i < H) put(b, 1, 3 + i, `${g.bullet} ${u}`, W - 2, i === updates.length - 1 && Math.floor(t * 2) % 2 ? th.ink : th.muted); });\n      }\n    };\n  },\n};\n\nconst pager = {\n  id: 'status-oncall-pager',\n  title: 'On-call Page',\n  category: 'Toasts & Alerts',\n  description: 'An incoming page: the alert, its context, ringing indicator and an acknowledge countdown that escalates when it runs out.',\n  tags: ['pager', 'on-call', 'alert', 'acknowledge', 'escalation'],\n  kind: 'widget',\n  size: { w: 56, h: 9 },\n  minSize: { w: 34, h: 6 },\n  loop: 12,\n  order: 40,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    return (t) => {\n      const cycle = t % 12, left = Math.max(0, 10 - cycle), escalated = left <= 0;\n      const ring = Math.floor(t * 3) % 2 === 0;\n      const c = escalated ? th.crit : th.neg;\n      const w = Math.min(W, 54), x0 = Math.floor((W - w) / 2);\n      const bg = mix(th.bg, th.panel, 0.95);\n      for (let y = 0; y < Math.min(H, 8); y++) for (let x = 0; x < w; x++) b.set(x0 + x, y, ' ', undefined, bg);\n      b.text(x0 + 1, 0, ring ? (g.u ? '◉' : '*') : (g.u ? '○' : ' '), c, bg, ATTR.BOLD);\n      b.text(x0 + 3, 0, escalated ? 'ESCALATED to secondary' : 'PAGE · high urgency', c, bg, ATTR.BOLD);\n      putR(b, x0 + w - 1, 0, '14:02:11', th.dim, bg);\n      put(b, x0 + 1, 2, 'checkout p95 > 1s for 5 minutes', w - 2, th.ink, bg, ATTR.BOLD);\n      put(b, x0 + 1, 3, 'service checkout · env prod · region us-east-1', w - 2, th.muted, bg);\n      put(b, x0 + 1, 4, 'runbook: wiki.example/runbooks/checkout-latency', w - 2, th.info, bg);\n      if (H >= 7) {\n        const bw = w - 14;\n        const fill = Math.round((left / 10) * bw);\n        b.text(x0 + 1, 6, escalated ? 'ack window' : `ack in ${left.toFixed(0).padStart(2)}s`, th.muted, bg);\n        for (let k = 0; k < bw; k++) b.set(x0 + 12 + k, 6, k < fill ? (g.u ? '█' : '#') : (g.u ? '░' : '.'), k < fill ? mix(th.warn, th.neg, 1 - left / 10) : th.line, bg);\n      }\n      if (H >= 8) {\n        b.text(x0 + 1, 7, ' [a] Acknowledge ', th.bg, th.pos, ATTR.BOLD);\n        b.text(x0 + 20, 7, ' [e] Escalate ', th.ink, mix(th.bg, th.line, 0.8));\n      }\n    };\n  },\n};\n\nconst deploy = {\n  id: 'status-deploy-success',\n  title: 'Deploy Succeeded',\n  category: 'Toasts & Alerts',\n  description: 'The good-news moment: rollout dots fill host by host, the check lands, confetti falls and the summary — version, hosts, duration — settles in.',\n  tags: ['deploy', 'success', 'celebration', 'confetti', 'release'],\n  kind: 'widget',\n  size: { w: 60, h: 12 },\n  minSize: { w: 30, h: 6 },\n  loop: 9,\n  order: 50,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    const colors = [th.accent, th.pos, th.warn, th.crit, th.info, th.neg];\n    const bits = Array.from({ length: Math.round(W * H * 0.12) }, () => ({ x: ctx.rng() * W, v: 2 + ctx.rng() * 5, ph: ctx.rng() * 6.28, c: colors[Math.floor(ctx.rng() * colors.length)], ch: ctx.rng.pick(g.u ? ['▪', '•', '◆', '▴', '✦', '·'] : ['*', '+', 'o', '.', '`']) }));\n    return (t) => {\n      const cyc = t % 9;\n      const hosts = 12, done = Math.min(hosts, Math.floor(cyc / 0.22));\n      const cx = Math.floor(W / 2);\n      if (cyc < hosts * 0.22 + 0.3) {\n        const label = `rolling out checkout v2.14.2  ${done}/${hosts}`;\n        b.text(Math.max(0, cx - Math.floor(label.length / 2)), Math.floor(H / 2) - 1, label, th.muted);\n        let x = cx - hosts;\n        for (let i = 0; i < hosts; i++) { b.set(x, Math.floor(H / 2) + 1, i < done ? g.dot : g.ring, i < done ? th.pos : th.line); x += 2; }\n        return;\n      }\n      const k = cyc - (hosts * 0.22 + 0.3);\n      for (const p of bits) {\n        const y = (k * p.v + p.ph) % (H + 2) - 1;\n        const x = Math.round(p.x + Math.sin(k * 2 + p.ph) * 1.5);\n        if (y >= 0) b.set(x, Math.floor(y), p.ch, p.c);\n      }\n      const mid = Math.floor(H / 2) - 1;\n      const pop = smooth(clamp(k / 0.35, 0, 1));\n      const msg = `${g.check}  Deploy succeeded`;\n      b.text(cx - Math.floor(msg.length / 2), mid, msg, pop > 0.5 ? th.pos : th.dim, undefined, ATTR.BOLD);\n      if (H >= 6 && k > 0.4) put(b, Math.max(0, cx - 21), mid + 2, 'checkout v2.14.2 · 12/12 hosts healthy · 2m 41s', W, th.ink);\n      if (H >= 8 && k > 0.8) put(b, Math.max(0, cx - 21), mid + 3, 'error rate 0.02% · p95 212 ms · no rollbacks', W, th.muted);\n    };\n  },\n};\n\nexport default [toastStack, alertBanners, incident, pager, deploy];\n",
  "prism-term/src/facets/status/badges.js": "// Badges & Pills: level and state badges with live counts, README-style\n// shields, and a CI pipeline whose jobs pass, fail and retry.\nimport { mix } from '../../color.js';\nimport { glyphs, put, putR, safe, clamp, lift, onFill, ATTR } from '../charts/_data.js';\nimport { LEVEL_KEYS, levelColor } from './_logs.js';\n\n/** A rounded pill: half-block caps on Unicode terminals, a padded chip otherwise. */\nfunction pill(b, g, x, y, text, fg, bg) {\n  if (!g.u) return b.text(x, y, ` ${text} `, fg, bg, ATTR.BOLD);\n  b.set(x, y, '▐', bg);\n  const e = b.text(x + 1, y, text, fg, bg, ATTR.BOLD);\n  b.set(e, y, '▌', bg);\n  return e + 1;\n}\n\nconst SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];\nconst spin = (g, t) => (g.u ? SPIN[Math.floor(t * 12) % SPIN.length] : '|/-\\\\'[Math.floor(t * 8) % 4]);\n\nconst badges = {\n  id: 'status-badges',\n  title: 'Badges & Pills',\n  category: 'Badges & Pills',\n  description: 'Log-level badges with ticking counts, state chips, outline tags, environment pills and notification counters, all theme-tinted.',\n  tags: ['badge', 'pill', 'chip', 'tag', 'counter'],\n  kind: 'widget',\n  size: { w: 72, h: 9 },\n  minSize: { w: 36, h: 4 },\n  loop: 10,\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    const rates = { debug: 9.2, info: 21.5, warn: 2.1, error: 0.45, crit: 0.06 };\n    const base = { debug: 41210, info: 120488, warn: 882, error: 121, crit: 3 };\n    const step = H >= 9 ? 2 : 1;\n    return (t) => {\n      let y = 0;\n      // Level badges with counts.\n      let x = 0;\n      for (const l of LEVEL_KEYS) {\n        const n = Math.floor(base[l] + rates[l] * t);\n        const c = levelColor(th, l);\n        const bump = (rates[l] * t) % 1 < 0.12 && rates[l] < 3;\n        const lab = l.toUpperCase();\n        if (x + lab.length + 10 > W) break;\n        x = pill(b, g, x, y, lab, th.bg, bump ? lift(th, c, 0.35) : c);\n        x = b.text(x + 1, y, n.toLocaleString('en-US'), bump ? th.ink : th.muted, undefined, bump ? ATTR.BOLD : 0) + 2;\n      }\n      y += step;\n      if (y >= H) return;\n      // State chips (soft style), the pending one breathing.\n      const states = [\n        ['running', g.dot, th.info], ['pending', g.u ? '◌' : '.', th.warn], ['passed', g.check, th.pos],\n        ['failed', g.cross, th.neg], ['skipped', g.u ? '⊘' : '-', th.dim], ['paused', g.u ? '‖' : '=', th.muted],\n      ];\n      x = 0;\n      for (const [name, ic, c] of states) {\n        if (x + name.length + 5 > W) break;\n        const breathe = name === 'pending' ? 0.18 + 0.14 * (0.5 + 0.5 * Math.sin(t * 4)) : 0.22;\n        const icon = name === 'running' ? spin(g, t) : ic;\n        x = b.text(x, y, ` ${icon} ${name} `, c, mix(th.bg, c, breathe), ATTR.BOLD) + 1;\n      }\n      y += step;\n      if (y >= H) return;\n      // Outline tags.\n      x = 0;\n      const pct = 10 + Math.floor(clamp((t % 10) / 8, 0, 1) * 4) * 15;\n      for (const [tag, c] of [['v2.14.2', th.accent], ['prod', th.neg], ['us-east-1', th.info], [`canary ${pct}%`, th.warn], ['owner: payments', th.muted]]) {\n        if (x + tag.length + 3 > W) break;\n        x = b.text(x, y, `[${tag}]`, c, undefined, ATTR.BOLD) + 1;\n      }\n      y += step;\n      if (y >= H) return;\n      // Environment pills: the selected one is solid, others soft.\n      x = 0;\n      const envs = ['dev', 'staging', 'canary', 'prod'];\n      const sel = Math.floor(t / 2.5) % envs.length;\n      x = b.text(x, y, 'env ', th.dim);\n      envs.forEach((e, i) => {\n        const c = e === 'prod' ? th.neg : e === 'canary' ? th.warn : e === 'staging' ? th.info : th.pos;\n        if (x + e.length + 4 > W) return;\n        x = i === sel ? pill(b, g, x, y, e.toUpperCase(), th.bg, c) + 1 : b.text(x, y, ` ${e.toUpperCase()} `, c, mix(th.bg, c, 0.14)) + 1;\n      });\n      y += step;\n      if (y >= H) return;\n      // Notification counters.\n      x = 0;\n      const counters = [['Inbox', 3 + Math.floor(t / 3.3) % 4, th.accent], ['PRs', 12, th.info], ['Alerts', 2 + (Math.floor(t / 2) % 3), th.neg], ['Mentions', 0, th.dim]];\n      for (const [name, n, c] of counters) {\n        if (x + name.length + 6 > W) break;\n        x = b.text(x, y, name, th.ink) + 1;\n        x = n ? pill(b, g, x, y, String(n), th.bg, c) + 2 : b.text(x, y, '0', th.dim) + 2;\n      }\n    };\n  },\n};\n\nconst shields = {\n  id: 'status-shields',\n  title: 'README Shields',\n  category: 'Badges & Pills',\n  description: 'Two-tone README shields — build, tests, coverage, version, licence, downloads — with the build badge running, passing and failing.',\n  tags: ['shields', 'badges', 'readme', 'build status', 'coverage'],\n  kind: 'widget',\n  size: { w: 72, h: 6 },\n  minSize: { w: 30, h: 3 },\n  loop: 12,\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    const label = mix(th.bg, th.muted, 0.55);\n    return (t) => {\n      const lt = t % 12;\n      const build = lt < 3 ? ['running', th.warn] : lt < 8 ? ['passing', th.pos] : lt < 9 ? ['running', th.warn] : ['failing', th.neg];\n      const cov = 86.2 + Math.min(1.2, lt * 0.12);\n      const tests = lt < 9 ? 1204 : 1203;\n      const list = [\n        ['build', build[0] === 'running' ? `${spin(g, t)} running` : build[0], build[1]],\n        ['tests', lt < 9 ? `${tests.toLocaleString('en-US')} passed` : `1 failed, ${tests.toLocaleString('en-US')} passed`, lt < 9 ? th.pos : th.neg],\n        ['coverage', `${cov.toFixed(1)}%`, cov >= 87 ? th.pos : th.warn],\n        ['release', 'v2.14.2', th.accent],\n        ['license', 'MIT', th.info],\n        ['downloads', `${(12.3 + t * 0.01).toFixed(1)}k/week`, th.pos],\n        ['uptime', '99.98%', th.pos],\n        ['vulns', lt < 6 ? '0 known' : '1 moderate', lt < 6 ? th.pos : th.warn],\n        ['docs', 'passing', th.pos],\n        ['chat', '412 online', th.accent],\n      ];\n      let x = 0, y = 0;\n      for (const [k, v, c] of list) {\n        const w = k.length + v.length + 4;\n        if (x + w > W) { x = 0; y += H >= 6 ? 2 : 1; }\n        if (y >= H) break;\n        x = b.text(x, y, ` ${k} `, th.ink, label);\n        x = b.text(x, y, ` ${v} `, onFill(th, mix(c, th.bg, 0.15)), mix(c, th.bg, 0.15), ATTR.BOLD) + 1;\n      }\n    };\n  },\n};\n\nconst STAGES = [\n  ['build', [['compile', 1.1], ['docker-image', 1.8]]],\n  ['test', [['unit', 1.6], ['integration', 2.6], ['lint', 0.8]]],\n  ['security', [['sast', 1.2], ['deps-audit', 0.9]]],\n  ['staging', [['deploy', 1.4], ['smoke', 1.1]]],\n  ['production', [['canary 10%', 1.6], ['promote', 1.0]]],\n];\n\nconst pipeline = {\n  id: 'status-ci-pipeline',\n  title: 'CI Pipeline',\n  category: 'Badges & Pills',\n  description: 'A CI pipeline graph: stages of jobs going pending → running → passed, one flaky test failing and retrying, and the overall badge.',\n  tags: ['ci', 'pipeline', 'build', 'jobs', 'deploy'],\n  kind: 'widget',\n  size: { w: 80, h: 10 },\n  minSize: { w: 44, h: 6 },\n  loop: 18,\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    // Schedule: stages run sequentially, jobs within a stage in parallel. The\n    // integration job fails once and is retried.\n    const sched = [];\n    let at = 0.6;\n    for (const [, jobs] of STAGES) {\n      let end = at;\n      const row = jobs.map(([name, d]) => {\n        const flaky = name === 'integration';\n        const j = { name, start: at, end: at + d, flaky, retryEnd: flaky ? at + d + 1.8 : 0 };\n        end = Math.max(end, flaky ? j.retryEnd : j.end);\n        return j;\n      });\n      sched.push(row);\n      at = end + 0.25;\n    }\n    const total = at;\n    const colW = Math.floor(W / STAGES.length);\n    const state = (j, lt) => {\n      if (lt < j.start) return 'pending';\n      if (lt < j.end) return 'running';\n      if (j.flaky && lt < j.end + 0.5) return 'failed';\n      if (j.flaky && lt < j.retryEnd) return 'retrying';\n      return 'passed';\n    };\n    const look = {\n      pending: [g.u ? '○' : 'o', th.dim], running: [null, th.info], retrying: [null, th.warn],\n      failed: [g.cross, th.neg], passed: [g.check, th.pos],\n    };\n    return (t) => {\n      const lt = t % 18;\n      const done = lt >= total;\n      const anyFail = sched.flat().some((j) => state(j, lt) === 'failed');\n      const head = done ? ['passed', th.pos] : anyFail ? ['failing', th.neg] : ['running', th.info];\n      const el = Math.min(lt, total);\n      let x = b.text(0, 0, ` ${head[0] === 'running' ? spin(g, t) : head[0] === 'passed' ? g.check : g.cross} ${head[0]} `, th.bg, head[1], ATTR.BOLD);\n      x = put(b, x + 1, 0, `Pipeline #48213 · main · 4c1e9a2 \"await sleep in pool\"`, W - x - 10, th.ink);\n      putR(b, W, 0, `${Math.floor(el / 60 * 20)}m ${String(Math.floor(el * 20) % 60).padStart(2, '0')}s`, th.muted);\n      const top = H >= 8 ? 2 : 1;\n      STAGES.forEach(([stage], si) => {\n        const x0 = si * colW;\n        const jobs = sched[si];\n        const states = jobs.map((j) => state(j, lt));\n        const sc = states.includes('failed') ? th.neg : states.every((s) => s === 'passed') ? th.pos : states.some((s) => s === 'running' || s === 'retrying') ? th.info : th.dim;\n        put(b, x0, top, stage, colW - 3, sc, undefined, ATTR.BOLD);\n        jobs.forEach((j, ji) => {\n          const y = top + 1 + ji + (H >= 10 ? 1 : 0);\n          if (y >= H) return;\n          const st = states[ji];\n          const [ic, c] = look[st];\n          const icon = ic ?? spin(g, t + ji * 0.3);\n          const bg = st === 'running' || st === 'retrying' ? mix(th.bg, c, 0.14) : undefined;\n          b.set(x0, y, icon, c, bg, ATTR.BOLD);\n          put(b, x0 + 2, y, st === 'retrying' ? `${j.name} #2` : j.name, colW - 5, st === 'pending' ? th.dim : th.ink, bg);\n        });\n        if (si < STAGES.length - 1) {\n          const y = top + 1 + (H >= 10 ? 1 : 0);\n          const passed = states.every((s) => s === 'passed');\n          for (let k = colW - 3; k < colW; k++) b.set(x0 + k, y, g.h, passed ? th.pos : th.line);\n        }\n      });\n      if (H >= 10) {\n        const flat = sched.flat();\n        const n = flat.filter((j) => state(j, lt) === 'passed').length;\n        put(b, 0, H - 1, `${n}/${flat.length} jobs passed · 1 retried · artifacts 3 · coverage 87.4%`, W, th.muted);\n      }\n    };\n  },\n};\n\nexport default [badges, shields, pipeline];\n",
  "prism-term/src/facets/status/health.js": "// Health & Uptime: live health checks, a 90-day uptime history with SLO\n// budget, a services × regions status matrix and a heartbeat monitor.\nimport { mix } from '../../color.js';\nimport { rng } from '../../easing.js';\nimport { signal, glyphs, put, putR, safe, clamp, light, VBLOCKS, ATTR } from '../charts/_data.js';\n\nconst CHECKS = [\n  ['api-gateway', 'GET /healthz', 18],\n  ['auth', 'GET /v1/ping', 24],\n  ['checkout', 'GET /ready', 41],\n  ['payments', 'TCP 10.20.4.12:8443', 35],\n  ['search', 'GET /_cluster/health', 62],\n  ['postgres', 'SELECT 1', 3],\n  ['redis', 'PING', 1],\n  ['kafka', 'metadata', 9],\n];\n\nconst healthChecks = {\n  id: 'status-health-checks',\n  title: 'Health Checks',\n  category: 'Health & Uptime',\n  description: 'Service health checks re-running in a sweep: live status dots and words, latency, last-checked age and a latency trail per row.',\n  tags: ['health check', 'status dot', 'monitoring', 'latency', 'probe'],\n  kind: 'widget',\n  size: { w: 76, h: 10 },\n  minSize: { w: 36, h: 4 },\n  loop: 16,\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    const PERIOD = 4;\n    const lat = CHECKS.map(([, , base], i) => signal(ctx, { seed: 200 + i, base, amp: base * 0.3, rate: 1 / PERIOD, spike: base * 0.8, min: 0.5 }));\n    const showTrail = W >= 64, showEp = W >= 54;\n    const rows = Math.min(CHECKS.length, H - 1);\n    // The search cluster degrades and goes down mid-loop, then recovers.\n    const verdict = (i, k) => {\n      const lt = (k * PERIOD) % 16;\n      if (CHECKS[i][0] === 'search') return lt >= 6 && lt < 10 ? 'down' : lt >= 4 && lt < 12 ? 'degraded' : 'ok';\n      const v = lat[i].at(k);\n      return v > CHECKS[i][2] * 2.1 ? 'degraded' : 'ok';\n    };\n    const look = { ok: [th.pos, 'OK'], degraded: [th.warn, 'DEGRADED'], down: [th.neg, 'DOWN'] };\n    return (t) => {\n      const cols = showEp ? [0, 14, 37, 49, 58] : [0, 13, 0, 25, 31];\n      b.text(cols[0] + 2, 0, 'SERVICE', th.dim, undefined, ATTR.BOLD);\n      if (showEp) b.text(cols[1], 0, 'CHECK', th.dim, undefined, ATTR.BOLD);\n      b.text(showEp ? cols[2] : cols[1], 0, 'STATUS', th.dim, undefined, ATTR.BOLD);\n      b.text(cols[3], 0, 'LATENCY', th.dim, undefined, ATTR.BOLD);\n      if (W >= 40) b.text(cols[4], 0, 'AGE', th.dim, undefined, ATTR.BOLD);\n      for (let i = 0; i < rows; i++) {\n        const y = i + 1;\n        // Rows are probed in a staggered sweep, PERIOD seconds apart.\n        const phase = t / PERIOD - i / CHECKS.length;\n        const k = Math.floor(phase), since = (phase - k) * PERIOD;\n        const checking = since < 0.35;\n        const v = verdict(i, k);\n        const [c, word] = look[v];\n        const pulse = v === 'ok' ? 0.75 + 0.25 * Math.sin(t * 3 + i) : 0.5 + 0.5 * Math.sin(t * 7);\n        b.set(0, y, checking ? (g.u ? '◌' : '.') : g.dot, checking ? th.dim : mix(th.bg, c, 0.35 + 0.65 * pulse), undefined, ATTR.BOLD);\n        put(b, 2, y, CHECKS[i][0], (showEp ? cols[1] : cols[1]) - 3, th.ink);\n        if (showEp) put(b, cols[1], y, CHECKS[i][1], cols[2] - cols[1] - 1, th.muted);\n        const sx = showEp ? cols[2] : cols[1];\n        if (checking) b.text(sx, y, 'checking', th.dim);\n        else b.text(sx, y, word, c, v === 'ok' ? undefined : mix(th.bg, c, 0.15), ATTR.BOLD);\n        const ms = v === 'down' ? null : lat[i].at(k) * (v === 'degraded' && CHECKS[i][0] === 'search' ? 9 : 1);\n        b.text(cols[3], y, ms == null ? 'timeout' : `${ms < 10 ? ms.toFixed(1) : Math.round(ms)} ms`.padStart(7), ms == null ? th.neg : ms > CHECKS[i][2] * 2 ? th.warn : th.ink);\n        if (W >= 40) b.text(cols[4], y, checking ? '  now' : `${Math.floor(since)}s ago`.padStart(5), th.dim);\n        if (showTrail) {\n          const tx = cols[4] + 7, n = W - tx;\n          let hi = 0, lo = Infinity;\n          const vals = [];\n          for (let j = 0; j < n; j++) { const kk = k - n + 1 + j; const vv = verdict(i, kk) === 'down' ? -1 : lat[i].at(kk); vals.push(vv); if (vv >= 0) { hi = Math.max(hi, vv); lo = Math.min(lo, vv); } }\n          vals.forEach((vv, j) => {\n            if (vv < 0) { b.set(tx + j, y, g.u ? '▁' : '_', th.neg); return; }\n            const lvl = clamp(1 + Math.round(((vv - lo) / (hi - lo || 1)) * 7), 1, 8);\n            b.set(tx + j, y, g.u ? VBLOCKS[lvl] : '.:-=+*#@'[lvl - 1], vv > CHECKS[i][2] * 2 ? th.warn : mix(th.dim, th.pos, 0.6));\n          });\n        }\n      }\n    };\n  },\n};\n\nconst SERVICES = [['API', 0.9995], ['Checkout', 0.999], ['Payments', 0.9995], ['Search', 0.995], ['Webhooks', 0.999]];\nconst MONTHS = [['Jun', 30], ['Jul', 31], ['Aug', 31], ['Sep', 30], ['Oct', 31]];\n/** Calendar date `n` days before Oct 13 (the fictional today). */\nfunction dateOf(n) {\n  let m = 4, d = 13 - n;\n  while (d <= 0) { m--; d += MONTHS[m][1]; }\n  return `${MONTHS[m][0]} ${String(d).padStart(2)}`;\n}\n\nconst uptime = {\n  id: 'status-uptime-slo',\n  title: 'Uptime & SLO',\n  category: 'Health & Uptime',\n  description: 'A status-page uptime history: 90 daily bars per service, uptime against its SLO, the error budget left and a cursor reading each day.',\n  tags: ['uptime', 'slo', 'error budget', 'status page', 'history'],\n  kind: 'widget',\n  size: { w: 80, h: 13 },\n  minSize: { w: 40, h: 4 },\n  loop: 18,\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    const nameW = W >= 60 ? 10 : 9, statW = W >= 60 ? 16 : 8;\n    const days = Math.max(10, Math.min(90, W - nameW - statW));\n    // Per-day downtime minutes, mostly zero with a few incidents.\n    const hist = SERVICES.map((_, si) => {\n      const r = rng(9100 + si * 31);\n      return Array.from({ length: 90 }, () => { const p = r(); return p > 0.985 ? r.int(40, 180) : p > 0.94 ? r.int(2, 30) : 0; });\n    });\n    hist[3][86] = 95; hist[1][61] = 22;\n    const step = H >= 13 ? 2 : 1;\n    const rows = Math.min(SERVICES.length, Math.floor((H - (H >= 6 ? 2 : 0)) / step));\n    const dayColor = (m) => (m === 0 ? th.pos : m < 15 ? mix(th.pos, th.warn, 0.7) : m < 60 ? th.warn : th.neg);\n    const dayGlyph = (m) => (g.u ? (m === 0 ? '█' : m < 15 ? '▇' : m < 60 ? '▆' : '▅') : m === 0 ? '#' : m < 60 ? '=' : '_');\n    return (t) => {\n      const cursor = Math.floor(((t % 18) / 18) * days);\n      for (let r = 0; r < rows; r++) {\n        const [name, slo] = SERVICES[r], y = r * step;\n        const d = hist[r].slice(90 - days);\n        const down = hist[r].reduce((a, m) => a + m, 0);\n        const up = 1 - down / (90 * 1440);\n        put(b, 0, y, name, nameW - 1, th.ink, undefined, ATTR.BOLD);\n        for (let i = 0; i < days; i++) {\n          const m = d[i];\n          const hot = i === cursor;\n          b.set(nameW + i, y, dayGlyph(m), hot ? th.ink : dayColor(m), hot ? mix(th.bg, th.ink, 0.25) : undefined);\n        }\n        const budget = clamp(1 - (1 - up) / (1 - slo), -1, 1);\n        const ok = up >= slo;\n        if (statW >= 16) {\n          b.text(nameW + days + 1, y, `${(up * 100).toFixed(up > 0.9999 ? 3 : 2)}%`, ok ? th.pos : th.neg, undefined, ATTR.BOLD);\n          putR(b, W, y, `${budget >= 0 ? Math.round(budget * 100) : 0}% left`, budget > 0.25 ? th.muted : th.neg);\n        } else b.text(nameW + days + 1, y, `${(up * 100).toFixed(1)}`, ok ? th.pos : th.neg);\n        if (step === 2 && W >= 60) {\n          const bw = Math.min(days, 40);\n          const fill = Math.round(clamp(budget, 0, 1) * bw);\n          b.text(nameW, y + 1, `SLO ${(slo * 100).toFixed(slo >= 0.999 ? 2 : 1)}%`, th.dim);\n          for (let k = 0; k < bw; k++) b.set(nameW + 12 + k, y + 1, g.u ? '━' : '-', k < fill ? (budget > 0.25 ? mix(th.bg, th.accent, 0.7) : th.neg) : mix(th.bg, th.line, 0.6));\n          b.text(nameW + 13 + bw, y + 1, 'error budget', th.dim);\n        }\n      }\n      if (H >= 6) {\n        const y = rows * step;\n        b.text(nameW, y, `${days} days ago`, th.dim);\n        putR(b, nameW + days, y, 'today', th.dim);\n        const daysAgo = days - 1 - cursor;\n        const date = daysAgo === 0 ? 'Today' : dateOf(daysAgo);\n        const worst = SERVICES.map((s, i) => [s[0], hist[i][90 - days + cursor]]).sort((a, c) => c[1] - a[1])[0];\n        const note = worst[1] ? `${date}: ${worst[0]} ${worst[1]} min down` : `${date}: no downtime`;\n        put(b, nameW, y + 1, `${g.arrowR} ${note}`, W - nameW, worst[1] ? dayColor(worst[1]) : th.muted);\n      }\n    };\n  },\n};\n\nconst MSVCS = ['api', 'auth', 'checkout', 'payments', 'search', 'cdn', 'email', 'kafka'];\nconst REGIONS = ['us-east-1', 'us-west-2', 'eu-west-1', 'eu-north-1', 'ap-south-1', 'ap-east-1'];\n\nconst matrix = {\n  id: 'status-service-matrix',\n  title: 'Service Matrix',\n  category: 'Health & Uptime',\n  description: 'Services by region as a status grid: an incident starts in one cell, spreads to its neighbors, then heals, with words, not just color.',\n  tags: ['status matrix', 'regions', 'services', 'incident', 'grid'],\n  kind: 'widget',\n  size: { w: 80, h: 11 },\n  minSize: { w: 44, h: 5 },\n  loop: 16,\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    const nameW = 10;\n    const cw = Math.max(5, Math.min(12, Math.floor((W - nameW) / REGIONS.length)));\n    const nR = Math.min(REGIONS.length, Math.floor((W - nameW) / cw));\n    const nS = Math.min(MSVCS.length, H - (H >= 7 ? 2 : 1));\n    // Incident epicentre: payments × eu-west-1, plus a scheduled maintenance cell.\n    const E = [3, 2];\n    const state = (s, r, lt) => {\n      if (s === 6 && r === 4) return 'maint';\n      const d = Math.abs(s - E[0]) + Math.abs(r - E[1]);\n      const onset = 2 + d * 1.1, heal = 11 - d * 0.6;\n      if (lt < onset || lt > heal) return 'ok';\n      if (d === 0 && lt > onset + 1.5 && lt < heal - 1.5) return 'down';\n      return d <= 2 ? 'degraded' : 'ok';\n    };\n    const look = { ok: [th.pos, 'ok'], degraded: [th.warn, 'degr'], down: [th.neg, 'down'], maint: [th.info, 'maint'] };\n    return (t) => {\n      const lt = t % 16;\n      for (let r = 0; r < nR; r++) put(b, nameW + r * cw, 0, cw >= 11 ? REGIONS[r] : REGIONS[r].replace(/-(\\w+)-/, (m, a) => a[0]).slice(0, cw - 1), cw - 1, th.muted, undefined, ATTR.BOLD);\n      const tally = { ok: 0, degraded: 0, down: 0, maint: 0 };\n      for (let s = 0; s < nS; s++) {\n        const y = s + 1;\n        put(b, 0, y, MSVCS[s], nameW - 1, th.ink);\n        for (let r = 0; r < nR; r++) {\n          const st = state(s, r, lt), prev = state(s, r, lt - 0.4);\n          tally[st]++;\n          const [c, word] = look[st];\n          const flash = st !== prev;\n          const bg = flash ? mix(th.bg, c, 0.45) : st === 'ok' ? undefined : mix(th.bg, c, 0.16);\n          const x = nameW + r * cw;\n          if (bg) for (let k = 0; k < cw - 1; k++) b.set(x + k, y, ' ', undefined, bg);\n          const pulse = st === 'down' ? 0.5 + 0.5 * Math.sin(t * 8) : 1;\n          b.set(x, y, st === 'maint' ? (g.u ? '◆' : '#') : g.dot, mix(th.bg, c, 0.4 + 0.6 * pulse), bg);\n          if (cw >= 7) b.text(x + 2, y, word, st === 'ok' ? th.muted : c, bg, st === 'ok' ? 0 : ATTR.BOLD);\n        }\n      }\n      if (H >= 7) {\n        const y = nS + 1;\n        let x = 0;\n        for (const k of ['ok', 'degraded', 'down', 'maint']) {\n          const [c] = look[k];\n          b.set(x, y, k === 'maint' ? (g.u ? '◆' : '#') : g.dot, c);\n          x = b.text(x + 2, y, `${tally[k]} ${k}`, k === 'ok' ? th.muted : c) + 2;\n        }\n        const note = tally.down ? 'INC-2471 payments eu-west-1' : tally.degraded ? 'recovering' : 'all systems normal';\n        if (x + note.length <= W) putR(b, W, y, note, tally.down ? th.neg : tally.degraded ? th.warn : th.pos);\n      }\n    };\n  },\n};\n\nconst heartbeat = {\n  id: 'status-heartbeat',\n  title: 'Heartbeat Monitor',\n  category: 'Health & Uptime',\n  description: 'A cron and worker heartbeat monitor drawn as an ECG trace: each check-in is a beat, missed beats flatline red and raise the alarm.',\n  tags: ['heartbeat', 'ecg', 'monitor', 'dead man switch', 'braille'],\n  kind: 'widget',\n  size: { w: 72, h: 9 },\n  minSize: { w: 30, h: 4 },\n  loop: 24,\n  canvas: 'line',\n  order: 40,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    const head = 1, foot = H >= 6 ? 1 : 0;\n    const rows = H - head - foot;\n    const cv = ctx.canvas('line', { cols: W, rows });\n    const PERIOD = 0.8;\n    // Beats 20..22 of every 30 go missing: the worker stalls, then resumes.\n    const missed = (k) => { const m = ((k % 30) + 30) % 30; return m >= 20 && m <= 22; };\n    const wave = (u) => {\n      // One PQRST complex over u in [0,1).\n      const gb = (c, w, a) => a * Math.exp(-(((u - c) / w) ** 2));\n      return gb(0.18, 0.04, 0.12) - gb(0.36, 0.015, 0.18) + gb(0.4, 0.018, 1) - gb(0.44, 0.02, 0.3) + gb(0.68, 0.06, 0.22);\n    };\n    return (t) => {\n      cv.clear();\n      const span = 6; // seconds across the screen\n      const mid = cv.height * 0.62, amp = cv.height * 0.55;\n      let px = null;\n      for (let x = 0; x < cv.width; x++) {\n        const ts = t - span + (x / cv.width) * span;\n        const k = Math.floor(ts / PERIOD), u = ts / PERIOD - k;\n        const miss = missed(k);\n        const y = mid - (miss ? 0 : wave(u) * amp);\n        const fade = 0.45 + 0.55 * (x / cv.width);\n        const c = miss ? mix(th.bg, th.neg, fade) : mix(th.bg, th.pos, fade);\n        if (px) cv.line(px[0], px[1], x, y, c); else cv.set(x, y, c);\n        px = [x, y];\n      }\n      cv.set(cv.width - 1, px[1], light(th));\n      cv.paint(b, 0, head);\n      const k = Math.floor(t / PERIOD);\n      let last = k; while (missed(last) && last > k - 20) last--;\n      const ago = t - (last * PERIOD + 0.4 * PERIOD);\n      const alarm = missed(k) && ago > PERIOD * 1.5;\n      let x = b.text(0, 0, 'billing-worker', th.ink, undefined, ATTR.BOLD);\n      x = b.text(x + 1, 0, alarm ? ' MISSED ' : ' ALIVE ', th.bg, alarm ? (Math.floor(t * 4) % 2 ? th.neg : th.crit) : th.pos, ATTR.BOLD);\n      if (W >= 44) putR(b, W, 0, `every 0.8s · last beat ${Math.max(0, ago).toFixed(1)}s ago`, alarm ? th.neg : th.muted);\n      if (foot) {\n        const lost = [...Array(15)].reduce((a, _, i) => a + (missed(k - i) ? 1 : 0), 0);\n        put(b, 0, H - 1, `${15 - lost}/15 check-ins in the last 12 s · ${lost} missed · alert after 2`, W, lost ? th.warn : th.dim);\n      }\n    };\n  },\n};\n\nexport default [healthChecks, uptime, matrix, heartbeat];\n",
  "prism-term/src/facets/status/lines.js": "// Status Lines: a vim statusline over a live buffer, a tmux session with its\n// status bar, and a powerline prompt with git and kube segments.\nimport { mix } from '../../color.js';\nimport { glyphs, put, putR, safe, ATTR } from '../charts/_data.js';\nimport { tail, stamp, levelColor } from './_logs.js';\n\nconst KW = new Set(['export', 'async', 'function', 'const', 'let', 'while', 'if', 'throw', 'new', 'await', 'return']);\n\n/** Minimal JS-ish highlighter: keywords, numbers, strings, calls, punctuation. */\nfunction code(b, x, y, src, w, th, bg) {\n  const re = /(\\s+)|([A-Za-z_$][\\w$]*)|(\\d+)|('[^']*')|(.)/g;\n  let m, cx = x;\n  while ((m = re.exec(src)) && cx < x + w) {\n    const [tok, ws, id, n, str] = m;\n    let c = th.ink;\n    if (ws) c = th.ink;\n    else if (id) c = KW.has(id) ? th.accent : src[re.lastIndex] === '(' ? th.info : th.ink;\n    else if (n) c = th.warn;\n    else if (str) c = th.pos;\n    else c = th.muted;\n    cx = put(b, cx, y, tok, x + w - cx, c, bg, id && KW.has(id) ? ATTR.BOLD : 0);\n  }\n  return cx;\n}\n\nconst SRC = [\n  'export async function acquire(pool, ms = 2000) {',\n  '  const started = performance.now();',\n  '  while (pool.inUse >= pool.size) {',\n  '    if (performance.now() - started > ms) throw new PoolTimeout(pool.name);',\n  '    await sleep(5);',\n  '  }',\n  '  pool.inUse++;',\n  '  return lease(pool);',\n  '}',\n];\nconst TYPED = '    metrics.waiting.inc(pool.name);';\n\n// [start, mode, cursor line (0-based into the shown buffer), command line text]\nconst SCRIPT = [\n  [0, 'NORMAL', 1], [0.6, 'NORMAL', 2], [1.2, 'NORMAL', 3], [1.8, 'NORMAL', 4],\n  [2.4, 'INSERT', 4], [7.4, 'NORMAL', 4], [8.2, 'COMMAND', 4], [9.4, 'WRITTEN', 4], [11.2, 'VISUAL', 6],\n];\n\nconst vimLine = {\n  id: 'status-vim-line',\n  title: 'Vim Statusline',\n  category: 'Status Lines',\n  description: 'A lightline-style vim statusline over a live buffer: modes change, text is typed in, :w writes and the ruler follows the cursor.',\n  tags: ['vim', 'statusline', 'lightline', 'editor', 'modes'],\n  kind: 'widget',\n  size: { w: 80, h: 9 },\n  minSize: { w: 40, h: 3 },\n  loop: 13,\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    const modeColor = { NORMAL: th.accent, INSERT: th.pos, VISUAL: th.warn, COMMAND: th.info, WRITTEN: th.accent };\n    const body = Math.max(0, H - 2), FIRST = 38;\n    const sep = g.u ? '│' : '|';\n    return (t) => {\n      const lt = t % 13;\n      let step = SCRIPT[0];\n      for (const s of SCRIPT) if (lt >= s[0]) step = s;\n      const [, mode, cur0] = step;\n      // While inserting, a new line grows below the cursor line.\n      const inserted = lt >= 2.4;\n      const typedN = inserted ? Math.min(TYPED.length, Math.floor((lt - 2.4) * 9)) : 0;\n      const lines = inserted ? [...SRC.slice(0, 4), TYPED.slice(0, Math.max(4, typedN)), ...SRC.slice(4)] : SRC;\n      const cur = Math.min(lines.length - 1, cur0);\n      const col = mode === 'INSERT' ? Math.max(4, typedN) : mode === 'VISUAL' ? 2 : 4;\n      const top = Math.max(0, Math.min(lines.length - body, cur - Math.floor(body / 2)));\n      const gutter = 5;\n      for (let r = 0; r < body; r++) {\n        const li = top + r;\n        if (li >= lines.length) { b.text(0, r, '~', th.dim); continue; }\n        const n = FIRST + li;\n        const rowBg = li === cur ? mix(th.bg, th.line, 0.35) : undefined;\n        if (rowBg) for (let x = 0; x < W; x++) b.set(x, r, ' ', undefined, rowBg);\n        b.text(0, r, String(n).padStart(gutter - 1), li === cur ? th.warn : th.dim, rowBg, li === cur ? ATTR.BOLD : 0);\n        const selected = mode === 'VISUAL' && li >= 6 && li <= cur;\n        code(b, gutter, r, lines[li], W - gutter, th, selected ? mix(th.bg, th.warn, 0.3) : rowBg);\n        if (n === 41 && W >= 50) b.set(gutter - 1, r, g.u ? '▲' : 'W', th.warn, rowBg);\n        if (li === cur) {\n          const cx = gutter + col;\n          const ch = lines[li][col] ?? ' ';\n          if (mode === 'INSERT') b.set(cx, r, g.u ? '▏' : '|', th.ink, rowBg);\n          else b.set(cx, r, ch, th.bg, th.ink);\n        }\n      }\n      // Statusline.\n      const y = H - (H >= 3 ? 2 : 1), mc = modeColor[mode];\n      const barBg = mix(th.bg, th.panel2 ?? th.line, 0.9), midBg = mix(th.bg, th.line, 0.7);\n      for (let x = 0; x < W; x++) b.set(x, y, ' ', undefined, barBg);\n      const label = mode === 'WRITTEN' ? 'NORMAL' : mode;\n      let x = b.text(0, y, ` ${label} `, th.bg, mc, ATTR.BOLD);\n      x = b.text(x, y, ` ${g.u ? '⎇' : 'git:'} main `, th.ink, midBg);\n      const dirty = inserted && lt < 9.4;\n      x = b.text(x, y, ` src/pool.js${dirty ? ' [+]' : ''} `, th.ink, barBg, ATTR.BOLD);\n      if (W >= 60) {\n        x = b.text(x, y, ` ${g.cross} 0 `, th.dim, barBg);\n        x = b.text(x, y, `${g.warn} 1 `, th.warn, barBg);\n      }\n      const pct = Math.round(((FIRST + cur) / 96) * 100);\n      const right = W >= 64 ? ` utf-8 ${sep} unix ${sep} javascript ` : W >= 48 ? ' javascript ' : '';\n      const ruler = ` ${String(FIRST + cur).padStart(3)}:${String(col + 1).padEnd(2)} `, pc = ` ${String(pct).padStart(3)}% `;\n      let rx = W - ruler.length;\n      b.text(rx, y, ruler, th.bg, mc, ATTR.BOLD);\n      if (rx - pc.length > x + 1) { rx -= pc.length; b.text(rx, y, pc, th.ink, midBg); }\n      if (right && rx - right.length > x + 1) b.text(rx - right.length, y, right, th.muted, barBg);\n      // Command line.\n      if (H >= 3) {\n        const cy = H - 1;\n        if (mode === 'INSERT') b.text(0, cy, '-- INSERT --', th.ink, undefined, ATTR.BOLD);\n        else if (mode === 'VISUAL') b.text(0, cy, '-- VISUAL LINE --', th.ink, undefined, ATTR.BOLD);\n        else if (mode === 'COMMAND') { const cmd = ':w'.slice(0, 1 + Math.floor((lt - 8.2) * 2.5)); const e = b.text(0, cy, cmd, th.ink); b.set(e, cy, ' ', th.bg, th.ink); }\n        else if (mode === 'WRITTEN') put(b, 0, cy, `\"src/pool.js\" 97L, 2874B written`, W, th.muted);\n      }\n    };\n  },\n};\n\nconst WINDOWS = ['zsh', 'vim', 'logs', 'k9s', 'psql'];\n\nconst SHELL = [\n  ['p', 'make test'],\n  ['o', 'ok   checkout/pool     0.412s', 'pos'],\n  ['o', 'ok   checkout/cart     0.208s', 'pos'],\n  ['o', '--- FAIL: TestRefund (0.03s)', 'neg'],\n  ['o', 'FAIL checkout/refund   0.031s', 'neg'],\n  ['p', 'git stash && make test'],\n  ['o', 'ok   checkout/refund   0.029s', 'pos'],\n  ['p', ''],\n];\n\nconst tmuxBar = {\n  id: 'status-tmux-bar',\n  title: 'tmux Session',\n  category: 'Status Lines',\n  description: 'A tmux session: split panes with a shell and a live log tail, the active-pane border, and the classic green status bar with a ticking clock.',\n  tags: ['tmux', 'status bar', 'panes', 'multiplexer', 'session'],\n  kind: 'widget',\n  size: { w: 80, h: 12 },\n  minSize: { w: 40, h: 4 },\n  loop: 12,\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    const split = Math.floor(W * 0.5), paneH = H - 1;\n    const prompt = '~/src/checkout $ ';\n    return (t) => {\n      const lt = t % 12;\n      const active = lt < 6 ? 0 : 1; // the active pane flips half-way\n      // Left pane: a shell session that types and prints.\n      const shown = [];\n      const reveal = lt * 1.3;\n      for (let i = 0; i < SHELL.length; i++) {\n        const [kind, text, tone] = SHELL[i];\n        const at = i * 1.4;\n        if (i > 0 && reveal < at) break;\n        if (kind === 'p') {\n          const typed = text.slice(0, Math.max(0, Math.floor((reveal - at) * 9)));\n          shown.push([prompt + typed, th.ink, i === SHELL.length - 1 || typed.length < text.length]);\n        } else shown.push([text, th[tone] ?? th.ink, false]);\n      }\n      const view = shown.slice(-paneH);\n      view.forEach(([s, c, cursor], r) => {\n        if (s.startsWith(prompt)) { b.text(0, r, '~/src/checkout', th.info); b.text(15, r, '$', th.muted); put(b, 17, r, s.slice(prompt.length), split - 18, th.ink); }\n        else put(b, 0, r, s, split - 1, c);\n        if (cursor && active === 0 && Math.floor(t * 2) % 2 === 0) b.set(Math.min(split - 2, s.length), r, ' ', th.bg, th.ink);\n      });\n      // Pane border: tmux paints the half next to the active pane in the active color.\n      for (let y = 0; y < paneH; y++) b.set(split, y, g.v, (y < paneH / 2) === (active === 0) ? th.pos : th.line);\n      // Right pane: tail -f of the service log.\n      const rw = W - split - 1, lines = tail(7, t, 1.6, paneH);\n      lines.forEach((l, r) => {\n        const ts = stamp(l.i, 1.6).slice(7);\n        let x = b.text(split + 1, r, ts, th.dim);\n        x = b.text(x + 1, r, l.svc.padEnd(8).slice(0, 8), levelColor(th, l.level));\n        put(b, x + 1, r, l.msg, split + 1 + rw - x - 1, l.level === 'error' || l.level === 'crit' ? th.neg : th.muted);\n      });\n      if (active === 1 && Math.floor(t * 2) % 2 === 0) b.set(W - 1, paneH - 1, ' ', th.bg, th.ink);\n      // Status bar.\n      const y = H - 1, bar = th.pos, ink = th.bg;\n      for (let x = 0; x < W; x++) b.set(x, y, ' ', ink, bar);\n      let x = b.text(0, y, '[ops] ', ink, bar);\n      const cur = Math.floor(lt / 3) % 3 === 2 ? 2 : 1;\n      WINDOWS.forEach((w, i) => {\n        if (x + w.length + 4 > W - (W >= 60 ? 30 : 8)) return;\n        const flag = i === cur ? '*' : i === (cur === 1 ? 0 : 1) ? '-' : i === 2 && cur !== 2 && Math.floor(t * 1.5) % 2 ? '#' : ' ';\n        const s = `${i}:${w}${flag}`;\n        x = b.text(x, y, s, ink, i === cur ? mix(bar, 0xffffff, 0.25) : bar, i === cur ? ATTR.BOLD : 0) + 1;\n      });\n      const secs = 14 * 3600 + 2 * 60 + 31 + Math.floor(t);\n      const clock = `${String(Math.floor(secs / 3600)).padStart(2, '0')}:${String(Math.floor(secs / 60) % 60).padStart(2, '0')}`;\n      const right = W >= 60 ? `\"web-01\" ${clock} 13-Oct-26` : clock;\n      putR(b, W, y, right, ink, bar);\n      if (W >= 60 && Math.floor(lt / 3) % 3 === 1 && lt % 3 < 0.5) putR(b, W - right.length - 1, y, ' ^B ', bar, ink, ATTR.BOLD);\n    };\n  },\n};\n\nconst HIST = [\n  { cmd: 'git pull --rebase', out: 'Successfully rebased and updated refs/heads/main.', code: 0, dur: '1.2s', kube: 'staging', git: 'main', dirty: 0, ahead: 0 },\n  { cmd: 'kubectx prod-us-east', out: 'Switched to context \"prod-us-east\".', code: 0, dur: '84ms', kube: 'staging', git: 'main', dirty: 0, ahead: 0 },\n  { cmd: 'kubectl rollout status deploy/checkout', out: 'deployment \"checkout\" successfully rolled out', code: 0, dur: '38s', kube: 'prod-us-east', git: 'main', dirty: 0, ahead: 0 },\n  { cmd: 'npm run lint', out: 'src/pool.js:41:7  error  no-floating-promises', code: 1, dur: '3.4s', kube: 'prod-us-east', git: 'main', dirty: 2, ahead: 0 },\n  { cmd: 'git commit -am \"await sleep in pool\"', out: '[main 4c1e9a2] await sleep in pool', code: 0, dur: '210ms', kube: 'prod-us-east', git: 'main', dirty: 2, ahead: 0 },\n];\n\nconst powerline = {\n  id: 'status-powerline',\n  title: 'Powerline Prompt',\n  category: 'Status Lines',\n  description: 'A two-line powerline shell prompt — user, path, git and kube segments, exit code, duration and clock — as commands run.',\n  tags: ['powerline', 'prompt', 'shell', 'git', 'kubernetes'],\n  kind: 'widget',\n  size: { w: 80, h: 12 },\n  minSize: { w: 40, h: 3 },\n  loop: 15,\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    const arrow = g.u ? '▶' : '>', arrowL = g.u ? '◀' : '<';\n    const EACH = 3;\n    // Segments are [text, fg, bg]; each separator is drawn in the previous segment's color.\n    const segs = (row, x, list) => {\n      list.forEach(([text, fg, bg], i) => {\n        x = b.text(x, row, ` ${text} `, fg, bg, i === 0 ? ATTR.BOLD : 0);\n        const next = list[i + 1]?.[2];\n        x = b.text(x, row, arrow, bg, next);\n      });\n      return x;\n    };\n    const segsR = (row, xEnd, list) => {\n      let x = xEnd;\n      for (let i = list.length - 1; i >= 0; i--) {\n        const [text, fg, bg] = list[i];\n        x -= text.length + 2;\n        b.text(x, row, ` ${text} `, fg, bg);\n        x -= 1;\n        b.text(x, row, arrowL, bg, list[i - 1]?.[2]);\n      }\n    };\n    const left = (h, code) => {\n      const gitBg = h.dirty ? th.warn : th.pos;\n      const gitTxt = `${g.u ? '⎇' : 'git:'} ${h.git}${h.dirty ? ` ${g.u ? '±' : '+-'}${h.dirty}` : ''}${h.ahead ? ` ${g.u ? '⇡' : '^'}${h.ahead}` : ''}`;\n      const prod = h.kube.startsWith('prod');\n      const list = [\n        ['deploy@web-01', th.bg, th.accent],\n        ['~/src/checkout', th.ink, mix(th.bg, th.line, 0.9)],\n        [gitTxt, th.bg, gitBg],\n      ];\n      if (W >= 64) list.push([`${g.u ? '⎈' : 'k8s:'} ${h.kube}`, prod ? 0xffffff : th.bg, prod ? th.neg : th.info]);\n      if (code) list.push([`${g.cross} ${code}`, 0xffffff, th.crit]);\n      return list;\n    };\n    return (t) => {\n      const lt = t % (HIST.length * EACH);\n      const cur = Math.floor(lt / EACH), within = lt - cur * EACH;\n      const blocks = [];\n      for (let i = 0; i <= cur; i++) {\n        const h = HIST[i], prev = HIST[i - 1];\n        const typing = i === cur;\n        const typed = typing ? h.cmd.slice(0, Math.floor(within * 18)) : h.cmd;\n        const done = !typing || within > 2.2;\n        blocks.push({ h, st: h, typed, done, typing, code: prev && prev.code ? prev.code : 0, prevDur: prev ? prev.dur : '12ms' });\n      }\n      const rowsPer = (blk) => 2 + (blk.done ? 1 : 0);\n      let need = 0, start = blocks.length;\n      while (start > 0 && need + rowsPer(blocks[start - 1]) <= H) { need += rowsPer(blocks[start - 1]); start--; }\n      let y = 0;\n      for (let i = start; i < blocks.length; i++) {\n        const blk = blocks[i];\n        segs(y, 0, left(blk.st, blk.code));\n        if (W >= 70) {\n          const secs = 14 * 3600 + 2 * 60 + 31 + Math.floor(t - (blocks.length - 1 - i) * EACH);\n          const clk = `${String(Math.floor(secs / 3600) % 24).padStart(2, '0')}:${String(Math.floor(secs / 60) % 60).padStart(2, '0')}:${String(secs % 60).padStart(2, '0')}`;\n          segsR(y, W, [[blk.prevDur, th.ink, mix(th.bg, th.line, 0.9)], [clk, th.bg, th.muted]]);\n        }\n        y++;\n        let x = b.text(0, y, g.u ? '❯ ' : '> ', blk.code ? th.neg : th.pos, undefined, ATTR.BOLD);\n        x = put(b, x, y, blk.typed, W - 3, th.ink);\n        if (blk.typing && !blk.done && Math.floor(t * 2.5) % 2 === 0) b.set(x, y, ' ', th.bg, th.ink);\n        y++;\n        if (blk.done) { put(b, 0, y, blk.h.out, W, blk.h.code ? th.neg : th.muted); y++; }\n      }\n    };\n  },\n};\n\nexport default [vimLine, tmuxBar, powerline];\n",
  "prism-term/src/facets/status/logs.js": "// Log Lines: a journalctl -f follow with priority colors, JSON lines with\n// token coloring, and logfmt with a level filter bar.\nimport { mix } from '../../color.js';\nimport { glyphs, put, putR, safe, ATTR } from '../charts/_data.js';\nimport { tail, stamp, logLine, LEVEL_KEYS, levelColor } from './_logs.js';\n\n/** Fade-in highlight for the newest line; `age` in lines. */\nconst fresh = (th, c, k) => (k < 1 ? mix(th.bg, c, 0.16 * (1 - k)) : undefined);\n\nconst syslog = {\n  id: 'status-log-syslog',\n  title: 'journalctl -f',\n  category: 'Log Lines',\n  description: 'A journal follow in syslog format with journald priority colors — red errors, yellow warnings, dim debug — scrolling as lines arrive.',\n  tags: ['journalctl', 'syslog', 'logs', 'tail -f', 'priority'],\n  kind: 'widget',\n  size: { w: 80, h: 14 },\n  minSize: { w: 40, h: 4 },\n  loop: 20,\n  order: 10,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    const RATE = 2.2;\n    // journald conventions: emerg..err red (crit bold), warning yellow, notice bold, info plain, debug dim.\n    const style = {\n      crit: [th.neg, ATTR.BOLD], error: [th.neg, 0], warn: [th.warn, 0], info: [th.ink, 0], debug: [th.dim, 0],\n    };\n    return (t) => {\n      const head = H >= 8 ? 1 : 0;\n      const n = H - head;\n      if (head) put(b, 0, 0, '-- Journal begins at Mon 2026-10-12 00:00:01 UTC. --', W, th.dim);\n      const lines = tail(11, t, RATE, n);\n      const frac = t * RATE - Math.floor(t * RATE);\n      lines.forEach((l, r) => {\n        const y = head + r;\n        const [c, at] = style[l.level];\n        const bg = r === n - 1 ? fresh(th, c === th.ink ? th.accent : c, frac * 2) : undefined;\n        if (bg) for (let x = 0; x < W; x++) b.set(x, y, ' ', undefined, bg);\n        let x = b.text(0, y, stamp(l.i, RATE), th.muted, bg);\n        x = b.text(x + 1, y, l.host, th.muted, bg);\n        const unit = l.svc === 'kernel' ? 'kernel' : `${l.svc}[${l.pid}]`;\n        x = b.text(x + 1, y, `${unit}:`, th.info, bg);\n        put(b, x + 1, y, l.msg, W - x - 1, c, bg, at);\n      });\n    };\n  },\n};\n\n/** Draw a JSON object on one line with token colors; returns the end column. */\nfunction jsonLine(b, x, y, w, obj, th, levelC, bg) {\n  const end = x + w;\n  const out = (s, c, at) => { x = put(b, x, y, s, end - x, c, bg, at); };\n  out('{', th.dim);\n  const keys = Object.keys(obj);\n  keys.forEach((k, i) => {\n    if (x >= end) return;\n    out(`\"${k}\"`, th.info);\n    out(':', th.dim);\n    const v = obj[k];\n    if (typeof v === 'number') out(String(v), th.warn);\n    else if (k === 'level') out(`\"${v}\"`, levelC, ATTR.BOLD);\n    else out(JSON.stringify(v), k === 'msg' ? th.ink : th.pos);\n    if (i < keys.length - 1) out(',', th.dim);\n  });\n  out('}', th.dim);\n  return x;\n}\n\nconst jsonLogs = {\n  id: 'status-log-json',\n  title: 'JSON Log Lines',\n  category: 'Log Lines',\n  description: 'Structured JSON logs piped through jq -c: keys, strings, numbers and level values each colored, with errors flagged in the gutter.',\n  tags: ['json', 'structured logs', 'jq', 'logs', 'syntax color'],\n  kind: 'widget',\n  size: { w: 80, h: 14 },\n  minSize: { w: 40, h: 4 },\n  loop: 20,\n  order: 20,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    const RATE = 1.6;\n    return (t) => {\n      const head = H >= 8 ? 1 : 0;\n      const n = H - head;\n      if (head) { const x = b.text(0, 0, '$ ', th.pos, undefined, ATTR.BOLD); put(b, x, 0, 'kubectl logs -f deploy/checkout | jq -c', W - x, th.ink); }\n      const lines = tail(23, t, RATE, n);\n      const frac = t * RATE - Math.floor(t * RATE);\n      lines.forEach((l, r) => {\n        const y = head + r, c = levelColor(th, l.level);\n        const bad = l.level === 'error' || l.level === 'crit';\n        const bg = r === n - 1 ? fresh(th, th.accent, frac * 2) : undefined;\n        if (bg) for (let x = 0; x < W; x++) b.set(x, y, ' ', undefined, bg);\n        b.set(0, y, bad ? (g.u ? '▌' : '!') : ' ', c, bg);\n        const obj = { ts: stamp(l.i, RATE, 'iso').slice(11), level: l.level, svc: l.svc, msg: l.msg };\n        if (W >= 100 || !bad) obj.latency_ms = l.fields.latency_ms;\n        obj.trace = l.fields.trace;\n        jsonLine(b, 2, y, W - 2, obj, th, c, bg);\n      });\n    };\n  },\n};\n\nconst logfmt = {\n  id: 'status-log-logfmt',\n  title: 'logfmt Viewer',\n  category: 'Log Lines',\n  description: 'logfmt key=value lines under a level filter bar with live counts; the filter steps from all levels to warnings and errors only.',\n  tags: ['logfmt', 'logs', 'filter', 'level badges', 'key value'],\n  kind: 'widget',\n  size: { w: 80, h: 14 },\n  minSize: { w: 40, h: 4 },\n  loop: 18,\n  order: 30,\n  setup(ctx) {\n    const th = ctx.theme, g = glyphs(ctx), W = ctx.width, H = ctx.height, b = safe(ctx.buffer, g);\n    const RATE = 3;\n    const RANK = { debug: 0, info: 1, warn: 2, error: 3, crit: 4 };\n    // Counts per level up to line i, sampled cheaply: a fixed historical base plus a running tally.\n    const BASE = 4000;\n    const counts = new Map();\n    const tally = (upto) => {\n      const out = { debug: 0, info: 0, warn: 0, error: 0, crit: 0 };\n      for (let i = upto - 400; i <= upto; i++) out[logLine(31, i).level]++;\n      const scale = (BASE + upto) / 401;\n      for (const k of LEVEL_KEYS) out[k] = Math.round(out[k] * scale);\n      return out;\n    };\n    return (t) => {\n      const lt = t % 18;\n      const min = lt < 6 ? 0 : lt < 12 ? 2 : 1;\n      // The filter bar.\n      const upto = Math.floor(t * RATE);\n      const key = Math.floor(upto / 8);\n      if (!counts.has(key)) { counts.clear(); counts.set(key, tally(key * 8)); }\n      const cnt = counts.get(key);\n      let x = 0;\n      for (const l of LEVEL_KEYS) {\n        const on = RANK[l] >= min, c = levelColor(th, l);\n        const lab = ` ${l.toUpperCase()} ${cnt[l].toLocaleString('en-US')} `;\n        if (x + lab.length > W) break;\n        x = b.text(x, 0, lab, on ? th.bg : mix(th.bg, c, 0.7), on ? c : mix(th.bg, c, 0.12), on ? ATTR.BOLD : 0) + 1;\n      }\n      if (W >= 76) putR(b, W, 0, min ? `level>=${min === 2 ? 'warn' : 'info'}` : 'all', th.muted);\n      // Matching lines, newest last.\n      const n = H - 1;\n      const out = [];\n      for (let i = upto + n; out.length < n && i > upto - 200; i--) { const l = logLine(31, i); if (RANK[l.level] >= min) out.push(l); }\n      out.reverse().forEach((l, r) => {\n        const y = 1 + r, c = levelColor(th, l.level);\n        const pairs = [\n          ['ts', stamp(l.i, RATE, 'iso').slice(11, 23)], ['level', l.level], ['svc', l.svc],\n          ['msg', `\"${l.msg.replace(/\"/g, \"'\")}\"`], ['latency_ms', String(l.fields.latency_ms)], ['host', l.host], ['trace', l.fields.trace],\n        ];\n        let cx = 0;\n        for (const [k, v] of pairs) {\n          if (cx >= W) break;\n          cx = put(b, cx, y, k, W - cx, th.dim);\n          cx = put(b, cx, y, '=', W - cx, th.dim);\n          const vc = k === 'level' ? c : k === 'msg' ? th.ink : k === 'latency_ms' ? (+v > 500 ? th.warn : th.info) : th.muted;\n          cx = put(b, cx, y, v, W - cx, vc, undefined, k === 'level' ? ATTR.BOLD : 0) + 1;\n        }\n      });\n    };\n  },\n};\n\nexport default [syslog, jsonLogs, logfmt];\n",
  "prism-term/src/facets/text/_fonts.js": "// Hand-authored display faces for the Text Effects gallery, in the same\n// '#'/'.' source format as src/text/fonts.js. Pass the exported object as\n// `font:` to drawBanner / measureBanner / layoutText (getFont passes objects\n// through). Lowercase falls back to the caps, as with the built-in faces.\n//\n//   cyber  5×7 squared techno caps & digits — hard corners, chamfered B/D,\n//          square counters; reads like a 1980s console bezel\n\nimport { defineFont } from '../../text/fonts.js';\n\nconst g = (s) => s.trim().split(/\\s+/);\n\nconst CYBER = {\n  A: g('####. #...# #...# ##### #...# #...# #...#'),\n  B: g('####. #...# #...# ##### #...# #...# ####.'),\n  C: g('##### #.... #.... #.... #.... #.... #####'),\n  D: g('####. #...# #...# #...# #...# #...# ####.'),\n  E: g('##### #.... #.... ####. #.... #.... #####'),\n  F: g('##### #.... #.... ####. #.... #.... #....'),\n  G: g('##### #.... #.... #..## #...# #...# #####'),\n  H: g('#...# #...# #...# ##### #...# #...# #...#'),\n  I: g('##### ..#.. ..#.. ..#.. ..#.. ..#.. #####'),\n  J: g('..### ....# ....# ....# ....# #...# #####'),\n  K: g('#...# #..#. #.#.. ####. #...# #...# #...#'),\n  L: g('#.... #.... #.... #.... #.... #.... #####'),\n  M: g('##### #.#.# #.#.# #.#.# #...# #...# #...#'),\n  N: g('#...# ##..# #.#.# #.#.# #..## #...# #...#'),\n  O: g('##### #...# #...# #...# #...# #...# #####'),\n  P: g('##### #...# #...# ##### #.... #.... #....'),\n  Q: g('##### #...# #...# #...# #.#.# #..#. ###.#'),\n  R: g('##### #...# #...# ####. #..#. #...# #...#'),\n  S: g('##### #.... #.... ##### ....# ....# #####'),\n  T: g('##### ..#.. ..#.. ..#.. ..#.. ..#.. ..#..'),\n  U: g('#...# #...# #...# #...# #...# #...# #####'),\n  V: g('#...# #...# #...# #...# #...# .#.#. ..#..'),\n  W: g('#...# #...# #...# #.#.# #.#.# #.#.# #####'),\n  X: g('#...# #...# .#.#. ..#.. .#.#. #...# #...#'),\n  Y: g('#...# #...# #...# ##### ..#.. ..#.. ..#..'),\n  Z: g('##### ....# ...#. ..#.. .#... #.... #####'),\n  0: g('##### #...# #..## #.#.# ##..# #...# #####'),\n  1: g('###.. ..#.. ..#.. ..#.. ..#.. ..#.. #####'),\n  2: g('##### ....# ....# ##### #.... #.... #####'),\n  3: g('##### ....# ....# .#### ....# ....# #####'),\n  4: g('#...# #...# #...# ##### ....# ....# ....#'),\n  5: g('##### #.... ####. ....# ....# ....# ####.'),\n  6: g('##### #.... #.... ##### #...# #...# #####'),\n  7: g('##### ....# ....# ...#. ..#.. ..#.. ..#..'),\n  8: g('##### #...# #...# ##### #...# #...# #####'),\n  9: g('##### #...# #...# ##### ....# ....# #####'),\n  ' ': g('... ... ... ... ... ... ...'),\n  '.': g('. . . . . . #'),\n  ',': g('. . . . . # #'),\n  ':': g('. # . . . # .'),\n  '!': g('# # # # # . #'),\n  '?': g('##### ....# ....# ..### ..#.. ..... ..#..'),\n  '-': g('.... .... .... #### .... .... ....'),\n  '_': g('..... ..... ..... ..... ..... ..... #####'),\n  '+': g('..... ..#.. ..#.. ##### ..#.. ..#.. .....'),\n  '=': g('..... ..... ##### ..... ##### ..... .....'),\n  '/': g('....# ....# ...#. ..#.. .#... #.... #....'),\n  \"'\": g('# # . . . . .'),\n  '#': g('.#.#. ##### .#.#. .#.#. .#.#. ##### .#.#.'),\n  '>': g('#.... .#... ..#.. ...#. ..#.. .#... #....'),\n  '<': g('....# ...#. ..#.. .#... ..#.. ...#. ....#'),\n  '[': g('## #. #. #. #. #. ##'),\n  ']': g('## .# .# .# .# .# ##'),\n};\n\nexport const CYBER_FONT = defineFont('cyber', 5, 7, 0, CYBER);\n",
  "prism-term/src/facets/text/_text.js": "// Shared helpers for the Text Effects gallery.\nimport { measureBanner, layoutText } from '../../text/banner.js';\nimport { Canvas } from '../../canvas.js';\n\n/** Banner mode: the user's forced mode, else `preferred` if the terminal can show it. */\nexport function bannerMode(ctx, preferred = 'half') {\n  if (ctx.mode) return ctx.mode;\n  const caps = ctx.caps || {};\n  if (caps.unicode === false) return 'ascii';\n  if (preferred === 'sextant' && caps.sextant === false) return 'quad';\n  if (preferred === 'braille' && caps.braille === false) return 'half';\n  return preferred;\n}\n\n/** Largest integer scale (≥ 1) at which `text` fits in maxW × maxH cells. */\nexport function fitScale(text, cfg, maxW, maxH, max = 8) {\n  let best = 1;\n  for (let s = 1; s <= max; s++) {\n    const m = measureBanner(text, { ...cfg, scale: s });\n    if (m.width <= maxW && m.height <= maxH) best = s;\n    else break;\n  }\n  return best;\n}\n\n/** First font in `fonts` that fits at scale 1, with its best scale. */\nexport function fitFont(text, fonts, cfg, maxW, maxH) {\n  for (const font of fonts) {\n    const m = measureBanner(text, { ...cfg, font, scale: 1 });\n    if (m.width <= maxW && m.height <= maxH) return { font, scale: fitScale(text, { ...cfg, font }, maxW, maxH) };\n  }\n  return { font: fonts[fonts.length - 1], scale: 1 };\n}\n\n/** Deterministic hash of up to three ints → [0, 1). */\nexport function hash(a, b = 0, c = 0) {\n  let h = Math.imul((a | 0) ^ 0x9e3779b9, 0x85ebca6b) ^ Math.imul((b | 0) + 0x632be5ab, 0xc2b2ae35) ^ Math.imul((c | 0) + 0x165667b1, 0x27d4eb2f);\n  h ^= h >>> 15;\n  h = Math.imul(h, 0x2c1b3c6d);\n  h ^= h >>> 12;\n  return (h >>> 0) / 4294967296;\n}\n\n/** Text rasterized to a 0/1 font-pixel mask: { w, h, at(x, y), lay }. */\nexport function textMask(text, font) {\n  const lay = layoutText(text, { font });\n  const w = lay.width, h = lay.height, px = new Uint8Array(Math.max(1, w * h));\n  for (const l of lay.lines) for (const { gl, x } of l.glyphs) {\n    for (let gy = 0; gy < gl.h; gy++) for (let gx = 0; gx < gl.w; gx++) if (gl.bits[gy * gl.w + gx]) px[(l.y + gy) * w + l.x + x + gx] = 1;\n  }\n  return { w, h, lay, at: (x, y) => (x >= 0 && y >= 0 && x < w && y < h ? px[y * w + x] : 0) };\n}\n\n/** Copy `src` canvas pixels into `dst` with a per-row horizontal offset fn(y) → dx. */\nexport function shiftRows(src, dst, dxOf, ox = 0, oy = 0) {\n  for (let y = 0; y < src.height; y++) {\n    const dx = Math.round(dxOf(y));\n    for (let x = 0; x < src.width; x++) {\n      const c = src.px[y * src.width + x];\n      if (c !== -1) dst.set(x + dx + ox, y + oy, c);\n    }\n  }\n}\n\n\n/** An empty canvas in the same mode/encoding as `src` (optionally a different size in cells). */\nexport function canvasLike(src, cols = src.cols, rows = src.rows) {\n  return new Canvas(cols, rows, { mode: src.mode, ramp: src.ramp, threshold: src.threshold, dither: src.dither });\n}\n\n// Banner MODE_SCALE (src/text/banner.js): an explicit [x, y] scale replaces it, so re-apply it.\nconst BASE_SCALE = { quad: [2, 1], ascii: [2, 1] };\nconst STRETCH = [[4, 4], [3, 4], [3, 3], [2, 3], [2, 2], [1, 2], [1, 1]];\n\n/** Biggest [x, y] banner scale (uniform or up to one step taller — condensed type) that fits. */\nexport function stretchFit(text, cfg, maxW, maxH) {\n  const [bx, by] = BASE_SCALE[cfg.mode] || [1, 1];\n  for (const [sx, sy] of STRETCH) {\n    const scale = [bx * sx, by * sy];\n    const m = measureBanner(text, { ...cfg, scale });\n    if (m.width <= maxW && m.height <= maxH) return scale;\n  }\n  // Nothing fits at the mode's proportions: one cell per font pixel beats clipping.\n  return [1, 1];\n}\n\n/** stretchFit across fonts: the first face (heaviest first) that fits without clipping. */\nexport function fitType(text, cfg, maxW, maxH, fonts = ['bold', 'standard', 'tiny']) {\n  for (const font of fonts) {\n    const scale = stretchFit(text, { ...cfg, font }, maxW, maxH);\n    const m = measureBanner(text, { ...cfg, font, scale });\n    if (m.width <= maxW && m.height <= maxH) return { font, scale };\n  }\n  return { font: fonts[fonts.length - 1], scale: [1, 1] };\n}\n",
  "prism-term/src/facets/text/banner.js": "// Big gradient text over a soft spotlight, with twinkling sparkles. Cycles\n// through fonts and resolution modes, revealing each with a glowing wipe.\nimport { mix, mixOklab, NONE } from '../../color.js';\nimport { drawBanner, measureBanner } from '../../text/banner.js';\nimport { shimmer } from '../../text/effects.js';\n\nconst PERIOD = 6;\n\nfunction fitScale(text, cfg, maxW, maxH) {\n  let best = 1;\n  for (let s = 1; s <= 8; s++) {\n    const m = measureBanner(text, { ...cfg, scale: s });\n    if (m.width <= maxW && m.height <= maxH) best = s;\n    else break;\n  }\n  return best;\n}\n\nexport default {\n  title: 'Big Text',\n  gallery: 'text',\n  category: 'Banners & Fonts',\n  kind: 'scene',\n  tags: ['banner', 'big text', 'fonts', 'figlet'],\n  order: 10,\n  description: 'Gradient banner text with drop shadow and sparkles, cycling fonts and resolutions (--set text=… to change it).',\n  canvas: 'hires',\n  palette: 'prism',\n  setup(ctx) {\n    const { width: W, height: H, theme: th, caps } = ctx;\n    const text = String(ctx.param('text', 'PRISM'));\n    const subtitle = String(ctx.param('subtitle', 'prism-term · terminal facets'));\n    const pal = ctx.palette();\n    const unicode = caps.unicode !== false;\n\n    let configs = unicode\n      ? [\n          { font: 'bold', mode: 'half', shadow: true, label: 'bold · half-block' },\n          { font: 'standard', mode: caps.sextant ? 'sextant' : 'quad', shadow: true, label: `standard · ${caps.sextant ? 'sextant' : 'quadrant'}` },\n          { font: 'bold', mode: 'braille', label: 'bold · braille' },\n          { font: 'bold', mode: 'block', wide: true, shadow: true, label: 'bold · block' },\n        ]\n      : [{ font: 'bold', mode: 'ascii', label: 'bold · ascii' }];\n    if (ctx.mode) configs = [{ font: 'bold', mode: ctx.mode, shadow: ctx.mode !== 'braille', label: `bold · ${ctx.mode}` }];\n    const maxW = Math.max(4, W - 4), maxH = Math.max(3, H - 6);\n    for (const c of configs) {\n      c.fill = pal;\n      c.theme = th;\n      c.scale = fitScale(text, c, maxW, maxH);\n      c.size = measureBanner(text, c);\n    }\n\n    // Spotlight behind the text (half-block field, transparent outside the glow).\n    const glow = ctx.canvas('field', { mode: unicode ? 'half' : 'ascii' });\n    const gcx = glow.width / 2, gcy = glow.height / 2, gr = Math.max(glow.width, glow.height * glow.aspect) * 0.55;\n    const accent = th.accent;\n    for (let y = 0; y < glow.height; y++)\n      for (let x = 0; x < glow.width; x++) {\n        const d = Math.hypot(x - gcx, (y - gcy) * glow.aspect) / gr;\n        const k = Math.max(0, 1 - d);\n        glow.px[y * glow.width + x] = k > 0.02 ? mixOklab(ctx.bg === NONE ? th.bg : ctx.bg, accent, 0.16 * k * k) : NONE;\n      }\n    const showGlow = unicode && ctx.bg !== NONE;\n\n    const rnd = ctx.rng;\n    const stars = Array.from({ length: Math.max(6, Math.round((W * H) / 45)) }, () => ({\n      x: Math.floor(rnd() * W), y: Math.floor(rnd() * H), ph: rnd() * Math.PI * 2, sp: 0.8 + rnd() * 2.2, hue: rnd(),\n    }));\n    const starChars = unicode ? ['·', '+', '✦'] : ['.', '+', '*'];\n\n    return (t) => {\n      const buf = ctx.buffer;\n      if (showGlow) glow.paint(buf);\n      for (const s of stars) {\n        const b = Math.pow(Math.max(0, Math.sin(t * s.sp + s.ph)), 3);\n        if (b < 0.08) continue;\n        const ch = starChars[b > 0.7 ? 2 : b > 0.35 ? 1 : 0];\n        buf.set(s.x, s.y, ch, mix(th.dim, pal(s.hue), b));\n      }\n\n      const idx = Math.floor(t / PERIOD) % configs.length;\n      const lt = t - Math.floor(t / PERIOD) * PERIOD;\n      const cfg = configs[idx];\n      const { width: bw, height: bh } = cfg.size;\n      const x0 = Math.floor((W - bw) / 2), y0 = Math.max(0, Math.floor((H - bh) / 2) - 1);\n      const p = Math.min(1.2, lt / 0.7);\n      drawBanner(buf, x0, y0, text, {\n        ...cfg,\n        t,\n        speed: 0.18,\n        direction: 'diagonal',\n        shader: p >= 1.2 ? undefined : (c, u) => (u > p ? NONE : p - u < 0.1 ? mixOklab(c, 0xffffff, 1 - (p - u) / 0.1) : c),\n      });\n\n      const sy = y0 + bh + 1;\n      if (sy < H) shimmer(buf, 'center', sy, subtitle, t, { theme: th, color: th.muted, period: 3 });\n      if (sy + 1 < H) {\n        const label = cfg.label;\n        buf.text(Math.floor((W - label.length) / 2), sy + 1, label, th.dim);\n      }\n    };\n  },\n};\n",
  "prism-term/src/facets/text/banners.js": "// Banner styles — a figlet session (standard / small / block), gradient swatch\n// book, outlined / hollow lettering, and a rotating 3D extrusion built from\n// stacked offset copies.\nimport { NONE, mix, mixOklab } from '../../color.js';\nimport { drawBanner, measureBanner } from '../../text/banner.js';\nimport { bannerMode, fitScale, fitFont } from './_text.js';\nimport { figlet, bitmap } from '../ascii/_ascii.js';\n\nconst shared = { gallery: 'text', category: 'Banners & Fonts', kind: 'text', glyphs: 'unicode', color: 'truecolor', animated: true };\nconst asciiOnly = (ctx) => ctx.caps.unicode === false || ctx.mode === 'ascii';\n\n// ── figlet session ──────────────────────────────────────────────────────────\nconst RUNS = [['standard', 'Hello'], ['small', 'PRISM'], ['block', 'OPS'], ['standard', 'ok!']];\nconst RSEG = 4.5;\n\n/** Solid block letters (figlet \"block\"-style) with a drop shadow one font pixel down-right. */\nfunction shadowBlock(text, font, ascii) {\n  const { w, h, px } = bitmap(text, font, 2);\n  const at = (x, y) => (x >= 0 && y >= 0 && x < w && y < h ? px[y][x] : 0);\n  const [ink, shade] = ascii ? ['#', ':'] : ['█', '░'];\n  const out = [];\n  for (let y = 0; y <= h; y++) {\n    let s = '';\n    for (let x = 0; x < w + 2; x++) s += at(x, y) ? ink : at(x - 2, y - 1) ? shade : ' ';\n    out.push(s);\n  }\n  return out;\n}\n\nconst figletStyles = {\n  ...shared,\n  id: 'text-figlet-styles',\n  title: 'figlet: Standard, Small, Block',\n  tags: ['figlet', 'banner', 'shell', 'standard', 'small', 'block'],\n  order: 135,\n  loop: RSEG * RUNS.length,\n  size: { w: 72, h: 14 },\n  minSize: { w: 36, h: 9 },\n  description: 'A shell session running figlet three ways: the outlined standard face, the compact small face and solid block letters with a shadow.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const ascii = asciiOnly(ctx);\n    const cursor = ascii ? '_' : '▌';\n    const runs = RUNS.map(([style, word]) => {\n      const make = (font) => (style === 'block' ? shadowBlock(word, font, ascii) : figlet(word, font));\n      let lines = make(style === 'small' ? 'tiny' : style === 'block' ? 'bold' : 'standard');\n      if (lines[0].length > W || lines.length > H - 2) lines = make('tiny');\n      while (lines.length && !lines[lines.length - 1].trim()) lines.pop();\n      return { style, word, lines };\n    });\n    return (t) => {\n      const b = ctx.buffer;\n      const k = Math.floor(t / RSEG) % runs.length, tau = t % RSEG;\n      const { style, word, lines } = runs[k];\n      const cmd = `figlet -f ${style} ${word}`;\n      const n = Math.min(cmd.length, Math.max(0, Math.floor((tau - 0.2) * 26)));\n      b.text(0, 0, '$ ', th.pos);\n      b.text(2, 0, cmd.slice(0, n), th.ink);\n      const outAt = 0.2 + cmd.length / 26 + 0.25;\n      if (tau < outAt) { b.text(2 + n, 0, cursor, th.accent); return; }\n      const shown = Math.min(lines.length, Math.floor((tau - outAt) * 60));\n      const color = style === 'block' ? (ch) => (ch === '█' || ch === '#' ? th.accent : th.dim) : style === 'small' ? () => th.info : () => th.ink;\n      for (let r = 0; r < shown && r + 1 < H; r++) {\n        const l = lines[r];\n        for (let x = 0; x < Math.min(l.length, W); x++) if (l[x] !== ' ') b.set(x, r + 1, l[x], color(l[x]));\n      }\n      if (shown === lines.length && lines.length + 1 < H) {\n        b.text(0, lines.length + 1, '$ ', th.pos);\n        if (Math.floor(tau / 0.53) % 2 === 0) b.text(2, lines.length + 1, cursor, th.accent);\n      }\n    };\n  },\n};\n\n// ── gradient swatch book ────────────────────────────────────────────────────\nconst FILLS = [\n  ['prism', 'horizontal'],\n  ['sunset', 'vertical'],\n  ['aurora', 'diagonal'],\n  ['ocean', 'horizontal'],\n  ['candy', 'diagonal'],\n  ['gold', 'vertical'],\n  ['toxic', 'horizontal'],\n  ['magma', 'diagonal'],\n];\n\nconst gradientBook = {\n  ...shared,\n  id: 'text-banner-gradient',\n  title: 'Gradient Swatch Book',\n  tags: ['gradient', 'banner', 'palette', 'fill', 'lolcat'],\n  order: 140,\n  loop: 16,\n  size: { w: 80, h: 24 },\n  minSize: { w: 36, h: 10 },\n  description: 'One word stacked in stripes, each filled with a different palette sweeping horizontally, vertically or diagonally.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const mode = bannerMode(ctx, 'half');\n    const text = String(ctx.param('text', 'GRADIENT'));\n    const labelW = W >= 60 ? 12 : 0;\n    const { font, scale } = fitFont(text, ['bold', 'standard', 'tiny'], { mode }, W - labelW - 2, H);\n    const rowH = measureBanner(text, { font, mode, scale }).height + (mode === 'braille' ? 0 : 1);\n    const rows = Math.max(1, Math.floor((H + 1) / rowH));\n    return (t) => {\n      const b = ctx.buffer;\n      const shift = Math.floor(t / 4);\n      for (let r = 0; r < rows; r++) {\n        const [pal, dir] = FILLS[(r + shift) % FILLS.length];\n        const y = r * rowH;\n        if (labelW) {\n          b.text(1, y, pal, th.ink);\n          b.text(1, y + 1, dir.slice(0, labelW - 1), th.dim);\n        }\n        drawBanner(b, labelW + 1, y, text, { font, mode, scale, fill: pal, direction: dir, theme: th, t: t + r * 0.7, speed: 0.22 });\n      }\n    };\n  },\n};\n\n// ── outline / hollow ────────────────────────────────────────────────────────\nconst STYLES = ['hollow', 'scanline', 'outlined', 'shadow'];\n\nconst outline = {\n  ...shared,\n  id: 'text-banner-outline',\n  title: 'Outline & Shadow',\n  tags: ['outline', 'hollow', 'shadow', 'banner', '80s'],\n  order: 150,\n  loop: 16,\n  size: { w: 60, h: 12 },\n  minSize: { w: 36, h: 8 },\n  description: 'Hollow outline lettering, scanline-filled 80s type, outlined gradients and drop shadows — four looks, one word.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const mode = bannerMode(ctx, ctx.caps.sextant === false ? 'quad' : 'sextant');\n    const text = String(ctx.param('text', 'OUTLINE'));\n    const font = 'bold';\n    const base = { font, mode, outline: true, shadow: true, shadowOffset: [2, 2] };\n    const scale = fitScale(text, base, W - 2, H - 2);\n    return (t) => {\n      const b = ctx.buffer;\n      const k = Math.floor(t / 4) % STYLES.length, lt = t % 4;\n      const style = STYLES[k];\n      const o = { font, mode, scale, theme: th, t, speed: 0.15 };\n      if (style === 'hollow') Object.assign(o, { fill: () => NONE, outline: th.accent });\n      else if (style === 'scanline') Object.assign(o, { fill: [th.warn, th.crit], outline: th.ink, shader: (c, u, v) => (Math.floor(v * 14 + t * 6) % 3 === 0 ? c : NONE) });\n      else if (style === 'outlined') Object.assign(o, { fill: 'synthwave', direction: 'vertical', outline: th.ink });\n      else Object.assign(o, { fill: [th.info, th.pos], shadow: mix(th.bg, th.accent, 0.45), shadowOffset: [2, 2] });\n      // Settle-in: each style slides up a little as it arrives.\n      const rise = lt < 0.4 ? Math.round((0.4 - lt) * 5) : 0;\n      const m = measureBanner(text, { ...base, scale });\n      const r = drawBanner(b, Math.floor((W - m.width) / 2), Math.max(0, Math.floor((H - m.height) / 2) - 1 + rise), text, o);\n      const label = `${style}`;\n      if (r.y + r.height < H) b.text(Math.floor((W - label.length) / 2), Math.min(H - 1, r.y + r.height), label, th.dim);\n    };\n  },\n};\n\n// ── 3D extrusion ────────────────────────────────────────────────────────────\nconst extrude = {\n  ...shared,\n  id: 'text-extrude',\n  title: '3D Extruded Banner',\n  tags: ['3d', 'extrude', 'depth', 'banner', 'retro'],\n  order: 160,\n  loop: 12.566,\n  size: { w: 80, h: 20 },\n  minSize: { w: 36, h: 10 },\n  description: 'Solid 3D block letters: a stack of shaded offset copies forms the sides, and the light direction swings slowly around.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const mode = bannerMode(ctx, 'half');\n    const text = String(ctx.param('text', 'DEPTH'));\n    const depth = mode === 'braille' ? 6 : mode === 'ascii' ? 2 : 4;\n    const pad = depth + 1;\n    const font = 'bold';\n    const scale = fitScale(text, { font, mode, padX: pad, padY: pad }, W - 1, H - 1);\n    const size = measureBanner(text, { font, mode, scale, padX: pad, padY: pad });\n    const side = th.accent, face = [mixOklab(th.ink, th.warn, 0.25), th.warn];\n    return (t) => {\n      const b = ctx.buffer;\n      const a = t * 0.5;\n      const ux = Math.cos(a) * 1.0, uy = Math.sin(a) * 0.6 + 0.4;\n      const ghosts = [];\n      for (let k = depth; k >= 1; k--) {\n        const shade = 0.25 + 0.55 * (k / depth);\n        ghosts.push({ dx: Math.round(ux * k), dy: Math.round(uy * k), color: mixOklab(side, th.bg, shade) });\n      }\n      drawBanner(b, Math.floor((W - size.width) / 2), Math.floor((H - size.height) / 2), text, {\n        font, mode, scale, padX: pad, padY: pad, fill: face, direction: 'vertical', theme: th, ghosts,\n        shader: (c, u, v) => (Math.abs(u - ((t * 0.25) % 1.6 - 0.3) - v * 0.2) < 0.04 ? mixOklab(c, 0xffffff, 0.7) : c),\n      });\n    };\n  },\n};\n\nexport default [figletStyles, gradientBook, outline, extrude];\n",
  "prism-term/src/facets/text/fonts.js": "// Font specimens — one facet per bitmap face, laid out like a type-foundry\n// waterfall: the sample at its largest scale, then smaller, with the face's\n// character set ticking past underneath.\nimport { NONE, mix, mixOklab } from '../../color.js';\nimport { drawBanner, measureBanner } from '../../text/banner.js';\nimport { getFont } from '../../text/fonts.js';\nimport { CYBER_FONT } from './_fonts.js';\nimport { bannerMode } from './_text.js';\n\nconst PAGE = 4;\n\nfunction specimen({ id, title, font, label, samples, stops, order, description, tags }) {\n  return {\n    id,\n    title,\n    gallery: 'text',\n    category: 'Banners & Fonts',\n    kind: 'text',\n    tags: ['font', 'specimen', 'bitmap font', 'waterfall', ...tags],\n    order,\n    animated: true,\n    loop: PAGE * samples.length,\n    glyphs: 'unicode',\n    color: 'truecolor',\n    size: { w: 80, h: 24 },\n    minSize: { w: 36, h: 10 },\n    description,\n    setup(ctx) {\n      const { width: W, height: H, theme: th } = ctx;\n      const mode = bannerMode(ctx, 'half');\n      const f = getFont(font);\n      const fill = stops(th), chartInk = mix(th.muted, th.bg, 0.25);\n      const m = (s, scale = 1) => measureBanner(s, { font: f, mode, scale });\n      const fits = samples.filter((s) => m(s).width <= W - 2);\n      const pages = fits.length ? fits : [samples[0].slice(0, Math.max(1, Math.floor((W - 2) / Math.max(1, m('M').width + 1))))];\n      const top = 2, heroMax = Math.max(m(pages[0]).height, Math.floor((H - top - 1) * 0.5));\n      const heroScale = pages.map((s) => {\n        let k = 1;\n        while (k < 6 && m(s, k + 1).width <= W - 2 && m(s, k + 1).height <= heroMax) k++;\n        return k;\n      });\n      // Glyph chart: the face's own character set, set in itself, wrapped to the width.\n      const chart = [];\n      let cur = '';\n      for (const ch of Object.keys(f.glyphs)) {\n        if (ch === ' ') continue;\n        if (cur && m(cur + ch).width > W - 2) { chart.push(cur); cur = ''; }\n        cur += ch;\n      }\n      if (cur) chart.push(cur);\n      return (t) => {\n        const b = ctx.buffer;\n        const page = Math.floor(t / PAGE) % pages.length, lt = t % PAGE;\n        const text = pages[page], scale = heroScale[page];\n        const head = `${label}  ${f.width}x${f.height}${f.descent ? `+${f.descent}` : ''}  ${Object.keys(f.glyphs).length} glyphs`;\n        b.text(1, 0, head.slice(0, W - 2), th.ink, undefined, 1);\n        const tag = `mode ${mode}  ${scale}x`;\n        if (W > head.length + tag.length + 4) b.text(W - tag.length - 1, 0, tag, th.dim);\n        b.text(1, 1, '-'.repeat(Math.max(0, W - 2)), th.line);\n        const reveal = Math.min(1.15, lt / 0.6);\n        const r = drawBanner(b, 1, top, text, {\n          font: f, mode, scale, fill, theme: th, t, speed: 0.12, direction: 'diagonal',\n          shader: reveal >= 1.15 ? undefined : (c, u) => (u > reveal ? NONE : reveal - u < 0.12 ? mixOklab(c, th.ink, 1 - (reveal - u) / 0.12) : c),\n        });\n        let y = top + r.height + 1;\n        for (let k = 0; k < chart.length; k++) {\n          const line = chart[(k + page * 2) % chart.length];\n          const h = m(line).height;\n          if (y + h > H) break;\n          const hi = Math.floor(t * 5) % line.length;\n          drawBanner(b, 1, y, line, { font: f, mode, fill: chartInk, theme: th, charStyle: (ci) => (ci === hi ? { fg: th.accent } : null) });\n          y += h + (mode === 'braille' ? 0 : 1);\n        }\n      };\n    },\n  };\n}\n\nexport default [\n  specimen({\n    id: 'text-font-standard',\n    title: 'Standard 5x7 Specimen',\n    font: 'standard',\n    label: 'STANDARD',\n    samples: ['Hello, World!', 'Sphinx of quartz', 'grep -i \"jq\"', 'Prism 0123'],\n    stops: (th) => [th.accent, th.info, th.pos],\n    order: 100,\n    tags: ['standard', 'lcd', 'lowercase'],\n    description: 'Type-specimen waterfall for the standard 5x7 face — upper and lower case with descenders — at every scale that fits.',\n  }),\n  specimen({\n    id: 'text-font-tiny',\n    title: 'Tiny 3x5 Specimen',\n    font: 'tiny',\n    label: 'TINY',\n    samples: ['CPU 42%', 'P99 180MS', 'UPTIME 9D', '1,204 RPS'],\n    stops: (th) => [th.pos, th.info],\n    order: 110,\n    tags: ['tiny', 'compact', 'kpi', 'numbers'],\n    description: 'The compact 3x5 caps-and-digits face set as KPI read-outs, in a waterfall from display size down to its native pixels.',\n  }),\n  specimen({\n    id: 'text-font-bold',\n    title: 'Bold 6x7 Specimen',\n    font: 'bold',\n    label: 'BOLD',\n    samples: ['BOLD', 'SHIP IT', 'ALERT!', '2026'],\n    stops: (th) => [th.warn, th.crit, th.accent],\n    order: 120,\n    tags: ['bold', 'display', 'heavy'],\n    description: 'The 6x7 display face with two-pixel stems — the heavyweight for headlines — shown as a scaled waterfall with its glyph set.',\n  }),\n  specimen({\n    id: 'text-font-cyber',\n    title: 'Cyber 5x7 Specimen',\n    font: CYBER_FONT,\n    label: 'CYBER',\n    samples: ['CYBER', 'ONLINE', 'GRID 07', 'XR-84'],\n    stops: (th) => [th.info, th.accent, th.crit],\n    order: 130,\n    tags: ['cyber', 'techno', 'squared', 'new face'],\n    description: 'A new hand-drawn squared techno face — hard corners, square counters, chamfered B and D — in a specimen waterfall.',\n  }),\n];\n",
  "prism-term/src/facets/text/glitch.js": "// Glitch & Distort — pixel-level abuse of big type: tearing RGB splits, a\n// worn VHS tape with tracking noise, and letters that melt and drip.\nimport { NONE, mix, mixOklab } from '../../color.js';\nimport { bannerCanvas } from '../../text/banner.js';\nimport { glitch as glitchSmall } from '../../text/effects.js';\nimport { bannerMode, fitScale, fitType, hash, canvasLike } from './_text.js';\n\nconst shared = { gallery: 'text', category: 'Glitch & Distort', kind: 'text', glyphs: 'unicode', color: 'truecolor', animated: true };\nconst asciiOnly = (ctx) => ctx.caps.unicode === false || ctx.mode === 'ascii';\n\n// ── RGB split / tearing ─────────────────────────────────────────────────────\nconst glitchText = {\n  ...shared,\n  id: 'text-glitch',\n  title: 'RGB Split Glitch',\n  tags: ['glitch', 'rgb split', 'tearing', 'datamosh', 'corruption'],\n  order: 100,\n  loop: 6,\n  size: { w: 72, h: 16 },\n  minSize: { w: 36, h: 8 },\n  description: 'Big type torn into horizontal slices, red and cyan channels pulled apart, blocks displaced — in bursts, like a failing signal.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const mode = bannerMode(ctx, 'half');\n    const text = String(ctx.param('text', 'GLITCH'));\n    const pad = 5;\n    const { font, scale } = fitType(text, { mode, padX: pad, padY: 2 }, W, H - 2);\n    const base = bannerCanvas(text, { font, mode, scale, padX: pad, padY: 2, fill: th.ink, theme: th });\n    const out = canvasLike(base);\n    const w = base.width, h = base.height;\n    const rowDx = new Int16Array(h);\n    const red = th.neg, cyan = th.info;\n    const captions = ['SIGNAL CORRUPTED', 'ERR 0x5F FRAME DROP', 'RESYNCING...'];\n    const ascii = asciiOnly(ctx);\n    return (t) => {\n      const b = ctx.buffer;\n      const k = Math.floor(t * 12);\n      const burst = t % 3 < 0.35 || hash(k, 1) < 0.22;\n      const amt = burst ? 1 : 0.12;\n      // Horizontal tears in bands of 1–4 pixel rows.\n      for (let y = 0; y < h;) {\n        const bh = 1 + Math.floor(hash(y, k, 2) * 4);\n        const dx = hash(y, k, 3) < 0.4 * amt ? Math.round((hash(y, k, 4) - 0.5) * 14 * amt) : 0;\n        for (let r = y; r < Math.min(h, y + bh); r++) rowDx[r] = dx;\n        y += bh;\n      }\n      // At rest a 1-px fringe; none in ascii mode, where the copies would fuse into '#'.\n      const s = burst ? 2 + Math.floor(hash(k, 5) * 3) : mode === 'ascii' ? 0 : 1;\n      out.clear();\n      if (s) for (const [ox, oy, col] of [[-s, 0, red], [s, burst ? 1 : 0, cyan]])\n        for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (base.px[y * w + x] !== NONE) out.set(x + rowDx[y] + ox, y + oy, col);\n      for (let y = 0; y < h; y++) {\n        const invert = burst && hash(y, k, 6) < 0.08;\n        for (let x = 0; x < w; x++) if (base.px[y * w + x] !== NONE) out.set(x + rowDx[y], y, invert ? th.bg : th.ink);\n      }\n      // Displaced blocks: chunks of the image copied a little way off.\n      if (burst) for (let n = 0; n < 3; n++) {\n        const bw = 4 + Math.floor(hash(n, k, 7) * 10), bh = 1 + Math.floor(hash(n, k, 8) * 3);\n        const sx = Math.floor(hash(n, k, 9) * (w - bw)), sy = Math.floor(hash(n, k, 10) * (h - bh));\n        const dx = Math.round((hash(n, k, 11) - 0.5) * 16);\n        const tint = n === 0 ? red : n === 1 ? cyan : th.accent;\n        for (let y = sy; y < sy + bh; y++) for (let x = sx; x < sx + bw; x++) out.set(x + dx, y, base.px[y * w + x] !== NONE ? tint : NONE);\n      }\n      // A noisy scanline\n      const ny = Math.floor(hash(k, 12) * h);\n      for (let x = 0; x < w; x++) if (hash(x, k, 13) < (burst ? 0.5 : 0.12)) out.set(x, ny, mix(th.muted, th.bg, hash(x, k, 14) * 0.6));\n      const x0 = Math.floor((W - out.cols) / 2), y0 = Math.max(0, Math.floor((H - out.rows - 1) / 2));\n      out.paint(b, x0, y0);\n      const cap = captions[Math.floor(t / 2) % captions.length];\n      const cy = Math.min(H - 1, y0 + out.rows);\n      glitchSmall(b, Math.floor((W - cap.length) / 2), cy, cap, t, { theme: th, color: th.muted, intensity: burst ? 1 : 0.2, ascii });\n    };\n  },\n};\n\n// ── VHS ─────────────────────────────────────────────────────────────────────\nconst vhs = {\n  ...shared,\n  id: 'text-vhs',\n  title: 'VHS Tracking',\n  tags: ['vhs', 'tape', 'tracking', 'scanlines', 'retro', 'osd'],\n  order: 110,\n  loop: 8,\n  size: { w: 80, h: 20 },\n  minSize: { w: 36, h: 10 },\n  description: 'A title card off a worn tape: wobbling rows, chroma bleed, scanlines and a rolling tracking band, under a PLAY on-screen display.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const mode = bannerMode(ctx, 'half');\n    const ascii = asciiOnly(ctx);\n    const text = String(ctx.param('text', 'LATE SHOW'));\n    const { font, scale } = fitType(text, { mode, padX: 3 }, W - 2, H - 5);\n    const src = bannerCanvas(text, { font, mode, scale, padX: 3, fill: [th.ink, th.crit], direction: 'vertical', theme: th });\n    const cv = canvasLike(src, W, H);\n    const ox = Math.floor((cv.width - src.width) / 2), oy = Math.floor((cv.height - src.height) / 2);\n    const play = ascii ? 'PLAY >' : 'PLAY ▶';\n    const bleed = mode !== 'ascii';\n    return (t) => {\n      const b = ctx.buffer;\n      cv.clear();\n      const bandH = Math.max(2, Math.round(cv.height * 0.12));\n      const bandY = Math.floor((t * cv.height * 0.35) % (cv.height + bandH * 4)) - bandH * 2;\n      const roll = t % 8 < 1.6; // tracking slips for a moment every cycle\n      const dxOf = (y) => {\n        const Y = y + oy;\n        let d = Math.sin(Y * 0.35 + t * 2.3) * 0.7;\n        if (Y >= bandY && Y < bandY + bandH) d += (hash(Y, Math.floor(t * 24)) - 0.5) * (roll ? 14 : 6);\n        return Math.round(d);\n      };\n      for (let y = 0; y < src.height; y++) {\n        const dx = dxOf(y), scan = (y + oy) % 2 === 1;\n        for (let x = 0; x < src.width; x++) {\n          const c = src.px[y * src.width + x];\n          if (c === NONE) continue;\n          const X = x + ox + dx, Y = y + oy;\n          if (bleed) { cv.set(X - 1, Y, mix(th.neg, th.bg, 0.5)); cv.set(X + 1, Y, mix(th.info, th.bg, 0.6)); }\n        }\n        for (let x = 0; x < src.width; x++) {\n          const c = src.px[y * src.width + x];\n          if (c !== NONE) cv.set(x + ox + dx, y + oy, scan ? mixOklab(c, th.bg, 0.3) : c);\n        }\n      }\n      // Tracking noise band across the full frame\n      for (let Y = Math.max(0, bandY); Y < Math.min(cv.height, bandY + bandH); Y++)\n        for (let X = 0; X < cv.width; X++) {\n          const r = hash(X, Y, Math.floor(t * 30));\n          if (r < (roll ? 0.4 : 0.16)) cv.set(X, Y, mix(th.ink, th.bg, 0.25 + r));\n        }\n      cv.paint(b, 0, 0);\n      // On-screen display\n      b.text(2, 1, play, th.ink, undefined, 1);\n      const s = Math.floor(754 + t);\n      const counter = `SP ${Math.floor(s / 3600)}:${String(Math.floor(s / 60) % 60).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;\n      b.text(W - counter.length - 2, H - 2, counter, th.ink);\n      if (W > 34) b.text(2, H - 2, 'OCT.13 2026', th.ink);\n      if (roll) {\n        const lvl = Math.floor((t % 8) / 0.32) % 6;\n        const meter = ascii ? '#'.repeat(lvl) + '.'.repeat(5 - lvl) : '▮'.repeat(lvl) + '▯'.repeat(5 - lvl);\n        const tr = `TRACKING ${meter}`;\n        b.text(Math.floor((W - tr.length) / 2), H - 2, tr, th.ink);\n      }\n    };\n  },\n};\n\n// ── melt ────────────────────────────────────────────────────────────────────\nconst MCYCLE = 10;\n\nconst melt = {\n  ...shared,\n  id: 'text-melt',\n  title: 'Melting Type',\n  tags: ['melt', 'drip', 'liquid', 'horror', 'distort'],\n  order: 120,\n  loop: MCYCLE,\n  size: { w: 72, h: 20 },\n  minSize: { w: 36, h: 10 },\n  description: 'Letters soften and sag, drips run from their undersides and drop into a spreading puddle — then the word sets again.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const mode = bannerMode(ctx, 'half');\n    const text = String(ctx.param('text', 'MELT'));\n    const scale = fitScale(text, { font: 'bold', mode }, W - 4, Math.floor(H * 0.5));\n    const src = bannerCanvas(text, { font: 'bold', mode, scale, fill: [th.warn, th.crit], direction: 'vertical', theme: th });\n    const cv = canvasLike(src, W, H);\n    const ox = Math.floor((cv.width - src.width) / 2), oy = Math.max(1, Math.floor(cv.height * 0.12));\n    const floor = cv.height - 1;\n    // Each pixel column: its lowest ink pixel, color there, and whether / how fast it drips.\n    const cols = [];\n    for (let x = 0; x < src.width; x++) {\n      let bottom = -1;\n      for (let y = 0; y < src.height; y++) if (src.px[y * src.width + x] !== NONE) bottom = y;\n      cols.push({ bottom, color: bottom >= 0 ? src.px[bottom * src.width + x] : NONE, drips: hash(x, 3) < 0.42, start: 1 + hash(x, 4) * 3, speed: 2 + hash(x, 5) * 7 });\n    }\n    return (t) => {\n      const b = ctx.buffer;\n      const tau = t % MCYCLE;\n      cv.clear();\n      const melt = Math.min(1, Math.max(0, (tau - 0.8) / 6));\n      const reform = tau > MCYCLE - 1 ? (tau - (MCYCLE - 1)) : 0;\n      let puddle = 0;\n      for (let x = 0; x < src.width; x++) {\n        const c = cols[x];\n        if (c.bottom < 0) continue;\n        // Sag: lower rows slide down more, wobbling per column.\n        const sag = melt * (1.5 + hash(x >> 1, 6) * 2.5) * (1 - reform);\n        for (let y = 0; y < src.height; y++) {\n          const col = src.px[y * src.width + x];\n          if (col === NONE) continue;\n          // Stretch each pixel down to where the next row lands, so sagging letters stay solid.\n          const d0 = Math.round(sag * (y / src.height)), d1 = Math.round(sag * ((y + 1) / src.height));\n          const mc = mixOklab(col, th.crit, melt * 0.35);\n          for (let r = d0; r <= Math.max(d0, d1); r++) cv.set(x + ox, y + oy + r, mc);\n        }\n        if (!c.drips || reform) continue;\n        const life = tau - c.start;\n        if (life <= 0) continue;\n        const top = c.bottom + oy + Math.round(sag) + 1;\n        const len = Math.min(floor - top, Math.floor(life * c.speed * 0.6));\n        for (let k = 0; k < len; k++) cv.set(x + ox, top + k, mixOklab(c.color, th.crit, 0.3 + 0.4 * (k / Math.max(1, len))));\n        // Droplets break off the tip and fall to the floor.\n        const fallT = (life * 1.3) % 1.4;\n        const dropY = top + len + Math.floor(0.5 * 60 * fallT * fallT);\n        if (len > 2 && dropY < floor) cv.set(x + ox, dropY, c.color);\n        if (top + len >= floor - 1 || life > 3) puddle++;\n      }\n      // Puddle spreads along the floor from under the word.\n      const pw = Math.min(cv.width, Math.round(src.width * 0.6 + puddle * 1.2));\n      const px0 = Math.floor((cv.width - pw) / 2);\n      if (melt > 0.15 && !reform) for (let x = px0; x < px0 + pw; x++) cv.set(x, floor, mixOklab(th.crit, th.bg, 0.35 + 0.3 * Math.abs((x - px0) / pw - 0.5)));\n      cv.paint(b, 0, 0);\n    };\n  },\n};\n\nexport default [glitchText, vhs, melt];\n",
  "prism-term/src/facets/text/light.js": "// Color & Light — type that emits or reflects: a flickering neon sign, words\n// on fire, a rainbow sweep, lolcat'd output and 80s chrome.\nimport { NONE, rgb, mix, mixOklab, luminance } from '../../color.js';\nimport { drawBanner, bannerCanvas, measureBanner, layoutText } from '../../text/banner.js';\nimport { bannerMode, fitType, hash } from './_text.js';\n\nconst shared = { gallery: 'text', category: 'Color & Light', kind: 'text', glyphs: 'unicode', color: 'truecolor', animated: true };\nconst asciiOnly = (ctx) => ctx.caps.unicode === false || ctx.mode === 'ascii';\n\n// ── neon sign ───────────────────────────────────────────────────────────────\nconst NCYCLE = 8;\n\n/** Is letter `i` of a tube lit at time tau? Power-on stutter, then one buzzing letter. */\nfunction tubeLit(i, tau, onAt, buzz) {\n  if (tau < onAt) return false;\n  if (tau < onAt + 0.35) return hash(i, Math.floor(tau * 22)) > 0.45;\n  if (i === buzz && ((tau > 3 && tau < 3.9) || (tau > 6.1 && tau < 6.5))) return hash(i, Math.floor(tau * 17), 3) > 0.55;\n  return true;\n}\n\n/**\n * Bend glass along the letters: each font pixel becomes a node at s× spacing and\n * neighbouring nodes are joined by 1-px tube segments (diagonals only where no\n * square corner exists), so strokes stay thin however large the sign.\n */\nfunction tubePaths(text, font, s) {\n  const lay = layoutText(text, { font });\n  const paths = [];\n  for (const l of lay.lines) for (const { gl, x, ci } of l.glyphs) {\n    const segs = [];\n    const on = (gx, gy) => gx >= 0 && gy >= 0 && gx < gl.w && gy < gl.h && gl.bits[gy * gl.w + gx] === 1;\n    for (let gy = 0; gy < gl.h; gy++) for (let gx = 0; gx < gl.w; gx++) {\n      if (!on(gx, gy)) continue;\n      const cx = (l.x + x + gx) * s + (s >> 1), cy = (l.y + gy) * s + (s >> 1);\n      let linked = false;\n      for (const [dx, dy] of [[1, 0], [0, 1], [1, 1], [-1, 1]]) {\n        if (!on(gx + dx, gy + dy) || (dx && dy && (on(gx + dx, gy) || on(gx, gy + dy)))) continue;\n        segs.push([cx, cy, cx + dx * s, cy + dy * s]);\n        linked = true;\n      }\n      if (!linked && !on(gx - 1, gy) && !on(gx, gy - 1) && !on(gx - 1, gy - 1) && !on(gx + 1, gy - 1)) segs.push([cx, cy, cx, cy]);\n    }\n    paths.push({ ci, segs });\n  }\n  return { paths, w: lay.width * s, h: lay.height * s };\n}\n\nfunction plot(buf, W, H, x0, y0, x1, y1, v) {\n  const dx = Math.abs(x1 - x0), dy = -Math.abs(y1 - y0), sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1;\n  let err = dx + dy;\n  for (;;) {\n    if (x0 >= 0 && y0 >= 0 && x0 < W && y0 < H) buf[y0 * W + x0] = v;\n    if (x0 === x1 && y0 === y1) break;\n    const e2 = 2 * err;\n    if (e2 >= dy) { err += dy; x0 += sx; }\n    if (e2 <= dx) { err += dx; y0 += sy; }\n  }\n}\n\nconst neon = {\n  ...shared,\n  id: 'text-neon-sign',\n  title: 'Neon Sign',\n  tags: ['neon', 'sign', 'flicker', 'glow', 'night'],\n  order: 100,\n  loop: NCYCLE,\n  size: { w: 64, h: 18 },\n  minSize: { w: 34, h: 10 },\n  description: 'Thin glass tubes bent along the letters stutter on one by one with a soft halo; one tired letter keeps buzzing out, like every real sign.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const field = ctx.canvas('field');\n    const FW = field.width, FH = field.height;\n    const top = String(ctx.param('text', 'OPEN')).toUpperCase();\n    const sub = String(ctx.param('subtitle', 'ALL NIGHT')).toUpperCase();\n    const pink = mixOklab(th.crit, th.accent, 0.2), blue = th.info;\n    const tw = layoutText(top, { font: 'tiny' }).width, sw = layoutText(sub, { font: 'standard' }).width;\n    const s1 = Math.max(1, Math.min(Math.floor((FW - 6) / tw), Math.floor((FH * 0.6) / 5)));\n    const T1 = tubePaths(top, 'tiny', s1);\n    const s2 = Math.max(1, Math.min(s1 - 1, Math.floor((FW - 6) / sw), Math.floor((FH - T1.h - 6) / 7)));\n    const showSub = sw * s2 <= FW - 4 && T1.h + 7 * s2 + 5 <= FH;\n    const T2 = showSub ? tubePaths(sub, 'standard', s2) : null;\n    const total = T1.h + (T2 ? T2.h + 3 : 0);\n    const y1 = Math.max(1, Math.floor((FH - total) / 2)), x1 = Math.floor((FW - T1.w) / 2);\n    const y2 = y1 + T1.h + 3, x2 = T2 ? Math.floor((FW - T2.w) / 2) : 0;\n    const core = new Int32Array(FW * FH), lit = new Uint8Array(FW * FH);\n    const halo = (c, k) => mix(c, th.bg, k);\n    const lightWall = luminance(th.bg) > 0.5; // white-hot cores vanish on a light wall\n    return (t) => {\n      const b = ctx.buffer;\n      const tau = t % NCYCLE;\n      core.fill(NONE); lit.fill(0);\n      const draw = (T, ox, oy, color, isLit) => {\n        for (const { ci, segs } of T.paths) {\n          const on = isLit(ci);\n          const c = on ? (lightWall ? color : mixOklab(color, 0xffffff, 0.45)) : mix(color, th.bg, 0.8);\n          for (const [a, b0, c0, d] of segs) plot(core, FW, FH, a + ox, b0 + oy, c0 + ox, d + oy, c);\n          if (on) for (const [a, b0, c0, d] of segs) plot(lit, FW, FH, a + ox, b0 + oy, c0 + ox, d + oy, T === T1 ? 1 : 2);\n        }\n      };\n      let n = 0;\n      draw(T1, x1, y1, pink, (ci) => { const v = tubeLit(ci, tau, 0.2 + ci * 0.18, 2); n += v; return v; });\n      if (T2) draw(T2, x2, y2, blue, (ci) => tubeLit(ci + 20, tau, 1.5, -1));\n      // Wall glow behind the sign, following how much of it is lit.\n      const glow = n / Math.max(1, top.length);\n      if (glow > 0 && field.mode !== 'ascii') {\n        const rows = Math.ceil((T1.h + 4) / field.sy), r0 = Math.floor((y1 - 2) / field.sy);\n        for (let y = Math.max(0, r0); y < Math.min(H, r0 + rows); y++) for (let x = 0; x < W; x++) {\n          const d = Math.abs(x - W / 2) / (W / 2);\n          if (d < 0.8) b.set(x, y, ' ', undefined, mix(mix(th.bg, pink, 0.09 * glow), th.bg, d / 0.8));\n        }\n      }\n      // Halo: two dilation rings around lit glass, then the glass itself.\n      field.clear();\n      for (let y = 0; y < FH; y++) for (let x = 0; x < FW; x++) {\n        const L = lit[y * FW + x];\n        if (!L) continue;\n        const c = L === 1 ? pink : blue, reach = (L === 1 ? s1 : s2) >= 3 ? 2 : 1;\n        for (let dy = -reach; dy <= reach; dy++) for (let dx = -reach; dx <= reach; dx++) {\n          const X = x + dx, Y = y + dy;\n          if (X < 0 || Y < 0 || X >= FW || Y >= FH || core[Y * FW + X] !== NONE) continue;\n          const far = Math.max(Math.abs(dx), Math.abs(dy)) > 1;\n          const i = Y * FW + X, hc = halo(c, far ? 0.84 : 0.55);\n          if (!far || field.px[i] === NONE) field.px[i] = hc;\n        }\n      }\n      for (let i = 0; i < FW * FH; i++) if (core[i] !== NONE) field.px[i] = core[i];\n      field.paint(b, 0, 0);\n    };\n  },\n};\n// ── fire text ───────────────────────────────────────────────────────────────\nconst fireText = {\n  ...shared,\n  id: 'text-fire',\n  title: 'Text on Fire',\n  tags: ['fire', 'flames', 'burning', 'heat', 'doom fire'],\n  order: 110,\n  loop: 6,\n  size: { w: 72, h: 20 },\n  minSize: { w: 36, h: 10 },\n  description: 'The letters are the fuel: every ink pixel feeds a rising flame field, so fire licks off the tops and edges of the word.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const mode = bannerMode(ctx, 'half');\n    const text = String(ctx.param('text', 'FIRE'));\n    const field = ctx.canvas('field');\n    const { font, scale } = fitType(text, { mode }, W - 4, Math.floor(H * 0.5));\n    const src = bannerCanvas(text, { font, mode, scale, fill: th.ink, theme: th });\n    const FW = field.width, FH = field.height;\n    const ox = Math.floor((FW - src.width) / 2), oy = Math.max(0, FH - src.height - Math.floor(FH * 0.12));\n    // ink: the letters; fuel: their upward-facing edges, where flames start.\n    const ink = new Uint8Array(FW * FH), fuel = new Uint8Array(FW * FH);\n    const inkAt = (x, y) => x >= 0 && y >= 0 && x < src.width && y < src.height && src.px[y * src.width + x] !== NONE;\n    for (let y = 0; y < src.height; y++) for (let x = 0; x < src.width; x++) {\n      if (!inkAt(x, y) || x + ox >= FW || y + oy >= FH) continue;\n      ink[(y + oy) * FW + x + ox] = 1;\n      if (!inkAt(x, y - 1)) fuel[(y + oy) * FW + x + ox] = 1;\n    }\n    let heat = new Float32Array(FW * FH), next = new Float32Array(FW * FH);\n    const cool = 2.6 / FH;\n    // Theme-aware flame ramp: background → deep red → orange → yellow → white-hot.\n    const ramp = [th.bg, mix(th.bg, th.neg, 0.55), th.neg, th.warn, mixOklab(th.warn, 0xffffff, 0.55), 0xffffff];\n    const lut = Array.from({ length: 64 }, (_, i) => {\n      const g = (i / 63) * (ramp.length - 1), k = Math.min(ramp.length - 2, Math.floor(g));\n      return mixOklab(ramp[k], ramp[k + 1], g - k);\n    });\n    let acc = 0, step = 0;\n    const tick = () => {\n      step++;\n      for (let y = 0; y < FH; y++) for (let x = 0; x < FW; x++) {\n        const i = y * FW + x;\n        if (fuel[i]) { next[i] = 0.7 + 0.3 * hash(x, y, step); continue; }\n        if (ink[i]) { next[i] = 0; continue; }\n        if (y === FH - 1) { next[i] = 0; continue; }\n        const r = hash(x, y, step);\n        const sx = Math.min(FW - 1, Math.max(0, x + (r < 0.33 ? -1 : r < 0.66 ? 0 : 1)));\n        next[i] = Math.max(0, heat[(y + 1) * FW + sx] - cool * (0.4 + r * 1.2));\n      }\n      [heat, next] = [next, heat];\n    };\n    return (t, dt) => {\n      const b = ctx.buffer;\n      acc += Math.min(0.25, dt || 0.05);\n      while (acc > 1 / 30) { tick(); acc -= 1 / 30; }\n      field.clear();\n      for (let i = 0; i < FW * FH; i++) {\n        const h = heat[i];\n        if (!ink[i] && h > 0.08) field.px[i] = lut[Math.min(63, Math.floor(h * 63))];\n      }\n      // A dark rim keeps the word legible against its own flames…\n      for (let y = 0; y < src.height; y++) for (let x = 0; x < src.width; x++) {\n        if (inkAt(x, y)) continue;\n        if (inkAt(x - 1, y) || inkAt(x + 1, y) || inkAt(x, y + 1) || inkAt(x, y - 1)) field.set(x + ox, y + oy, mix(th.bg, th.neg, 0.2));\n      }\n      // …and the letters glow like embers, hottest at the top.\n      for (let y = 0; y < src.height; y++) for (let x = 0; x < src.width; x++) {\n        if (!inkAt(x, y)) continue;\n        const flick = hash(x >> 1, y >> 1, Math.floor(t * 8));\n        const v = y / src.height;\n        field.set(x + ox, y + oy, mixOklab(mixOklab(th.warn, 0xffffff, 0.5), th.neg, v * 0.7 + flick * 0.15));\n      }\n      field.paint(b, 0, 0);\n    };\n  },\n};\n\n// ── rainbow sweep ───────────────────────────────────────────────────────────\nconst sweep = {\n  ...shared,\n  id: 'text-rainbow-sweep',\n  title: 'Rainbow Sweep',\n  tags: ['rainbow', 'sweep', 'spectrum', 'wipe', 'prism'],\n  order: 120,\n  loop: 6,\n  size: { w: 72, h: 14 },\n  minSize: { w: 36, h: 8 },\n  description: 'A bright leading edge sweeps over gray type and leaves a flowing spectrum behind it — then sweeps back and drains it out.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const mode = bannerMode(ctx, 'half');\n    const text = String(ctx.param('text', 'SPECTRUM'));\n    const pal = ctx.palette('prism');\n    // In ascii mode every pixel is '#', so a shadow would just fuse the letters.\n    const shadow = mode !== 'ascii';\n    const { font, scale } = fitType(text, { mode, shadow }, W - 2, H - 3);\n    const grey = mix(th.muted, th.bg, 0.35);\n    return (t) => {\n      const b = ctx.buffer;\n      const pass = Math.floor(t / 3), p = (t % 3) / 2.4 * 1.3 - 0.15;\n      const filling = pass % 2 === 0;\n      const r = drawBanner(b, 'center', Math.max(0, Math.floor((H - 3) / 2) - 2), text, {\n        font, mode, scale, theme: th, shadow,\n        fill: (g) => g,\n        shader: (_, u, v) => {\n          const e = u + v * 0.15 - p; // slanted edge\n          const lit = filling ? e < 0 : e > 0;\n          if (Math.abs(e) < 0.04) return mixOklab(0xffffff, pal((u + t * 0.3) % 1), 0.25);\n          return lit ? pal((u * 0.8 - t * 0.35 + 10) % 1) : grey;\n        },\n      });\n      // A spectrum rule underneath, sweeping with the edge\n      const y = Math.min(H - 1, r.y + r.height + 1);\n      const ch = asciiOnly(ctx) ? '=' : '━';\n      for (let x = r.x; x < r.x + r.width; x++) {\n        const u = (x - r.x) / Math.max(1, r.width);\n        const lit = filling ? u < p : u > p;\n        b.set(x, y, ch, lit ? pal((u * 0.8 - t * 0.35 + 10) % 1) : th.line);\n      }\n    };\n  },\n};\n\n// ── lolcat ──────────────────────────────────────────────────────────────────\nconst LISTING = [\n  'total 48',\n  'drwxr-xr-x  8 ops ops 4096 Oct 13 09:41 .',\n  'drwxr-xr-x 24 ops ops 4096 Oct 12 18:02 ..',\n  '-rw-r--r--  1 ops ops  220 Oct  2 11:15 .editorconfig',\n  'drwxr-xr-x  8 ops ops 4096 Oct 13 09:40 .git',\n  '-rw-r--r--  1 ops ops 1532 Oct 11 16:27 README.md',\n  'drwxr-xr-x  4 ops ops 4096 Oct  9 10:03 docs',\n  '-rwxr-xr-x  1 ops ops 8721 Oct 13 08:55 deploy.sh',\n  'drwxr-xr-x  6 ops ops 4096 Oct 12 22:48 src',\n  'drwxr-xr-x  3 ops ops 4096 Oct 10 14:30 test',\n];\nconst CMD = 'ls -la ~/projects | lolcat -a';\nconst LCYCLE = 12;\n\n/** lolcat's rainbow: three phase-shifted sines. */\nconst lol = (i) => rgb(Math.sin(0.1 * i) * 127 + 128, Math.sin(0.1 * i + 2.094) * 127 + 128, Math.sin(0.1 * i + 4.189) * 127 + 128);\n\nconst lolcat = {\n  ...shared,\n  id: 'text-lolcat',\n  title: 'lolcat Output',\n  tags: ['lolcat', 'rainbow', 'terminal', 'pipe', 'ls'],\n  order: 130,\n  loop: LCYCLE,\n  size: { w: 64, h: 14 },\n  minSize: { w: 36, h: 8 },\n  description: 'A directory listing piped through lolcat: the classic diagonal rainbow of phase-shifted sines, flowing as with lolcat -a.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const cursor = asciiOnly(ctx) ? '_' : '▌';\n    return (t) => {\n      const b = ctx.buffer;\n      const tau = t % LCYCLE;\n      const typed = CMD.slice(0, Math.max(0, Math.floor((tau - 0.3) * 22)));\n      b.text(0, 0, '$ ', th.pos);\n      b.text(2, 0, typed, th.ink);\n      const doneTyping = typed.length === CMD.length;\n      const outStart = 0.3 + CMD.length / 22 + 0.3;\n      const shown = doneTyping ? Math.min(LISTING.length, Math.floor((tau - outStart) * 30)) : 0;\n      if (!doneTyping || shown < 0) b.text(2 + typed.length, 0, cursor, th.accent);\n      const rows = Math.min(shown, H - 2);\n      const first = Math.max(0, shown - (H - 2));\n      const os = 20 + t * 18;\n      for (let r = 0; r < rows; r++) {\n        const line = LISTING[first + r];\n        for (let x = 0; x < Math.min(line.length, W); x++) if (line[x] !== ' ') b.set(x, r + 1, line[x], mixOklab(lol(os + (first + r) + x / 3), th.ink, 0.12));\n      }\n      if (shown >= LISTING.length && rows + 1 < H) {\n        b.text(0, rows + 1, '$ ', th.pos);\n        if (Math.floor(tau / 0.53) % 2 === 0) b.text(2, rows + 1, cursor, th.accent);\n      }\n    };\n  },\n};\n\n// ── chrome ──────────────────────────────────────────────────────────────────\nconst chrome = {\n  ...shared,\n  id: 'text-chrome',\n  title: '80s Chrome',\n  tags: ['chrome', 'metal', '80s', 'glint', 'retro'],\n  order: 140,\n  loop: 5,\n  size: { w: 72, h: 16 },\n  minSize: { w: 36, h: 9 },\n  description: 'Polished-chrome lettering — sky above a hard horizon line, warm ground below — with a glint that slides across and a star-burst sparkle.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const mode = bannerMode(ctx, 'half');\n    const text = String(ctx.param('text', 'CHROME'));\n    const base0 = { mode, outline: mode !== 'ascii', letterSpacing: 3 };\n    const { font, scale } = fitType(text, base0, W - 2, H - 3);\n    const base = { ...base0, font };\n    const m = measureBanner(text, { ...base, scale });\n    const sky = mixOklab(th.info, 0xffffff, 0.75), mid = mixOklab(th.info, th.bg, 0.25), horizon = mixOklab(th.bg, 0x000000, 0.3);\n    const ground = mixOklab(th.warn, th.neg, 0.35), glow = mixOklab(th.warn, 0xffffff, 0.6);\n    const fill = (v) => (v < 0.5 ? mixOklab(sky, mid, v / 0.5) : v < 0.56 ? horizon : mixOklab(ground, glow, (v - 0.56) / 0.44));\n    const x0 = Math.floor((W - m.width) / 2), y0 = Math.max(0, Math.floor((H - m.height - 2) / 2));\n    const star = asciiOnly(ctx) ? ['+', '*'] : ['+', '✦'];\n    return (t) => {\n      const b = ctx.buffer;\n      const g = (t % 5) / 3.2 * 1.6 - 0.3;\n      drawBanner(b, x0, y0, text, {\n        ...base, scale, outline: base.outline && mix(th.ink, th.bg, 0.86), theme: th, direction: 'vertical',\n        fill,\n        shader: (c, u, v) => {\n          const d = Math.abs(u - g + (v - 0.5) * 0.35);\n          return d < 0.05 ? mixOklab(c, 0xffffff, 0.8 * (1 - d / 0.05)) : c;\n        },\n      });\n      // Sparkle where the glint leaves the top edge\n      const sp = (t % 5) - 1.2;\n      if (sp > 0 && sp < 0.6) {\n        const sx = x0 + Math.round(m.width * Math.min(0.95, Math.max(0.05, g + 0.1)));\n        b.set(sx, y0, sp < 0.3 ? star[1] : star[0], 0xffffff, undefined, 1);\n        if (sp < 0.3 && sx + 1 < W) { b.set(sx - 1, y0, '-', th.ink); b.set(sx + 1, y0, '-', th.ink); }\n      }\n      const sub = 'est. 1986';\n      if (y0 + m.height + 1 < H) b.text(Math.floor((W - sub.length) / 2), y0 + m.height + 1, sub, th.crit, undefined, 1);\n    };\n  },\n};\n\nexport default [neon, fireText, sweep, lolcat, chrome];\n",
  "prism-term/src/facets/text/motion.js": "// Motion — type that moves: a bobbing wave over its rippling reflection, a\n// demoscene sine scroller, a dot-matrix LED board and a big 7-segment clock.\nimport { NONE, mix, mixOklab } from '../../color.js';\nimport { bannerCanvas, layoutText } from '../../text/banner.js';\nimport { bannerMode, fitType, canvasLike } from './_text.js';\n\nconst shared = { gallery: 'text', category: 'Motion', kind: 'text', glyphs: 'unicode', color: 'truecolor', animated: true };\nconst asciiOnly = (ctx) => ctx.caps.unicode === false || ctx.mode === 'ascii';\n\n// ── wave + reflection ───────────────────────────────────────────────────────\nconst wave = {\n  ...shared,\n  id: 'text-wave',\n  title: 'Wave & Ripple',\n  tags: ['wave', 'ripple', 'reflection', 'water', 'bounce'],\n  order: 100,\n  loop: 6.283,\n  size: { w: 72, h: 20 },\n  minSize: { w: 36, h: 10 },\n  description: 'Letters ride a traveling sine wave while their reflection shimmers below in rippling water, rows sliding as the surface moves.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const mode = bannerMode(ctx, 'half');\n    const text = String(ctx.param('text', 'RIPPLE'));\n    const { font, scale } = fitType(text, { mode }, W - 2, Math.floor(H * 0.42));\n    const cfg0 = { font, mode };\n    const sy = Math.max(1, Array.isArray(scale) ? scale[1] : 1);\n    const amp = 2 * sy;\n    const cfg = { ...cfg0, scale, padY: amp };\n    const probe = bannerCanvas(text, cfg);\n    const cv = canvasLike(probe, W, H);\n    const ox = Math.floor((cv.width - probe.width) / 2);\n    const oy = Math.max(0, Math.floor(cv.height * 0.5) - probe.height);\n    const water = oy + probe.height;\n    return (t) => {\n      const b = ctx.buffer;\n      const src = bannerCanvas(text, {\n        ...cfg, fill: [th.info, th.accent, th.crit], theme: th, t, speed: 0.2,\n        charStyle: (ci) => ({ dy: Math.round(Math.sin(t * 3 - ci * 0.7) * amp) }),\n      });\n      cv.clear();\n      cv.draw(src, ox, oy);\n      // Surface line\n      for (let x = 0; x < cv.width; x++) cv.set(x, water, mix(th.info, th.bg, Math.sin(x * 0.45 - t * 4) > 0.6 ? 0.35 : 0.72));\n      // Reflection: rows flipped and squashed, each slid by the ripple, darkening with depth.\n      const depth = cv.height - water - 1;\n      for (let r = 1; r <= depth; r++) {\n        const srcY = src.height - 1 - r;\n        if (srcY < 0) break;\n        const dx = Math.round(Math.sin(r * 0.8 - t * 4) * (0.5 + r * 0.06));\n        const k = 0.5 + 0.4 * (r / Math.max(1, depth));\n        for (let x = 0; x < src.width; x++) {\n          const c = src.px[srcY * src.width + x];\n          if (c !== NONE) cv.set(x + ox + dx, water + r, mixOklab(mix(c, th.info, 0.3), th.bg, k));\n        }\n      }\n      cv.paint(b, 0, 0);\n    };\n  },\n};\n\n// ── sine scroller ───────────────────────────────────────────────────────────\nconst SCROLL = 'GREETINGS TO EVERYONE STILL CODING AT 3AM ... PRISM-TERM SAYS HI ... ' +\n  'THIS SCROLLER IS PURE TEXT, EVERY PIXEL A HALF BLOCK ... KEEP YOUR LOOPS TIGHT AND YOUR FRAMES UNDER 25 MS ... ';\n\nconst scroller = {\n  ...shared,\n  id: 'text-sine-scroller',\n  title: 'Sine Scroller',\n  tags: ['scroller', 'sine', 'demoscene', 'cracktro', 'greetings'],\n  order: 110,\n  loop: 30,\n  size: { w: 80, h: 16 },\n  minSize: { w: 36, h: 8 },\n  description: 'The demoscene classic: an endless greetings message scrolls past in big rainbow letters, every pixel column riding a sine curve.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const mode = bannerMode(ctx, 'half');\n    const text = String(ctx.param('text', SCROLL));\n    const font = H >= 12 ? 'bold' : 'tiny';\n    const src = bannerCanvas(text + text.slice(0, 30), { font, mode, fill: th.ink, theme: th });\n    const pal = ctx.palette('prism');\n    const span = bannerCanvas(text, { font, mode }).width;\n    const cv = canvasLike(src, W, H);\n    const amp = Math.max(1, Math.floor((cv.height - src.height) / 2) - 1);\n    const mid = Math.floor((cv.height - src.height) / 2);\n    const pxPerSec = 12 * (cv.width / W) * 1.6;\n    return (t) => {\n      const b = ctx.buffer;\n      cv.clear();\n      const off = Math.floor(t * pxPerSec) % span;\n      for (let x = 0; x < cv.width; x++) {\n        const sx = x + off;\n        if (sx >= src.width) continue;\n        const dy = Math.round(Math.sin(x * 0.09 + t * 2.4) * amp * 0.85 + Math.sin(x * 0.031 - t * 1.1) * amp * 0.15);\n        const c = pal((x / cv.width * 0.8 + t * 0.15) % 1);\n        for (let y = 0; y < src.height; y++) if (src.px[y * src.width + sx] !== NONE) cv.set(x, mid + y + dy, y === 0 ? mixOklab(c, 0xffffff, 0.4) : c);\n      }\n      // A soft copper line under the scroller\n      const cy = cv.height - 1;\n      for (let x = 0; x < cv.width; x++) cv.set(x, cy, mix(th.accent, th.bg, 0.55 + 0.35 * Math.abs(Math.sin(x * 0.05 + t))));\n      cv.paint(b, 0, 0);\n    };\n  },\n};\n\n// ── LED dot-matrix board ────────────────────────────────────────────────────\nconst LED_MSG = 'NEXT TRAIN  HARBOUR POINT  2 MIN   ***   PLEASE MIND THE GAP   ***   ';\n\nconst led = {\n  ...shared,\n  id: 'text-marquee',\n  title: 'LED Ticker Board',\n  tags: ['marquee', 'ticker', 'led', 'dot matrix', 'sign'],\n  order: 120,\n  loop: 24,\n  size: { w: 72, h: 11 },\n  minSize: { w: 30, h: 9 },\n  description: 'An amber dot-matrix sign: every LED is a cell, unlit ones faintly visible, and the message steps left one column at a time.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const ascii = asciiOnly(ctx);\n    const msg = String(ctx.param('text', LED_MSG)).toUpperCase();\n    const lay = layoutText(msg, { font: 'standard' });\n    const MW = lay.width + 1, MH = 7;\n    const bits = new Uint8Array(MW * MH);\n    for (const l of lay.lines) for (const { gl, x } of l.glyphs)\n      for (let gy = 0; gy < Math.min(MH, gl.h); gy++) for (let gx = 0; gx < gl.w; gx++) if (gl.bits[gy * gl.w + gx]) bits[gy * MW + x + gx] = 1;\n    const amber = th.warn, offC = mix(th.warn, th.bg, 0.82), glowBg = mix(th.warn, th.bg, 0.9);\n    const [on, off] = ascii ? ['O', '.'] : ['●', '·'];\n    const bw = W - 2, y0 = Math.max(1, Math.floor((H - MH - 2) / 2) + 1);\n    const box = ascii ? ['+', '+', '+', '+', '-', '|'] : ['╭', '╮', '╰', '╯', '─', '│'];\n    return (t) => {\n      const b = ctx.buffer;\n      const step = Math.floor(t * 12);\n      b.text(0, y0 - 1, box[0] + box[4].repeat(W - 2) + box[1], th.line);\n      b.text(0, y0 + MH, box[2] + box[4].repeat(W - 2) + box[3], th.line);\n      for (let y = 0; y < MH; y++) {\n        b.set(0, y0 + y, box[5], th.line);\n        b.set(W - 1, y0 + y, box[5], th.line);\n        for (let x = 0; x < bw; x++) {\n          const lit = bits[y * MW + ((x + step) % MW)];\n          b.set(1 + x, y0 + y, lit ? on : off, lit ? amber : offC, lit && !ascii ? glowBg : undefined);\n        }\n      }\n      if (y0 + MH + 1 < H) b.text(1, y0 + MH + 1, `PLATFORM 2   ${String(9 + Math.floor(t / 60)).padStart(2, '0')}:41`, th.dim);\n    };\n  },\n};\n\n// ── 7-segment clock ─────────────────────────────────────────────────────────\n//                 a  b  c  d  e  f  g\nconst SEGS = ['1111110', '0110000', '1101101', '1111001', '0110011', '1011011', '1011111', '1110000', '1111111', '1111011'];\n\nfunction hbar(cv, x0, x1, y, T, c) {\n  for (let r = 0; r < T; r++) {\n    const inset = T > 2 ? Math.min(r, T - 1 - r) : 0;\n    for (let x = x0 + inset; x <= x1 - inset; x++) cv.set(x, y + r, c);\n  }\n}\nfunction vbar(cv, x, y0, y1, T, c) {\n  for (let r = 0; r < T; r++) {\n    const inset = T > 2 ? Math.min(r, T - 1 - r) : 0;\n    for (let y = y0 + inset; y <= y1 - inset; y++) cv.set(x + r, y, c);\n  }\n}\n/** Draw one 7-segment digit; unlit segments in `off` (LCD ghosting). */\nfunction digit(cv, d, x, y, w, h, T, on, off) {\n  const s = SEGS[d] || SEGS[8];\n  const mid = y + Math.floor((h - T) / 2);\n  const col = (i) => (s[i] === '1' ? on : off);\n  hbar(cv, x + 1, x + w - 2, y, T, col(0));                    // a\n  vbar(cv, x + w - T, y + 1, mid - 1, T, col(1));              // b\n  vbar(cv, x + w - T, mid + T, y + h - 2, T, col(2));          // c\n  hbar(cv, x + 1, x + w - 2, y + h - T, T, col(3));            // d\n  vbar(cv, x, mid + T, y + h - 2, T, col(4));                  // e\n  vbar(cv, x, y + 1, mid - 1, T, col(5));                      // f\n  hbar(cv, x + 1, x + w - 2, mid, T, col(6));                  // g\n}\n\nconst clock = {\n  ...shared,\n  id: 'text-clock',\n  title: 'Big Digital Clock',\n  tags: ['clock', '7 segment', 'digital', 'time', 'lcd'],\n  order: 130,\n  loop: 60,\n  size: { w: 72, h: 16 },\n  minSize: { w: 36, h: 8 },\n  description: 'A big seven-segment clock — HH:MM with a blinking colon, small seconds, faint unlit segments like a real LCD, and the date.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const cv = ctx.canvas('field');\n    const FW = cv.width, FH = cv.height;\n    const rows = H > 10 ? 2 : 1;\n    const capH = Math.ceil((rows * FH) / H);\n    const gap = FW < 60 ? 1 : Math.max(2, Math.round(FW / 40));\n    // Digit width with the small seconds; narrow screens drop them so HH:MM stays big.\n    const fitW = (sec) => Math.floor((FW - 8 - gap * (sec ? 6 : 4)) / (sec ? 5.2 : 4));\n    let dw = fitW(true) >= 6 ? fitW(true) : fitW(false);\n    let dh = Math.min(FH - capH - 2, Math.round(dw * 1.75));\n    dw = Math.min(dw, Math.round(dh / 1.75));\n    const T = Math.max(1, Math.round(dw / 5));\n    const sw = Math.max(3, Math.round(dw * 0.6)), sh = Math.max(5, Math.round(dh * 0.6)), sT = T <= 2 ? T : Math.round(T * 0.7);\n    const colonW = T * 2 + 2;\n    const hm = dw * 4 + gap * 4 + colonW, withSec = hm + gap * 2 + sw * 2;\n    const showSec = withSec <= FW;\n    const total = showSec ? withSec : hm - gap;\n    const x0 = Math.max(0, Math.floor((FW - total) / 2)), y0 = Math.max(0, Math.floor((FH - dh - capH) / 2));\n    const on = th.accent, off = mix(th.accent, th.bg, 0.9), sOn = th.info, sOff = mix(th.info, th.bg, 0.9);\n    const start = 9 * 3600 + 41 * 60;\n    const DAYS = ['TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN', 'MON'];\n    return (t) => {\n      const b = ctx.buffer;\n      cv.clear();\n      const s = Math.floor(start + t);\n      const hh = Math.floor(s / 3600) % 24, mm = Math.floor(s / 60) % 60, ss = s % 60;\n      const h12 = hh % 12 || 12;\n      const ds = [Math.floor(h12 / 10), h12 % 10, Math.floor(mm / 10), mm % 10];\n      let x = x0;\n      ds.forEach((d, i) => {\n        if (i === 0 && d === 0) digit(cv, 8, x, y0, dw, dh, T, off, off); // leading zero stays dark\n        else digit(cv, d, x, y0, dw, dh, T, on, off);\n        x += dw + gap;\n        if (i === 1) {\n          const lit = (t % 1) < 0.5 ? on : off;\n          for (const fy of [0.3, 0.7]) for (let r = 0; r < T; r++) for (let c = 0; c < T; c++) cv.set(x + 1 + c, y0 + Math.round(dh * fy) - (T >> 1) + r, lit);\n          x += colonW + gap;\n        }\n      });\n      x += gap;\n      const syy = y0 + dh - sh;\n      if (showSec) {\n        digit(cv, Math.floor(ss / 10), x, syy, sw, sh, sT, sOn, sOff);\n        digit(cv, ss % 10, x + sw + gap, syy, sw, sh, sT, sOn, sOff);\n      }\n      cv.paint(b, 0, 0);\n      // Captions in cells: AM/PM over the seconds, the date underneath.\n      const cellX = (px) => Math.floor(px / cv.sx);\n      const ampm = hh < 12 ? 'AM' : 'PM';\n      const ay = Math.floor(y0 / cv.sy);\n      if (showSec) b.text(Math.min(W - 2, cellX(x)), ay, ampm, th.ink, undefined, 1);\n      const date = `${DAYS[Math.floor(s / 86400) % 7]} 13 OCT 2026`;\n      const dy = Math.min(H - 1, Math.ceil((y0 + dh) / cv.sy) + (rows > 1 ? 1 : 0));\n      if (dy < H && dy * cv.sy >= y0 + dh) b.text(Math.floor((W - date.length) / 2), dy, date, th.muted);\n    };\n  },\n};\n\nexport default [wave, scroller, led, clock];\n",
  "prism-term/src/facets/text/reveal.js": "// Reveal & Type — text that arrives: a human-paced typewriter, big-type\n// type/delete loops, decrypts, digital-rain reveals, letters that drop in\n// under gravity, a split-flap departures board and four scramble styles.\nimport { mix, mixOklab } from '../../color.js';\nimport { drawBanner, measureBanner } from '../../text/banner.js';\nimport { bannerMode, fitFont, hash } from './_text.js';\n\nconst shared = { gallery: 'text', category: 'Reveal & Type', kind: 'text', glyphs: 'unicode', color: 'truecolor', animated: true };\nconst asciiOnly = (ctx) => ctx.caps.unicode === false || ctx.mode === 'ascii';\n\n// ── typewriter with a human rhythm ──────────────────────────────────────────\nconst NOTE = [\n  'Dear night shift,',\n  '',\n  'The migration finished at 03:12 UTC and nothing was lost.',\n  'Cache warm-up ran long, so next time we pre-warm the',\n  'edge nodes first and keep the coffee closer.',\n  '',\n  'Thanks for watching the graphs with me.',\n  '  -- R.',\n];\n// Neighbouring keys on a QWERTY row, for believable typos.\nconst NEIGHBOR = { a: 's', s: 'd', d: 'f', e: 'r', r: 't', t: 'y', i: 'o', o: 'p', n: 'm', h: 'j', l: 'k', c: 'v', g: 'h', u: 'i', w: 'e', m: 'n' };\n// Common English bigrams roll off the fingers faster.\nconst FAST = new Set('th he in er an re on at en nd ti es or te of ed is it al ar st to nt ng se ha as ou io le ve co me de hi ri ro ic ne ea ra ce'.split(' '));\n\nfunction typingSchedule(text, rng) {\n  const ev = []; // [time, op, ch] — op: 'c' type, 'b' backspace, 'n' newline\n  let t = 0.8;\n  for (let i = 0; i < text.length; i++) {\n    const ch = text[i], prev = text[i - 1] || ' ';\n    if (ch === '\\n') { t += 0.3 + rng() * 0.45; ev.push([t, 'n']); t += 0.2; continue; }\n    let d = 0.05 + rng() * 0.065;\n    if (FAST.has((prev + ch).toLowerCase())) d *= 0.55;\n    if (prev === ' ') d += rng() < 0.18 ? 0.25 + rng() * 0.4 : 0.03; // a pause to think of the next word\n    if (/[.,;:]/.test(prev)) d += 0.16 + rng() * 0.22;\n    if (/[A-Z0-9:]/.test(ch)) d += 0.05; // reach for shift / the number row\n    t += d;\n    const wrong = NEIGHBOR[ch.toLowerCase()];\n    if (wrong && rng() < 0.04) {\n      ev.push([t, 'c', wrong]);\n      let extra = 0;\n      if (rng() < 0.5 && text[i + 1] && text[i + 1] !== '\\n') { t += 0.07; ev.push([t, 'c', text[i + 1]]); extra = 1; }\n      t += 0.28 + rng() * 0.25; // notice…\n      for (let k = 0; k <= extra; k++) { ev.push([t, 'b']); t += 0.085; }\n      t += 0.1;\n    }\n    ev.push([t, 'c', ch]);\n  }\n  return { ev, end: t };\n}\n\nfunction wrapHard(s, w) {\n  if (s.length <= w) return [s];\n  const out = [];\n  for (let i = 0; i < s.length; i += w) out.push(s.slice(i, i + w));\n  return out;\n}\n\nconst typewriter = {\n  ...shared,\n  id: 'text-typewriter',\n  title: 'Typewriter, Human Rhythm',\n  tags: ['typewriter', 'typing', 'cursor', 'blink', 'realistic'],\n  order: 100,\n  loop: 30,\n  size: { w: 72, h: 16 },\n  minSize: { w: 40, h: 10 },\n  description: 'A note typed at human speed — quick bigrams, pauses between words, the odd typo backspaced — with a cursor that blinks at rest.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th, rng } = ctx;\n    const ascii = asciiOnly(ctx);\n    const { ev, end } = typingSchedule(NOTE.join('\\n'), rng);\n    const period = end + 3.5;\n    const cursor = ascii ? '_' : '▌';\n    const rule = ascii ? '-' : '─';\n    return (t) => {\n      const b = ctx.buffer;\n      const tau = t % period;\n      const lines = [''];\n      let keys = 0, bs = 0, last = 0, typed = 0;\n      for (const [et, op, ch] of ev) {\n        if (et > tau) break;\n        keys++; last = et;\n        if (op === 'c') { lines[lines.length - 1] += ch; typed++; } else if (op === 'b') { lines[lines.length - 1] = lines[lines.length - 1].slice(0, -1); bs++; typed--; } else lines.push('');\n      }\n      // Header and status\n      b.text(1, 0, 'note.txt', th.ink, undefined, 1);\n      const wpm = tau > 2 ? Math.round(typed / 5 / ((Math.min(tau, end) - 0.8) / 60)) : 0;\n      const right = `${wpm} wpm`;\n      b.text(W - right.length - 1, 0, right, th.accent);\n      b.text(0, 1, rule.repeat(W), th.line);\n      const vis = [];\n      for (const l of lines) vis.push(...wrapHard(l, W - 4));\n      const rows = H - 3;\n      const first = Math.max(0, vis.length - rows);\n      for (let r = first; r < vis.length; r++) b.text(2, 2 + r - first, vis[r], th.ink);\n      // Cursor: solid while keys are flying, blinking (530 ms) once idle.\n      const idle = tau - last;\n      const on = tau < end + 2.6 && (idle < 0.5 || Math.floor(idle / 0.53) % 2 === 0);\n      const cl = vis.length - 1 - first;\n      if (on) b.text(2 + vis[vis.length - 1].length, 2 + cl, cursor, th.accent);\n      const pos = `ln ${lines.length}, col ${lines[lines.length - 1].length + 1}`;\n      b.text(1, H - 1, `${pos}   keys ${keys}   backspaces ${bs}`.slice(0, W - 2), th.dim);\n    };\n  },\n};\n\n// ── big type / delete loop ──────────────────────────────────────────────────\nconst WORDS = ['HELLO', 'TYPE IT', 'DELETE', 'REPEAT'];\n\nconst typeBanner = {\n  ...shared,\n  id: 'text-type-banner',\n  title: 'Big Type & Delete',\n  tags: ['typewriter', 'banner', 'cursor', 'hero', 'loop'],\n  order: 110,\n  loop: 18,\n  size: { w: 72, h: 14 },\n  minSize: { w: 36, h: 8 },\n  description: 'Big letters typed in one keystroke at a time behind a blinking block cursor, held, deleted back to nothing, then the next word.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const mode = bannerMode(ctx, 'half');\n    const longest = WORDS.reduce((a, w) => (w.length > a.length ? w : a), '');\n    const { font, scale } = fitFont(longest + 'M', ['bold', 'standard', 'tiny'], { mode }, W - 2, H - 2);\n    const cfg = { font, mode, scale };\n    // Timeline: [time, visible chars] for each word.\n    const ev = [];\n    let t0 = 0.3;\n    WORDS.forEach((w, wi) => {\n      for (let i = 1; i <= w.length; i++) { t0 += 0.09 + hash(wi, i) * 0.16 + (w[i - 1] === ' ' ? 0.15 : 0); ev.push([t0, i, wi]); }\n      t0 += 1.6;\n      for (let i = w.length - 1; i >= 0; i--) { t0 += 0.045; ev.push([t0, i, wi]); }\n      t0 += 0.45;\n    });\n    const period = t0;\n    return (t) => {\n      const b = ctx.buffer;\n      const tau = t % period;\n      let n = 0, wi = 0, last = 0;\n      for (const e of ev) { if (e[0] > tau) { wi = e[2]; break; } n = e[1]; wi = e[2]; last = e[0]; }\n      const word = WORDS[wi];\n      const full = measureBanner(word + 'M', cfg);\n      const x = Math.max(0, Math.floor((W - full.width) / 2)), y = Math.max(0, Math.floor((H - full.height) / 2));\n      const idle = tau - last;\n      const blinkOn = idle < 0.35 || Math.floor(idle / 0.5) % 2 === 0;\n      const text = n ? word.slice(0, n) : ' ';\n      drawBanner(b, x, y, text, { ...cfg, fill: [th.accent, th.info], theme: th, t, speed: 0.1, cursorAt: blinkOn ? (n ? n : 0) : undefined, cursorColor: th.ink });\n    };\n  },\n};\n\n// ── decrypt ─────────────────────────────────────────────────────────────────\nconst SCRAMBLE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#$%&*+=?@<>';\nconst SECRETS = ['ENCRYPTED', 'HANDSHAKE', 'VERIFIED'];\nconst DSEG = 5;\n\nconst decrypt = {\n  ...shared,\n  id: 'text-decrypt',\n  title: 'Decrypt Reveal',\n  tags: ['decrypt', 'scramble', 'hacker', 'reveal', 'cipher'],\n  order: 120,\n  loop: DSEG * SECRETS.length,\n  size: { w: 80, h: 16 },\n  minSize: { w: 36, h: 9 },\n  description: 'Fixed-pitch big letters churn through random glyphs and lock in left to right, with a key fingerprint and progress bar resolving below.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const mode = bannerMode(ctx, 'half');\n    const ascii = asciiOnly(ctx);\n    const n = Math.max(...SECRETS.map((s) => s.length));\n    // Fixed pitch so scrambling glyphs never shift their neighbors.\n    let pick = null;\n    for (const font of ['bold', 'standard', 'tiny']) {\n      for (let scale = 4; scale >= 1; scale--) {\n        const cw = measureBanner('W', { font, mode, scale }).width, pitch = measureBanner('WW', { font, mode, scale }).width - cw;\n        const h = measureBanner('W', { font, mode, scale }).height;\n        if (pitch * n <= W - 2 && h <= H - 5) { pick = { font, scale, cw, pitch, h }; break; }\n      }\n      if (pick) break;\n    }\n    pick ||= { font: 'tiny', scale: 1, cw: 3, pitch: 4, h: measureBanner('W', { font: 'tiny', mode }).height };\n    const { font, scale, cw, pitch, h } = pick;\n    const KEY = '7f:3a:91:c4:0e:b2:58:d6';\n    return (t) => {\n      const b = ctx.buffer;\n      const k = Math.floor(t / DSEG) % SECRETS.length, tau = t % DSEG;\n      const word = SECRETS[k];\n      const x0 = Math.floor((W - (pitch * word.length - (pitch - cw))) / 2), y0 = Math.max(0, Math.floor((H - h - 3) / 2));\n      let locked = 0;\n      for (let i = 0; i < word.length; i++) {\n        const lockAt = 0.3 + i * 0.16 + hash(i, k) * 0.35;\n        const relockAt = DSEG - 0.6 + (word.length - i) * 0.03;\n        const solved = tau >= lockAt && tau < relockAt;\n        if (solved) locked++;\n        const ch = solved ? word[i] : SCRAMBLE[Math.floor(hash(i, Math.floor(t * 16), k) * SCRAMBLE.length)];\n        const flash = solved && tau - lockAt < 0.18;\n        const fill = solved ? (flash ? th.ink : [th.pos, th.info]) : mix(th.pos, th.bg, 0.35 + 0.35 * hash(i, Math.floor(t * 9)));\n        const gw = measureBanner(ch, { font, mode, scale }).width;\n        drawBanner(b, x0 + i * pitch + Math.floor((cw - gw) / 2), y0, ch, { font, mode, scale, fill, direction: 'vertical', theme: th });\n      }\n      // Fingerprint + progress\n      const p = locked / word.length;\n      const y1 = y0 + h + 1;\n      const keyShown = [...KEY].map((c, i) => (c === ':' || i / KEY.length < p ? c : '0123456789abcdef'[Math.floor(hash(i, Math.floor(t * 20)) * 16)])).join('');\n      const line = `key ${keyShown}`;\n      if (y1 < H) b.text(Math.floor((W - line.length) / 2), y1, line, p >= 1 ? th.ink : th.muted);\n      const bw = Math.min(30, W - 12), f = Math.round(bw * p);\n      const bar = ascii ? `[${'#'.repeat(f)}${'.'.repeat(bw - f)}]` : `${'█'.repeat(f)}${'░'.repeat(bw - f)}`;\n      const status = `${bar} ${String(Math.round(p * 100)).padStart(3)}%`;\n      if (y1 + 1 < H) b.text(Math.floor((W - status.length) / 2), y1 + 1, status, p >= 1 ? th.pos : th.accent);\n    };\n  },\n};\n\n// ── matrix reveal ───────────────────────────────────────────────────────────\nconst KANA = 'ｦｧｨｩｪｫｬｭｮｯｱｲｳｴｵｶｷｸｹｺｻｼｽｾｿﾀﾁﾂﾃﾄﾅﾆﾇﾈﾉﾊﾋﾌﾍﾎﾏﾐﾑﾒﾓﾔﾕﾖﾗﾘﾙﾚﾛﾜﾝ0123456789';\nconst RAIN_ASCII = '0123456789ABCDEFXZ$+-*/=%<>#&@';\nconst MESSAGE = ['wake up, operator...', 'the build has you.', 'follow the green prompt.'];\nconst MCYCLE = 12;\n\nconst matrixReveal = {\n  ...shared,\n  id: 'text-matrix-reveal',\n  title: 'Digital Rain Reveal',\n  tags: ['matrix', 'digital rain', 'reveal', 'katakana', 'message'],\n  order: 130,\n  loop: MCYCLE,\n  size: { w: 80, h: 24 },\n  minSize: { w: 30, h: 10 },\n  description: 'Green code rain pours down; wherever a falling head crosses the hidden message, that letter locks in bright and stays.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th, rng } = ctx;\n    const glyphs = asciiOnly(ctx) ? RAIN_ASCII : KANA;\n    const cols = Array.from({ length: W }, () => ({ speed: 7 + rng() * 14, off: rng() * 3.2, len: 6 + Math.floor(rng() * H * 0.7), gap: 0.3 + rng() * 1.5 }));\n    const lines = MESSAGE.map((l) => (l.length > W - 2 ? l.slice(0, W - 2) : l));\n    const my = Math.floor(H / 2) - Math.floor(lines.length / 2) - 1;\n    const msg = new Map();\n    lines.forEach((l, r) => {\n      const x0 = Math.floor((W - l.length) / 2);\n      for (let i = 0; i < l.length; i++) if (l[i] !== ' ') msg.set((my + r * 2) * W + x0 + i, l[i]);\n    });\n    const head = mixOklab(th.pos, th.ink, 0.7);\n    return (t) => {\n      const b = ctx.buffer;\n      const tau = t % MCYCLE;\n      for (let x = 0; x < W; x++) {\n        const c = cols[x];\n        const per = (H + c.len) / c.speed + c.gap;\n        const k0 = Math.floor((tau - c.off) / per);\n        for (let k = Math.max(0, k0 - 1); k <= k0; k++) {\n          const hy = Math.floor((tau - c.off - k * per) * c.speed);\n          for (let j = 0; j < c.len; j++) {\n            const y = hy - j;\n            if (y < 0 || y >= H) continue;\n            const g = glyphs[Math.floor(hash(x, y, Math.floor(t * (j === 0 ? 14 : 2)) + j) * glyphs.length)];\n            const fade = j / c.len;\n            b.set(x, y, g, j === 0 ? head : mix(th.pos, th.bg, Math.min(0.85, fade * 0.95)), undefined, j === 0 ? 1 : 0);\n          }\n        }\n        // Locked message cells in this column\n        for (let y = 0; y < H; y++) {\n          const ch = msg.get(y * W + x);\n          if (!ch) continue;\n          const lockAt = c.off + (y + 1) / c.speed;\n          const unlockAt = MCYCLE - 2.2 + hash(x, y) * 1.6;\n          if (tau < lockAt || tau > unlockAt) continue;\n          const fresh = tau - lockAt < 0.3;\n          b.set(x, y, ch, fresh ? th.ink : mixOklab(th.pos, th.ink, 0.35), th.bg, 1);\n        }\n      }\n    };\n  },\n};\n\n// ── falling letters ─────────────────────────────────────────────────────────\nconst DROPS = ['GRAVITY', 'BOUNCE', 'RAIN', 'TUMBLE'];\nconst FSEG = 5;\n\n/** Height above rest (px) of a body dropped from h0 with gravity g and restitution e, tau seconds after release. */\nfunction bounceHeight(tau, h0, g, e = 0.42) {\n  const t1 = Math.sqrt((2 * h0) / g);\n  if (tau < t1) return h0 - 0.5 * g * tau * tau;\n  tau -= t1;\n  let v = e * g * t1;\n  while (v > 4) {\n    const flight = (2 * v) / g;\n    if (tau < flight) return v * tau - 0.5 * g * tau * tau;\n    tau -= flight; v *= e;\n  }\n  return 0;\n}\n\nconst falling = {\n  ...shared,\n  id: 'text-falling-letters',\n  title: 'Falling Letters',\n  tags: ['falling', 'gravity', 'bounce', 'rain', 'physics'],\n  order: 140,\n  loop: FSEG * DROPS.length,\n  size: { w: 72, h: 18 },\n  minSize: { w: 36, h: 10 },\n  description: 'Big letters rain down one after another, bounce to rest under gravity, hold, then drop away through the floor for the next word.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th, rng } = ctx;\n    const mode = bannerMode(ctx, 'half');\n    const sy = { braille: 4, sextant: 3, quad: 2, half: 2, ascii: 1 }[mode] || 1;\n    const longest = DROPS.reduce((a, w) => (w.length > a.length ? w : a), '');\n    const { font, scale } = fitFont(longest, ['bold', 'standard', 'tiny'], { mode }, W - 2, Math.floor(H * 0.55));\n    const textRows = measureBanner(longest, { font, mode, scale }).height;\n    const padY = Math.max(1, Math.ceil((H - textRows) / 2)) * sy;\n    const restPx = padY + 7 * scale; // rest → just above the canvas top\n    const g = (2 * restPx) / (0.55 * 0.55);\n    const drizzle = Array.from({ length: Math.round(W / 3) }, () => ({ x: Math.floor(rng() * W), sp: 6 + rng() * 10, off: rng() * 10, ch: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[Math.floor(rng() * 26)] }));\n    return (t) => {\n      const b = ctx.buffer;\n      for (const d of drizzle) {\n        const y = Math.floor((d.off + t * d.sp) % (H + 4)) - 2;\n        b.set(d.x, y, d.ch.toLowerCase(), mix(th.dim, th.bg, 0.2));\n      }\n      const k = Math.floor(t / FSEG) % DROPS.length, tau = t % FSEG;\n      const word = DROPS[k];\n      const style = (ci) => {\n        const t0 = 0.08 + ci * 0.11;\n        if (tau < t0) return { hidden: true };\n        const exit = 3.7 + ci * 0.07;\n        if (tau > exit) { const d = 0.5 * g * (tau - exit) ** 2; return d > padY * 2 + textRows * sy ? { hidden: true } : { dy: Math.round(d) }; }\n        return { dy: -Math.round(bounceHeight(tau - t0, restPx, g)) };\n      };\n      const m = measureBanner(word, { font, mode, scale, padY });\n      drawBanner(b, Math.floor((W - m.width) / 2), Math.floor((H - m.height) / 2), word, { font, mode, scale, padY, fill: [th.warn, th.crit], direction: 'vertical', theme: th, charStyle: style });\n    };\n  },\n};\n\n// ── split-flap board ────────────────────────────────────────────────────────\nconst FLAPS = ' ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:.-';\nconst PAGES = [\n  ['09:41 LISBON      A12 BOARDING ', '09:55 REYKJAVIK   B03 ON TIME  ', '10:10 OSAKA       C07 DELAYED  ', '10:25 NAIROBI     A04 ON TIME  ', '10:40 VALPARAISO  D11 CANCELLED'],\n  ['10:55 TALLINN     B09 ON TIME  ', '11:05 HOBART      C02 BOARDING ', '11:20 MARRAKESH   A07 ON TIME  ', '11:35 QUEBEC      D04 DELAYED  ', '11:50 ULAANBAATAR B12 ON TIME  '],\n];\nconst SSEG = 7;\n\nconst splitFlap = {\n  ...shared,\n  id: 'text-split-flap',\n  title: 'Split-Flap Board',\n  tags: ['split flap', 'solari', 'departures', 'board', 'airport'],\n  order: 150,\n  loop: SSEG * PAGES.length,\n  size: { w: 66, h: 14 },\n  minSize: { w: 34, h: 9 },\n  description: 'A departures board whose tiles riffle through the alphabet one flap at a time until every row settles on its new flight.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const cols = PAGES[0][0].length;\n    const tile = W >= cols * 2 + 2 ? 2 : 1;\n    const x0 = Math.max(0, Math.floor((W - cols * tile) / 2));\n    const rows = Math.min(PAGES[0].length, Math.max(1, Math.floor((H - 3) / (H >= 13 ? 2 : 1))));\n    const step = H >= 13 ? 2 : 1;\n    const idx = (c) => Math.max(0, FLAPS.indexOf(c));\n    const statusColor = (s) => (/BOARD/.test(s) ? th.pos : /DELAY/.test(s) ? th.warn : /CANCEL/.test(s) ? th.neg : th.ink);\n    return (t) => {\n      const b = ctx.buffer;\n      const p = Math.floor(t / SSEG) % PAGES.length, tau = t % SSEG;\n      const from = PAGES[(p + PAGES.length - 1) % PAGES.length], to = PAGES[p];\n      b.text(x0, 0, 'DEPARTURES', th.warn, undefined, 1);\n      const clock = to[0].slice(0, 5);\n      b.text(x0 + cols * tile - clock.length, 0, clock, th.muted);\n      for (const [c, label] of [[0, 'TIME'], [6, tile === 2 ? 'DESTINATION' : 'DEST'], [18, tile === 2 ? 'GATE' : 'GT'], [22, 'STATUS']]) b.text(x0 + c * tile, 1, label, th.dim);\n      for (let r = 0; r < rows; r++) {\n        const y = 2 + r * step;\n        for (let c = 0; c < cols; c++) {\n          const a = idx(from[r][c]), z = idx(to[r][c]);\n          const dist = (z - a + FLAPS.length) % FLAPS.length;\n          const start = 0.2 + r * 0.25 + c * 0.03 + hash(r, c) * 0.3;\n          const flips = Math.max(0, Math.min(dist, Math.floor((tau - start) * 20)));\n          const ch = FLAPS[(a + flips) % FLAPS.length];\n          const moving = flips < dist && tau >= start;\n          const fg = moving ? th.muted : c >= 22 ? statusColor(to[r].slice(22)) : c < 5 ? th.warn : th.ink;\n          const bg = moving ? th.panel2 : th.panel;\n          b.set(x0 + c * tile, y, ch, fg, bg, moving ? 0 : 1);\n        }\n      }\n    };\n  },\n};\n\n// ── scramble styles ─────────────────────────────────────────────────────────\nconst STYLES = [\n  { name: 'glyphs', set: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ#$%&*?' },\n  { name: 'binary', set: '01' },\n  { name: 'hex', set: '0123456789abcdef' },\n  { name: 'blocks', set: '░▒▓█', ascii: '.:#@' },\n];\nconst PHRASES = [\n  ['root cause: pool exhausted', 'retrying in 3 seconds', 'all checks passed', 'deploy window open'],\n  ['00101 handshake ok', 'session key rotated', 'payload verified', 'tunnel established'],\n  ['sha 9f3c...e1a0 match', 'block 4.2M sealed', 'index rebuilt', 'cache primed'],\n  ['signal acquired', 'locking frequency', 'channel 7 clear', 'transmission ends'],\n];\n\nconst scramble = {\n  ...shared,\n  id: 'text-scramble',\n  title: 'Scramble Styles',\n  tags: ['scramble', 'decode', 'morph', 'binary', 'hex'],\n  order: 160,\n  loop: 16,\n  size: { w: 60, h: 12 },\n  minSize: { w: 34, h: 8 },\n  description: 'Four ways to scramble a line — glyph noise, binary, hex and shaded blocks — each morphing phrase to phrase, char by char.',\n  setup(ctx) {\n    const { width: W, height: H, theme: th } = ctx;\n    const ascii = asciiOnly(ctx);\n    const labelW = W >= 48 ? 9 : 0;\n    const step = H >= 11 ? 2 : 1;\n    const top = Math.max(0, Math.floor((H - STYLES.length * step) / 2));\n    return (t) => {\n      const b = ctx.buffer;\n      STYLES.forEach((st, r) => {\n        const y = top + r * step;\n        if (y >= H) return;\n        const set = ascii && st.ascii ? st.ascii : st.set;\n        const tt = t + r * 0.9;\n        const k = Math.floor(tt / 4), tau = tt % 4;\n        const from = PHRASES[r][(k + 3) % 4], to = PHRASES[r][k % 4];\n        const n = Math.max(from.length, to.length);\n        if (labelW) b.text(1, y, st.name, th.dim);\n        for (let i = 0; i < Math.min(n, W - labelW - 2); i++) {\n          const s0 = hash(i, r, k) * 0.9, s1 = s0 + 0.25 + hash(i, r + 9, k) * 0.7;\n          let ch, fg;\n          if (tau < s0) { ch = from[i] || ' '; fg = th.muted; }\n          else if (tau < s1) { ch = set[Math.floor(hash(i, Math.floor(t * 18), r) * set.length)]; fg = r === 1 ? th.pos : r === 2 ? th.info : r === 3 ? th.warn : th.accent; }\n          else { ch = to[i] || ' '; fg = th.ink; }\n          b.set(labelW + 1 + i, y, ch, fg);\n        }\n      });\n    };\n  },\n};\n\nexport default [typewriter, typeBanner, decrypt, matrixReveal, falling, splitFlap, scramble];\n",
  "prism-term/src/facets/text/textfx.js": "// Text effects showcase: every effect in src/text/effects.js, applied to big\n// banner text and to a plain line of log-style text, with captions.\nimport { MODES } from '../../canvas.js';\nimport { measureBanner } from '../../text/banner.js';\nimport { EFFECTS } from '../../text/effects.js';\n\nconst SEGMENT = 4.2;\nconst ORDER = ['decrypt', 'typewriter', 'wave', 'rainbow', 'shimmer', 'glitch', 'fadeIn', 'pulse', 'marquee', 'wipe'];\n\nexport default {\n  title: 'Text Effects',\n  gallery: 'text',\n  category: 'Reveal & Type',\n  kind: 'scene',\n  tags: ['text effects', 'typewriter', 'decrypt', 'glitch'],\n  order: 20,\n  description: 'Decrypt, typewriter, wave, rainbow, shimmer, glitch, fade, pulse, marquee and wipe — on big and small text.',\n  canvas: 'field',\n  palette: 'prism',\n  setup(ctx) {\n    const { width: W, height: H, theme: th, caps } = ctx;\n    const big = String(ctx.param('text', 'PRISM'));\n    const line = String(ctx.param('line', 'root cause: connection pool exhausted on db-primary (p99 2.4s)'));\n    const unicode = caps.unicode !== false;\n    const mode = ctx.mode || (unicode ? 'half' : 'ascii');\n    const font = 'bold';\n    const sy = MODES[mode]?.sy || 1;\n\n    const shadow = mode !== 'braille';\n    let scale = 1;\n    for (let s = 1; s <= 6; s++) {\n      const m = measureBanner(big, { font, mode, scale: s, shadow });\n      if (m.width <= W - 6 && m.height <= Math.max(3, H - 10)) scale = s;\n      else break;\n    }\n    const size = measureBanner(big, { font, mode, scale, shadow });\n    const waveRows = Math.ceil((2 * scale) / sy);\n    const bigY = Math.max(2, Math.floor((H - size.height) / 2) - 2);\n    const lineY = Math.min(H - 3, bigY + size.height + waveRows + 1);\n    const pal = ctx.palette();\n    const bigFill = ctx.params.palette || 'prism';\n\n    return (t) => {\n      const buf = ctx.buffer;\n      const i = Math.floor(t / SEGMENT) % ORDER.length;\n      const lt = t - Math.floor(t / SEGMENT) * SEGMENT;\n      const name = ORDER[i];\n      const fx = EFFECTS[name];\n      const common = { theme: th, ascii: !unicode };\n\n      // Header: title + progress dots.\n      buf.text(2, 0, 'TEXT EFFECTS', th.muted);\n      let dx = W - 2 - ORDER.length * 2;\n      for (let k = 0; k < ORDER.length; k++) {\n        buf.text(dx, 0, k === i ? (unicode ? '●' : '*') : (unicode ? '·' : '.'), k === i ? pal(k / ORDER.length) : th.dim);\n        dx += 2;\n      }\n\n      // Big text.\n      const bigOpts = { ...common, font, mode, scale, fill: bigFill, shadow };\n      if (name === 'typewriter') bigOpts.cps = Math.max(2, big.length / 1.6);\n      if (name === 'decrypt') bigOpts.duration = 1.8;\n      if (name === 'fadeIn') bigOpts.duration = 1.6;\n      if (name === 'wipe') bigOpts.duration = 1.2;\n      if (name === 'shimmer' || name === 'pulse') bigOpts.fill = undefined;\n      if (name === 'marquee') fx.fn(buf, 0, bigY, big + '  *', lt, { ...bigOpts, speed: 18, gap: 8, width: W });\n      else {\n        fx.fn(buf, 'center', bigY - (name === 'wave' ? waveRows : 0), big, lt, bigOpts);\n      }\n\n      // Small text line.\n      const lineOpts = { ...common, color: th.ink };\n      if (name === 'typewriter') lineOpts.cps = 30;\n      if (name === 'decrypt') lineOpts.duration = 1.6;\n      if (name === 'rainbow') lineOpts.spread = 0.6;\n      if (name === 'wave') lineOpts.fill = 'candy';\n      if (name === 'marquee') fx.fn(buf, 4, lineY, line, lt, { ...lineOpts, width: W - 8, speed: 14 });\n      else fx.fn(buf, 'center', lineY, line, lt, lineOpts);\n\n      // Caption.\n      const cap = `${name}`;\n      const desc = fx.description;\n      const cy = H - 1;\n      const cx = Math.max(1, Math.floor((W - cap.length - desc.length - 3) / 2));\n      const end = buf.text(cx, cy, cap, th.accent);\n      buf.text(end, cy, ' — ' + desc, th.muted);\n    };\n  },\n};\n",
  "prism-term/src/math3d.js": "// Minimal 3D toolkit for wireframes and shaded meshes: vectors as [x,y,z]\n// arrays, rotation, perspective projection onto a Canvas, and a few meshes.\n\nexport const v3 = {\n  add: (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]],\n  sub: (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]],\n  scale: (a, k) => [a[0] * k, a[1] * k, a[2] * k],\n  dot: (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2],\n  cross: (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]],\n  len: (a) => Math.hypot(a[0], a[1], a[2]),\n  norm: (a) => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; },\n};\n\n/** Rotate a point by Euler angles (radians) in X → Y → Z order. */\nexport function rotate([x, y, z], ax = 0, ay = 0, az = 0) {\n  let c = Math.cos(ax), s = Math.sin(ax);\n  [y, z] = [y * c - z * s, y * s + z * c];\n  c = Math.cos(ay); s = Math.sin(ay);\n  [x, z] = [x * c + z * s, -x * s + z * c];\n  c = Math.cos(az); s = Math.sin(az);\n  [x, y] = [x * c - y * s, x * s + y * c];\n  return [x, y, z];\n}\n\n/**\n * Build a projector for a canvas: maps world [x,y,z] (camera at z = -distance,\n * looking +z) to canvas pixels, aspect-corrected, scaled so a unit radius\n * spans `fill` of the canvas' shorter visual side. Returns [px, py, depth].\n */\nexport function projector(canvas, { distance = 3.2, fov = 1, fill = 0.42, cx, cy } = {}) {\n  const w = canvas.width, h = canvas.height;\n  const ox = cx ?? w / 2, oy = cy ?? h / 2;\n  const visualH = h * canvas.aspect; // height measured in horizontal-pixel units\n  const s = Math.min(w, visualH) * fill * distance * fov;\n  return ([x, y, z]) => {\n    const d = z + distance;\n    const k = d > 0.05 ? s / d : s / 0.05;\n    return [ox + x * k, oy + (y * k) / canvas.aspect, d];\n  };\n}\n\nfunction uniqueEdges(faces) {\n  const seen = new Set(), edges = [];\n  for (const f of faces)\n    for (let i = 0; i < f.length; i++) {\n      const a = f[i], b = f[(i + 1) % f.length];\n      const k = a < b ? `${a},${b}` : `${b},${a}`;\n      if (!seen.has(k)) { seen.add(k); edges.push([a, b]); }\n    }\n  return edges;\n}\n\nfunction mesh(vertices, faces) {\n  return { vertices, faces, edges: uniqueEdges(faces) };\n}\n\nexport const meshes = {\n  cube() {\n    const v = [];\n    for (const x of [-1, 1]) for (const y of [-1, 1]) for (const z of [-1, 1]) v.push([x * 0.8, y * 0.8, z * 0.8]);\n    return mesh(v, [[0, 1, 3, 2], [4, 6, 7, 5], [0, 4, 5, 1], [2, 3, 7, 6], [0, 2, 6, 4], [1, 5, 7, 3]]);\n  },\n  tetrahedron() {\n    const v = [[1, 1, 1], [-1, -1, 1], [-1, 1, -1], [1, -1, -1]].map((p) => p.map((c) => c * 0.75));\n    return mesh(v, [[0, 1, 2], [0, 3, 1], [0, 2, 3], [1, 3, 2]]);\n  },\n  octahedron() {\n    const v = [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]];\n    return mesh(v, [[0, 2, 4], [2, 1, 4], [1, 3, 4], [3, 0, 4], [2, 0, 5], [1, 2, 5], [3, 1, 5], [0, 3, 5]]);\n  },\n  icosahedron() {\n    const t = (1 + Math.sqrt(5)) / 2;\n    const v = [[-1, t, 0], [1, t, 0], [-1, -t, 0], [1, -t, 0], [0, -1, t], [0, 1, t], [0, -1, -t], [0, 1, -t], [t, 0, -1], [t, 0, 1], [-t, 0, -1], [-t, 0, 1]]\n      .map((p) => v3.norm(p));\n    const f = [[0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11], [1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8],\n      [3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9], [4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1]];\n    return mesh(v, f);\n  },\n  /** Torus with major radius R, minor r, segment counts. */\n  torus(R = 0.7, r = 0.3, seg = 24, sides = 12) {\n    const v = [], f = [];\n    for (let i = 0; i < seg; i++) {\n      const a = (i / seg) * Math.PI * 2;\n      for (let j = 0; j < sides; j++) {\n        const b = (j / sides) * Math.PI * 2;\n        v.push([(R + r * Math.cos(b)) * Math.cos(a), r * Math.sin(b), (R + r * Math.cos(b)) * Math.sin(a)]);\n      }\n    }\n    for (let i = 0; i < seg; i++)\n      for (let j = 0; j < sides; j++) {\n        const a = i * sides + j, b = ((i + 1) % seg) * sides + j;\n        const c = ((i + 1) % seg) * sides + ((j + 1) % sides), d = i * sides + ((j + 1) % sides);\n        f.push([a, b, c, d]);\n      }\n    return mesh(v, f);\n  },\n  /** UV sphere. */\n  sphere(lat = 10, lon = 16) {\n    const v = [], f = [];\n    for (let i = 0; i <= lat; i++) {\n      const th = (i / lat) * Math.PI;\n      for (let j = 0; j < lon; j++) {\n        const ph = (j / lon) * Math.PI * 2;\n        v.push([Math.sin(th) * Math.cos(ph), Math.cos(th), Math.sin(th) * Math.sin(ph)]);\n      }\n    }\n    for (let i = 0; i < lat; i++)\n      for (let j = 0; j < lon; j++) {\n        const a = i * lon + j, b = (i + 1) * lon + j, c = (i + 1) * lon + ((j + 1) % lon), d = i * lon + ((j + 1) % lon);\n        f.push([a, b, c, d]);\n      }\n    return mesh(v, f);\n  },\n};\n",
  "prism-term/src/noise.js": "// Seeded gradient noise (Ken Perlin's improved noise, 2002) plus fractal\n// Brownian motion. Output is roughly in [-1, 1].\n\nimport { rng } from './easing.js';\n\nconst GRAD3 = [\n  [1, 1, 0], [-1, 1, 0], [1, -1, 0], [-1, -1, 0], [1, 0, 1], [-1, 0, 1],\n  [1, 0, -1], [-1, 0, -1], [0, 1, 1], [0, -1, 1], [0, 1, -1], [0, -1, -1],\n];\n\nconst fade = (t) => t * t * t * (t * (t * 6 - 15) + 10);\n\nexport function createNoise(seed = 1337) {\n  const rand = rng(seed);\n  const p = new Uint8Array(256).map((_, i) => i);\n  for (let i = 255; i > 0; i--) {\n    const j = Math.floor(rand() * (i + 1));\n    [p[i], p[j]] = [p[j], p[i]];\n  }\n  const perm = new Uint8Array(512);\n  for (let i = 0; i < 512; i++) perm[i] = p[i & 255];\n\n  const g = (h, x, y, z) => {\n    const v = GRAD3[h % 12];\n    return v[0] * x + v[1] * y + v[2] * z;\n  };\n\n  function noise3(x, y, z = 0) {\n    const X = Math.floor(x) & 255, Y = Math.floor(y) & 255, Z = Math.floor(z) & 255;\n    x -= Math.floor(x); y -= Math.floor(y); z -= Math.floor(z);\n    const u = fade(x), v = fade(y), w = fade(z);\n    const A = perm[X] + Y, AA = perm[A] + Z, AB = perm[A + 1] + Z;\n    const B = perm[X + 1] + Y, BA = perm[B] + Z, BB = perm[B + 1] + Z;\n    const l1 = (a, b, t) => a + t * (b - a);\n    return l1(\n      l1(l1(g(perm[AA], x, y, z), g(perm[BA], x - 1, y, z), u), l1(g(perm[AB], x, y - 1, z), g(perm[BB], x - 1, y - 1, z), u), v),\n      l1(l1(g(perm[AA + 1], x, y, z - 1), g(perm[BA + 1], x - 1, y, z - 1), u), l1(g(perm[AB + 1], x, y - 1, z - 1), g(perm[BB + 1], x - 1, y - 1, z - 1), u), v),\n      w,\n    );\n  }\n\n  /** Fractal Brownian motion: `octaves` layers of noise3, each at double frequency. */\n  function fbm(x, y, z = 0, octaves = 4, lacunarity = 2, gain = 0.5) {\n    let sum = 0, amp = 1, freq = 1, norm = 0;\n    for (let o = 0; o < octaves; o++) {\n      sum += amp * noise3(x * freq, y * freq, z * freq);\n      norm += amp;\n      amp *= gain;\n      freq *= lacunarity;\n    }\n    return sum / norm;\n  }\n\n  return { noise3, noise2: (x, y) => noise3(x, y, 0), fbm };\n}\n\nconst shared = createNoise();\nexport const noise3 = shared.noise3;\nexport const noise2 = shared.noise2;\nexport const fbm = shared.fbm;\n",
  "prism-term/src/palettes.js": "// Themes and gradient palettes.\n//\n// Themes are Prism's design-system token sets (synced from the Prism theme\n// registry — see scripts/sync-themes.mjs), resolved to packed colors so scenes\n// and widgets can say `theme.accent` / `theme.neg` and reskin with --theme.\n// The default is Refraction's Prism Night, or Prism Day on a light terminal\n// (src/theme-detect.js: PRISM_TERM_THEME, COLORFGBG, OSC 11).\n//\n// Palettes are named gradients for fields and particles. Three are derived from\n// the active theme ('theme', 'theme-heat', and 'spectrum' — Refraction's 12-stop\n// spectrum in the tier made for the theme's mode) so effects can match a design\n// system instead of using the stock looks.\n//\n// Loading this module also tells the color quantizer about every theme token:\n// each gets its exact xterm-256 index, and the Prism themes' tokens get their\n// designed ANSI-16 slots (TERMINAL_SCHEMES roles), so lower depths look intended.\n// Prism's text colors — its tokens, the spectrum ink and the terminal gallery\n// hues — are pinned to their designed xterm-256 indexes (design/tokens.json:\n// the nearest index that keeps 4.5:1 on every 256-color surface, 3:1 for marks).\n\nimport { THEME_DATA, DEFAULT_THEME_ID, TERMINAL_SCHEMES, SPECTRUM, TERMINAL_GALLERIES } from './themes.js';\nimport { parse, gradient, mix, toOklab, fromOklab, exact256, pin256, setAnsi16Role, NONE } from './color.js';\nimport { detectedTheme } from './theme-detect.js';\n\nconst THEME_KEYS = ['bg', 'panel', 'panel2', 'card', 'line', 'ink', 'muted', 'dim', 'accent', 'accent2', 'info', 'pos', 'warn', 'neg', 'crit'];\nconst BY_ID = new Map(THEME_DATA.map((t) => [t.id, t]));\nconst MODE_ALIASES = { day: 'light', light: 'light', night: 'dark', dark: 'dark' };\n\nexport const THEME_IDS = THEME_DATA.map((t) => t.id);\nexport { DEFAULT_THEME_ID, TERMINAL_SCHEMES, SPECTRUM };\n\n/** The theme used when none is given: the detected Day/Night theme, else DEFAULT_THEME_ID. */\nexport function defaultThemeId() {\n  const d = detectedTheme();\n  return d && BY_ID.has(d.id) ? d.id : DEFAULT_THEME_ID;\n}\n\n/**\n * Resolve a theme by id, by 'auto' / nothing (the detected default), by 'day' /\n * 'night', or by a design-system name like 'primer' (its variant for the detected\n * mode, dark when unknown). Unknown ids fall back to the default. Returns packed\n * colors plus { id, name, ds, mode }.\n */\nexport function getTheme(id) {\n  if (id && typeof id === 'object') return id; // already resolved\n  const key = id == null || id === '' || id === 'auto' ? defaultThemeId() : String(id);\n  const mode = MODE_ALIASES[key.toLowerCase()];\n  const t = (mode && BY_ID.get(`prism-${mode}`)) || BY_ID.get(key)\n    || BY_ID.get(`${key}-${detectedTheme()?.mode || 'dark'}`) || BY_ID.get(`${key}-dark`) || BY_ID.get(defaultThemeId());\n  const out = { id: t.id, name: t.name, ds: t.ds, mode: t.mode };\n  for (const k of THEME_KEYS) out[k] = parse(t[k]);\n  return out;\n}\n\nexport function listThemes() {\n  return THEME_DATA.map((t) => ({ id: t.id, name: t.name, mode: t.mode, accent: t.accent, ds: t.ds }));\n}\n\n/** The terminal color scheme (hex strings) for a Prism theme id, or null. */\nexport function terminalScheme(id) {\n  const s = TERMINAL_SCHEMES[getTheme(id).id];\n  return s ? { id: getTheme(id).id, name: getTheme(id).name, mode: getTheme(id).mode, ...s, ansi: [...s.ansi] } : null;\n}\n\n// ── tell the quantizer about the tokens (see src/color.js) ───────────────────\n// Spectrum and scheme colors first, then theme tokens with Prism's last, so the\n// tokens win any 256-LUT cell they share (Night's brightWhite and Day's bg do).\nexact256(...SPECTRUM.flatMap((st) => [st.night, st.nightVivid, st.day, st.dayVivid].map(parse)));\nfor (const s of Object.values(TERMINAL_SCHEMES)) exact256(...s.ansi.map(parse));\nfor (const t of [...THEME_DATA].sort((a, b) => (a.ds === 'prism') - (b.ds === 'prism'))) exact256(...THEME_KEYS.map((k) => parse(t[k])));\n// Designed 256 indexes, lowest priority first (a later pin wins a LUT cell two\n// colors share): ANSI slots, spectrum ink, gallery ink / vivid, then the tokens.\nfor (const s of Object.values(TERMINAL_SCHEMES)) (s.ansi256 || []).forEach((i, n) => pin256(parse(s.ansi[n]), i));\nfor (const st of SPECTRUM) { pin256(parse(st.night), st.night256); pin256(parse(st.day), st.day256); }\nfor (const g of TERMINAL_GALLERIES || []) {\n  pin256(parse(g.nightVivid), g.nightVivid256); pin256(parse(g.dayVivid), g.dayVivid256);\n  pin256(parse(g.night), g.night256); pin256(parse(g.day), g.day256);\n}\nfor (const [id, s] of Object.entries(TERMINAL_SCHEMES)) {\n  const t = BY_ID.get(id);\n  if (t) for (const [k, i] of Object.entries(s.tokens256 || {})) pin256(parse(t[k]), i);\n}\nfor (const [id, s] of Object.entries(TERMINAL_SCHEMES)) {\n  const t = BY_ID.get(id);\n  if (!t) continue;\n  for (const [k, [fg, bg]] of Object.entries(s.roles)) {\n    const c = parse(t[k]);\n    // Pure black/white are everyday art colors in every theme: never pin those.\n    if (c !== NONE && c !== 0 && c !== 0xffffff) setAnsi16Role(c, fg, bg);\n  }\n}\n\nconst spectrumTier = (key) => SPECTRUM.map((st) => st[key]);\n\nexport const PALETTES = {\n  // Prism's own palette is Refraction's spectrum: the 12 OKLCH stops violet → rose\n  // (cyclic through magenta). palette() picks the tier made for the theme's mode —\n  // Night vivid on dark themes, Day vivid on light ones, so a rainbow stays legible\n  // on pearl. 'prism-ink' is the text-safe ink tier; 'neon' is the classic\n  // saturated rainbow prism-term shipped before Refraction.\n  prism: { stops: spectrumTier('nightVivid'), light: spectrumTier('dayVivid'), cyclic: true },\n  'prism-ink': { stops: spectrumTier('night'), light: spectrumTier('day'), cyclic: true },\n  // 'prism-sheen' is 'prism' for full-field fills: on light themes it takes the Day sheen\n  // tier (design/tokens.json › spectrum.stops[].day.sheen), since the vivid tier is tuned\n  // for marks on pearl and turns olive where it fills every cell. In Night it equals 'prism'.\n  'prism-sheen': { stops: spectrumTier('nightVivid'), light: ['#b299f3', '#8ba6fd', '#5ab3f7', '#19bfe2', '#00c8c3', '#53cea0', '#8fcf7d', '#c1c95f', '#e5ba53', '#f7a65f', '#fb947c', '#f48a9f'], cyclic: true },\n  neon: { stops: ['#ff3b6b', '#ff9f1c', '#ffe14d', '#3ddc84', '#2ec5ff', '#7a5cff', '#ff3bd4'], cyclic: true },\n  fire: { stops: ['#000000', '#1f0500', '#6b1300', '#c43a00', '#ff7a00', '#ffc42e', '#fff4c2', '#ffffff'] },\n  ocean: { stops: ['#01040c', '#03193a', '#074b80', '#0f8ec2', '#3fd0e8', '#b8f6ff', '#ffffff'] },\n  aurora: { stops: ['#02040f', '#08303a', '#0f8c6e', '#5ff5a8', '#9b7bff', '#ff7ae0'] },\n  synthwave: { stops: ['#0d0221', '#2b0f54', '#7b1fa2', '#ff2a6d', '#ff9e3d', '#ffe66d'] },\n  matrix: { stops: ['#000300', '#002b06', '#007a1e', '#00d43a', '#7dff9c', '#e8ffe8'] },\n  ice: { stops: ['#020617', '#0c2a4a', '#2563a8', '#60a5fa', '#c7e3ff', '#ffffff'] },\n  sunset: { stops: ['#14002e', '#4a0e5c', '#a3195b', '#f05a28', '#ffb03a', '#fff1a8'] },\n  toxic: { stops: ['#020a02', '#123d0b', '#3f8f00', '#a6ff00', '#f4ff8a'] },\n  magma: { stops: ['#000004', '#2c115f', '#721f81', '#b73779', '#f1605d', '#feb078', '#fcfdbf'] },\n  viridis: { stops: ['#440154', '#3b528b', '#21918c', '#5ec962', '#fde725'] },\n  candy: { stops: ['#ff9ad5', '#c39bff', '#8ec5ff', '#9fffe0', '#fff59a'], cyclic: true },\n  gold: { stops: ['#0b0700', '#3d2600', '#8a5a00', '#d69a00', '#ffd75e', '#fff6d6'] },\n  mono: { stops: ['#000000', '#ffffff'] },\n};\n\nexport const PALETTE_NAMES = [...Object.keys(PALETTES), 'theme', 'theme-heat'];\n\n/**\n * Fade a mark toward the theme background — the trail / afterglow idiom\n * (k = 0 → the background, 1 → the full color). Light on glass reads at a whisper;\n * ink on paper doesn't, so on light themes k is eased up (1 − (1 − k)²) and a\n * 20%-faded trail still shows. Use it instead of mix(theme.bg, color, k) for strokes.\n */\nconst linear = (u) => (u <= 0.04045 ? u / 12.92 : ((u + 0.055) / 1.055) ** 2.4);\nconst wcagLum = (c) => 0.2126 * linear(((c >> 16) & 255) / 255) + 0.7152 * linear(((c >> 8) & 255) / 255) + 0.0722 * linear((c & 255) / 255);\n/** WCAG 2 contrast ratio of two packed colors (1..21). */\nexport function contrastRatio(a, b) {\n  const x = wcagLum(a), y = wcagLum(b);\n  return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);\n}\n\n/**\n * The nearest color to `c` (same Oklab hue and chroma, lightness moved away from\n * the background) that reaches `ratio`:1 contrast on the theme background.\n * Colors that already do are returned unchanged. For pastel art palettes drawn as\n * ink on a light page, or deep ones drawn as light on glass.\n */\nexport function legible(theme, c, ratio = 3) {\n  const bg = theme.bg;\n  if (c === NONE || bg === NONE || contrastRatio(c, bg) >= ratio) return c;\n  const [L, A, B] = toOklab(c);\n  const darker = wcagLum(bg) > 0.18; // light page → move toward ink\n  let lo = darker ? 0 : L, hi = darker ? L : 1;\n  for (let i = 0; i < 16; i++) {\n    const mid = (lo + hi) / 2, ok = contrastRatio(fromOklab([mid, A, B]), bg) >= ratio;\n    if (darker) { if (ok) lo = mid; else hi = mid; } else if (ok) hi = mid; else lo = mid;\n  }\n  return fromOklab([darker ? lo : hi, A, B]);\n}\n\nexport function fadeMark(theme, color, k) {\n  const bg = theme.bg;\n  if (theme.mode === 'light') k = 1 - (1 - k) * (1 - k);\n  return bg === NONE ? color : mix(bg, color, k < 0 ? 0 : k > 1 ? 1 : k);\n}\n\n/**\n * Build a gradient function t → color.\n * @param {string|Array} name  palette name, or an explicit stop list\n * @param {object} theme       resolved theme (for 'theme' palettes and the spectrum's tier)\n */\nexport function palette(name = 'prism', theme = getTheme(), opts = {}) {\n  if (Array.isArray(name)) return gradient(name, opts);\n  if (name === 'theme') return gradient([theme.bg, theme.accent, theme.info, theme.pos, theme.warn, theme.crit], opts);\n  if (name === 'theme-heat') return gradient([theme.bg, theme.info, theme.pos, theme.warn, theme.neg, theme.ink], opts);\n  const p = PALETTES[name] || PALETTES.prism;\n  const stops = p.light && theme && theme.mode === 'light' ? p.light : p.stops;\n  return gradient(stops, { cyclic: !!p.cyclic, ...opts });\n}\n",
  "prism-term/src/spinners.js": "// Spinner collection. Each entry is { frames, interval (ms) } and optionally\n// { palette } — a gradient that sweeps across the frame's characters over time\n// on color terminals (the glyphs still animate on their own in mono).\n//\n// The classic glyph loops are hand-listed; prism-term's originals (snake,\n// orbit, thinking, wave, dna, radar, …) are generated from small formulas.\n// Every spinner is width-stable: all of its frames occupy the same columns.\n\nimport { stringWidth } from './width.js';\nimport { getTheme, palette as makePalette, PALETTES } from './palettes.js';\nimport { rng } from './easing.js';\nimport { parse } from './color.js';\n\nconst BITS = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]];\n\n/** Encode dot coordinates ([[x,y], …], x in 0..2*cells-1, y in 0..3) as a braille string. */\nfunction braille(cells, dots) {\n  const m = new Array(cells).fill(0);\n  for (const [x, y] of dots) {\n    const xi = Math.round(x), yi = Math.round(y);\n    if (xi < 0 || yi < 0 || yi > 3 || xi >= cells * 2) continue;\n    m[xi >> 1] |= BITS[yi][xi & 1];\n  }\n  return m.map((v) => String.fromCharCode(0x2800 + v)).join('');\n}\n\nconst range = (n, f) => Array.from({ length: n }, (_, i) => f(i));\nconst LOWER = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];\n\nfunction bounce(n) {\n  // 0,1,…,n-1,n-2,…,1 — positions for a back-and-forth sweep.\n  return [...range(n, (i) => i), ...range(Math.max(0, n - 2), (i) => n - 2 - i)];\n}\n\nconst generated = {\n  // A 4-dot snake running around a 2-cell (4×4 dot) perimeter.\n  snake: (() => {\n    const path = [[0, 0], [1, 0], [2, 0], [3, 0], [3, 1], [3, 2], [3, 3], [2, 3], [1, 3], [0, 3], [0, 2], [0, 1]];\n    return { frames: range(path.length, (k) => braille(2, range(4, (j) => path[(k - j + path.length) % path.length]))), interval: 70 };\n  })(),\n  // A planet with a short tail orbiting a lit \"sun\", 3 cells wide.\n  orbit: {\n    frames: range(20, (k) => {\n      const dots = [[2, 1], [3, 1], [2, 2], [3, 2]];\n      for (let j = 0; j < 3; j++) {\n        const a = ((k - j * 0.8) / 20) * Math.PI * 2;\n        dots.push([2.5 + 2.5 * Math.cos(a), 1.5 + 1.5 * Math.sin(a)]);\n      }\n      return braille(3, dots);\n    }),\n    interval: 60,\n    palette: 'ice',\n  },\n  // Typing-indicator style: three 2-dot blobs bobbing on a phase-shifted wave.\n  thinking: {\n    frames: range(12, (k) => braille(3, range(3, (i) => {\n      const yy = 1.5 - 1.5 * Math.sin((k / 12) * Math.PI * 2 - i * 0.9);\n      return [[i * 2, yy], [i * 2 + 1, yy]];\n    }).flat())),\n    interval: 80,\n    palette: 'aurora',\n  },\n  // A sine wave of 1/8 blocks rolling across 8 cells.\n  wave: {\n    frames: range(16, (k) => range(8, (i) => LOWER[Math.round(3.5 + 3.5 * Math.sin((k / 16) * Math.PI * 2 + i * 0.75))]).join('')),\n    interval: 70,\n    palette: 'ocean',\n  },\n  // Five-band audio equalizer.\n  equalizer: {\n    frames: range(24, (k) => range(5, (i) => LOWER[Math.round(3.5 + 3.5 * Math.sin(k * 0.55 * (0.6 + i * 0.37) + i * 1.3) * Math.cos(k * 0.21 + i))]).join('')),\n    interval: 90,\n    palette: 'synthwave',\n  },\n  // Two helix strands twisting with rungs, 6 cells wide.\n  dna: {\n    frames: range(16, (k) => {\n      const dots = [];\n      for (let x = 0; x < 12; x++) {\n        const ph = (k / 16) * Math.PI * 2 + x * 0.55;\n        const a = 1.5 + 1.5 * Math.sin(ph), b = 1.5 - 1.5 * Math.sin(ph);\n        dots.push([x, a], [x, b]);\n        if (x % 3 === 0 && Math.abs(a - b) >= 2) for (let y = Math.min(a, b) + 1; y < Math.max(a, b); y++) dots.push([x, y]);\n      }\n      return braille(6, dots);\n    }),\n    interval: 80,\n    palette: 'candy',\n  },\n  // A scanning beam with a fading afterglow.\n  radar: {\n    frames: (() => {\n      const pos = bounce(8), out = [];\n      pos.forEach((p, k) => {\n        const dir = k < 8 ? 1 : -1;\n        const cells = range(8, () => ' ');\n        ['█', '▓', '▒', '░'].forEach((g, j) => { const q = p - dir * j; if (q >= 0 && q < 8) cells[q] = g; });\n        out.push(cells.join(''));\n      });\n      return out;\n    })(),\n    interval: 70,\n    palette: 'ocean',\n  },\n  // Faceted diamonds catching a rainbow sweep.\n  prism: {\n    frames: range(8, (k) => range(5, (i) => ['◇', '◈', '◆', '◈'][(k + i) % 4]).join('')),\n    interval: 90,\n    palette: 'prism',\n  },\n  // One braille cell filling dot by dot from the bottom, then draining.\n  fill: (() => {\n    const order = [[0, 3], [1, 3], [0, 2], [1, 2], [0, 1], [1, 1], [0, 0], [1, 0]];\n    const up = range(9, (k) => braille(1, order.slice(0, k)));\n    const down = range(8, (k) => braille(1, order.slice(k + 1)));\n    return { frames: [...up, ...down], interval: 80 };\n  })(),\n  binary: (() => {\n    const r = rng(42);\n    return { frames: range(12, () => range(6, () => (r() < 0.5 ? '0' : '1')).join('')), interval: 80, palette: 'matrix' };\n  })(),\n  matrix: (() => {\n    const r = rng(7);\n    const glyphs = 'ｱｲｳｴｵｶｷｸｹｺｻｼｽｾｿﾀﾁﾂﾃﾄ0123456789';\n    return { frames: range(12, () => range(6, () => glyphs[Math.floor(r() * glyphs.length)]).join('')), interval: 80, palette: 'matrix' };\n  })(),\n  bouncingBar: {\n    frames: bounce(4).map((p) => `[${range(6, (i) => (i >= p && i < p + 3 ? '=' : ' ')).join('')}]`),\n    interval: 80,\n  },\n  bouncingBall: {\n    frames: bounce(6).map((p) => `( ${range(6, (i) => (i === p ? '●' : ' ')).join('')} )`),\n    interval: 80,\n  },\n  aesthetic: {\n    frames: range(8, (k) => range(7, (i) => (i <= k - 1 ? '▰' : '▱')).join('')),\n    interval: 90,\n  },\n  arrows2: {\n    frames: range(6, (k) => range(5, (i) => (i === k - 1 ? '▸' : '▹')).join('')),\n    interval: 120,\n  },\n  point: {\n    frames: range(5, (k) => range(3, (i) => (i === k - 1 ? '●' : '∙')).join('')),\n    interval: 200,\n  },\n};\n\nexport const SPINNERS = {\n  dots: { frames: [...'⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'], interval: 80 },\n  dots2: { frames: [...'⣾⣽⣻⢿⡿⣟⣯⣷'], interval: 80 },\n  dots3: { frames: [...'⠁⠂⠄⡀⢀⠠⠐⠈'], interval: 90 },\n  line: { frames: ['-', '\\\\', '|', '/'], interval: 130 },\n  pipe: { frames: [...'┤┘┴└├┌┬┐'], interval: 100 },\n  arc: { frames: [...'◜◠◝◞◡◟'], interval: 100 },\n  circle: { frames: [...'◐◓◑◒'], interval: 120 },\n  quarter: { frames: [...'◴◷◶◵'], interval: 120 },\n  squareCorners: { frames: [...'◰◳◲◱'], interval: 180 },\n  triangle: { frames: [...'◢◣◤◥'], interval: 60 },\n  bounce: { frames: [...'⠁⠂⠄⠂'], interval: 120 },\n  pulse: { frames: ['·', '•', '●', '•'], interval: 140 },\n  pulseRing: { frames: ['·', '∘', '○', '◎', '◉', '●', '◉', '◎', '○', '∘'], interval: 90 },\n  grow: { frames: [...'▁▂▃▄▅▆▇█▇▆▅▄▃▂'], interval: 80 },\n  growH: { frames: [...'▏▎▍▌▋▊▉█▉▊▋▌▍▎'], interval: 80 },\n  bars: { frames: [...'▉▊▋▌▍▎▏▎▍▌▋▊▉'], interval: 100 },\n  loadingBlocks: { frames: [...'⣀⣤⣶⣿⣶⣤'], interval: 100 },\n  toggle: { frames: ['⊶', '⊷'], interval: 250 },\n  moon: { frames: [...'🌑🌒🌓🌔🌕🌖🌗🌘'], interval: 100 },\n  clock: { frames: [...'🕛🕐🕑🕒🕓🕔🕕🕖🕗🕘🕙🕚'], interval: 100 },\n  earth: { frames: [...'🌍🌎🌏'], interval: 180 },\n  hourglass: { frames: ['⏳', '⌛'], interval: 500 },\n  arrows: { frames: [...'←↖↑↗→↘↓↙'], interval: 100 },\n  boxBounce: { frames: [...'▖▘▝▗'], interval: 120 },\n  boxBounce2: { frames: [...'▌▀▐▄'], interval: 100 },\n  star: { frames: [...'✶✸✹✺✹✷'], interval: 70 },\n  hamburger: { frames: [...'☱☲☴'], interval: 100 },\n  noise: { frames: [...'▓▒░'], interval: 100 },\n  layer: { frames: [...'-=≡'], interval: 150 },\n  flip: { frames: [...'___-``\\'´-___'], interval: 70 },\n  dqpb: { frames: [...'dqpb'], interval: 100 },\n  heart: { frames: ['♡', '♥'], interval: 300 },\n  simpleDots: { frames: ['.  ', '.. ', '...', '   '], interval: 300 },\n  ...generated,\n};\n\nexport const SPINNER_NAMES = Object.keys(SPINNERS);\n\n/** Resolve a spinner by name or pass through a { frames, interval } object. */\nexport function getSpinner(name = 'dots') {\n  if (name && typeof name === 'object' && Array.isArray(name.frames)) return name;\n  return SPINNERS[name] || SPINNERS.dots;\n}\n\n/** The frame string to show at time t (seconds). */\nexport function spinnerFrame(name, t) {\n  const s = getSpinner(name);\n  const i = Math.floor((Math.max(0, t) * 1000) / s.interval);\n  return s.frames[i % s.frames.length];\n}\n\n/** Column width of a spinner (all frames share it). */\nexport function spinnerWidth(name) {\n  return stringWidth(getSpinner(name).frames[0]);\n}\n\nconst paletteCache = new Map();\n\n/**\n * Draw a spinner frame into a Surface at (x, y). Spinners with a `palette`\n * get a moving per-character gradient unless `opts.color` is given.\n * @param {Surface} surface\n * @param {number} x @param {number} y\n * @param {string|object} name\n * @param {number} t  seconds\n * @param {object} [opts] { color, theme }\n * @returns {number} the column after the spinner\n */\nexport function drawSpinner(surface, x, y, name, t, opts = {}) {\n  const s = getSpinner(name);\n  const frame = spinnerFrame(s, t);\n  const theme = getTheme(opts.theme);\n  if (opts.color === undefined && s.palette) {\n    let g = paletteCache.get(s.palette);\n    if (!g) { g = makePalette(s.palette, theme); paletteCache.set(s.palette, g); }\n    const chars = [...frame];\n    let cx = x;\n    chars.forEach((ch, i) => {\n      const u = ((t * 0.45 + (i / Math.max(1, chars.length)) * 0.5) % 1 + 1) % 1;\n      // Cyclic palettes are bright all the way round; ramps (ocean, matrix…) start\n      // near black, so ping-pong over their bright half instead.\n      cx = surface.text(cx, y, ch, PALETTES[s.palette]?.cyclic ? g(u) : g(0.45 + 0.55 * (u < 0.5 ? u * 2 : 2 - u * 2)));\n    });\n    return cx;\n  }\n  const c = opts.color === undefined ? theme.accent : typeof opts.color === 'number' ? opts.color : (theme[opts.color] ?? parse(opts.color));\n  return surface.text(x, y, frame, c);\n}\n",
  "prism-term/src/text/banner.js": "// Big text. Text is laid out in font pixels, then rasterized onto a Canvas so\n// one banner renders in any resolution mode:\n//\n//   half     each font pixel is one half-block — square pixels, the default\n//   quad     2×1 canvas pixels per font pixel (same size as half, crisper edges)\n//   sextant  compact hi-res (Unicode 13 2×3 blocks)\n//   braille  tiny hi-res: a 5×7 letter fits in ~2.5×2 cells\n//   block    every font pixel is a full '█' cell (`wide: true` doubles it to '██')\n//   ascii    '#' cells, for terminals without Unicode\n//\n// Fills are a color, a palette name, stop list or gradient function, swept\n// horizontally / vertically / diagonally, and animated by passing `t`.\n\nimport { Canvas, pickMode, MODES } from '../canvas.js';\nimport { CellBuffer } from '../buffer.js';\nimport { NONE, parse, mix, gradient, fgParam, normalizeDepth, luminance } from '../color.js';\nimport { getTheme, palette as makePalette, PALETTES, legible } from '../palettes.js';\nimport { detect } from '../caps.js';\nimport { getFont, glyph } from './fonts.js';\n\nexport const BANNER_MODES = ['half', 'quad', 'sextant', 'braille', 'block', 'ascii'];\n\n// Font pixel → canvas pixel scale per mode, chosen so letters keep their\n// designed proportions (a terminal cell is ~1:2).\nconst MODE_SCALE = { half: [1, 1], quad: [2, 1], sextant: [1, 1], braille: [1, 1], block: [1, 1], ascii: [2, 1] };\n\nconst fract = (x) => x - Math.floor(x);\nconst pingpong = (x) => 1 - Math.abs(fract(x / 2) * 2 - 1);\n\n/**\n * Resolve a fill spec into { at(g) → color, cyclic }.\n * @param {*} fill   color | palette name | stop array | gradient fn\n */\nexport function resolveFill(fill, theme) {\n  const th = getTheme(theme);\n  if (fill == null) return { at: gradient([th.accent, th.crit]), cyclic: false };\n  if (typeof fill === 'function') return { at: fill, cyclic: false };\n  if (Array.isArray(fill)) return { at: gradient(fill), cyclic: false };\n  if (typeof fill === 'string' && (PALETTES[fill] || fill === 'theme' || fill === 'theme-heat')) {\n    // Most field palettes start near black (great for fire, invisible as text ink):\n    // skip the dark head so every letter reads.\n    let fn = makePalette(fill, th);\n    // On a light page the pale end is the invisible one: every palette color is\n    // drawn as ink at large-text contrast (3:1), same hue and chroma.\n    if (th.mode === 'light' && th.bg !== NONE) {\n      const lut = Int32Array.from(fn.lut, (c) => legible(th, c, 3)), last = lut.length - 1;\n      const cyc = !!PALETTES[fill]?.cyclic;\n      fn = cyc ? (t) => lut[((Math.floor(t * lut.length) % lut.length) + lut.length) % lut.length] : (t) => lut[t <= 0 ? 0 : t >= 1 ? last : (t * last) | 0];\n      fn.lut = lut;\n      fn.at = (i) => lut[i < 0 ? 0 : i > last ? last : i | 0];\n    }\n    let lo = 0;\n    while (lo < 160 && luminance(fn.lut[lo]) < 0.22) lo++;\n    const span = fn.lut.length - 1 - lo;\n    return { at: lo ? (g) => fn.at(lo + g * span) : fn, cyclic: !!PALETTES[fill]?.cyclic };\n  }\n  if (typeof fill === 'string' && fill in th && typeof th[fill] === 'number') { const c = th[fill]; return { at: () => c, cyclic: false, solid: c }; }\n  const c = parse(fill);\n  return { at: () => c, cyclic: false, solid: c };\n}\n\nfunction resolveMode(mode, caps) {\n  if (!mode || mode === 'auto') return pickMode('field', caps || detect());\n  if (mode === 'block' || mode === 'ascii' || MODES[mode]) return mode;\n  return 'half';\n}\n\n// scale: a number multiplies the mode's base; an [x, y] array replaces it (exact canvas pixels per font pixel).\n// stretch: an [x, y] array multiplies whatever scale resolved to, so condensed/tall type keeps the mode's aspect fix.\nfunction resolveScale(mode, scale, wide, stretch) {\n  let [x, y] = MODE_SCALE[mode] || [1, 1];\n  if (Array.isArray(scale)) [x, y] = scale;\n  else if (typeof scale === 'number' && scale > 0) { x *= scale; y *= scale; }\n  if (Array.isArray(stretch)) { x *= stretch[0] || 1; y *= stretch[1] || 1; }\n  if (wide && mode === 'block') x *= 2;\n  return [Math.max(1, Math.round(x)), Math.max(1, Math.round(y))];\n}\n\n/** Lay out text in font pixels. Returns glyph placements and the bitmap size. */\nexport function layoutText(text, { font, letterSpacing, lineSpacing, align = 'left' } = {}) {\n  const f = getFont(font);\n  const sp = letterSpacing ?? f.spacing, ls = lineSpacing ?? f.lineSpacing;\n  const lines = String(text ?? '').replace(/\\t/g, '  ').replace(/\\r/g, '').split('\\n');\n  let ci = 0, desc = false;\n  const out = lines.map((line) => {\n    const glyphs = [];\n    let x = 0;\n    for (const ch of line) {\n      const gl = glyph(f, ch);\n      if (gl.h > f.height) desc = true;\n      glyphs.push({ gl, x, ci: ci++, ch });\n      x += gl.w + sp;\n    }\n    return { glyphs, width: Math.max(0, x - sp) };\n  });\n  const width = Math.max(0, ...out.map((l) => l.width));\n  if (!width) return { font: f, lines: out, width: 0, height: 0, lineHeight: 0, count: ci };\n  const lineHeight = f.height + (desc ? f.descent : 0);\n  out.forEach((l, i) => {\n    l.y = i * (lineHeight + ls);\n    l.x = align === 'center' ? Math.floor((width - l.width) / 2) : align === 'right' ? width - l.width : 0;\n  });\n  return { font: f, lines: out, width, height: out.length * lineHeight + (out.length - 1) * ls, lineHeight, count: ci };\n}\n\nfunction geometry(text, opts = {}) {\n  const mode = resolveMode(opts.mode, opts.caps);\n  const [kx, ky] = resolveScale(mode, opts.scale, opts.wide, opts.stretch);\n  const lay = layoutText(text, opts);\n  const padF = opts.outline ? 1 : 0;\n  const shOff = opts.shadow ? (opts.shadowOffset || [1, 1]) : [0, 0];\n  const padX = Math.max(0, Math.round(opts.padX || 0)), padY = Math.max(0, Math.round(opts.padY || 0));\n  const { sx, sy } = MODES[mode === 'block' || mode === 'ascii' ? 'ascii' : mode];\n  const empty = !lay.width;\n  const wPx = empty ? 0 : (lay.width + 2 * padF + Math.abs(shOff[0])) * kx + 2 * padX;\n  const hPx = empty ? 0 : (lay.height + 2 * padF + Math.abs(shOff[1])) * ky + 2 * padY;\n  return {\n    mode, kx, ky, lay, padF, shOff, padX, padY, sx, sy, wPx, hPx,\n    cols: Math.ceil(wPx / sx), rows: Math.ceil(hPx / sy),\n    // Canvas-pixel origin of font pixel (0,0) of the text body.\n    ox: padX + (padF + Math.max(0, -shOff[0])) * kx,\n    oy: padY + (padF + Math.max(0, -shOff[1])) * ky,\n  };\n}\n\n/** Size of a banner in terminal cells: { width, height }. */\nexport function measureBanner(text, opts = {}) {\n  const g = geometry(text, opts);\n  return { width: g.cols, height: g.rows };\n}\n\nfunction makeCanvas(cols, rows, mode) {\n  if (mode === 'block') return new Canvas(cols, rows, { mode: 'ascii', ramp: ' █' });\n  if (mode === 'ascii') return new Canvas(cols, rows, { mode: 'ascii', ramp: ' #' });\n  if (mode === 'braille') return new Canvas(cols, rows, { mode: 'braille', threshold: 0 });\n  return new Canvas(cols, rows, { mode });\n}\n\n/**\n * Rasterize text into a Canvas sized to fit it.\n * @param {string} text\n * @param {object} opts\n *   font, mode, scale (number | [x,y]), stretch ([x,y] on top of scale), wide (block), letterSpacing, lineSpacing, align\n *   fill        color | palette name | stops | gradient fn (default theme accent→crit)\n *   direction   'horizontal' | 'vertical' | 'diagonal'\n *   t, speed    animate the fill: phase = t × speed (default speed 0.25/s)\n *   shadow      true (fill tinted toward the theme bg) | color;  shadowOffset [dx, dy] in font pixels\n *   outline     true (theme ink) | color\n *   theme       theme id or resolved theme\n *   shader      (color, u, v, ci) → color   per-pixel color hook (u,v ∈ 0..1)\n *   charStyle   (ci) → { fg, dx, dy, hidden }   per-character overrides (dx/dy in canvas px)\n *   ghosts      [{ dx, dy, color }]   extra offset copies drawn under the text (glitch)\n *   cursorAt    character index to draw a text cursor before (typewriter)\n *   padX, padY  extra canvas-pixel margin (room for displacement effects)\n * @returns {Canvas} with `.layout` and `.geometry` attached\n */\nexport function bannerCanvas(text, opts = {}) {\n  const th = getTheme(opts.theme);\n  const G = geometry(text, opts);\n  const { mode, kx, ky, lay, padF, shOff, ox, oy } = G;\n  const cv = makeCanvas(G.cols, G.rows, mode);\n  cv.layout = lay;\n  cv.geometry = G;\n  if (!lay.width) return cv;\n\n  const fill = resolveFill(opts.fill, th);\n  const dir = opts.direction || 'horizontal';\n  const animated = opts.t !== undefined && opts.t !== null;\n  const phase = animated ? opts.t * (opts.speed ?? 0.25) : 0;\n  const tw = Math.max(1, lay.width * kx), thh = Math.max(1, lay.height * ky);\n  const shader = opts.shader, charStyle = opts.charStyle;\n  const colorAt = (X, Y, ci) => {\n    const u = (X - ox) / tw, v = (Y - oy) / thh;\n    let gpos = dir === 'vertical' ? v : dir === 'diagonal' ? (u + v) / 2 : u;\n    gpos += phase;\n    if (fill.cyclic) gpos = fract(gpos);\n    else if (animated) gpos = pingpong(gpos);\n    let c = fill.at(Math.min(1, Math.max(0, gpos)));\n    if (shader) c = shader(c, u, v, ci);\n    return c;\n  };\n\n  // Gather ink pixels once: [fontX, fontY, ci, style]\n  const ink = [];\n  const styles = new Map();\n  for (const line of lay.lines) {\n    for (const { gl, x, ci } of line.glyphs) {\n      let st = styles.get(ci);\n      if (st === undefined) { st = charStyle ? charStyle(ci) || null : null; styles.set(ci, st); }\n      if (st && st.hidden) continue;\n      for (let gy = 0; gy < gl.h; gy++)\n        for (let gx = 0; gx < gl.w; gx++)\n          if (gl.bits[gy * gl.w + gx]) ink.push(line.x + x + gx, line.y + gy, ci, st);\n    }\n  }\n\n  const block = (fx, fy, st, color) => {\n    const X0 = ox + fx * kx + (st?.dx || 0), Y0 = oy + fy * ky + (st?.dy || 0);\n    for (let py = 0; py < ky; py++)\n      for (let px = 0; px < kx; px++) {\n        const X = X0 + px, Y = Y0 + py;\n        cv.set(X, Y, typeof color === 'function' ? color(X - (st?.dx || 0), Y - (st?.dy || 0)) : color);\n      }\n  };\n  const inkColor = (ci, st) => {\n    if (st && st.fg !== undefined && st.fg !== null) {\n      const c = typeof st.fg === 'number' ? st.fg : parse(st.fg);\n      return shader ? (X, Y) => shader(c, (X - ox) / tw, (Y - oy) / thh, ci) : c;\n    }\n    return (X, Y) => colorAt(X, Y, ci);\n  };\n\n  if (opts.shadow) {\n    const sc = opts.shadow === true ? null : parse(opts.shadow);\n    for (let i = 0; i < ink.length; i += 4) {\n      const ci = ink[i + 2], st = ink[i + 3];\n      const base = inkColor(ci, st);\n      // Default shadow: the ink color pulled 70% back toward the theme background, so it\n      // stays tinted, works on light themes, and fades along with fading text.\n      const col = sc !== null ? sc : (X, Y) => {\n        const c = typeof base === 'function' ? base(X - shOff[0] * kx, Y - shOff[1] * ky) : base;\n        return c === NONE ? NONE : mix(th.bg, c, 0.3);\n      };\n      block(ink[i] + shOff[0], ink[i + 1] + shOff[1], st, col);\n    }\n  }\n  if (opts.outline) {\n    const oc = opts.outline === true ? th.ink : parse(opts.outline);\n    for (let i = 0; i < ink.length; i += 4)\n      for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) if (dx || dy) block(ink[i] + dx, ink[i + 1] + dy, ink[i + 3], oc);\n  }\n  if (opts.ghosts) {\n    for (const gh of opts.ghosts) {\n      const gc = parse(gh.color);\n      for (let i = 0; i < ink.length; i += 4) {\n        const st = ink[i + 3];\n        block(ink[i], ink[i + 1], { dx: (st?.dx || 0) + (gh.dx || 0), dy: (st?.dy || 0) + (gh.dy || 0) }, gc);\n      }\n    }\n  }\n  for (let i = 0; i < ink.length; i += 4) block(ink[i], ink[i + 1], ink[i + 3], inkColor(ink[i + 2], ink[i + 3]));\n\n  if (opts.cursorAt !== undefined && opts.cursorAt !== null) {\n    const pos = cursorPosition(lay, opts.cursorAt);\n    const cc = opts.cursorColor !== undefined ? parse(opts.cursorColor) : th.accent;\n    const cw = Math.max(1, Math.round(lay.font.width / 4));\n    for (let fy = 0; fy < lay.font.height; fy++) for (let fx = 0; fx < cw; fx++) block(pos.x + fx, pos.y + fy, null, cc);\n  }\n  return cv;\n}\n\n/** Font-pixel position where a cursor before character `ci` sits. */\nfunction cursorPosition(lay, ci) {\n  let last = { x: 0, y: 0 };\n  for (const line of lay.lines) {\n    last = { x: line.x + line.width + (line.glyphs.length ? lay.font.spacing : 0), y: line.y };\n    for (const gp of line.glyphs) if (gp.ci === ci) return { x: line.x + gp.x, y: line.y };\n  }\n  return last;\n}\n\n/**\n * Draw a banner onto a surface. `x`/`y` may be 'center'.\n * @returns {{ x, y, width, height }} placement in cells\n */\nexport function drawBanner(surface, x, y, text, opts = {}) {\n  const cv = bannerCanvas(text, opts);\n  const cx = x === 'center' ? Math.floor((surface.width - cv.cols) / 2) : Math.round(x || 0);\n  const cy = y === 'center' ? Math.floor((surface.height - cv.rows) / 2) : Math.round(y || 0);\n  cv.paint(surface, cx, cy);\n  return { x: cx, y: cy, width: cv.cols, height: cv.rows };\n}\n\n/**\n * Render a banner to an ANSI string for printing into scrollback:\n *   console.log(banner('PROTEUS', { fill: 'prism', shadow: true }))\n * opts.depth defaults to the detected depth of stdout. In 'mono' the shape is\n * kept with solid glyphs (shadow/outline are dropped — they'd be invisible).\n */\nexport function banner(text, opts = {}) {\n  const depth = normalizeDepth(opts.depth || detect().depth);\n  const o = depth === 'mono' ? { ...opts, shadow: false, outline: false } : opts;\n  const { width, height } = measureBanner(text, o);\n  if (!width || !height) return '';\n  const buf = new CellBuffer(width, height);\n  drawBanner(buf, 0, 0, text, o);\n  if (depth === 'mono') {\n    // Background-colored cells (half/quad/sextant solid areas) carry no glyph; make\n    // them ink, and drop colors entirely so the output has no escapes at all.\n    for (let yy = 0; yy < height; yy++)\n      for (let xx = 0; xx < width; xx++) {\n        const c = buf.get(xx, yy);\n        buf.set(xx, yy, c.bg !== NONE ? '█' : undefined, NONE, NONE);\n      }\n  }\n  return buf.toAnsi(depth);\n}\n\n/**\n * Per-character gradient text as an ANSI string (no big font):\n *   console.log(gradientText('Analyzing 1,204 traces…', 'aurora'))\n * @param {string} str\n * @param {*} fill     palette name | stops | gradient fn | color\n * @param {object} opts { depth, bold, theme, t (phase shift) }\n */\nexport function gradientText(str, fill = 'prism', opts = {}) {\n  const s = String(str ?? '');\n  const depth = normalizeDepth(opts.depth || detect().depth);\n  if (depth === 'mono' || !s) return s;\n  const f = resolveFill(fill, opts.theme);\n  const chars = [...s];\n  // Cyclic palettes wrap, so spread over [0,1) — otherwise the last char repeats the first color.\n  const n = f.cyclic ? Math.max(1, chars.length) : Math.max(1, chars.length - 1);\n  const phase = opts.t ? opts.t * (opts.speed ?? 0.25) : 0;\n  let out = opts.bold ? '\\x1b[1m' : '', last = null;\n  chars.forEach((ch, i) => {\n    if (ch === ' ' || ch === '\\n') { out += ch; return; }\n    let g = i / n + phase;\n    g = f.cyclic ? fract(g) : phase ? pingpong(g) : Math.min(1, g);\n    const p = fgParam(f.at(g), depth);\n    if (p !== last) { out += `\\x1b[${p}m`; last = p; }\n    out += ch;\n  });\n  return out + '\\x1b[0m';\n}\n",
  "prism-term/src/text/effects.js": "// Time-based text effects.\n//\n// Every effect is a pure function of time:\n//\n//   effect(surface, x, y, text, t, opts) → done\n//\n// so the same call works inside a scene, a dashboard panel, or an inline\n// animation — just pass the current t (seconds). One-shot effects (typewriter,\n// decrypt, fadeIn, wipe) return true once settled; continuous ones return\n// `t >= opts.duration` when a duration is given, else false.\n//\n// Pass `opts.font` ('standard' | 'tiny' | 'bold') to apply the effect to big\n// banner text instead of plain cells; banner options (mode, scale, fill,\n// shadow, outline, direction…) pass straight through. `x`/`y` may be 'center'.\n// `opts.final: true` draws the settled frame (used when an animation ends).\n//\n// Effects draw only through the Surface API, so a View works as a target.\n\nimport { getTheme, PALETTES } from '../palettes.js';\nimport { NONE, parse, mix, mixOklab, scale } from '../color.js';\nimport { charWidth, stringWidth } from '../width.js';\nimport { drawBanner, measureBanner, resolveFill } from './banner.js';\nimport { getFont, glyph, FONTS } from './fonts.js';\n\nconst clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);\nconst fract = (x) => x - Math.floor(x);\nconst pingpong = (x) => 1 - Math.abs(fract(x / 2) * 2 - 1);\n\n/** Deterministic hash of two ints → [0, 1). */\nfunction hash(a, b = 0) {\n  let h = Math.imul((a | 0) ^ 0x9e3779b9, 0x85ebca6b) ^ Math.imul((b | 0) + 0x632be5ab, 0xc2b2ae35);\n  h ^= h >>> 13;\n  h = Math.imul(h, 0x27d4eb2f);\n  h ^= h >>> 16;\n  return (h >>> 0) / 4294967296;\n}\n\nconst SCRAMBLE = 'ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#$%&*+=?@<>/\\\\';\nconst GLITCH_GLYPHS = '▓▒░█▀▄▚▞▌▐';\nconst GLITCH_ASCII = '#%&@$*+=';\n\nfunction colorOf(v, fallback) {\n  if (v === undefined || v === null) return fallback;\n  return typeof v === 'number' ? v : parse(v);\n}\n\n// ── shared drawing ───────────────────────────────────────────────────────────\n\n/** Per-character gradient lookup for small text (`opts.fill`), else null. */\nfunction smallFill(opts, th, len, phase = 0) {\n  if (opts.fill === undefined || opts.fill === null) return null;\n  const f = resolveFill(opts.fill, th);\n  const n = f.cyclic ? Math.max(1, len) : Math.max(1, len - 1);\n  return (i) => {\n    let g = i / n + phase;\n    g = f.cyclic ? fract(g) : phase ? pingpong(g) : clamp01(g);\n    return f.at(g);\n  };\n}\n\nfunction originSmall(surface, x, y, text) {\n  const cx = x === 'center' ? Math.floor((surface.width - stringWidth(text)) / 2) : Math.round(x || 0);\n  const cy = y === 'center' ? Math.floor(surface.height / 2) : Math.round(y || 0);\n  return [cx, cy];\n}\n\n/**\n * Draw plain-cell text where each character may be restyled:\n * style(i, ch) → { ch, fg, bg, attr, dx, dy, hidden } | null\n */\nfunction drawSmall(surface, x, y, text, style, base) {\n  const [x0, y0] = originSmall(surface, x, y, text);\n  let col = 0, i = 0;\n  for (const ch of String(text)) {\n    const w = charWidth(ch.codePointAt(0));\n    if (w === 0) continue;\n    const s = style ? style(i, ch) : null;\n    if (!(s && s.hidden) && ch !== ' ') {\n      surface.text(x0 + col + (s?.dx || 0), y0 + (s?.dy || 0), s?.ch ?? ch, s?.fg ?? base.fg, s?.bg ?? base.bg, s?.attr ?? base.attr);\n    }\n    col += w;\n    i++;\n  }\n}\n\nfunction bannerOpts(opts, th, extra) {\n  return {\n    font: opts.font, mode: opts.mode, scale: opts.scale, wide: opts.wide, caps: opts.caps,\n    letterSpacing: opts.letterSpacing, lineSpacing: opts.lineSpacing, align: opts.align,\n    fill: opts.fill ?? (opts.color !== undefined ? opts.color : undefined),\n    direction: opts.direction, shadow: opts.shadow, shadowOffset: opts.shadowOffset, outline: opts.outline,\n    theme: th, ...extra,\n  };\n}\n\n/** Canvas-pixel vertical scale for big text (one font pixel). */\nfunction fontPx(opts) {\n  const m = opts.mode || 'half';\n  const base = { half: 1, quad: 1, sextant: 1, braille: 1, block: 1, ascii: 1 }[m] || 1;\n  const s = Array.isArray(opts.scale) ? opts.scale[1] : typeof opts.scale === 'number' ? opts.scale : 1;\n  return Math.max(1, Math.round(base * s));\n}\n\nconst len = (text) => [...String(text)].length;\n\n// ── effects ──────────────────────────────────────────────────────────────────\n\n/**\n * Characters appear one by one with a cursor.\n * opts: { cps=24, delay=0, color, cursor='▌', cursorColor, keepCursor=false, theme, font… }\n */\nexport function typewriter(surface, x, y, text, t, opts = {}) {\n  const th = getTheme(opts.theme);\n  const n0 = len(text);\n  const cps = opts.cps ?? 24;\n  const n = opts.final ? n0 : Math.max(0, Math.min(n0, Math.floor((t - (opts.delay || 0)) * cps)));\n  const done = n >= n0;\n  const blinkOn = !done || Math.floor(t * 2.5) % 2 === 0;\n  const showCursor = !opts.final && blinkOn && (!done || opts.keepCursor);\n  const cursorColor = colorOf(opts.cursorColor, th.accent);\n  if (opts.font) {\n    drawBanner(surface, x, y, text, bannerOpts(opts, th, { charStyle: (ci) => (ci >= n ? { hidden: true } : null), cursorAt: showCursor ? n : null, cursorColor }));\n    return done;\n  }\n  const fill = smallFill(opts, th, n0);\n  const fg = colorOf(opts.color, th.ink);\n  drawSmall(surface, x, y, text, (i) => (i >= n ? { hidden: true } : fill ? { fg: fill(i) } : null), { fg, attr: opts.attr });\n  if (showCursor) {\n    const [x0, y0] = originSmall(surface, x, y, text);\n    const col = stringWidth([...String(text)].slice(0, n).join(''));\n    surface.text(x0 + col, y0, opts.cursor ?? (opts.ascii ? '_' : '▌'), cursorColor);\n  }\n  return done;\n}\n\n/**\n * Scrambled glyphs resolve into the real text, left to right.\n * opts: { duration=1.2, charset, rate=18, color, scrambleColor, theme, font… }\n */\nexport function decrypt(surface, x, y, text, t, opts = {}) {\n  const th = getTheme(opts.theme);\n  const chars = [...String(text)];\n  const n = Math.max(1, chars.length);\n  const dur = opts.duration ?? 1.2;\n  const rate = opts.rate ?? 18;\n  const charset = opts.charset || SCRAMBLE;\n  const k = Math.floor(t * rate);\n  const fg = colorOf(opts.color, th.ink);\n  const sc = colorOf(opts.scrambleColor, th.accent);\n  const resolveAt = (i) => dur * (0.3 + 0.7 * ((i + 1) / n) - 0.12 * hash(i, 99) * ((i + 1) / n));\n  const state = (i) => {\n    if (opts.final || t >= dur) return 1;\n    return t >= resolveAt(i) ? 1 : 0;\n  };\n  const flash = (i) => clamp01(1 - (t - resolveAt(i)) / 0.18);\n\n  if (opts.font) {\n    // Substitute glyphs of the same advance so the layout never jitters.\n    const f = getFont(opts.font);\n    const byWidth = widthBuckets(f, charset);\n    const shown = chars.map((ch, i) => {\n      if (ch === ' ' || state(i)) return ch;\n      const bucket = byWidth.get(glyph(f, ch).w);\n      return bucket ? bucket[Math.floor(hash(i, k) * bucket.length)] : ch;\n    }).join('');\n    drawBanner(surface, x, y, shown, bannerOpts(opts, th, {\n      charStyle: (ci) => (state(ci) ? null : { fg: scale(sc, 0.6 + 0.4 * hash(ci, k + 1)) }),\n      shader: opts.final ? undefined : (c, u, v, ci) => (state(ci) && flash(ci) > 0 ? mix(c, 0xffffff, flash(ci) * 0.7) : c),\n    }));\n    return opts.final || t >= dur;\n  }\n  const fill = smallFill(opts, th, chars.length);\n  drawSmall(surface, x, y, text, (i, ch) => {\n    if (state(i)) {\n      const c = fill ? fill(i) : fg;\n      return { fg: !opts.final && flash(i) > 0 ? mix(c, 0xffffff, flash(i) * 0.7) : c };\n    }\n    return { ch: charset[Math.floor(hash(i, k) * charset.length)], fg: scale(sc, 0.55 + 0.45 * hash(i, k + 1)) };\n  }, { fg, attr: opts.attr });\n  return opts.final || t >= dur;\n}\n\nconst bucketCache = new WeakMap();\nfunction widthBuckets(font, charset) {\n  let per = bucketCache.get(font);\n  if (!per) bucketCache.set(font, (per = new Map()));\n  let m = per.get(charset);\n  if (!m) {\n    m = new Map();\n    for (const ch of charset) {\n      const g = glyph(font, ch);\n      if (g === font.tofu) continue;\n      if (!m.has(g.w)) m.set(g.w, []);\n      m.get(g.w).push(ch);\n    }\n    per.set(charset, m);\n  }\n  return m;\n}\n\n/**\n * Bursty digital corruption: RGB-split ghosts, slice offsets, glyph noise.\n * opts: { intensity=0.6, rate=14, color, duration, theme, font… }\n */\nexport function glitch(surface, x, y, text, t, opts = {}) {\n  const th = getTheme(opts.theme);\n  const intensity = clamp01(opts.intensity ?? 0.6);\n  const k = Math.floor(t * (opts.rate ?? 14));\n  const on = !opts.final && hash(k, 7) < intensity * 0.55;\n  const strong = on && hash(k, 8) < 0.4;\n  const fg = colorOf(opts.color, th.ink);\n  const red = colorOf(opts.splitA, 0xff2a55), cyan = colorOf(opts.splitB, 0x22e5ff);\n  const n = len(text);\n  const sliceAt = Math.floor(hash(k, 3) * n), sliceLen = 1 + Math.floor(hash(k, 4) * Math.max(1, n / 3));\n  const sliceShift = hash(k, 5) < 0.5 ? -1 : 1;\n  const done = opts.duration ? t >= opts.duration : false;\n\n  if (opts.font) {\n    const px = fontPx(opts) * 2;\n    drawBanner(surface, x, y, text, bannerOpts(opts, th, {\n      padX: px * 2,\n      ghosts: on ? [{ dx: -px, dy: 0, color: red }, { dx: px, dy: strong ? 1 : 0, color: cyan }] : undefined,\n      charStyle: (ci) => {\n        if (!on) return null;\n        if (ci >= sliceAt && ci < sliceAt + sliceLen) return { dx: sliceShift * px * (strong ? 2 : 1) };\n        if (strong && hash(ci, k) < 0.12) return { hidden: true };\n        return null;\n      },\n      shader: on ? (c, u, v) => (hash(Math.floor(v * 9), k) < 0.18 ? mix(c, 0xffffff, 0.5) : c) : undefined,\n    }));\n    return done;\n  }\n  const fill = smallFill(opts, th, n);\n  const glyphs = opts.ascii ? GLITCH_ASCII : GLITCH_GLYPHS;\n  if (on) {\n    drawSmall(surface, x, y, text, () => ({ dx: -1, fg: scale(red, 0.85) }), { fg: red });\n    drawSmall(surface, x, y, text, () => ({ dx: 1, fg: scale(cyan, 0.85) }), { fg: cyan });\n  }\n  drawSmall(surface, x, y, text, (i) => {\n    const s = { fg: fill ? fill(i) : fg };\n    if (!on) return s;\n    if (i >= sliceAt && i < sliceAt + sliceLen) s.dx = sliceShift;\n    if (hash(i, k) < (strong ? 0.25 : 0.08)) s.ch = glyphs[Math.floor(hash(i, k + 9) * glyphs.length)];\n    return s;\n  }, { fg, attr: opts.attr });\n  return done;\n}\n\n/**\n * Characters ride a sine wave. Small text moves whole rows (reserve `amp`\n * rows above and below); big text moves in canvas pixels and pads itself.\n * opts: { amp (rows | font px, default 1 | 2), freq (0.22 small | 0.6 big), speed=5, color, fill, duration, font… }\n */\nexport function wave(surface, x, y, text, t, opts = {}) {\n  const th = getTheme(opts.theme);\n  const freq = opts.freq ?? 0.6, speed = opts.speed ?? 5;\n  const done = opts.duration ? t >= opts.duration : false;\n  if (opts.font) {\n    const ampPx = Math.round((opts.amp ?? 2) * fontPx(opts));\n    drawBanner(surface, x, y, text, bannerOpts(opts, th, {\n      padY: ampPx,\n      charStyle: (ci) => ({ dy: opts.final ? 0 : Math.round(Math.sin(ci * freq - t * speed) * ampPx) }),\n    }));\n    return done;\n  }\n  // Cells can only move whole rows, so a gentler default frequency keeps words legible.\n  const amp = opts.amp ?? 1, sfreq = opts.freq ?? 0.22;\n  const fill = smallFill(opts, th, len(text));\n  const fg = colorOf(opts.color, th.accent);\n  drawSmall(surface, x, y, text, (i) => ({\n    dy: opts.final ? 0 : Math.round(Math.sin(i * sfreq - t * speed) * amp),\n    fg: fill ? fill(i) : fg,\n  }), { fg, attr: opts.attr });\n  return done;\n}\n\n/**\n * A palette sweeps through the text.\n * opts: { fill='prism', speed=0.6, spread=1, duration, font… }\n */\nexport function rainbow(surface, x, y, text, t, opts = {}) {\n  const th = getTheme(opts.theme);\n  const speed = opts.speed ?? 0.6, spread = opts.spread ?? 1;\n  const fillSpec = opts.fill ?? 'prism';\n  const done = opts.duration ? t >= opts.duration : false;\n  if (opts.font) {\n    drawBanner(surface, x, y, text, bannerOpts({ ...opts, fill: fillSpec }, th, { t, speed: -speed }));\n    return done;\n  }\n  const f = resolveFill(fillSpec, th);\n  const n = Math.max(1, len(text));\n  drawSmall(surface, x, y, text, (i) => {\n    let g = (i / n) * spread - t * speed;\n    g = f.cyclic ? fract(g) : pingpong(g);\n    return { fg: f.at(g) };\n  }, { fg: th.ink, attr: opts.attr });\n  return done;\n}\n\n/**\n * A bright highlight band sweeps across the text.\n * opts: { color (base), highlight, width=5 chars (0.18 of big text), period=2.2, duration, font… }\n */\nexport function shimmer(surface, x, y, text, t, opts = {}) {\n  const th = getTheme(opts.theme);\n  const period = opts.period ?? 2.2;\n  const base = colorOf(opts.color, th.muted);\n  const hi = colorOf(opts.highlight, mix(th.ink, 0xffffff, 0.6));\n  const done = opts.duration ? t >= opts.duration : false;\n  if (opts.font) {\n    const w = opts.width ?? 0.18;\n    const p = fract(t / period) * (1 + 3 * w) - 1.5 * w;\n    drawBanner(surface, x, y, text, bannerOpts({ ...opts, fill: opts.fill ?? base }, th, {\n      shader: opts.final ? undefined : (c, u, v) => {\n        const k = clamp01(1 - Math.abs(u - p + (v - 0.5) * 0.12) / w);\n        return k > 0 ? mixOklab(c, hi, k * k) : c;\n      },\n    }));\n    return done;\n  }\n  const n = len(text), w = opts.width ?? 5;\n  const p = fract(t / period) * (n + 2 * w) - w;\n  const fill = smallFill(opts, th, n);\n  drawSmall(surface, x, y, text, (i) => {\n    const c = fill ? fill(i) : base;\n    const k = opts.final ? 0 : clamp01(1 - Math.abs(i - p) / w);\n    return { fg: k > 0 ? mixOklab(c, hi, k * k) : c };\n  }, { fg: base, attr: opts.attr });\n  return done;\n}\n\n/**\n * Characters fade up from the background, staggered left to right.\n * opts: { duration=0.9, stagger=0.6, color, from (default theme bg), font… }\n */\nexport function fadeIn(surface, x, y, text, t, opts = {}) {\n  const th = getTheme(opts.theme);\n  const dur = opts.duration ?? 0.9, stagger = clamp01(opts.stagger ?? 0.6);\n  const from = colorOf(opts.from, th.bg);\n  const n = Math.max(1, len(text));\n  const alpha = (i) => (opts.final ? 1 : clamp01((t - stagger * dur * (i / n)) / Math.max(0.001, dur * (1 - stagger))));\n  const done = opts.final || t >= dur;\n  if (opts.font) {\n    drawBanner(surface, x, y, text, bannerOpts(opts, th, {\n      charStyle: (ci) => (alpha(ci) <= 0 ? { hidden: true } : null),\n      shader: (c, u, v, ci) => (alpha(ci) >= 1 ? c : mixOklab(from, c, alpha(ci))),\n    }));\n    return done;\n  }\n  const fill = smallFill(opts, th, n);\n  const fg = colorOf(opts.color, th.ink);\n  drawSmall(surface, x, y, text, (i) => {\n    const a = alpha(i);\n    return a <= 0 ? { hidden: true } : { fg: mixOklab(from, fill ? fill(i) : fg, a) };\n  }, { fg, attr: opts.attr });\n  return done;\n}\n\n/**\n * Breathing brightness between `color` and `to`.\n * opts: { color (default accent), to, period=1.4, duration, font… }\n */\nexport function pulse(surface, x, y, text, t, opts = {}) {\n  const th = getTheme(opts.theme);\n  const period = opts.period ?? 1.4;\n  const base = colorOf(opts.color, th.accent);\n  const to = colorOf(opts.to, mix(base, 0xffffff, 0.65));\n  const k = opts.final ? 0 : 0.5 - 0.5 * Math.cos((2 * Math.PI * t) / period);\n  const done = opts.duration ? t >= opts.duration : false;\n  if (opts.font) {\n    drawBanner(surface, x, y, text, bannerOpts({ ...opts, fill: opts.fill ?? base }, th, { shader: (c) => mixOklab(c, to, k) }));\n    return done;\n  }\n  const fill = smallFill(opts, th, len(text));\n  drawSmall(surface, x, y, text, (i) => ({ fg: mixOklab(fill ? fill(i) : base, to, k) }), { fg: base, attr: opts.attr });\n  return done;\n}\n\n/**\n * Text scrolls through a fixed-width window, looping.\n * opts: { width (cells, default to surface edge), speed=10 cells/s, gap=6, color, fill, font… }\n */\nexport function marquee(surface, x, y, text, t, opts = {}) {\n  const th = getTheme(opts.theme);\n  const speed = opts.speed ?? 10, gap = opts.gap ?? 6;\n  const x0 = x === 'center' ? 0 : Math.round(x || 0), y0 = y === 'center' ? Math.floor(surface.height / 2) : Math.round(y || 0);\n  const width = Math.max(0, opts.width ?? surface.width - x0);\n  const done = opts.duration ? t >= opts.duration : false;\n  if (opts.font) {\n    const bo = bannerOpts(opts, th, {});\n    const { width: bw, height: bh } = measureBanner(text, bo);\n    if (!bw) return done;\n    const view = surface.view(x0, y0, width, bh);\n    const period = bw + gap;\n    const off = opts.final ? 0 : Math.floor((t * speed) % period);\n    for (let px = -off; px < width; px += period) drawBanner(view, px, 0, text, bo);\n    return done;\n  }\n  const chars = [...String(text)];\n  if (!chars.length) return done;\n  const period = chars.length + gap;\n  const off = opts.final ? 0 : Math.floor(t * speed);\n  const fill = smallFill(opts, th, chars.length);\n  const fg = colorOf(opts.color, th.ink);\n  for (let col = 0; col < width; col++) {\n    const idx = (((col + off) % period) + period) % period;\n    if (idx >= chars.length || chars[idx] === ' ') continue;\n    surface.text(x0 + col, y0, chars[idx], fill ? fill(idx) : fg, undefined, opts.attr);\n  }\n  return done;\n}\n\n/**\n * Left-to-right reveal with a glowing leading edge.\n * opts: { duration=0.8, edge (color), color, fill, font… }\n */\nexport function wipe(surface, x, y, text, t, opts = {}) {\n  const th = getTheme(opts.theme);\n  const dur = opts.duration ?? 0.8;\n  const p = opts.final ? 2 : clamp01(t / dur) * 1.15;\n  const edge = colorOf(opts.edge, 0xffffff);\n  const done = opts.final || t >= dur;\n  const at = (u, c) => {\n    if (u > p) return NONE;\n    const d = p - u;\n    return d < 0.12 ? mixOklab(c, edge, 1 - d / 0.12) : c;\n  };\n  if (opts.font) {\n    drawBanner(surface, x, y, text, bannerOpts(opts, th, { shader: (c, u) => at(u, c) }));\n    return done;\n  }\n  const n = Math.max(1, len(text));\n  const fill = smallFill(opts, th, n);\n  const fg = colorOf(opts.color, th.ink);\n  drawSmall(surface, x, y, text, (i) => {\n    const c = at(i / n, fill ? fill(i) : fg);\n    return c === NONE ? { hidden: true } : { fg: c };\n  }, { fg, attr: opts.attr });\n  return done;\n}\n\n/** Registry: name → { fn, oneShot, duration (default seconds), description }. */\nexport const EFFECTS = {\n  typewriter: { fn: typewriter, oneShot: true, duration: null, description: 'Characters typed out one by one behind a blinking cursor' },\n  decrypt: { fn: decrypt, oneShot: true, duration: 1.2, description: 'Scrambled glyphs resolve into the real text, left to right' },\n  glitch: { fn: glitch, oneShot: false, duration: 1.6, description: 'RGB-split ghosts, slice offsets and glyph corruption in bursts' },\n  wave: { fn: wave, oneShot: false, duration: 2, description: 'Letters ride a traveling sine wave' },\n  rainbow: { fn: rainbow, oneShot: false, duration: 2, description: 'A palette sweeps continuously through the text' },\n  shimmer: { fn: shimmer, oneShot: false, duration: 2.2, description: 'A bright highlight band glides across' },\n  fadeIn: { fn: fadeIn, oneShot: true, duration: 0.9, description: 'Characters fade up from the background, staggered' },\n  pulse: { fn: pulse, oneShot: false, duration: 2.8, description: 'Breathing brightness between two tones' },\n  marquee: { fn: marquee, oneShot: false, duration: 3, description: 'Text scrolls through a fixed window, looping' },\n  wipe: { fn: wipe, oneShot: true, duration: 0.8, description: 'Left-to-right reveal with a glowing edge' },\n};\n\nexport const EFFECT_NAMES = Object.keys(EFFECTS);\n\n/** Default duration for an effect on `text` (typewriter depends on length). */\nexport function effectDuration(name, text, opts = {}) {\n  const e = EFFECTS[name];\n  if (!e) return 1;\n  if (name === 'typewriter') return len(text) / (opts.cps ?? 24) + 0.35;\n  return e.duration;\n}\n\n/**\n * Play an effect inline at the cursor and leave the settled text in scrollback.\n *   await animateText('Root cause found: connection pool exhausted', { effect: 'decrypt' })\n *   await animateText('PROTEUS', { effect: 'shimmer', font: 'bold', fill: 'prism' })\n * Non-TTY streams just get the plain text and a newline.\n * opts: { effect='decrypt', duration, stream=process.stdout, fps=30, theme, …effect/banner opts }\n */\nexport async function animateText(text, opts = {}) {\n  const { Screen } = await import('../screen.js'); // Node-only; loaded on demand\n  const { effect = 'decrypt', stream = process.stdout, fps = 30, ...rest } = opts;\n  const fx = EFFECTS[effect];\n  if (!fx) throw new Error(`unknown text effect \"${effect}\" (expected ${EFFECT_NAMES.join('|')})`);\n  const str = String(text ?? '');\n  if (!stream || !stream.isTTY) {\n    stream?.write?.(str + '\\n');\n    return;\n  }\n  const duration = opts.duration ?? effectDuration(effect, str, rest);\n  let height, y;\n  if (rest.font) {\n    const extraPad = effect === 'wave' ? Math.round((rest.amp ?? 2) * fontPx(rest)) : 0;\n    const h = measureBanner(str, { ...rest, theme: undefined, padY: extraPad }).height;\n    height = Math.max(1, h);\n    y = 0;\n  } else {\n    const amp = effect === 'wave' ? Math.max(0, Math.round(rest.amp ?? 1)) : 0;\n    height = 1 + 2 * amp;\n    y = amp;\n  }\n  const screen = new Screen({ mode: 'inline', height, output: stream, keys: false });\n  await screen.run((t) => {\n    const buf = screen.buffer;\n    buf.clear();\n    const settled = t >= duration;\n    fx.fn(buf, 0, y, str, settled ? duration : t, settled ? { ...rest, final: true } : rest);\n  }, { fps, duration });\n}\n",
  "prism-term/src/text/fonts.js": "// Bitmap fonts for big text.\n//\n// Glyphs are authored as rows of '#' (ink) and '.' (paper) so they stay\n// readable — and editable — in source. Widths are proportional (punctuation is\n// narrow; digits are always full width so numbers stay tabular). A glyph has\n// exactly `height` rows, or `height + descent` rows for letters that drop below\n// the baseline (g, j, p, q, y in `standard`).\n//\n//   standard  5×7 + 1 descender row, upper and lower case — classic LCD feel\n//   tiny      3×5 caps & digits — compact KPI numbers and labels\n//   bold      6×7 caps & digits with 2-pixel stems — display weight\n//\n// Characters a font lacks fall back to uppercase, then to a hollow \"tofu\" box.\n\nconst g = (s) => s.trim().split(/\\s+/);\n\nconst STANDARD = {\n  A: g('.###. #...# #...# ##### #...# #...# #...#'),\n  B: g('####. #...# #...# ####. #...# #...# ####.'),\n  C: g('.###. #...# #.... #.... #.... #...# .###.'),\n  D: g('###.. #..#. #...# #...# #...# #..#. ###..'),\n  E: g('##### #.... #.... ####. #.... #.... #####'),\n  F: g('##### #.... #.... ####. #.... #.... #....'),\n  G: g('.###. #...# #.... #.### #...# #...# .####'),\n  H: g('#...# #...# #...# ##### #...# #...# #...#'),\n  I: g('### .#. .#. .#. .#. .#. ###'),\n  J: g('..### ...#. ...#. ...#. ...#. #..#. .##..'),\n  K: g('#...# #..#. #.#.. ##... #.#.. #..#. #...#'),\n  L: g('#.... #.... #.... #.... #.... #.... #####'),\n  M: g('#...# ##.## #.#.# #.#.# #...# #...# #...#'),\n  N: g('#...# #...# ##..# #.#.# #..## #...# #...#'),\n  O: g('.###. #...# #...# #...# #...# #...# .###.'),\n  P: g('####. #...# #...# ####. #.... #.... #....'),\n  Q: g('.###. #...# #...# #...# #.#.# #..#. .##.#'),\n  R: g('####. #...# #...# ####. #.#.. #..#. #...#'),\n  S: g('.#### #.... #.... .###. ....# ....# ####.'),\n  T: g('##### ..#.. ..#.. ..#.. ..#.. ..#.. ..#..'),\n  U: g('#...# #...# #...# #...# #...# #...# .###.'),\n  V: g('#...# #...# #...# #...# #...# .#.#. ..#..'),\n  W: g('#...# #...# #...# #.#.# #.#.# #.#.# .#.#.'),\n  X: g('#...# #...# .#.#. ..#.. .#.#. #...# #...#'),\n  Y: g('#...# #...# .#.#. ..#.. ..#.. ..#.. ..#..'),\n  Z: g('##### ....# ...#. ..#.. .#... #.... #####'),\n\n  a: g('..... ..... .###. ....# .#### #...# .####'),\n  b: g('#.... #.... #.##. ##..# #...# #...# ####.'),\n  c: g('..... ..... .###. #.... #.... #...# .###.'),\n  d: g('....# ....# .##.# #..## #...# #...# .####'),\n  e: g('..... ..... .###. #...# ##### #.... .###.'),\n  f: g('..##. .#..# .#... ###.. .#... .#... .#...'),\n  g: g('..... ..... .#### #...# #...# .#### ....# .###.'),\n  h: g('#.... #.... #.##. ##..# #...# #...# #...#'),\n  i: g('.#. ... ##. .#. .#. .#. ###'),\n  j: g('..# ... .## ..# ..# ..# #.# .#.'),\n  k: g('#.... #.... #..#. #.#.. ##... #.#.. #..#.'),\n  l: g('##. .#. .#. .#. .#. .#. ###'),\n  m: g('..... ..... ##.#. #.#.# #.#.# #...# #...#'),\n  n: g('..... ..... #.##. ##..# #...# #...# #...#'),\n  o: g('..... ..... .###. #...# #...# #...# .###.'),\n  p: g('..... ..... ####. #...# #...# ####. #.... #....'),\n  q: g('..... ..... .#### #...# #...# .#### ....# ....#'),\n  r: g('..... ..... #.##. ##..# #.... #.... #....'),\n  s: g('..... ..... .#### #.... .###. ....# ####.'),\n  t: g('.#... .#... ###.. .#... .#... .#..# ..##.'),\n  u: g('..... ..... #...# #...# #...# #..## .##.#'),\n  v: g('..... ..... #...# #...# #...# .#.#. ..#..'),\n  w: g('..... ..... #...# #...# #.#.# #.#.# .#.#.'),\n  x: g('..... ..... #...# .#.#. ..#.. .#.#. #...#'),\n  y: g('..... ..... #...# #...# #...# .#### ....# .###.'),\n  z: g('..... ..... ##### ...#. ..#.. .#... #####'),\n\n  0: g('.###. #...# #..## #.#.# ##..# #...# .###.'),\n  1: g('..#.. .##.. ..#.. ..#.. ..#.. ..#.. .###.'),\n  2: g('.###. #...# ....# ...#. ..#.. .#... #####'),\n  3: g('##### ...#. ..#.. ...#. ....# #...# .###.'),\n  4: g('...#. ..##. .#.#. #..#. ##### ...#. ...#.'),\n  5: g('##### #.... ####. ....# ....# #...# .###.'),\n  6: g('..##. .#... #.... ####. #...# #...# .###.'),\n  7: g('##### ....# ...#. ..#.. .#... .#... .#...'),\n  8: g('.###. #...# #...# .###. #...# #...# .###.'),\n  9: g('.###. #...# #...# .#### ....# ...#. .##..'),\n\n  ' ': g('... ... ... ... ... ... ...'),\n  '.': g('.. .. .. .. .. ## ##'),\n  ',': g('.. .. .. .. ## .# #.'),\n  ':': g('.. ## ## .. ## ## ..'),\n  ';': g('.. ## ## .. ## .# #.'),\n  '!': g('# # # # # . #'),\n  '?': g('.###. #...# ....# ...#. ..#.. ..... ..#..'),\n  '-': g('.... .... .... #### .... .... ....'),\n  '_': g('..... ..... ..... ..... ..... ..... #####'),\n  '+': g('..... ..#.. ..#.. ##### ..#.. ..#.. .....'),\n  '=': g('..... ..... ##### ..... ##### ..... .....'),\n  '/': g('..... ....# ...#. ..#.. .#... #.... .....'),\n  '\\\\': g('..... #.... .#... ..#.. ...#. ....# .....'),\n  '(': g('..# .#. #.. #.. #.. .#. ..#'),\n  ')': g('#.. .#. ..# ..# ..# .#. #..'),\n  '[': g('### #.. #.. #.. #.. #.. ###'),\n  ']': g('### ..# ..# ..# ..# ..# ###'),\n  '<': g('...# ..#. .#.. #... .#.. ..#. ...#'),\n  '>': g('#... .#.. ..#. ...# ..#. .#.. #...'),\n  '#': g('.#.#. .#.#. ##### .#.#. ##### .#.#. .#.#.'),\n  '%': g('##... ##..# ...#. ..#.. .#... #..## ...##'),\n  '&': g('.##.. #..#. #.#.. .#... #.#.# #..#. .##.#'),\n  '*': g('..... ..#.. #.#.# .###. #.#.# ..#.. .....'),\n  \"'\": g('# # . . . . .'),\n  '\"': g('#.# #.# ... ... ... ... ...'),\n  '@': g('.###. #...# #.### #.#.# #.### #.... .###.'),\n  '$': g('..#.. .#### #.#.. .###. ..#.# ####. ..#..'),\n  '^': g('..#.. .#.#. #...# ..... ..... ..... .....'),\n  '~': g('..... ..... .#... #.#.# ...#. ..... .....'),\n  '|': g('# # # # # # #'),\n};\n\nconst TINY = {\n  A: g('.#. #.# ### #.# #.#'),\n  B: g('##. #.# ##. #.# ##.'),\n  C: g('.## #.. #.. #.. .##'),\n  D: g('##. #.# #.# #.# ##.'),\n  E: g('### #.. ##. #.. ###'),\n  F: g('### #.. ##. #.. #..'),\n  G: g('.## #.. #.# #.# .##'),\n  H: g('#.# #.# ### #.# #.#'),\n  I: g('### .#. .#. .#. ###'),\n  J: g('..# ..# ..# #.# .#.'),\n  K: g('#.# #.# ##. #.# #.#'),\n  L: g('#.. #.. #.. #.. ###'),\n  M: g('#...# ##.## #.#.# #...# #...#'),\n  N: g('#..# ##.# #.## #..# #..#'),\n  O: g('.#. #.# #.# #.# .#.'),\n  P: g('##. #.# ##. #.. #..'),\n  Q: g('.#. #.# #.# ##. .##'),\n  R: g('##. #.# ##. #.# #.#'),\n  S: g('.## #.. .#. ..# ##.'),\n  T: g('### .#. .#. .#. .#.'),\n  U: g('#.# #.# #.# #.# ###'),\n  V: g('#.# #.# #.# #.# .#.'),\n  W: g('#...# #...# #.#.# ##.## #...#'),\n  X: g('#.# #.# .#. #.# #.#'),\n  Y: g('#.# #.# .#. .#. .#.'),\n  Z: g('### ..# .#. #.. ###'),\n\n  0: g('### #.# #.# #.# ###'),\n  1: g('.#. ##. .#. .#. ###'),\n  2: g('### ..# ### #.. ###'),\n  3: g('### ..# .## ..# ###'),\n  4: g('#.# #.# ### ..# ..#'),\n  5: g('### #.. ### ..# ###'),\n  6: g('### #.. ### #.# ###'),\n  7: g('### ..# ..# ..# ..#'),\n  8: g('### #.# ### #.# ###'),\n  9: g('### #.# ### ..# ###'),\n\n  ' ': g('.. .. .. .. ..'),\n  '.': g('. . . . #'),\n  ',': g('.. .. .. .# #.'),\n  ':': g('. # . # .'),\n  ';': g('.. .# .. .# #.'),\n  '!': g('# # # . #'),\n  '?': g('##. ..# .#. ... .#.'),\n  '-': g('... ... ### ... ...'),\n  '_': g('... ... ... ... ###'),\n  '+': g('... .#. ### .#. ...'),\n  '=': g('... ### ... ### ...'),\n  '/': g('..# ..# .#. #.. #..'),\n  '\\\\': g('#.. #.. .#. ..# ..#'),\n  '(': g('.# #. #. #. .#'),\n  ')': g('#. .# .# .# #.'),\n  '[': g('## #. #. #. ##'),\n  ']': g('## .# .# .# ##'),\n  '<': g('..# .#. #.. .#. ..#'),\n  '>': g('#.. .#. ..# .#. #..'),\n  '#': g('.#.#. ##### .#.#. ##### .#.#.'),\n  '%': g('#.# ..# .#. #.. #.#'),\n  '&': g('.#.. #.#. .#.. #.#. .#.#'),\n  '*': g('... #.# .#. #.# ...'),\n  \"'\": g('# # . . .'),\n  '\"': g('#.# #.# ... ... ...'),\n  '@': g('.##. #..# #.## #... .##.'),\n  '$': g('.## ##. .#. .## ##.'),\n  '^': g('.#. #.# ... ... ...'),\n  '~': g('.... .#.# #.#. .... ....'),\n  '|': g('# # # # #'),\n};\n\nconst BOLD = {\n  A: g('.####. ##..## ##..## ###### ##..## ##..## ##..##'),\n  B: g('#####. ##..## ##..## #####. ##..## ##..## #####.'),\n  C: g('.##### ##.... ##.... ##.... ##.... ##.... .#####'),\n  D: g('#####. ##..## ##..## ##..## ##..## ##..## #####.'),\n  E: g('###### ##.... ##.... #####. ##.... ##.... ######'),\n  F: g('###### ##.... ##.... #####. ##.... ##.... ##....'),\n  G: g('.##### ##.... ##.... ##.### ##..## ##..## .#####'),\n  H: g('##..## ##..## ##..## ###### ##..## ##..## ##..##'),\n  I: g('#### .##. .##. .##. .##. .##. ####'),\n  J: g('....## ....## ....## ....## ....## ##..## .####.'),\n  K: g('##..## ##.##. ####.. ###... ####.. ##.##. ##..##'),\n  L: g('##.... ##.... ##.... ##.... ##.... ##.... ######'),\n  M: g('##...## ###.### ####### ##.#.## ##...## ##...## ##...##'),\n  N: g('##..## ###.## ###### ##.### ##..## ##..## ##..##'),\n  O: g('.####. ##..## ##..## ##..## ##..## ##..## .####.'),\n  P: g('#####. ##..## ##..## #####. ##.... ##.... ##....'),\n  Q: g('.####. ##..## ##..## ##..## ##.### .####. ....##'),\n  R: g('#####. ##..## ##..## #####. ##.##. ##..## ##..##'),\n  S: g('.##### ##.... ##.... .####. ....## ....## #####.'),\n  T: g('###### ..##.. ..##.. ..##.. ..##.. ..##.. ..##..'),\n  U: g('##..## ##..## ##..## ##..## ##..## ##..## .####.'),\n  V: g('##..## ##..## ##..## ##..## ##..## .####. ..##..'),\n  W: g('##...## ##...## ##...## ##.#.## ####### ###.### ##...##'),\n  X: g('##..## ##..## .####. ..##.. .####. ##..## ##..##'),\n  Y: g('##..## ##..## .####. ..##.. ..##.. ..##.. ..##..'),\n  Z: g('###### ....## ...##. ..##.. .##... ##.... ######'),\n\n  0: g('.####. ##..## ##.### ###### ###.## ##..## .####.'),\n  1: g('..##.. .###.. ..##.. ..##.. ..##.. ..##.. .####.'),\n  2: g('.####. ##..## ....## ...##. ..##.. .##... ######'),\n  3: g('#####. ....## ....## .####. ....## ....## #####.'),\n  4: g('...### ..#### .##.## ##..## ###### ....## ....##'),\n  5: g('###### ##.... #####. ....## ....## ##..## .####.'),\n  6: g('.####. ##.... ##.... #####. ##..## ##..## .####.'),\n  7: g('###### ....## ...##. ..##.. ..##.. ..##.. ..##..'),\n  8: g('.####. ##..## ##..## .####. ##..## ##..## .####.'),\n  9: g('.####. ##..## ##..## .##### ....## ....## .####.'),\n\n  ' ': g('... ... ... ... ... ... ...'),\n  '.': g('.. .. .. .. .. ## ##'),\n  ',': g('.. .. .. .. ## .# #.'),\n  ':': g('.. ## ## .. ## ## ..'),\n  ';': g('.. ## ## .. ## .# #.'),\n  '!': g('## ## ## ## ## .. ##'),\n  '?': g('.####. ##..## ....## ...##. ..##.. ...... ..##..'),\n  '-': g('..... ..... ..... ##### ..... ..... .....'),\n  '_': g('...... ...... ...... ...... ...... ...... ######'),\n  '+': g('...... ..##.. ..##.. ###### ..##.. ..##.. ......'),\n  '=': g('...... ...... ###### ...... ###### ...... ......'),\n  '/': g('....## ....## ...##. ..##.. .##... ##.... ##....'),\n  '\\\\': g('##.... ##.... .##... ..##.. ...##. ....## ....##'),\n  '(': g('.## ##. ##. ##. ##. ##. .##'),\n  ')': g('##. .## .## .## .## .## ##.'),\n  '[': g('### ##. ##. ##. ##. ##. ###'),\n  ']': g('### .## .## .## .## .## ###'),\n  '<': g('...## ..##. .##.. ##... .##.. ..##. ...##'),\n  '>': g('##... .##.. ..##. ...## ..##. .##.. ##...'),\n  '#': g('.#..#. ###### .#..#. .#..#. .#..#. ###### .#..#.'),\n  '%': g('##...# ##..## ...##. ..##.. .##... ##..## #...##'),\n  '&': g('.###.. ##.##. .###.. .###.# ##.### ##..## .###.#'),\n  '*': g('...... .#..#. ..##.. ###### ..##.. .#..#. ......'),\n  \"'\": g('## ## .. .. .. .. ..'),\n  '\"': g('##.## ##.## ..... ..... ..... ..... .....'),\n  '@': g('.####. ##..## ##.### ##.### ##.##. ##.... .#####'),\n  '$': g('..#... .##### ##.#.. .####. ..#.## #####. ..#...'),\n  '^': g('..##.. .####. ##..## ...... ...... ...... ......'),\n  '~': g('...... ...... .###.# #.###. ...... ...... ......'),\n  '|': g('## ## ## ## ## ## ##'),\n};\n\n/**\n * Build a font from '#'/'.' rows: defineFont('cyber', 5, 7, 0, { A: ['.###.', …], … }).\n * Pass the result anywhere a font name is accepted (banner, measureBanner, glyph, …).\n */\nexport function defineFont(name, width, height, descent, rows) {\n  return compile(name, width, height, descent, rows);\n}\n\nfunction compile(name, width, height, descent, rows) {\n  const glyphs = {};\n  for (const [ch, r] of Object.entries(rows)) {\n    const w = r[0].length;\n    const bits = new Uint8Array(w * r.length);\n    r.forEach((line, y) => { for (let x = 0; x < w; x++) bits[y * w + x] = line[x] === '#' ? 1 : 0; });\n    glyphs[ch] = { ch, w, h: r.length, rows: r, bits };\n  }\n  // Tofu: a hollow box for characters the font doesn't know.\n  const tw = Math.max(3, width - 1), trows = [];\n  for (let y = 0; y < height; y++) trows.push(y === 0 || y === height - 1 ? '#'.repeat(tw) : '#' + '.'.repeat(tw - 2) + '#');\n  const tbits = new Uint8Array(tw * height);\n  trows.forEach((line, y) => { for (let x = 0; x < tw; x++) tbits[y * tw + x] = line[x] === '#' ? 1 : 0; });\n  const tofu = { ch: '�', w: tw, h: height, rows: trows, bits: tbits };\n  return { name, width, height, descent, spacing: 1, lineSpacing: 1, glyphs, tofu };\n}\n\nexport const FONTS = {\n  standard: compile('standard', 5, 7, 1, STANDARD),\n  tiny: compile('tiny', 3, 5, 0, TINY),\n  bold: compile('bold', 6, 7, 0, BOLD),\n};\n\nexport const FONT_NAMES = Object.keys(FONTS);\n\n/** Resolve a font by name (or pass a font object through). Unknown names → standard. */\nexport function getFont(font = 'standard') {\n  if (font && typeof font === 'object' && font.glyphs) return font;\n  return FONTS[font] || FONTS.standard;\n}\n\n/**\n * Glyph for a character: { ch, w, h, rows, bits } where bits is a w×h 0/1 array.\n * Falls back to uppercase, then the font's tofu box.\n */\nexport function glyph(font, ch) {\n  const f = getFont(font);\n  return f.glyphs[ch] || f.glyphs[String(ch).toUpperCase()] || f.tofu;\n}\n\n/** True if the font has a dedicated glyph for `ch` (no uppercase/tofu fallback). */\nexport function hasGlyph(font, ch) {\n  return Object.prototype.hasOwnProperty.call(getFont(font).glyphs, ch);\n}\n",
  "prism-term/src/text/index.js": "// Big text, fonts and text effects.\nexport { FONTS, FONT_NAMES, getFont, glyph, hasGlyph, defineFont } from './fonts.js';\nexport { BANNER_MODES, banner, bannerCanvas, drawBanner, measureBanner, gradientText, layoutText, resolveFill } from './banner.js';\nexport {\n  EFFECTS, EFFECT_NAMES, effectDuration, animateText,\n  typewriter, decrypt, glitch, wave, rainbow, shimmer, fadeIn, pulse, marquee, wipe,\n} from './effects.js';\n",
  "prism-term/src/theme-detect.js": "// Day / Night auto-detection — pick Prism Day on light-background terminals.\n//\n// Order (first answer wins):\n//   1. an explicit theme (--theme) — the caller passes it and never asks us\n//   2. PRISM_TERM_THEME=<theme id> | day | light | night | dark | auto\n//   3. COLORFGBG (rxvt, Konsole, iTerm2, …): \"fg;bg\" or \"fg;default;bg\" ANSI indexes\n//   4. an OSC 11 background-color query (async, opt-in via detectTheme())\n//   5. Prism Night (the default)\n//\n// Signals 3–4 describe the terminal we draw on, so they are used only when stdout\n// is a TTY and not in CI: pipes, `> file`, curl-site and catalog builds stay\n// deterministic. The OSC 11 query additionally needs stdin to be a TTY in cooked\n// mode; it is bounded by a short timeout, pairs the query with a DA1 request (which\n// every terminal answers, in order) so terminals that ignore OSC 11 end the wait\n// at once, swallows the replies so nothing reaches the shell, and hands any keys\n// typed meanwhile back to stdin.\n//\n// Browser-safe: no node:* imports; `process` is read through globalThis.\n\nimport { toOklab } from './color.js';\n\n/** OKLab lightness above which a background counts as light (tokens: L(bg) > 0.6 → Day). */\nexport const LIGHT_L = 0.6;\n\nconst ALIASES = { day: 'light', light: 'light', night: 'dark', dark: 'dark' };\nconst proc = () => globalThis.process;\n\n/** 'light' | 'dark' for a packed background color. */\nexport function modeOfColor(c) {\n  return toOklab(c & 0xffffff)[0] > LIGHT_L ? 'light' : 'dark';\n}\n\n/**\n * Parse COLORFGBG. The last field is the background ANSI index: 7 and 9–15 are\n * light backgrounds, 0–6 and 8 dark (the vim / rxvt reading). 'default' or junk → null.\n */\nexport function parseColorFgBg(v) {\n  if (typeof v !== 'string' || !v.trim()) return null;\n  const last = v.trim().split(';').pop().trim();\n  if (!/^\\d{1,3}$/.test(last)) return null;\n  const n = Number(last);\n  if (n > 15) return null;\n  return n === 7 || n >= 9 ? 'light' : 'dark';\n}\n\n/**\n * Parse an OSC 10/11 color reply (or just its payload) into a packed color:\n * `ESC ] 11 ; rgb:RRRR/GGGG/BBBB (BEL | ESC \\)`, also rgba:…, #rgb, #rrggbb, #rrrrggggbbbb.\n * Each component may have 1–4 hex digits (scaled to 8 bits). Returns null if unparseable.\n */\nexport function parseOscColor(reply) {\n  if (typeof reply !== 'string') return null;\n  const m = reply.match(/(?:\\x1b\\]1[01];)?(rgba?:[0-9a-f/]+|#[0-9a-f]+)/i);\n  if (!m) return null;\n  const spec = m[1];\n  let parts;\n  if (spec[0] === '#') {\n    const h = spec.slice(1);\n    if (h.length % 3 || h.length < 3 || h.length > 12) return null;\n    const n = h.length / 3;\n    parts = [h.slice(0, n), h.slice(n, 2 * n), h.slice(2 * n)];\n  } else {\n    parts = spec.slice(spec.indexOf(':') + 1).split('/').slice(0, 3);\n  }\n  if (parts.length !== 3 || !parts.every((p) => /^[0-9a-f]{1,4}$/i.test(p))) return null;\n  const [r, g, b] = parts.map((p) => Math.round((parseInt(p, 16) / (16 ** p.length - 1)) * 255));\n  return (r << 16) | (g << 8) | b;\n}\n\n/** The theme id for a mode within a design-system family (default: Prism). */\nexport function themeForMode(mode, family = 'prism') {\n  return `${family}-${mode === 'light' ? 'light' : 'dark'}`;\n}\n\nconst isCI = (env) => !!(env.CI || env.CONTINUOUS_INTEGRATION || env.BUILD_NUMBER || env.GITHUB_ACTIONS || env.TF_BUILD);\n\n/**\n * Synchronous detection from the environment (steps 2–3).\n * @returns {{ id?: string, mode: 'light'|'dark', source: string } | null}\n *          id is set only for an explicit PRISM_TERM_THEME theme id.\n */\nexport function detectThemeSync({ env = proc()?.env || {}, stdout = proc()?.stdout } = {}) {\n  const want = String(env.PRISM_TERM_THEME || '').trim().toLowerCase();\n  if (want && want !== 'auto') {\n    if (ALIASES[want]) return { mode: ALIASES[want], source: 'PRISM_TERM_THEME' };\n    return { id: want, mode: /-light$/.test(want) ? 'light' : 'dark', source: 'PRISM_TERM_THEME' };\n  }\n  if (!stdout || !stdout.isTTY || isCI(env)) return null;\n  const mode = parseColorFgBg(env.COLORFGBG);\n  return mode ? { mode, source: 'COLORFGBG' } : null;\n}\n\n/** Can we ask this terminal for its background (step 4)? */\nexport function canQueryBackground({ env = proc()?.env || {}, stdin = proc()?.stdin, stdout = proc()?.stdout } = {}) {\n  if (!stdin || !stdout || !stdin.isTTY || !stdout.isTTY) return false;\n  if (typeof stdin.setRawMode !== 'function' || stdin.isRaw) return false; // someone else owns stdin\n  if (isCI(env) || /^(1|true|yes)$/i.test(env.PRISM_TERM_NO_QUERY || '')) return false;\n  const term = env.TERM || '';\n  if (term === 'dumb' || term === 'linux' || /^(vt[0-9]|cons)/.test(term)) return false; // consoles that never answer\n  return true;\n}\n\nconst OSC11_REPLY = /\\x1b\\]11;([^\\x07\\x1b]*)(?:\\x07|\\x1b\\\\)/;\nconst DA1_REPLY = /\\x1b\\[\\?[0-9;]*c/;\n\n/**\n * Ask the terminal for its background color (OSC 11). Resolves to a packed color,\n * or null on timeout / no answer / not a TTY. Never rejects, never leaves stdin raw.\n * @param {object} o { stdin, stdout, env, platform, timeout (ms, default 100 or PRISM_TERM_QUERY_MS) }\n */\nexport function queryBackground({ stdin = proc()?.stdin, stdout = proc()?.stdout, env = proc()?.env || {}, platform = proc()?.platform || '', timeout } = {}) {\n  if (!canQueryBackground({ env, stdin, stdout })) return Promise.resolve(null);\n  const ms = Math.max(10, Math.min(1000, Number(timeout ?? env.PRISM_TERM_QUERY_MS ?? 100) || 100));\n  // Windows' ConPTY answers DA1 itself, possibly before the terminal answers OSC 11,\n  // so there only the OSC reply (or the timeout) ends the wait.\n  const sentinel = platform !== 'win32';\n  return new Promise((resolve) => {\n    let buf = '', done = false, timer = null;\n    const finish = (color) => {\n      if (done) return;\n      done = true;\n      clearTimeout(timer);\n      stdin.removeListener('data', onData);\n      try { stdin.setRawMode(false); } catch { /* stream closed */ }\n      stdin.pause();\n      // Keys typed during the query go back to whoever reads stdin next.\n      const rest = buf.replace(OSC11_REPLY, '').replace(DA1_REPLY, '');\n      if (rest && typeof stdin.unshift === 'function' && globalThis.Buffer) { try { stdin.unshift(globalThis.Buffer.from(rest, 'latin1')); } catch { /* ignore */ } }\n      resolve(color);\n    };\n    const onData = (chunk) => {\n      buf += typeof chunk === 'string' ? chunk : chunk.toString('latin1');\n      const osc = buf.match(OSC11_REPLY);\n      if (osc && (!sentinel || DA1_REPLY.test(buf.slice(osc.index + osc[0].length)))) return finish(parseOscColor(osc[1]));\n      if (!osc && sentinel && DA1_REPLY.test(buf)) return finish(null); // answered DA1 only: no OSC 11 support\n    };\n    try {\n      stdin.setRawMode(true);\n      stdin.on('data', onData);\n      stdin.resume();\n      timer = setTimeout(() => finish(buf.match(OSC11_REPLY) ? parseOscColor(buf.match(OSC11_REPLY)[1]) : null), ms);\n      stdout.write(sentinel ? '\\x1b]11;?\\x1b\\\\\\x1b[c' : '\\x1b]11;?\\x1b\\\\');\n    } catch {\n      finish(null);\n    }\n  });\n}\n\n// ── cached result, consumed by palettes.getTheme() for its default ───────────\nlet detected; // undefined = not computed; null = nothing found\n\n/**\n * Full detection (steps 2–4), cached for getTheme()'s default. Call once at\n * startup (before rendering) from a CLI; skips the query for pipes / CI.\n * @returns {Promise<{ id: string, mode: 'light'|'dark', source: string }>}\n */\nexport async function detectTheme(o = {}) {\n  const sync = detectThemeSync(o);\n  if (sync) return (detected = { id: sync.id || themeForMode(sync.mode, o.family), mode: sync.mode, source: sync.source });\n  const bg = await queryBackground(o);\n  if (bg != null) {\n    const mode = modeOfColor(bg);\n    return (detected = { id: themeForMode(mode, o.family), mode, source: 'OSC 11', background: bg });\n  }\n  detected = null;\n  return { id: themeForMode('dark', o.family), mode: 'dark', source: 'default' };\n}\n\n/** The detected { id, mode, source } (sync detection if detectTheme() hasn't run), or null. */\nexport function detectedTheme() {\n  if (detected === undefined) {\n    const sync = detectThemeSync();\n    detected = sync ? { id: sync.id || themeForMode(sync.mode), mode: sync.mode, source: sync.source } : null;\n  }\n  return detected;\n}\n\n/** Forget the cached result (tests, or after the terminal changes). Pass a value to set it. */\nexport function resetThemeDetection(value) { detected = value; }\n",
  "prism-term/src/themes.js": "// GENERATED by scripts/sync-themes.mjs from prism-mcp-server/utils/themes.js — do not edit.\n// Prism's design-system themes, reduced to the color tokens a terminal can use.\n\nexport const DEFAULT_THEME_ID = 'prism-dark';\n\nexport const THEME_DATA = [\n  { id: 'prism-dark', name: \"Prism Night\", ds: 'prism', mode: 'dark', bg: '#0a0c12', panel: '#10131c', panel2: '#171a25', card: '#12151f', line: '#252834', ink: '#f0f0f5', muted: '#a2a4b4', dim: '#8a8c9d', accent: '#b8a2f2', accent2: '#a8b9fc', info: '#86bdf9', pos: '#8bd8ba', warn: '#eec36f', neg: '#f5877f', crit: '#f07fb3' },\n  { id: 'prism-light', name: \"Prism Day\", ds: 'prism', mode: 'light', bg: '#f6f5f9', panel: '#ffffff', panel2: '#f0eff6', card: '#fdfcff', line: '#dedce9', ink: '#20202e', muted: '#55556a', dim: '#68687c', accent: '#7353bb', accent2: '#4a62c6', info: '#2461bf', pos: '#1f6b4f', warn: '#8a5a00', neg: '#bf3434', crit: '#b0256f' },\n  { id: 'cloudscape-dark', name: \"Cloudscape Dark\", ds: 'cloudscape', mode: 'dark', bg: '#0f1621', panel: '#192534', panel2: '#232f3e', card: '#1a2633', line: '#354150', ink: '#e9ebed', muted: '#a4b0c0', dim: '#6b7887', accent: '#539fe5', accent2: '#539fe5', info: '#539fe5', pos: '#37c26b', warn: '#f5b74e', neg: '#eb6f6f', crit: '#c471ff' },\n  { id: 'cloudscape-light', name: \"Cloudscape Light\", ds: 'cloudscape', mode: 'light', bg: '#f2f3f3', panel: '#ffffff', panel2: '#fafafa', card: '#ffffff', line: '#d5dbdb', ink: '#16191f', muted: '#5f6b7a', dim: '#8d99a8', accent: '#0972d3', accent2: '#0972d3', info: '#0972d3', pos: '#037f0c', warn: '#e07400', neg: '#d91515', crit: '#8b5cf6' },\n  { id: 'duolingo-dark', name: \"Duolingo Dark\", ds: 'duolingo', mode: 'dark', bg: '#131f24', panel: '#202f36', panel2: '#1b2a30', card: '#202f36', line: '#37464f', ink: '#f1f7fb', muted: '#a5b4bd', dim: '#6b7c85', accent: '#58cc02', accent2: '#58cc02', info: '#1cb0f6', pos: '#89e219', warn: '#ff9600', neg: '#ff4b4b', crit: '#ce82ff' },\n  { id: 'duolingo-light', name: \"Duolingo Light\", ds: 'duolingo', mode: 'light', bg: '#f7f7f7', panel: '#ffffff', panel2: '#fbfbfb', card: '#ffffff', line: '#e5e5e5', ink: '#3c3c3c', muted: '#777777', dim: '#afafaf', accent: '#58cc02', accent2: '#58cc02', info: '#1cb0f6', pos: '#89e219', warn: '#ff9600', neg: '#ff4b4b', crit: '#ce82ff' },\n  { id: 'mailchimp-dark', name: \"Mailchimp Dark\", ds: 'mailchimp', mode: 'dark', bg: '#241c15', panel: '#302720', panel2: '#2a221b', card: '#302720', line: '#463b30', ink: '#f7f3ea', muted: '#c2b6a4', dim: '#8a7d6c', accent: '#ffe01b', accent2: '#ffe01b', info: '#3fb6c3', pos: '#5cc95c', warn: '#ffab3f', neg: '#f0685a', crit: '#e069a0' },\n  { id: 'mailchimp-light', name: \"Mailchimp Light\", ds: 'mailchimp', mode: 'light', bg: '#fbf9f4', panel: '#ffffff', panel2: '#f6f2e9', card: '#ffffff', line: '#e6e0d3', ink: '#241c15', muted: '#6b6357', dim: '#a89f90', accent: '#ffe01b', accent2: '#ffe01b', info: '#007c89', pos: '#3caa3c', warn: '#ff9d1c', neg: '#e0503f', crit: '#c8467c' },\n  { id: 'stackoverflow-dark', name: \"Stack Overflow Dark\", ds: 'stackoverflow', mode: 'dark', bg: '#1e1e1e', panel: '#2d2d2d', panel2: '#262626', card: '#2d2d2d', line: '#3d3d3d', ink: '#e7e8eb', muted: '#9fa6ad', dim: '#6e767d', accent: '#f48024', accent2: '#f48024', info: '#4aa3ff', pos: '#5eba7d', warn: '#e3b341', neg: '#f4676c', crit: '#c264d6' },\n  { id: 'stackoverflow-light', name: \"Stack Overflow Light\", ds: 'stackoverflow', mode: 'light', bg: '#f8f9f9', panel: '#ffffff', panel2: '#f1f2f3', card: '#ffffff', line: '#d6d9dc', ink: '#232629', muted: '#6a737c', dim: '#9fa6ad', accent: '#f48024', accent2: '#f48024', info: '#0074cc', pos: '#2f6f44', warn: '#b8860b', neg: '#d1383d', crit: '#9c2bad' },\n  { id: 'monzo-dark', name: \"Monzo Dark\", ds: 'monzo', mode: 'dark', bg: '#06060a', panel: '#14161c', panel2: '#1c1f27', card: '#14161c', line: '#2a2e38', ink: '#f4f5f7', muted: '#a2a7b3', dim: '#6b7280', accent: '#ff4f40', accent2: '#ff4f40', info: '#1fc7d6', pos: '#6cc551', warn: '#ffc266', neg: '#f2555a', crit: '#b46ce6' },\n  { id: 'monzo-light', name: \"Monzo Light\", ds: 'monzo', mode: 'light', bg: '#fafbfc', panel: '#ffffff', panel2: '#f2f4f8', card: '#ffffff', line: '#e6e9ef', ink: '#14233c', muted: '#6b7385', dim: '#9aa2b1', accent: '#ff4f40', accent2: '#ff4f40', info: '#00a4b3', pos: '#52b03a', warn: '#ffb74a', neg: '#e5484d', crit: '#9d4edd' },\n  { id: 'heroku-dark', name: \"Heroku Dark\", ds: 'heroku', mode: 'dark', bg: '#1a1523', panel: '#2a2338', panel2: '#201a2b', card: '#2a2338', line: '#3a3350', ink: '#f2eef8', muted: '#b0a6c2', dim: '#7a7090', accent: '#79589f', accent2: '#79589f', info: '#8b96e2', pos: '#56c483', warn: '#edb455', neg: '#e26075', crit: '#cc63b3' },\n  { id: 'heroku-light', name: \"Heroku Light\", ds: 'heroku', mode: 'light', bg: '#faf9fc', panel: '#ffffff', panel2: '#f4f1f9', card: '#ffffff', line: '#e4e0ec', ink: '#2a2734', muted: '#6f6a7d', dim: '#a49db3', accent: '#79589f', accent2: '#79589f', info: '#6f7bd6', pos: '#3fae6b', warn: '#e0a13a', neg: '#d0455f', crit: '#b5479f' },\n  { id: 'polaris-dark', name: \"Shopify Polaris Dark\", ds: 'polaris', mode: 'dark', bg: '#0b0c0d', panel: '#202223', panel2: '#1a1c1d', card: '#202223', line: '#3f4246', ink: '#e3e5e7', muted: '#999fa4', dim: '#71767a', accent: '#008060', accent2: '#008060', info: '#4b9bff', pos: '#4ade80', warn: '#e6b800', neg: '#ff6b52', crit: '#ff5470' },\n  { id: 'polaris-light', name: \"Shopify Polaris Light\", ds: 'polaris', mode: 'light', bg: '#f6f6f7', panel: '#ffffff', panel2: '#fafbfb', card: '#ffffff', line: '#e1e3e5', ink: '#202223', muted: '#6d7175', dim: '#8c9196', accent: '#008060', accent2: '#008060', info: '#2c6ecb', pos: '#007f5f', warn: '#b98900', neg: '#d72c0d', crit: '#bf0711' },\n  { id: 'primer-dark', name: \"GitHub Primer Dark\", ds: 'primer', mode: 'dark', bg: '#0d1117', panel: '#161b22', panel2: '#161b22', card: '#161b22', line: '#30363d', ink: '#e6edf3', muted: '#7d8590', dim: '#6e7681', accent: '#2f81f7', accent2: '#2f81f7', info: '#2f81f7', pos: '#3fb950', warn: '#d29922', neg: '#f85149', crit: '#a371f7' },\n  { id: 'primer-light', name: \"GitHub Primer Light\", ds: 'primer', mode: 'light', bg: '#ffffff', panel: '#f6f8fa', panel2: '#f6f8fa', card: '#ffffff', line: '#d1d9e0', ink: '#1f2328', muted: '#59636e', dim: '#6e7781', accent: '#0969da', accent2: '#0969da', info: '#0969da', pos: '#1a7f37', warn: '#9a6700', neg: '#cf222e', crit: '#8250df' },\n  { id: 'antd-dark', name: \"Ant Design Dark\", ds: 'antd', mode: 'dark', bg: '#141414', panel: '#1f1f1f', panel2: '#262626', card: '#1f1f1f', line: '#424242', ink: '#e6e6e6', muted: '#a6a6a6', dim: '#737373', accent: '#1668dc', accent2: '#1668dc', info: '#1668dc', pos: '#49aa19', warn: '#d89614', neg: '#dc4446', crit: '#cb2b83' },\n  { id: 'antd-light', name: \"Ant Design Light\", ds: 'antd', mode: 'light', bg: '#f5f5f5', panel: '#ffffff', panel2: '#fafafa', card: '#ffffff', line: '#d9d9d9', ink: '#141414', muted: '#595959', dim: '#8c8c8c', accent: '#1677ff', accent2: '#1677ff', info: '#1677ff', pos: '#52c41a', warn: '#faad14', neg: '#ff4d4f', crit: '#eb2f96' },\n  { id: 'acorn-dark', name: \"Firefox Acorn Dark\", ds: 'acorn', mode: 'dark', bg: '#1c1b22', panel: '#2b2a33', panel2: '#42414d', card: '#2b2a33', line: '#52525e', ink: '#fbfbfe', muted: '#bfbfc9', dim: '#8f8f9d', accent: '#ff7139', accent2: '#ff7139', info: '#0a84ff', pos: '#2ac3a2', warn: '#ffbd4f', neg: '#ff6a75', crit: '#c069ff' },\n  { id: 'acorn-light', name: \"Firefox Acorn Light\", ds: 'acorn', mode: 'light', bg: '#f9f9fb', panel: '#ffffff', panel2: '#f0f0f4', card: '#ffffff', line: '#cfcfd8', ink: '#15141a', muted: '#5b5b66', dim: '#8f8f9d', accent: '#ff7139', accent2: '#ff7139', info: '#0060df', pos: '#017a5a', warn: '#a47f00', neg: '#d70022', crit: '#8000d7' },\n  { id: 'material3-dark', name: \"Material Design 3 Dark\", ds: 'material3', mode: 'dark', bg: '#1c1b1f', panel: '#1d1b20', panel2: '#2b2930', card: '#211f26', line: '#49454f', ink: '#e6e1e5', muted: '#cac4d0', dim: '#938f99', accent: '#d0bcff', accent2: '#efb8c8', info: '#9fcaff', pos: '#6edd8a', warn: '#f5bd3f', neg: '#f2b8b5', crit: '#efb8c8' },\n  { id: 'material3-light', name: \"Material Design 3 Light\", ds: 'material3', mode: 'light', bg: '#fffbfe', panel: '#f7f2fa', panel2: '#ece6f0', card: '#f3edf7', line: '#cac4d0', ink: '#1c1b1f', muted: '#49454f', dim: '#79747e', accent: '#6750a4', accent2: '#7d5260', info: '#35618e', pos: '#146c2e', warn: '#7d5700', neg: '#b3261e', crit: '#7d5260' },\n  { id: 'cloudflare-dark', name: \"Cloudflare Dark\", ds: 'cloudflare', mode: 'dark', bg: '#1b1b1c', panel: '#232325', panel2: '#2c2c2f', card: '#262628', line: '#3a3a3d', ink: '#f2f2f2', muted: '#b8b8bb', dim: '#9a9b9f', accent: '#f6821f', accent2: '#faae40', info: '#6ea8fe', pos: '#3fcf8e', warn: '#faae40', neg: '#ff6b5e', crit: '#c792ea' },\n  { id: 'cloudflare-light', name: \"Cloudflare Light\", ds: 'cloudflare', mode: 'light', bg: '#f2f2f2', panel: '#ffffff', panel2: '#f7f7f8', card: '#ffffff', line: '#d9d9d9', ink: '#1d1d1d', muted: '#4f4f50', dim: '#646568', accent: '#c25400', accent2: '#f38020', info: '#0051c3', pos: '#0a7a3e', warn: '#9a5a00', neg: '#c8231d', crit: '#7a3fb8' },\n];\n\n// Terminal color schemes for the Prism themes — source: design/tokens.json.\n// ansi: the 16 ANSI slots (0–7 normal, 8–15 bright) a terminal set to this scheme\n// shows; roles: the [text, fill] slot each theme token is drawn with at 16-color\n// depth (-1 = the terminal's default color); tokens256 / ansi256: designed xterm-256\n// indexes (text and ANSI text slots: >= 4.5:1 on every 256-color surface).\nexport const TERMINAL_SCHEMES = {\n  'prism-dark': {\n    background: '#0a0c12', foreground: '#e6e6ef', cursor: '#b8a2f2', cursorText: '#0a0c12', selectionBackground: '#2e2748', selectionForeground: '#f0f0f5',\n    ansi: ['#1d202b', '#f28885', '#7ad59c', '#ecca76', '#81aaf7', '#b8a2f2', '#67cfe3', '#c6c7d3',\n      '#76798e', '#ffaaa7', '#a5eac1', '#f7e59f', '#aac8ff', '#d9c6fe', '#9eeaf2', '#f6f6fb'],\n    ansi256: [234, 210, 79, 186, 111, 147, 80, 251, 102, 217, 151, 222, 153, 189, 152, 231],\n    roles: { bg: [0, -1], panel: [0, 0], panel2: [0, 0], card: [0, 0], line: [8, 0], ink: [-1, 15], muted: [7, 7], dim: [8, 8], accent: [5, 5], accent2: [12, 12], info: [4, 4], pos: [2, 2], warn: [3, 3], neg: [1, 1], crit: [13, 13] },\n    tokens256: { bg: 232, panel: 233, panel2: 234, card: 233, line: 235, ink: 255, muted: 248, dim: 245, accent: 147, accent2: 147, info: 111, pos: 115, warn: 215, neg: 210, crit: 211 },\n  },\n  'prism-light': {\n    background: '#f8f7fb', foreground: '#23222f', cursor: '#7353bb', cursorText: '#ffffff', selectionBackground: '#ddd5f3', selectionForeground: '#20202e',\n    ansi: ['#23222f', '#b73232', '#007240', '#835c00', '#2a5ec5', '#7353bb', '#006d7d', '#e6e5ee',\n      '#55546a', '#cf354b', '#00823a', '#8d6c00', '#006fd8', '#a04ac2', '#007c87', '#ffffff'],\n    ansi256: [235, 124, 22, 94, 26, 61, 24, 254, 240, 160, 22, 94, 26, 91, 23, 231],\n    roles: { bg: [15, -1], panel: [15, -1], panel2: [15, -1], card: [15, -1], line: [8, 7], ink: [-1, 0], muted: [8, 8], dim: [8, 8], accent: [5, 5], accent2: [4, 4], info: [4, 4], pos: [2, 2], warn: [3, 3], neg: [1, 1], crit: [13, 13] },\n    tokens256: { bg: 255, panel: 231, panel2: 255, card: 231, line: 253, ink: 235, muted: 240, dim: 242, accent: 61, accent2: 61, info: 25, pos: 23, warn: 94, neg: 160, crit: 125 },\n  },\n};\n\n// Refraction's spectrum: 12 OKLCH stops, violet → rose (decreasing hue), each with\n// Night/Day ink (text-safe) and vivid (fill) tiers, its ANSI-16 slot, and the ink's\n// designed xterm-256 index (night256 / day256).\nexport const SPECTRUM = [\n  { name: 'violet', h: 296, lambda: 'λ 436', night: '#c2aef8', nightVivid: '#ab91eb', day: '#7555b8', dayVivid: '#9876e3', ansi16: 5, night256: 147, day256: 61 },\n  { name: 'periwinkle', h: 269.8, lambda: 'λ 472', night: '#a2b9ff', nightVivid: '#839ef4', day: '#4961bf', dayVivid: '#6783ec', ansi16: 4, night256: 147, day256: 61 },\n  { name: 'azure', h: 243.6, lambda: 'λ 479', night: '#7fc4fb', nightVivid: '#52acee', day: '#006ca9', dayVivid: '#0090de', ansi16: 4, night256: 117, day256: 25 },\n  { name: 'cerulean', h: 217.5, lambda: 'λ 485', night: '#64ceea', nightVivid: '#00b8db', day: '#007188', dayVivid: '#0096b3', ansi16: 6, night256: 81, day256: 24 },\n  { name: 'cyan', h: 191.3, lambda: 'λ 492', night: '#61d5d0', nightVivid: '#00c3be', day: '#007471', dayVivid: '#009995', ansi16: 6, night256: 80, day256: 23 },\n  { name: 'mint', h: 165.1, lambda: 'λ 510', night: '#7dd9b2', nightVivid: '#52cea0', day: '#007655', dayVivid: '#009c71', ansi16: 2, night256: 115, day256: 23 },\n  { name: 'jade', h: 138.9, lambda: 'λ 554', night: '#a5d996', nightVivid: '#94d582', day: '#2e760f', dayVivid: '#4c9b32', ansi16: 2, night256: 150, day256: 22 },\n  { name: 'lime', h: 112.7, lambda: 'λ 569', night: '#ccd381', nightVivid: '#c9d168', day: '#686c00', dayVivid: '#8a8f00', ansi16: 10, night256: 186, day256: 58 },\n  { name: 'citrine', h: 86.5, lambda: 'λ 578', night: '#eac779', nightVivid: '#ebc059', day: '#836300', dayVivid: '#ac8300', ansi16: 3, night256: 186, day256: 94 },\n  { name: 'amber', h: 60.4, lambda: 'λ 585', night: '#fab880', nightVivid: '#f8a75f', day: '#9c5600', dayVivid: '#cc7300', ansi16: 3, night256: 216, day256: 94 },\n  { name: 'coral', h: 34.2, lambda: 'λ 597', night: '#ffaa96', nightVivid: '#f69077', day: '#b2432a', dayVivid: '#de6448', ansi16: 1, night256: 216, day256: 124 },\n  { name: 'rose', h: 8, lambda: 'λ 494c', night: '#faa2b2', nightVivid: '#ec8398', day: '#b13f5d', dayVivid: '#de5f7d', ansi16: 13, night256: 217, day256: 125 },\n];\n\n// The terminal catalog's gallery hues (galleries.terminal: one λ per gallery across surfaces —\n// a gallery with a web twin takes the web hue, terminal-only ones take free half-steps): ink (text) and vivid (marks) per\n// mode, with designed xterm-256 indexes (ink >= 4.5:1, vivid >= 3:1 on every\n// 256-color surface). src/refraction.js reads these.\nexport const TERMINAL_GALLERIES = [\n  { id: 'fx', h: 279.1, lambda: 'λ 467', night: '#adb5ff', nightVivid: '#9299f3', day: '#5b5cbe', dayVivid: '#7a7eeb', night256: 147, nightVivid256: 105, day256: 61, dayVivid256: 68 },\n  { id: 'objects', h: 228.2, lambda: 'λ 483', night: '#6dcaf2', nightVivid: '#2cb3e4', day: '#007093', dayVivid: '#0094c1', night256: 81, nightVivid256: 38, day256: 24, dayVivid256: 31 },\n  { id: 'sim', h: 270.6, lambda: 'λ 471', night: '#a3b9ff', nightVivid: '#859ef4', day: '#4a60bf', dayVivid: '#6882ec', night256: 147, nightVivid256: 111, day256: 61, dayVivid256: 68 },\n  { id: 'retro', h: 219.8, lambda: 'λ 485', night: '#65cdeb', nightVivid: '#0ab7dd', day: '#00718a', dayVivid: '#0096b6', night256: 81, nightVivid256: 38, day256: 24, dayVivid256: 31 },\n  { id: 'text', h: 194.4, lambda: 'λ 492', night: '#60d4d3', nightVivid: '#00c2c1', day: '#007474', dayVivid: '#009999', night256: 80, nightVivid256: 37, day256: 23, dayVivid256: 30 },\n  { id: 'loaders', h: 168.9, lambda: 'λ 506', night: '#77d8b7', nightVivid: '#48cda4', day: '#00765a', dayVivid: '#009c78', night256: 115, nightVivid256: 43, day256: 23, dayVivid256: 29 },\n  { id: 'progress', h: 135.1, lambda: 'λ 558', night: '#abd893', nightVivid: '#9dd57e', day: '#3b7500', dayVivid: '#579927', night256: 150, nightVivid256: 114, day256: 22, dayVivid256: 64 },\n  { id: 'charts', h: 296, lambda: 'λ 436', night: '#c2aef8', nightVivid: '#ab91eb', day: '#7555b8', dayVivid: '#9876e3', night256: 147, nightVivid256: 140, day256: 61, dayVivid256: 98 },\n  { id: 'ai', h: 245.2, lambda: 'λ 479', night: '#81c4fc', nightVivid: '#55abef', day: '#006cad', dayVivid: '#008fe2', night256: 117, nightVivid256: 75, day256: 25, dayVivid256: 32 },\n  { id: 'status', h: 143.5, lambda: 'λ 550', night: '#9ed99b', nightVivid: '#89d487', day: '#1a781f', dayVivid: '#3d9c3e', night256: 150, nightVivid256: 114, day256: 22, dayVivid256: 64 },\n  { id: 'ops', h: 84.2, lambda: 'λ 578', night: '#ecc679', nightVivid: '#edbe59', day: '#856200', dayVivid: '#af8200', night256: 186, nightVivid256: 215, day256: 94, dayVivid256: 100 },\n  { id: 'diagrams', h: 126.6, lambda: 'λ 563', night: '#b8d78b', nightVivid: '#afd575', day: '#527100', dayVivid: '#6d9600', night256: 150, nightVivid256: 150, day256: 58, dayVivid256: 64 },\n  { id: 'callouts', h: 109.6, lambda: 'λ 570', night: '#d0d280', nightVivid: '#ced065', day: '#6c6b00', dayVivid: '#8f8e00', night256: 186, nightVivid256: 185, day256: 58, dayVivid256: 100 },\n  { id: 'input', h: 211.3, lambda: 'λ 487', night: '#60d0e4', nightVivid: '#00bbd3', day: '#007282', dayVivid: '#0097ac', night256: 80, nightVivid256: 38, day256: 24, dayVivid256: 31 },\n  { id: 'maps', h: 160.5, lambda: 'λ 519', night: '#83d9ad', nightVivid: '#5fd09a', day: '#00774e', dayVivid: '#009d68', night256: 115, nightVivid256: 79, day256: 22, dayVivid256: 29 },\n  { id: 'lab', h: 262.1, lambda: 'λ 474', night: '#98bcff', nightVivid: '#76a2f4', day: '#3664be', dayVivid: '#5487eb', night256: 147, nightVivid256: 75, day256: 25, dayVivid256: 68 },\n  { id: 'ascii', h: 33.4, lambda: 'λ 597', night: '#ffaa96', nightVivid: '#f68f78', day: '#b2432c', dayVivid: '#df634a', night256: 216, nightVivid256: 209, day256: 124, dayVivid256: 167 },\n];\n",
  "prism-term/src/web/ansi.js": "// ANSI text → CellBuffer, for previewing terminal output (e.g. the curl-able\n// terminal site's pages) with the web renderer. Handles what static ANSI\n// documents use: SGR colors (16, 256, truecolor), bold/dim/italic/underline/\n// inverse/strike, resets, CR/LF and tabs. Cursor-movement sequences are\n// skipped (static pages don't need them).\n\nimport { CellBuffer, ATTR } from '../buffer.js';\nimport { NONE, ANSI16 } from '../color.js';\nimport { charWidth } from '../width.js';\n\nfunction xterm256(n) {\n  if (n < 16) return ANSI16[n];\n  if (n >= 232) { const v = 8 + (n - 232) * 10; return (v << 16) | (v << 8) | v; }\n  const i = n - 16, lv = [0, 95, 135, 175, 215, 255];\n  return (lv[Math.floor(i / 36)] << 16) | (lv[Math.floor(i / 6) % 6] << 8) | lv[i % 6];\n}\n\n/**\n * Parse ANSI text into a CellBuffer as wide as its longest line, between\n * `minWidth` and `maxWidth` columns (lines wider than maxWidth are clipped).\n */\nexport function parseAnsi(text, { minWidth = 1, maxWidth = 200, tabSize = 8 } = {}) {\n  const rows = [];\n  let row = [], fg = NONE, bg = NONE, at = 0;\n  const flush = () => { rows.push(row); row = []; };\n  const s = String(text).replace(/\\r\\n/g, '\\n');\n  for (let i = 0; i < s.length; i++) {\n    const c = s[i];\n    if (c === '\\x1b') {\n      if (s[i + 1] === '[') {\n        let j = i + 2;\n        while (j < s.length && !/[@-~]/.test(s[j])) j++;\n        if (s[j] === 'm') {\n          const ps = s.slice(i + 2, j).split(';').map((v) => (v === '' ? 0 : Number(v)));\n          for (let k = 0; k < ps.length; k++) {\n            const v = ps[k];\n            if (v === 0) { fg = NONE; bg = NONE; at = 0; }\n            else if (v === 1) at |= ATTR.BOLD; else if (v === 2) at |= ATTR.DIM; else if (v === 3) at |= ATTR.ITALIC;\n            else if (v === 4) at |= ATTR.UNDERLINE; else if (v === 7) at |= ATTR.INVERSE; else if (v === 9) at |= ATTR.STRIKE;\n            else if (v === 22) at &= ~(ATTR.BOLD | ATTR.DIM); else if (v === 23) at &= ~ATTR.ITALIC; else if (v === 24) at &= ~ATTR.UNDERLINE;\n            else if (v === 27) at &= ~ATTR.INVERSE; else if (v === 29) at &= ~ATTR.STRIKE;\n            else if (v >= 30 && v <= 37) fg = ANSI16[v - 30]; else if (v >= 90 && v <= 97) fg = ANSI16[v - 82];\n            else if (v >= 40 && v <= 47) bg = ANSI16[v - 40]; else if (v >= 100 && v <= 107) bg = ANSI16[v - 92];\n            else if (v === 39) fg = NONE; else if (v === 49) bg = NONE;\n            else if (v === 38 || v === 48) {\n              let col = NONE;\n              if (ps[k + 1] === 2) { col = ((ps[k + 2] & 255) << 16) | ((ps[k + 3] & 255) << 8) | (ps[k + 4] & 255); k += 4; }\n              else if (ps[k + 1] === 5) { col = xterm256(ps[k + 2] & 255); k += 2; }\n              if (v === 38) fg = col; else bg = col;\n            }\n          }\n        }\n        i = j;\n      } else if (s[i + 1] === ']') {\n        const end = s.indexOf('\\x07', i);\n        i = end < 0 ? s.length : end;\n      } else i++;\n      continue;\n    }\n    if (c === '\\n') { flush(); continue; }\n    if (c === '\\r') continue;\n    if (c === '\\t') { const n = tabSize - (row.length % tabSize); for (let k = 0; k < n; k++) row.push({ ch: ' ', fg, bg, at }); continue; }\n    const cp = s.codePointAt(i);\n    const ch = String.fromCodePoint(cp);\n    if (ch.length === 2) i++;\n    const w = charWidth(cp);\n    if (w === 0) continue;\n    row.push({ ch, fg, bg, at });\n    if (w === 2) row.push({ ch: '', fg, bg, at });\n  }\n  if (row.length) flush();\n  while (rows.length && rows.at(-1).length === 0) rows.pop();\n  const width = Math.min(maxWidth, rows.reduce((w, r) => Math.max(w, r.length), Math.max(1, minWidth)));\n  const b = new CellBuffer(width, Math.max(1, rows.length));\n  rows.forEach((r, y) => r.slice(0, width).forEach((c, x) => b.set(x, y, c.ch, c.fg, c.bg, c.at)));\n  return b;\n}\n",
  "prism-term/src/web/glyphs.js": "// Procedural glyph geometry for the browser renderer.\n//\n// Terminal art leans on four Unicode ranges whose look depends entirely on the\n// font: block elements, braille, sextants and box drawing. Fonts disagree on all\n// of them (gaps between half blocks, missing sextants, braille dots of every\n// size), so — like xterm.js's custom glyphs — the web renderer draws these from\n// geometry instead. This module is the pure, testable half: it describes each\n// glyph in cell-relative units; renderer.js turns that into pixels.\n\n/** Classify a code point: 'block' | 'braille' | 'sextant' | 'box' | null. */\nexport function glyphClass(cp) {\n  if (cp >= 0x2580 && cp <= 0x259f) return 'block';\n  if (cp >= 0x2800 && cp <= 0x28ff) return 'braille';\n  if (cp >= 0x1fb00 && cp <= 0x1fb3b) return 'sextant';\n  if (cp >= 0x2500 && cp <= 0x257f) return 'box';\n  return null;\n}\n\n// ── Block elements ─────────────────────────────────────────────────────────\n// [x, y, w, h] rectangles in cell units (0..1), plus optional alpha (shades).\nconst Q = { ul: [0, 0, 0.5, 0.5], ur: [0.5, 0, 0.5, 0.5], ll: [0, 0.5, 0.5, 0.5], lr: [0.5, 0.5, 0.5, 0.5] };\nconst BLOCKS = new Map([\n  [0x2580, [[0, 0, 1, 0.5]]],\n  ...[1, 2, 3, 4, 5, 6, 7, 8].map((k) => [0x2580 + k, [[0, 1 - k / 8, 1, k / 8]]]),          // ▁ … █\n  ...[7, 6, 5, 4, 3, 2, 1].map((k, i) => [0x2589 + i, [[0, 0, k / 8, 1]]]),                // ▉ … ▏\n  [0x2590, [[0.5, 0, 0.5, 1]]],\n  [0x2594, [[0, 0, 1, 1 / 8]]],\n  [0x2595, [[7 / 8, 0, 1 / 8, 1]]],\n  [0x2596, [Q.ll]], [0x2597, [Q.lr]], [0x2598, [Q.ul]],\n  [0x2599, [Q.ul, Q.ll, Q.lr]], [0x259a, [Q.ul, Q.lr]], [0x259b, [Q.ul, Q.ur, Q.ll]],\n  [0x259c, [Q.ul, Q.ur, Q.lr]], [0x259d, [Q.ur]], [0x259e, [Q.ur, Q.ll]], [0x259f, [Q.ur, Q.ll, Q.lr]],\n]);\nconst SHADES = new Map([[0x2591, 0.25], [0x2592, 0.5], [0x2593, 0.75]]);\n\n/** Rectangles for a block element, or { shade } for ░▒▓. */\nexport function blockGeometry(cp) {\n  if (SHADES.has(cp)) return { shade: SHADES.get(cp) };\n  return { rects: BLOCKS.get(cp) || [] };\n}\n\n// ── Braille ────────────────────────────────────────────────────────────────\n// Dot (dx, dy) ↔ bit, as in src/canvas.js.\nexport const BRAILLE_BITS = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]];\n\n/** [dx, dy] of every raised dot (dx 0..1, dy 0..3). */\nexport function brailleDots(cp) {\n  const mask = cp - 0x2800;\n  const out = [];\n  for (let dy = 0; dy < 4; dy++) for (let dx = 0; dx < 2; dx++) if (mask & BRAILLE_BITS[dy][dx]) out.push([dx, dy]);\n  return out;\n}\n\n// ── Sextants ───────────────────────────────────────────────────────────────\n// U+1FB00 + n encodes masks 1..62 except 21 (left half ▌) and 42 (right half ▐).\n// Bits: 1 TL, 2 TR, 4 ML, 8 MR, 16 BL, 32 BR — the same order src/canvas.js encodes.\nconst SEXTANT_MASK = new Map();\nfor (let m = 1; m < 63; m++) {\n  if (m === 21 || m === 42) continue;\n  SEXTANT_MASK.set(0x1fb00 + m - 1 - (m > 21 ? 1 : 0) - (m > 42 ? 1 : 0), m);\n}\n\n/** [col, row] of each filled sixth (col 0..1, row 0..2). */\nexport function sextantCells(cp) {\n  const m = SEXTANT_MASK.get(cp) || 0;\n  const out = [];\n  for (let k = 0; k < 6; k++) if (m & (1 << k)) out.push([k & 1, k >> 1]);\n  return out;\n}\n\n// ── Box drawing ────────────────────────────────────────────────────────────\n// Each glyph: [up, right, down, left] with 0 none, 1 light, 2 heavy, 3 double,\n// plus a style: 'dash2' | 'dash3' | 'dash4' (dashed), 'arc' (rounded corner),\n// or a diagonal ('/', '\\\\', 'x').\nconst B = (u, r, d, l, style) => ({ arms: [u, r, d, l], style });\nconst BOX = new Map([\n  [0x2500, B(0, 1, 0, 1)], [0x2501, B(0, 2, 0, 2)], [0x2502, B(1, 0, 1, 0)], [0x2503, B(2, 0, 2, 0)],\n  [0x2504, B(0, 1, 0, 1, 'dash3')], [0x2505, B(0, 2, 0, 2, 'dash3')], [0x2506, B(1, 0, 1, 0, 'dash3')], [0x2507, B(2, 0, 2, 0, 'dash3')],\n  [0x2508, B(0, 1, 0, 1, 'dash4')], [0x2509, B(0, 2, 0, 2, 'dash4')], [0x250a, B(1, 0, 1, 0, 'dash4')], [0x250b, B(2, 0, 2, 0, 'dash4')],\n  [0x250c, B(0, 1, 1, 0)], [0x250d, B(0, 2, 1, 0)], [0x250e, B(0, 1, 2, 0)], [0x250f, B(0, 2, 2, 0)],\n  [0x2510, B(0, 0, 1, 1)], [0x2511, B(0, 0, 1, 2)], [0x2512, B(0, 0, 2, 1)], [0x2513, B(0, 0, 2, 2)],\n  [0x2514, B(1, 1, 0, 0)], [0x2515, B(1, 2, 0, 0)], [0x2516, B(2, 1, 0, 0)], [0x2517, B(2, 2, 0, 0)],\n  [0x2518, B(1, 0, 0, 1)], [0x2519, B(1, 0, 0, 2)], [0x251a, B(2, 0, 0, 1)], [0x251b, B(2, 0, 0, 2)],\n  [0x251c, B(1, 1, 1, 0)], [0x251d, B(1, 2, 1, 0)], [0x251e, B(2, 1, 1, 0)], [0x251f, B(1, 1, 2, 0)],\n  [0x2520, B(2, 1, 2, 0)], [0x2521, B(2, 2, 1, 0)], [0x2522, B(1, 2, 2, 0)], [0x2523, B(2, 2, 2, 0)],\n  [0x2524, B(1, 0, 1, 1)], [0x2525, B(1, 0, 1, 2)], [0x2526, B(2, 0, 1, 1)], [0x2527, B(1, 0, 2, 1)],\n  [0x2528, B(2, 0, 2, 1)], [0x2529, B(2, 0, 1, 2)], [0x252a, B(1, 0, 2, 2)], [0x252b, B(2, 0, 2, 2)],\n  [0x252c, B(0, 1, 1, 1)], [0x252d, B(0, 1, 1, 2)], [0x252e, B(0, 2, 1, 1)], [0x252f, B(0, 2, 1, 2)],\n  [0x2530, B(0, 1, 2, 1)], [0x2531, B(0, 1, 2, 2)], [0x2532, B(0, 2, 2, 1)], [0x2533, B(0, 2, 2, 2)],\n  [0x2534, B(1, 1, 0, 1)], [0x2535, B(1, 1, 0, 2)], [0x2536, B(1, 2, 0, 1)], [0x2537, B(1, 2, 0, 2)],\n  [0x2538, B(2, 1, 0, 1)], [0x2539, B(2, 1, 0, 2)], [0x253a, B(2, 2, 0, 1)], [0x253b, B(2, 2, 0, 2)],\n  [0x253c, B(1, 1, 1, 1)], [0x253d, B(1, 1, 1, 2)], [0x253e, B(1, 2, 1, 1)], [0x253f, B(1, 2, 1, 2)],\n  [0x2540, B(2, 1, 1, 1)], [0x2541, B(1, 1, 2, 1)], [0x2542, B(2, 1, 2, 1)], [0x2543, B(2, 1, 1, 2)],\n  [0x2544, B(2, 2, 1, 1)], [0x2545, B(1, 1, 2, 2)], [0x2546, B(1, 2, 2, 1)], [0x2547, B(2, 2, 1, 2)],\n  [0x2548, B(1, 2, 2, 2)], [0x2549, B(2, 1, 2, 2)], [0x254a, B(2, 2, 2, 1)], [0x254b, B(2, 2, 2, 2)],\n  [0x254c, B(0, 1, 0, 1, 'dash2')], [0x254d, B(0, 2, 0, 2, 'dash2')], [0x254e, B(1, 0, 1, 0, 'dash2')], [0x254f, B(2, 0, 2, 0, 'dash2')],\n  [0x2550, B(0, 3, 0, 3)], [0x2551, B(3, 0, 3, 0)],\n  [0x2552, B(0, 3, 1, 0)], [0x2553, B(0, 1, 3, 0)], [0x2554, B(0, 3, 3, 0)],\n  [0x2555, B(0, 0, 1, 3)], [0x2556, B(0, 0, 3, 1)], [0x2557, B(0, 0, 3, 3)],\n  [0x2558, B(1, 3, 0, 0)], [0x2559, B(3, 1, 0, 0)], [0x255a, B(3, 3, 0, 0)],\n  [0x255b, B(1, 0, 0, 3)], [0x255c, B(3, 0, 0, 1)], [0x255d, B(3, 0, 0, 3)],\n  [0x255e, B(1, 3, 1, 0)], [0x255f, B(3, 1, 3, 0)], [0x2560, B(3, 3, 3, 0)],\n  [0x2561, B(1, 0, 1, 3)], [0x2562, B(3, 0, 3, 1)], [0x2563, B(3, 0, 3, 3)],\n  [0x2564, B(0, 3, 1, 3)], [0x2565, B(0, 1, 3, 1)], [0x2566, B(0, 3, 3, 3)],\n  [0x2567, B(1, 3, 0, 3)], [0x2568, B(3, 1, 0, 1)], [0x2569, B(3, 3, 0, 3)],\n  [0x256a, B(1, 3, 1, 3)], [0x256b, B(3, 1, 3, 1)], [0x256c, B(3, 3, 3, 3)],\n  [0x256d, B(0, 1, 1, 0, 'arc')], [0x256e, B(0, 0, 1, 1, 'arc')], [0x256f, B(1, 0, 0, 1, 'arc')], [0x2570, B(1, 1, 0, 0, 'arc')],\n  [0x2571, B(0, 0, 0, 0, '/')], [0x2572, B(0, 0, 0, 0, '\\\\')], [0x2573, B(0, 0, 0, 0, 'x')],\n  [0x2574, B(0, 0, 0, 1)], [0x2575, B(1, 0, 0, 0)], [0x2576, B(0, 1, 0, 0)], [0x2577, B(0, 0, 1, 0)],\n  [0x2578, B(0, 0, 0, 2)], [0x2579, B(2, 0, 0, 0)], [0x257a, B(0, 2, 0, 0)], [0x257b, B(0, 0, 2, 0)],\n  [0x257c, B(0, 2, 0, 1)], [0x257d, B(1, 0, 2, 0)], [0x257e, B(0, 1, 0, 2)], [0x257f, B(2, 0, 1, 0)],\n]);\n\n/** { arms: [up, right, down, left], style } for a box-drawing glyph. */\nexport function boxGeometry(cp) {\n  return BOX.get(cp) || null;\n}\n\n/** Every box-drawing code point this table covers (for tests). */\nexport const BOX_CODEPOINTS = [...BOX.keys()];\n",
  "prism-term/src/web/index.js": "// prism-term/web — browser runtime: a pixel-exact terminal renderer and a\n// live facet player. Browser-only (uses Canvas2D, requestAnimationFrame and\n// IntersectionObserver); the engine modules it imports are shared with Node.\n\nexport { TermCanvas, renderToCanvas, fitCells, FONT_STACK } from './renderer.js';\nexport { FacetPlayer, mountFacet, setGlobalMotion, isGlobalMotionPaused, prefersReducedMotion, playerStats, WEB_CAPS } from './player.js';\nexport { parseAnsi } from './ansi.js';\nexport { glyphClass, blockGeometry, brailleDots, sextantCells, boxGeometry } from './glyphs.js';\n",
  "prism-term/src/web/player.js": "// Facet player for the browser — runs any terminal facet live on a canvas.\n//\n// One shared requestAnimationFrame loop drives every player on the page; each\n// player throttles itself to its own fps, sleeps while scrolled out of view\n// (IntersectionObserver), and renders a single still under\n// prefers-reduced-motion or when the page pauses motion. Facets run exactly as\n// they do in a terminal: same scene contract, same engine, same seeded output.\n\nimport { CellBuffer } from '../buffer.js';\nimport { createContext } from '../context.js';\nimport { getTheme } from '../palettes.js';\nimport { normalizeDepth } from '../color.js';\nimport { TermCanvas, fitCells } from './renderer.js';\n\n/** What the browser \"terminal\" supports — everything, since glyphs are drawn procedurally. */\nexport const WEB_CAPS = Object.freeze({ isTTY: false, depth: 'truecolor', unicode: true, braille: true, sextant: true, width: 80, height: 24, program: 'web', platform: 'web' });\n\nconst players = new Set();\nlet raf = 0;\nlet globalPaused = false;\nlet motionChosen = false; // an explicit setGlobalMotion() outranks prefers-reduced-motion\n\n// Per-frame budget: when many players fall due on the same frame, the rest\n// wait a frame (starting from a rotating index, so nobody starves) instead of\n// stacking into one long frame.\nconst BUDGET_MS = 9;\nlet turn = 0;\nfunction pump(now) {\n  raf = 0;\n  let again = false;\n  const list = [...players], n = list.length, start = performance.now();\n  turn = (turn + 1) % Math.max(1, n);\n  for (let k = 0; k < n; k++) {\n    const p = list[(turn + k) % n];\n    if (!p._wants()) continue;\n    again = true;\n    if (performance.now() - start < BUDGET_MS) p._tick(now);\n  }\n  if (again) raf = requestAnimationFrame(pump);\n}\nfunction wake() { if (!raf && players.size) raf = requestAnimationFrame(pump); }\n\n// One MediaQueryList for the page (its .matches is live): every player asks on every frame.\nlet reducedQuery;\nexport const prefersReducedMotion = () => {\n  if (reducedQuery === undefined) reducedQuery = globalThis.matchMedia?.('(prefers-reduced-motion: reduce)') || null;\n  return !!reducedQuery?.matches;\n};\n\n/**\n * Pause or resume every player on the page (the site's motion toggle). Resuming\n * is an explicit choice, so it also plays under prefers-reduced-motion.\n */\nexport function setGlobalMotion(paused) {\n  globalPaused = !!paused;\n  motionChosen = true;\n  for (const p of players) if (!globalPaused) p._resync();\n  wake();\n}\nexport const isGlobalMotionPaused = () => globalPaused;\n\nlet observer = null;\nfunction observe(p) {\n  if (!globalThis.IntersectionObserver) { p.visible = true; return; }\n  observer ||= new IntersectionObserver((entries) => {\n    for (const e of entries) {\n      const pl = e.target.__prismPlayer;\n      if (!pl) continue;\n      pl.visible = e.isIntersecting;\n      if (pl.visible) { pl._resync(); wake(); }\n    }\n  }, { rootMargin: '0px' });   // play only while on screen (a player painted its first frame at mount)\n  p.canvas.__prismPlayer = p;\n  observer.observe(p.canvas);\n}\n\nexport class FacetPlayer {\n  /**\n   * @param {HTMLElement} el     container (a <canvas> is created inside it) or a <canvas>\n   * @param {object} facet       scene/facet object (setup(ctx) → frame(t, dt))\n   * @param {object} opts\n   *   cols, rows        terminal size in cells (default facet.size)\n   *   fit               { width, height } CSS box to fit the canvas into\n   *   cellWidth         fixed CSS px per cell (overrides fit)\n   *   theme, mode, params, seed, transparent\n   *   depth             emulate a 'truecolor' | '256' | '16' | 'mono' terminal (default truecolor)\n   *   ascii             emulate a terminal without Unicode (facets fall back to 7-bit glyphs)\n   *   fps (30), speed (1), autoPause (true), still (false), warmup (seconds), label\n   */\n  constructor(el, facet, opts = {}) {\n    this.facet = facet;\n    this.opts = { fps: 30, speed: 1, autoPause: true, seed: 7, ...opts };\n    this.canvas = el.tagName === 'CANVAS' ? el : el.appendChild(document.createElement('canvas'));\n    this.canvas.setAttribute('role', 'img');\n    this.canvas.setAttribute('aria-label', opts.label || `${facet.title || facet.id} — terminal animation`);\n    this.tc = new TermCanvas(this.canvas, { depth: this.opts.depth });\n    this.playing = true;\n    this.visible = !this.opts.autoPause;\n    this.stats = { frames: 0, ms: 0 };\n    this.error = null;\n    players.add(this);\n    if (this.opts.autoPause) observe(this);\n    this._build();\n    wake();\n  }\n\n  get animated() { return this.facet.animated !== false; }\n  get still() { return this.opts.still || (!motionChosen && prefersReducedMotion()) || !this.animated; }\n  get time() { return this.t; }\n\n  _size() {\n    const f = this.facet;\n    return { cols: this.opts.cols || f.size?.w || 80, rows: this.opts.rows || f.size?.h || 24 };\n  }\n\n  /** Capabilities of the terminal being emulated. */\n  get caps() {\n    const depth = normalizeDepth(this.opts.depth || 'truecolor'), u = !this.opts.ascii;\n    return { ...WEB_CAPS, depth, unicode: u, braille: u, sextant: u, width: this._size().cols, height: this._size().rows };\n  }\n\n  _build() {\n    const { cols, rows } = this._size();\n    this.theme = getTheme(this.opts.theme);\n    this.buffer = new CellBuffer(cols, rows);\n    this.tc.setTheme(this.theme);\n    this.tc.setDepth(this.opts.depth || 'truecolor');\n    this._layout();\n    this.error = null;\n    try {\n      const sheenKey = this._sheenKey();\n      this.sheen = !!sheenKey && this._sheenFor === sheenKey;\n      const params = this.sheen ? { ...this.opts.params, palette: 'prism-sheen' } : this.opts.params || {};\n      this.ctx = createContext({ scene: this.facet, buffer: this.buffer, caps: this.caps, theme: this.opts.theme, mode: this.opts.mode || null, params, seed: this.opts.seed, transparent: !!this.opts.transparent });\n      this.frame = this.facet.setup(this.ctx);\n    } catch (err) { this._fail(err); return; }\n    this.t = 0;\n    this.n = 0;\n    const loop = this.facet.loop || 8;\n    const target = this.still ? (this.animated ? Math.min(loop * 0.35, 6) : 0) : (this.opts.warmup ?? Math.min(1.2, loop * 0.15));\n    this._advanceTo(target);\n    if (!this.sheen && !this.error && this._fullField()) { this._sheenFor = this._sheenKey(); this._build(); return; }\n    this._paint();\n    this._resync();\n  }\n\n  // Day sheen: a scene painting Refraction's spectrum ('prism') that fills (nearly) every\n  // cell re-runs once with 'prism-sheen' — the Day vivid tier is tuned for marks on pearl\n  // and reads as muddy olive across a whole field (design/tokens.json › day.sheen).\n  // Light themes only, and never over a palette someone chose.\n  _sheenKey() {\n    const p = this.opts.params || {};\n    if (p.palette || this.theme.mode !== 'light' || this.facet.canvas !== 'field' || (this.facet.palette || 'prism') !== 'prism') return '';\n    const { cols, rows } = this._size();\n    return `${this.theme.id}|${cols}x${rows}|${JSON.stringify(p)}`;\n  }\n\n  _fullField() {\n    if (!this._sheenKey()) return false;\n    const { bg, fg, ch } = this.buffer, base = this.theme.bg;\n    let n = 0;\n    for (let i = 0; i < bg.length; i++) if ((bg[i] !== -1 && bg[i] !== base) || (ch[i] !== ' ' && fg[i] !== base)) n++;\n    return n >= 0.9 * bg.length;\n  }\n\n  _layout() {\n    const { cols, rows } = this._size();\n    const dpr = globalThis.devicePixelRatio || 1;\n    let fit;\n    if (this.opts.cellWidth) fit = { cw: Math.round(this.opts.cellWidth * dpr), cssWidth: cols * this.opts.cellWidth, cssHeight: rows * this.opts.cellWidth * 2 };\n    else fit = fitCells(cols, rows, this.opts.fit?.width || this.canvas.parentElement?.clientWidth || 640, this.opts.fit?.height || 0, dpr);\n    this.tc.layout(cols, rows, fit.cw);\n    this.canvas.style.width = `${fit.cssWidth}px`;\n    this.canvas.style.height = `${fit.cssHeight}px`;\n  }\n\n  /** Step deterministically from the current t to `target` at 15 fps (no painting). */\n  _advanceTo(target) {\n    if (!this.frame) return;\n    try {\n      const dt = 1 / 15;\n      if (target <= 0) { this.buffer.clear(this.ctx.bg); this.frame(0, dt, this.n++); return; }\n      while (this.t < target) {\n        this.t = Math.min(target, this.t + dt);\n        this.buffer.clear(this.ctx.bg);\n        this.frame(this.t, dt, this.n++);\n      }\n    } catch (err) { this._fail(err); }\n  }\n\n  _paint() {\n    if (this.error) return;\n    this.tc.draw(this.buffer);\n  }\n\n  _fail(err) {\n    this.error = err;\n    this.frame = null;\n    console.error(`[prism-term] facet ${this.facet.id} failed:`, err);\n    const b = this.buffer;\n    b.clear(this.theme.bg);\n    b.text(1, Math.floor(b.height / 2), `⚠ ${this.facet.id}: ${String(err?.message || err)}`.slice(0, b.width - 2), this.theme.neg);\n    this.tc.draw(b);\n  }\n\n  _wants() { return this.playing && this.visible && !globalPaused && !this.still && !!this.frame; }\n\n  // A random phase spreads players that start together across frames.\n  _resync() { const now = performance.now(); this.last = now - Math.random() * (1000 / this.opts.fps); this.lastStep = now; }\n\n  _tick(now) {\n    const interval = 1000 / this.opts.fps;\n    if (now - this.last < interval - 2) return;\n    this.last = now;\n    const dt = Math.min(0.1, (now - this.lastStep) / 1000) * this.opts.speed;\n    this.lastStep = now;\n    const t0 = performance.now();\n    try {\n      this.t += dt;\n      this.buffer.clear(this.ctx.bg);\n      this.frame(this.t, dt, this.n++);\n      this.tc.draw(this.buffer);\n    } catch (err) { this._fail(err); return; }\n    this.stats.frames++;\n    this.stats.ms += performance.now() - t0;\n  }\n\n  play() { this.playing = true; this._resync(); wake(); return this; }\n  pause() { this.playing = false; return this; }\n  toggle() { return this.playing ? this.pause() : this.play(); }\n  restart() { this._build(); wake(); return this; }\n\n  /** Change options; size/theme/mode/params/seed rebuild the scene, others apply live. */\n  set(next = {}) {\n    const rebuild = ['cols', 'rows', 'theme', 'mode', 'params', 'seed', 'transparent', 'still', 'depth', 'ascii'].some((k) => k in next && JSON.stringify(next[k]) !== JSON.stringify(this.opts[k]));\n    const relayout = ['fit', 'cellWidth'].some((k) => k in next);\n    Object.assign(this.opts, next);\n    if (rebuild) this._build();\n    else if (relayout) { this._layout(); this.tc.full = true; this._paint(); }\n    wake();\n    return this;\n  }\n\n  /** Average ms per live frame (compute + paint). */\n  get avgFrameMs() { return this.stats.frames ? this.stats.ms / this.stats.frames : 0; }\n\n  /** The current frame as a CellBuffer copy (for .ans / .txt downloads). */\n  snapshot() { return this.buffer.clone(); }\n\n  destroy() {\n    players.delete(this);\n    observer?.unobserve(this.canvas);\n    if (this.canvas.__prismPlayer === this) delete this.canvas.__prismPlayer;\n    this.frame = null;\n  }\n}\n\n/** Mount a live facet into `el`. Returns the FacetPlayer. */\nexport function mountFacet(el, facet, opts) {\n  return new FacetPlayer(el, facet, opts);\n}\n\n/** Number of players currently on the page and how many are animating. */\nexport function playerStats() {\n  let active = 0, ms = 0, frames = 0;\n  for (const p of players) { if (p._wants()) active++; ms += p.stats.ms; frames += p.stats.frames; }\n  return { players: players.size, active, avgFrameMs: frames ? ms / frames : 0 };\n}\n",
  "prism-term/src/web/renderer.js": "// Canvas2D renderer for prism-term cell buffers — the browser's terminal.\n//\n// Cells are drawn on an integer device-pixel grid (cell width cw, height 2·cw,\n// matching the 1:2 cell aspect the engine's canvases assume) so neighbouring\n// cells tile without seams. Block elements, braille, sextants and box drawing\n// are rasterised from geometry (glyphs.js); everything else is fillText with a\n// monospace stack. Frames are diffed against the previous one and only changed\n// cells are repainted, so mostly-static widgets cost almost nothing.\n\nimport { NONE, ANSI16, to256, normalizeDepth, ansi16Fg, ansi16Bg, parse } from '../color.js';\nimport { terminalScheme } from '../palettes.js';\nimport { ATTR } from '../buffer.js';\nimport { charWidth } from '../width.js';\nimport { glyphClass, blockGeometry, brailleDots, sextantCells, boxGeometry } from './glyphs.js';\n\nexport const FONT_STACK = '\"Cascadia Mono\",\"Cascadia Code\",\"JetBrains Mono\",\"SF Mono\",Menlo,Consolas,\"DejaVu Sans Mono\",\"Noto Sans Mono\",\"Liberation Mono\",monospace';\n\n// Color as a terminal of a given depth would show it (default colors stay default).\nfunction xterm256(n) {\n  if (n < 16) return ANSI16[n];\n  if (n >= 232) { const v = 8 + (n - 232) * 10; return (v << 16) | (v << 8) | v; }\n  const i = n - 16, lv = [0, 95, 135, 175, 215, 255];\n  return (lv[Math.floor(i / 36)] << 16) | (lv[Math.floor(i / 6) % 6] << 8) | lv[i % 6];\n}\nconst QUANT = {\n  truecolor: null,\n  '256': (c) => (c === NONE ? c : xterm256(to256(c))),\n  mono: () => NONE,\n};\n\n// The 16 colors a terminal set to this theme's scheme shows (Prism Night / Day ship\n// their own, src/themes.js › TERMINAL_SCHEMES); any other theme gets the xterm defaults.\nfunction ansiOf(theme) {\n  const s = theme?.id ? terminalScheme(theme.id) : null;\n  return s && s.id === theme.id ? s.ansi.map(parse) : ANSI16;\n}\n\n// Refraction's Prism Night canvas (design/tokens.json › terminal.night) until a theme is set.\nconst NIGHT = { id: 'prism-dark', bg: 0x0a0c12, ink: 0xe6e6ef };\n\n// Relative ink of common ASCII-art glyphs, for minimap text (default 0.62).\nconst INK = { '.': 0.7, ':': 0.8, '-': 0.75, '=': 0.7, '+': 0.42, '*': 0.5, '#': 0.78, '%': 0.72, '@': 0.9, '|': 0.5, '/': 0.48, '\\\\': 0.48, 'o': 0.5, 'O': 0.62, 'x': 0.5 };\n\nconst css = new Map();\nfunction cssColor(c) {\n  let s = css.get(c);\n  if (s === undefined) {\n    if (css.size > 8192) css.clear();\n    s = '#' + (c & 0xffffff).toString(16).padStart(6, '0');\n    css.set(c, s);\n  }\n  return s;\n}\n\nexport class TermCanvas {\n  /**\n   * @param {HTMLCanvasElement} canvas\n   * @param {object} opts { theme (resolved prism theme), font (CSS font-family), depth }\n   */\n  constructor(canvas, { theme, font = FONT_STACK, depth = 'truecolor' } = {}) {\n    this.canvas = canvas;\n    this.ctx = canvas.getContext('2d', { alpha: false });\n    this.font = font;\n    this.theme = theme || NIGHT;\n    this.ansi = ansiOf(this.theme);\n    this.setDepth(depth);\n    this.cols = 0; this.rows = 0; this.cw = 8; this.chh = 16;\n    this.prev = null;\n    this.full = true;\n  }\n\n  setTheme(theme) { this.theme = theme; this.ansi = ansiOf(theme); this.setDepth(this.depth); }\n\n  /** Show colors as a 'truecolor' | '256' | '16' | 'mono' terminal would. */\n  setDepth(depth) {\n    this.depth = normalizeDepth(depth);\n    // 16 colors: the engine's designed ANSI slots — separately for text and fills, slot -1 being\n    // the terminal's default color — exactly what real 16-color output sends (color.js › fgParam/bgParam).\n    // `quant` maps a fill (letterbox backgrounds use it); cells map text and fills apart.\n    this.quant = this.depth === '16' ? (c) => this._slot(c, ansi16Bg) : QUANT[this.depth];\n    this.full = true;\n  }\n\n  _slot(c, role) {\n    if (c === NONE) return c;\n    const n = role(c);\n    return n < 0 ? NONE : this.ansi[n] ?? ANSI16[n];\n  }\n\n  /**\n   * Size the backing store: cols × rows cells of cw device pixels (height 2·cw).\n   * The caller sets the CSS size (usually cw / devicePixelRatio per cell).\n   */\n  layout(cols, rows, cw) {\n    cw = Math.max(2, Math.round(cw));\n    if (cols === this.cols && rows === this.rows && cw === this.cw) return;\n    this.cols = cols; this.rows = rows; this.cw = cw; this.chh = cw * 2;\n    this.canvas.width = cols * cw;\n    this.canvas.height = rows * this.chh;\n    const lw = Math.max(1, Math.round(cw / 7));\n    this.metrics = {\n      lw,                                   // light line\n      hw: Math.max(lw + 1, Math.round(lw * 2.2)), // heavy line\n      gap: Math.max(lw, Math.round(cw / 4.5)),     // half-distance between double lines\n      dot: Math.max(1, Math.round(Math.min(cw / 2, this.chh / 4) * 0.66)),\n      fontPx: Math.max(4, Math.min(Math.floor(cw * 1.62), Math.floor(this.chh * 0.86))),\n    };\n    // Below ~7px, real glyphs turn to mush: draw text as minimap strokes instead.\n    this.minimap = this.metrics.fontPx < 7;\n    this.prev = null;\n    this.full = true;\n    this.fontKey = null; // resizing the canvas resets its 2D state\n  }\n\n  /** Paint `buffer` (a CellBuffer of exactly cols × rows), repainting only changed cells. */\n  draw(buffer) {\n    const { cols, rows, ctx } = this;\n    if (!cols || !rows) return 0;\n    const W = buffer.width, n = cols * rows;\n    if (!this.prev || this.prev.ch.length !== n) {\n      this.prev = { ch: new Array(n).fill(null), fg: new Int32Array(n), bg: new Int32Array(n), at: new Uint8Array(n) };\n      this.full = true;\n    }\n    const p = this.prev;\n    if (this.full) {\n      ctx.fillStyle = cssColor(this.theme.bg);\n      ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);\n    }\n    let painted = 0;\n    for (let y = 0; y < rows; y++) {\n      for (let x = 0; x < cols; x++) {\n        const i = y * cols + x, bi = y * W + x;\n        const ch = buffer.ch[bi], fg = buffer.fg[bi], bg = buffer.bg[bi], at = buffer.at[bi];\n        // A wide glyph and its '' continuation repaint together.\n        const wide = x + 1 < cols && buffer.ch[bi + 1] === '' && ch !== '';\n        let dirty = this.full || ch !== p.ch[i] || fg !== p.fg[i] || bg !== p.bg[i] || at !== p.at[i];\n        if (!dirty && wide) { const j = i + 1, bj = bi + 1; dirty = buffer.ch[bj] !== p.ch[j] || buffer.fg[bj] !== p.fg[j] || buffer.bg[bj] !== p.bg[j]; }\n        if (!dirty) continue;\n        if (ch === '' && x > 0 && buffer.ch[bi - 1] !== '' && !this.full) {\n          // Continuation changed alone: its owner (to the left) repaints both.\n          const owner = bi - 1;\n          this._cell(x - 1, y, buffer.ch[owner], buffer.fg[owner], buffer.bg[owner], buffer.at[owner], true);\n        } else if (ch !== '') {\n          this._cell(x, y, ch, fg, bg, at, wide);\n        }\n        p.ch[i] = ch; p.fg[i] = fg; p.bg[i] = bg; p.at[i] = at;\n        if (wide) { const j = i + 1, bj = bi + 1; p.ch[j] = buffer.ch[bj]; p.fg[j] = buffer.fg[bj]; p.bg[j] = buffer.bg[bj]; p.at[j] = buffer.at[bj]; }\n        painted++;\n      }\n    }\n    this.full = false;\n    return painted;\n  }\n\n  _cell(x, y, ch, fg, bg, at, wide) {\n    const { ctx, cw, chh, theme } = this;\n    const X = x * cw, Y = y * chh, w = wide ? cw * 2 : cw;\n    if (this.depth === '16') { fg = this._slot(fg, ansi16Fg); bg = this._slot(bg, ansi16Bg); }\n    else if (this.quant) { fg = this.quant(fg); bg = this.quant(bg); }\n    let f = fg === NONE ? theme.ink : fg, b = bg === NONE ? theme.bg : bg;\n    if (at & ATTR.INVERSE) [f, b] = [b, f];\n    ctx.fillStyle = cssColor(b);\n    ctx.fillRect(X, Y, w, chh);\n    if (ch === ' ' && !(at & (ATTR.UNDERLINE | ATTR.STRIKE))) return;\n    ctx.fillStyle = cssColor(f);\n    ctx.strokeStyle = ctx.fillStyle;\n    ctx.globalAlpha = at & ATTR.DIM ? 0.55 : 1;\n    if (ch !== ' ') {\n      const cp = ch.codePointAt(0);\n      const kind = glyphClass(cp);\n      if (kind === 'block') this._block(X, Y, cp);\n      else if (kind === 'braille') this._braille(X, Y, cp);\n      else if (kind === 'sextant') this._sextant(X, Y, cp);\n      else if (kind === 'box') this._box(X, Y, cp);\n      else if (this.minimap) this._mini(X, Y, w, ch);\n      else this._text(X, Y, w, ch, at);\n    }\n    const { lw } = this.metrics;\n    if (at & ATTR.UNDERLINE) ctx.fillRect(X, Y + chh - lw * 2, w, lw);\n    if (at & ATTR.STRIKE) ctx.fillRect(X, Y + Math.round(chh / 2), w, lw);\n    ctx.globalAlpha = 1;\n  }\n\n  _rect(X, Y, rx, ry, rw, rh) {\n    const { cw, chh } = this;\n    const x0 = X + Math.round(rx * cw), x1 = X + Math.round((rx + rw) * cw);\n    const y0 = Y + Math.round(ry * chh), y1 = Y + Math.round((ry + rh) * chh);\n    if (x1 > x0 && y1 > y0) this.ctx.fillRect(x0, y0, x1 - x0, y1 - y0);\n  }\n\n  _block(X, Y, cp) {\n    const g = blockGeometry(cp);\n    if (g.shade) {\n      const a = this.ctx.globalAlpha;\n      this.ctx.globalAlpha = a * g.shade;\n      this.ctx.fillRect(X, Y, this.cw, this.chh);\n      this.ctx.globalAlpha = a;\n      return;\n    }\n    for (const [rx, ry, rw, rh] of g.rects) this._rect(X, Y, rx, ry, rw, rh);\n  }\n\n  _braille(X, Y, cp) {\n    const { ctx, cw, chh } = this;\n    const d = this.metrics.dot;\n    const dots = brailleDots(cp);\n    if (d >= 4) {\n      ctx.beginPath();\n      for (const [dx, dy] of dots) {\n        const cx = X + (dx + 0.5) * cw / 2, cy = Y + (dy + 0.5) * chh / 4;\n        ctx.moveTo(cx + d / 2, cy);\n        ctx.arc(cx, cy, d / 2, 0, Math.PI * 2);\n      }\n      ctx.fill();\n    } else {\n      for (const [dx, dy] of dots) {\n        const cx = X + (dx + 0.5) * cw / 2, cy = Y + (dy + 0.5) * chh / 4;\n        ctx.fillRect(Math.round(cx - d / 2), Math.round(cy - d / 2), d, d);\n      }\n    }\n  }\n\n  _sextant(X, Y, cp) {\n    const { cw, chh, ctx } = this;\n    for (const [col, row] of sextantCells(cp)) {\n      const x0 = X + Math.round(col * cw / 2), x1 = X + Math.round((col + 1) * cw / 2);\n      const y0 = Y + Math.round(row * chh / 3), y1 = Y + Math.round((row + 1) * chh / 3);\n      ctx.fillRect(x0, y0, x1 - x0, y1 - y0);\n    }\n  }\n\n  _box(X, Y, cp) {\n    const g = boxGeometry(cp);\n    if (!g) return;\n    const { ctx, cw, chh } = this;\n    const { lw, hw, gap } = this.metrics;\n    const cx = X + Math.floor(cw / 2), cy = Y + Math.floor(chh / 2);\n    const [up, right, down, left] = g.arms;\n    const thick = (w) => (w === 2 ? hw : lw);\n    const half = (t) => Math.floor(t / 2);\n\n    if (g.style === '/' || g.style === '\\\\' || g.style === 'x') {\n      ctx.lineWidth = lw;\n      ctx.beginPath();\n      if (g.style !== '\\\\') { ctx.moveTo(X, Y + chh); ctx.lineTo(X + cw, Y); }\n      if (g.style !== '/') { ctx.moveTo(X, Y); ctx.lineTo(X + cw, Y + chh); }\n      ctx.stroke();\n      return;\n    }\n\n    if (g.style === 'arc') {\n      // Stroke along the same center line the straight segments fill.\n      const r = Math.max(1, Math.min(cw, chh) / 2);\n      const ax = cx - half(lw) + lw / 2, ay = cy - half(lw) + lw / 2;\n      ctx.lineWidth = lw;\n      ctx.beginPath();\n      if (right && down) { ctx.moveTo(X + cw, ay); ctx.arcTo(ax, ay, ax, Y + chh, r); ctx.lineTo(ax, Y + chh); }\n      else if (left && down) { ctx.moveTo(X, ay); ctx.arcTo(ax, ay, ax, Y + chh, r); ctx.lineTo(ax, Y + chh); }\n      else if (left && up) { ctx.moveTo(X, ay); ctx.arcTo(ax, ay, ax, Y, r); ctx.lineTo(ax, Y); }\n      else if (right && up) { ctx.moveTo(X + cw, ay); ctx.arcTo(ax, ay, ax, Y, r); ctx.lineTo(ax, Y); }\n      ctx.stroke();\n      return;\n    }\n\n    if (g.style && g.style.startsWith('dash')) {\n      const n = Number(g.style.slice(4));\n      const t = thick(Math.max(up, right, down, left));\n      if (right || left) {\n        const seg = cw / n;\n        for (let k = 0; k < n; k++) ctx.fillRect(Math.round(X + k * seg + seg * 0.15), cy - half(t), Math.max(1, Math.round(seg * 0.7)), t);\n      } else {\n        const seg = chh / n;\n        for (let k = 0; k < n; k++) ctx.fillRect(cx - half(t), Math.round(Y + k * seg + seg * 0.15), t, Math.max(1, Math.round(seg * 0.7)));\n      }\n      return;\n    }\n\n    const hDouble = right === 3 || left === 3, vDouble = up === 3 || down === 3;\n    const hline = (x0, x1, yy, t) => { if (x1 > x0) ctx.fillRect(x0, yy - half(t), x1 - x0, t); };\n    const vline = (y0, y1, xx, t) => { if (y1 > y0) ctx.fillRect(xx - half(t), y0, t, y1 - y0); };\n\n    // Single and heavy arms: from the center (overlapping it by half the\n    // crossing line's thickness) out to the edge; a single arm meeting a\n    // double crossing reaches the far double line.\n    const tv = Math.max(thick(up === 3 ? 0 : up), thick(down === 3 ? 0 : down));\n    const th = Math.max(thick(left === 3 ? 0 : left), thick(right === 3 ? 0 : right));\n    if (right && right !== 3) hline(cx - (vDouble ? gap : half(tv)), X + cw, cy, thick(right));\n    if (left && left !== 3) hline(X, cx + (vDouble ? gap : tv - half(tv)), cy, thick(left));\n    if (down && down !== 3) vline(cy - (hDouble ? gap : half(th)), Y + chh, cx, thick(down));\n    if (up && up !== 3) vline(Y, cy + (hDouble ? gap : th - half(th)), cx, thick(up));\n\n    // Double arms: two parallel light lines whose inner ends depend on what they meet.\n    if (right === 3) {\n      const s1 = up === 3 ? cx + gap : left === 3 ? cx : down === 3 ? cx - gap : cx;\n      const s2 = down === 3 ? cx + gap : left === 3 ? cx : up === 3 ? cx - gap : cx;\n      hline(s1 - half(lw), X + cw, cy - gap, lw);\n      hline(s2 - half(lw), X + cw, cy + gap, lw);\n    }\n    if (left === 3) {\n      const e1 = up === 3 ? cx - gap : right === 3 ? cx : down === 3 ? cx + gap : cx;\n      const e2 = down === 3 ? cx - gap : right === 3 ? cx : up === 3 ? cx + gap : cx;\n      hline(X, e1 + lw - half(lw), cy - gap, lw);\n      hline(X, e2 + lw - half(lw), cy + gap, lw);\n    }\n    if (down === 3) {\n      const s1 = left === 3 ? cy + gap : up === 3 ? cy : right === 3 ? cy - gap : cy;\n      const s2 = right === 3 ? cy + gap : up === 3 ? cy : left === 3 ? cy - gap : cy;\n      vline(s1 - half(lw), Y + chh, cx - gap, lw);\n      vline(s2 - half(lw), Y + chh, cx + gap, lw);\n    }\n    if (up === 3) {\n      const e1 = left === 3 ? cy - gap : down === 3 ? cy : right === 3 ? cy + gap : cy;\n      const e2 = right === 3 ? cy - gap : down === 3 ? cy : left === 3 ? cy + gap : cy;\n      vline(Y, e1 + lw - half(lw), cx - gap, lw);\n      vline(Y, e2 + lw - half(lw), cx + gap, lw);\n    }\n  }\n\n  _text(X, Y, w, ch, at) {\n    const { ctx, chh } = this;\n    const { fontPx } = this.metrics;\n    const key = (at & ATTR.ITALIC ? 1 : 0) | (at & ATTR.BOLD ? 2 : 0);\n    if (key !== this.fontKey) {\n      ctx.font = `${key & 1 ? 'italic ' : ''}${key & 2 ? '700' : '400'} ${fontPx}px ${this.font}`;\n      ctx.textAlign = 'center';\n      ctx.textBaseline = 'middle';\n      this.fontKey = key;\n    }\n    ctx.fillText(ch, X + w / 2, Y + chh / 2 + Math.round(fontPx * 0.04));\n  }\n\n  /** Thumbnail text: a soft stroke as dense as the glyph's ink, like an editor minimap. */\n  _mini(X, Y, w, ch) {\n    const { ctx, cw, chh } = this;\n    const a = ctx.globalAlpha;\n    ctx.globalAlpha = a * (INK[ch] ?? 0.62);\n    if (\".,:;'`\".includes(ch)) ctx.fillRect(X + Math.floor(cw / 3), Y + Math.round(chh * 0.6), Math.max(1, Math.round(cw / 3)), Math.max(1, Math.round(chh / 6)));\n    else if ('-_=~'.includes(ch)) ctx.fillRect(X, Y + Math.round(chh * (ch === '_' ? 0.78 : 0.46)), w, Math.max(1, Math.round(chh / (ch === '=' ? 6 : 10))));\n    else ctx.fillRect(X + (cw > 3 ? 1 : 0), Y + Math.round(chh * 0.22), Math.max(1, w - (cw > 3 ? 1 : 0)), Math.max(1, Math.round(chh * 0.6)));\n    ctx.globalAlpha = a;\n  }\n\n}\n\n/**\n * Pick a cell width (device px) that fits cols × rows into a CSS box.\n * Returns { cw, cssWidth, cssHeight }.\n */\nexport function fitCells(cols, rows, maxCssW, maxCssH, dpr = globalThis.devicePixelRatio || 1) {\n  const byW = Math.floor((maxCssW * dpr) / cols);\n  const byH = maxCssH ? Math.floor((maxCssH * dpr) / (rows * 2)) : byW;\n  const cw = Math.max(2, Math.min(byW, byH));\n  return { cw, cssWidth: (cols * cw) / dpr, cssHeight: (rows * cw * 2) / dpr };\n}\n\n/** One-shot: draw a CellBuffer onto a canvas sized to `opts.cellWidth` (CSS px) or `opts.width`. */\nexport function renderToCanvas(buffer, canvas, opts = {}) {\n  const dpr = opts.dpr || globalThis.devicePixelRatio || 1;\n  const tc = canvas.__prismTerm || (canvas.__prismTerm = new TermCanvas(canvas, opts));\n  if (opts.theme) tc.setTheme(opts.theme);\n  const fit = opts.cellWidth\n    ? { cw: Math.round(opts.cellWidth * dpr), cssWidth: buffer.width * opts.cellWidth, cssHeight: buffer.height * opts.cellWidth * 2 }\n    : fitCells(buffer.width, buffer.height, opts.width || canvas.clientWidth || 640, opts.height || 0, dpr);\n  tc.layout(buffer.width, buffer.height, fit.cw);\n  canvas.style.width = `${fit.cssWidth}px`;\n  canvas.style.height = `${fit.cssHeight}px`;\n  tc.draw(buffer);\n  return tc;\n}\n\nexport { charWidth };\n",
  "prism-term/src/widgets/badges.js": "// Small status widgets: badges, status dots, KPI tiles.\n\nimport { ATTR } from '../buffer.js';\nimport { mix } from '../color.js';\nimport { stringWidth, truncate } from '../width.js';\nimport { themeOf, col, contrast, tint, fmt } from './util.js';\nimport { box } from './box.js';\nimport { sparkline } from './bars.js';\n\n/**\n * Inline label chip.\n * @param {Surface} surface\n * @param {number} x @param {number} y\n * @param {string} text\n * @param {object} [opts]\n * @param {*} [opts.color='accent']\n * @param {*} [opts.fg]  text color (default: readable contrast / the color itself)\n * @param {'pill'|'solid'|'soft'|'outline'} [opts.style='pill']  pill uses ▐ ▌ half-block caps\n * @returns {number} width drawn\n */\nexport function badge(surface, x, y, text, opts = {}) {\n  const theme = themeOf(opts);\n  const c = col(opts.color, theme, theme.accent);\n  const t = String(text);\n  const style = opts.style || 'pill';\n  if (style === 'outline') {\n    const end = surface.text(x, y, `[${t}]`, c, undefined, ATTR.BOLD);\n    return end - x;\n  }\n  if (style === 'soft') {\n    const end = surface.text(x, y, ` ${t} `, col(opts.fg, theme, c), tint(c, theme, 0.25), ATTR.BOLD);\n    return end - x;\n  }\n  const fg = col(opts.fg, theme, contrast(c));\n  if (style === 'solid') return surface.text(x, y, ` ${t} `, fg, c, ATTR.BOLD) - x;\n  surface.set(x, y, '▐', c);\n  const end = surface.text(x + 1, y, t, fg, c, ATTR.BOLD);\n  surface.set(end, y, '▌', c);\n  return end + 1 - x;\n}\n\nconst STATUS = {\n  ok: 'pos', up: 'pos', healthy: 'pos', pass: 'pos', passed: 'pos', success: 'pos', online: 'pos',\n  warn: 'warn', warning: 'warn', degraded: 'warn', slow: 'warn',\n  error: 'neg', err: 'neg', down: 'neg', fail: 'neg', failed: 'neg', offline: 'neg',\n  crit: 'crit', critical: 'crit', fatal: 'crit',\n  info: 'info', pending: 'info', running: 'info', busy: 'info',\n  off: 'dim', idle: 'dim', unknown: 'dim', disabled: 'dim',\n};\nconst PULSING = new Set(['neg', 'crit', 'info']);\n\n/**\n * ● status indicator with optional label; pulses for error/crit/running when `t` is given.\n * @param {Surface} surface\n * @param {number} x @param {number} y\n * @param {string} status  ok|warn|error|crit|info|off (+ synonyms: up, down, degraded, running…)\n * @param {object} [opts] { label, t (seconds, enables pulse), pulse=true, theme }\n * @returns {number} width drawn\n */\nexport function statusDot(surface, x, y, status, opts = {}) {\n  const theme = themeOf(opts);\n  const key = STATUS[String(status).toLowerCase()] || 'dim';\n  let c = theme[key];\n  if (opts.t !== undefined && opts.pulse !== false && PULSING.has(key)) {\n    const k = 0.5 + 0.5 * Math.sin(opts.t * Math.PI * 2 * 1.1);\n    c = mix(mix(theme.bg, c, 0.35), c, k);\n  }\n  surface.set(x, y, '●', c);\n  if (!opts.label) return 1;\n  return surface.text(x + 2, y, String(opts.label), col(opts.labelColor, theme, theme.ink)) - x;\n}\n\n/**\n * KPI tile: label, big value + unit, delta arrow, and a trend sparkline.\n * @param {Surface} surface\n * @param {number} x @param {number} y @param {number} w @param {number} h\n * @param {object} o\n * @param {string} o.label\n * @param {number|string} o.value\n * @param {string} [o.unit]\n * @param {number|string} [o.delta]   number = percent change (▲ 4.2%), string shown verbatim\n * @param {'up'|'down'} [o.goodWhen='up']  which direction is good (colors the delta)\n * @param {number[]} [o.trend]\n * @param {*} [o.color]               value/trend color (default theme.accent)\n * @param {boolean} [o.box=true]\n * @param {'braille'|'bars'} [o.style='braille']\n */\nexport function kpi(surface, x, y, w, h, o = {}) {\n  const theme = themeOf(o);\n  const accent = col(o.color, theme, theme.accent);\n  let s;\n  let row = 0;\n  if (o.box !== false && w >= 6 && h >= 3) {\n    s = box(surface, x, y, w, h, { title: o.label, theme, color: o.borderColor, bg: o.bg, padding: 1 });\n  } else {\n    s = surface.view(x, y, w, h);\n    if (o.label) { s.text(0, 0, truncate(String(o.label), s.width), theme.muted); row = 1; }\n  }\n  if (s.width < 1 || s.height <= row) return;\n\n  const value = typeof o.value === 'number' ? fmt(o.value) : String(o.value ?? '–');\n  let cx = s.text(0, row, truncate(value, s.width), theme.ink, undefined, ATTR.BOLD);\n  if (o.unit && cx + 1 < s.width) cx = s.text(cx + 1, row, truncate(String(o.unit), s.width - cx - 1), theme.muted);\n  if (o.delta !== undefined && o.delta !== null) {\n    const num = typeof o.delta === 'number';\n    const up = num ? o.delta >= 0 : !String(o.delta).trim().startsWith('-');\n    const good = (o.goodWhen || 'up') === 'up' ? up : !up;\n    const txt = num ? `${up ? '▲' : '▼'} ${fmt(Math.abs(o.delta))}%` : String(o.delta);\n    const dw = stringWidth(txt);\n    const dx = Math.max(cx + 2, s.width - dw);\n    if (dx + dw <= s.width) s.text(dx, row, txt, good ? theme.pos : theme.neg);\n  }\n  const trendH = s.height - row - 1;\n  if (o.trend && o.trend.length && trendH >= 1) {\n    sparkline(s, 0, row + 1, s.width, o.trend, { theme, color: accent, style: o.style || 'braille', height: trendH, align: 'right' });\n  }\n}\n",
  "prism-term/src/widgets/bars.js": "// Bar-shaped widgets: progress bars, sparklines, bar charts, segmented meters.\n// All of them use eighth-block glyphs for 8× sub-cell precision along the bar.\n\nimport { NONE } from '../color.js';\nimport { ATTR } from '../buffer.js';\nimport { Canvas } from '../canvas.js';\nimport { stringWidth, truncate } from '../width.js';\nimport {\n  themeOf, col, grad, fmt, clamp01, align, zoneColor, extent,\n  LEFT_EIGHTHS, LOWER_EIGHTHS, DEFAULT_ZONES,\n} from './util.js';\n\n/**\n * Horizontal progress bar with 1/8-cell precision.\n * Layout: [label ][bar][ 42%]\n * @param {Surface} surface\n * @param {number} x @param {number} y @param {number} w  total width incl. label/percent\n * @param {number} value  0..1\n * @param {object} [opts]\n * @param {*} [opts.gradient]  palette name, stop list, function, or color (default theme accent→info)\n * @param {*} [opts.color]     solid fill color (used when no gradient)\n * @param {*} [opts.track]     track background (default theme.panel2; 'none' for transparent)\n * @param {string} [opts.trackChar] draw the track with this glyph instead of a background\n * @param {string} [opts.label]\n * @param {boolean} [opts.showPercent=false]\n * @param {boolean} [opts.ascii=false] '[####----]' fallback\n * @returns {{barX:number, barWidth:number, filled:number}}\n */\nexport function progressBar(surface, x, y, w, value, opts = {}) {\n  const theme = themeOf(opts);\n  const v = clamp01(Number(value) || 0);\n  x = Math.round(x); y = Math.round(y); w = Math.max(0, Math.round(w));\n  let bx = x;\n  if (opts.label) {\n    const label = truncate(String(opts.label), Math.max(0, w - 6));\n    if (label) bx = surface.text(x, y, label, col(opts.labelColor, theme, theme.ink)) + 1;\n  }\n  const pct = opts.showPercent ? ` ${String(Math.round(v * 100)).padStart(3)}%` : '';\n  const barW = Math.max(0, x + w - bx - stringWidth(pct));\n  const g = grad(opts.gradient ?? opts.color ?? [theme.accent, theme.info], theme, theme.accent);\n  const colorAt = (i) => g(barW > 1 ? i / (barW - 1) : 1);\n\n  if (opts.ascii) {\n    const inner = Math.max(0, barW - 2);\n    const n = Math.round(v * inner);\n    if (barW >= 2) { surface.set(bx, y, '[', theme.dim); surface.set(bx + barW - 1, y, ']', theme.dim); }\n    for (let i = 0; i < inner; i++) surface.set(bx + 1 + i, y, i < n ? '#' : '-', i < n ? colorAt(i) : theme.dim);\n    if (pct) surface.text(bx + barW, y, pct, col(opts.percentColor, theme, theme.muted));\n    return { barX: bx, barWidth: barW, filled: n };\n  }\n\n  const trackChar = opts.trackChar;\n  const track = trackChar ? NONE : col(opts.track, theme, theme.panel2);\n  const eighths = Math.round(v * barW * 8);\n  const full = eighths >> 3, rem = eighths & 7;\n  for (let i = 0; i < barW; i++) {\n    if (i < full) { const c = colorAt(i); surface.set(bx + i, y, ' ', c, c); }\n    else if (i === full && rem) surface.set(bx + i, y, LEFT_EIGHTHS[rem], colorAt(i), track);\n    else if (trackChar) surface.set(bx + i, y, trackChar, col(opts.trackColor, theme, theme.line), NONE);\n    else surface.set(bx + i, y, ' ', theme.dim, track);\n  }\n  if (pct) surface.text(bx + barW, y, pct, col(opts.percentColor, theme, theme.muted));\n  return { barX: bx, barWidth: barW, filled: full + (rem ? rem / 8 : 0) };\n}\n\n/**\n * Inline trend line.\n * @param {Surface} surface\n * @param {number} x @param {number} y @param {number} w\n * @param {number[]} data  newest last; only the tail that fits is drawn\n * @param {object} [opts]\n * @param {number} [opts.min] @param {number} [opts.max]\n * @param {*} [opts.color]     solid color (default theme.accent)\n * @param {*} [opts.gradient]  color by value level instead\n * @param {'bars'|'braille'} [opts.style='bars']  bars: ▁▂▃▄▅▆▇█, braille: 2×4 dots/cell line\n * @param {number} [opts.height=1]  rows (bars grow upward with 1/8 precision)\n * @param {'left'|'right'} [opts.align='left']  where a short series sits\n * @returns {{min:number, max:number, drawn:number}}\n */\nexport function sparkline(surface, x, y, w, data, opts = {}) {\n  const theme = themeOf(opts);\n  x = Math.round(x); y = Math.round(y); w = Math.max(0, Math.round(w));\n  const h = Math.max(1, Math.round(opts.height || 1));\n  const braille = opts.style === 'braille';\n  const cap = braille ? w * 2 : w;\n  const pts = (data || []).slice(-cap);\n  const [min, max] = extent(pts, opts.min, opts.max);\n  const norm = (v) => (max === min ? 0.5 : clamp01((v - min) / (max - min)));\n  const g = opts.gradient !== undefined ? grad(opts.gradient, theme, theme.accent) : null;\n  const solid = col(opts.color, theme, theme.accent);\n  const colorFor = (lv) => (g ? g(lv) : solid);\n  if (!pts.length || !w) return { min, max, drawn: 0 };\n\n  if (braille) {\n    const cols = Math.ceil(pts.length / 2);\n    const ox = opts.align === 'right' ? x + w - cols : x;\n    const cv = new Canvas(cols, h, { mode: 'braille', threshold: 0 });\n    const ph = cv.height - 1;\n    let prev = null;\n    pts.forEach((v, i) => {\n      if (!Number.isFinite(v)) { prev = null; return; }\n      const lv = norm(v), py = Math.round((1 - lv) * ph);\n      if (prev) cv.line(prev[0], prev[1], i, py, colorFor(lv));\n      else cv.set(i, py, colorFor(lv));\n      prev = [i, py];\n    });\n    cv.paint(surface, ox, y);\n    return { min, max, drawn: pts.length };\n  }\n\n  const ox = opts.align === 'right' ? x + w - pts.length : x;\n  pts.forEach((v, i) => {\n    if (!Number.isFinite(v)) return;\n    const lv = norm(v), c = colorFor(lv);\n    // Minimum one eighth so the lowest value still shows as ▁.\n    const eighths = Math.max(1, Math.round(lv * (h * 8 - 1)) + 1);\n    const full = eighths >> 3, rem = eighths & 7;\n    for (let r = 0; r < full && r < h; r++) surface.set(ox + i, y + h - 1 - r, '█', c);\n    if (rem && full < h) surface.set(ox + i, y + h - 1 - full, LOWER_EIGHTHS[rem], c);\n  });\n  return { min, max, drawn: pts.length };\n}\n\n/**\n * Bar chart, vertical (default) or horizontal, with 1/8-cell bar precision.\n * @param {Surface} surface\n * @param {number} x @param {number} y @param {number} w @param {number} h\n * @param {Array<{label?:string, value:number, color?:*}>} items\n * @param {object} [opts]\n * @param {boolean} [opts.horizontal=false]\n * @param {number} [opts.max]      scale max (default: largest value)\n * @param {*} [opts.gradient]      color along the bar's length instead of per item\n * @param {*} [opts.color]         default bar color (theme.accent)\n * @param {boolean} [opts.showValues=true]\n * @param {number} [opts.gap]      cells between bars (vertical: 1 if room, horizontal: 0)\n * @param {number} [opts.barWidth] vertical bar width in cells (default: fill the width)\n * @param {number} [opts.labelWidth] horizontal label column width\n * @param {function} [opts.format] value → string (default compact fmt)\n */\nexport function barChart(surface, x, y, w, h, items, opts = {}) {\n  const theme = themeOf(opts);\n  const s = surface.view(x, y, w, h);\n  items = (items || []).map((it) => (typeof it === 'number' ? { value: it } : it));\n  if (!items.length || s.width < 1 || s.height < 1) return;\n  const showValues = opts.showValues !== false;\n  const format = opts.format || fmt;\n  const max = opts.max ?? (Math.max(0, ...items.map((i) => (Number.isFinite(i.value) ? i.value : 0))) || 1);\n  const g = opts.gradient !== undefined ? grad(opts.gradient, theme, theme.accent) : null;\n  const base = col(opts.color, theme, theme.accent);\n  const itemColor = (it) => col(it.color, theme, base);\n\n  if (opts.horizontal) {\n    const gap = opts.gap ?? 0;\n    const labels = items.map((i) => String(i.label ?? ''));\n    const lw = Math.min(opts.labelWidth ?? Math.max(0, ...labels.map(stringWidth)), Math.floor(s.width * 0.4));\n    const vals = items.map((i) => format(i.value));\n    const vw = showValues ? Math.max(...vals.map(stringWidth)) + 1 : 0;\n    const bx = lw ? lw + 1 : 0;\n    const area = Math.max(0, s.width - bx - vw);\n    items.forEach((it, k) => {\n      const row = k * (1 + gap);\n      if (row >= s.height) return;\n      if (lw) { const a = align(labels[k], lw, 'right'); s.text(a.offset, row, a.text, theme.muted); }\n      const eighths = Math.round(clamp01((it.value || 0) / max) * area * 8);\n      const full = eighths >> 3, rem = eighths & 7;\n      for (let i = 0; i < full; i++) s.set(bx + i, row, '█', g ? g(area > 1 ? i / (area - 1) : 1) : itemColor(it));\n      if (rem) s.set(bx + full, row, LEFT_EIGHTHS[rem], g ? g(area > 1 ? full / (area - 1) : 1) : itemColor(it));\n      if (showValues) s.text(bx + full + (rem ? 1 : 0) + 1, row, vals[k], theme.ink);\n    });\n    return;\n  }\n\n  const n = items.length;\n  const gap = opts.gap ?? (n * 2 - 1 <= s.width ? 1 : 0);\n  const bw = Math.max(1, opts.barWidth ?? Math.floor((s.width - gap * (n - 1)) / n));\n  const hasLabels = items.some((i) => i.label);\n  const top = showValues ? 1 : 0;\n  const plotH = s.height - top - (hasLabels ? 1 : 0);\n  if (plotH < 1) return;\n  const bottom = top + plotH - 1;\n  items.forEach((it, k) => {\n    const bx = k * (bw + gap);\n    if (bx >= s.width) return;\n    const eighths = Math.round(clamp01((it.value || 0) / max) * plotH * 8);\n    const full = eighths >> 3, rem = eighths & 7;\n    for (let r = 0; r < full; r++) {\n      const c = g ? g(plotH > 1 ? r / (plotH - 1) : 1) : itemColor(it);\n      for (let i = 0; i < bw; i++) s.set(bx + i, bottom - r, '█', c);\n    }\n    if (rem) {\n      const c = g ? g(plotH > 1 ? full / (plotH - 1) : 1) : itemColor(it);\n      for (let i = 0; i < bw; i++) s.set(bx + i, bottom - full, LOWER_EIGHTHS[rem], c);\n    }\n    if (showValues) {\n      const a = align(format(it.value), bw + gap, 'center');\n      const vy = Math.max(0, bottom - full - (rem ? 1 : 0));\n      s.text(bx + Math.min(a.offset, Math.max(0, bw - a.width)), vy, a.text, theme.ink, undefined, ATTR.BOLD);\n    }\n    if (hasLabels) {\n      const a = align(String(it.label ?? ''), bw, 'center');\n      s.text(bx + a.offset, s.height - 1, a.text, theme.muted);\n    }\n  });\n}\n\n/**\n * Segmented LED-style meter: ▰▰▰▰▰▱▱▱ colored by zone.\n * @param {Surface} surface\n * @param {number} x @param {number} y @param {number} w\n * @param {number} value 0..1\n * @param {object} [opts] { segments=w, thresholds=[[0.7,'warn'],[0.9,'neg']], color (base, theme.pos), on='▰', off='▱', ascii }\n */\nexport function meter(surface, x, y, w, value, opts = {}) {\n  const theme = themeOf(opts);\n  const v = clamp01(Number(value) || 0);\n  const n = Math.max(0, Math.min(Math.round(w), opts.segments ?? Math.round(w)));\n  const on = opts.on || (opts.ascii ? '#' : '▰'), off = opts.off || (opts.ascii ? '.' : '▱');\n  const zones = opts.thresholds || DEFAULT_ZONES;\n  const base = col(opts.color, theme, theme.pos);\n  const lit = Math.round(v * n);\n  for (let i = 0; i < n; i++) {\n    const f = n > 1 ? i / (n - 1) : 1;\n    surface.set(x + i, y, i < lit ? on : off, i < lit ? zoneColor(f, zones, theme, base) : theme.line);\n  }\n}\n",
  "prism-term/src/widgets/box.js": "// Boxes / panels with titled borders.\n\nimport { NONE } from '../color.js';\nimport { ATTR } from '../buffer.js';\nimport { stringWidth, truncate } from '../width.js';\nimport { themeOf, col } from './util.js';\n\nexport const BORDERS = Object.freeze({\n  round: { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' },\n  single: { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│' },\n  double: { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║' },\n  heavy: { tl: '┏', tr: '┓', bl: '┗', br: '┛', h: '━', v: '┃' },\n  dashed: { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '╌', v: '╎' },\n  ascii: { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' },\n});\n\nfunction placeLabel(s, x, y, w, label, how, fg, bg, attr) {\n  const avail = w - 4;\n  if (avail < 1) return;\n  const text = truncate(` ${label} `, avail + 2);\n  const tw = stringWidth(text);\n  const lx = how === 'center' ? x + Math.floor((w - tw) / 2) : how === 'right' ? x + w - 2 - tw : x + 1;\n  s.text(lx, y, text, fg, bg, attr);\n}\n\n/**\n * Draw a bordered box and return the View for its interior.\n * @param {Surface} surface\n * @param {number} x @param {number} y @param {number} w @param {number} h\n * @param {object} [opts]\n * @param {'round'|'single'|'double'|'heavy'|'ascii'|'dashed'} [opts.style='round']\n * @param {string} [opts.title]        label set into the top border\n * @param {'left'|'center'|'right'} [opts.titleAlign='left']\n * @param {string} [opts.footer]       label set into the bottom border\n * @param {'left'|'center'|'right'} [opts.footerAlign='right']\n * @param {*} [opts.color]             border color (default theme.line)\n * @param {*} [opts.titleColor]        title color (default theme.ink)\n * @param {*} [opts.bg]                background for the whole box\n * @param {boolean} [opts.fill]        fill with theme.panel when no bg given\n * @param {number|number[]} [opts.padding] inner padding: n (horizontal) or [vertical, horizontal]\n * @returns {View} interior view (w-2 × h-2, minus padding)\n */\nexport function box(surface, x, y, w, h, opts = {}) {\n  const theme = themeOf(opts);\n  const b = BORDERS[opts.style] || BORDERS.round;\n  x = Math.round(x); y = Math.round(y); w = Math.max(0, Math.round(w)); h = Math.max(0, Math.round(h));\n  if (w <= 0 || h <= 0) return surface.view(x, y, 0, 0);\n  const color = col(opts.color, theme, theme.line);\n  const bg = opts.bg !== undefined ? col(opts.bg, theme, NONE) : opts.fill ? theme.panel : undefined;\n  if (bg !== undefined) surface.fill(x, y, w, h, ' ', undefined, bg, 0);\n\n  if (h === 1) {\n    for (let i = 0; i < w; i++) surface.set(x + i, y, b.h, color, bg);\n    if (opts.title) placeLabel(surface, x, y, w, opts.title, opts.titleAlign, col(opts.titleColor, theme, theme.ink), bg, ATTR.BOLD);\n    return surface.view(x, y, 0, 0);\n  }\n  if (w === 1) {\n    for (let j = 0; j < h; j++) surface.set(x, y + j, b.v, color, bg);\n    return surface.view(x, y, 0, 0);\n  }\n\n  surface.set(x, y, b.tl, color, bg);\n  surface.set(x + w - 1, y, b.tr, color, bg);\n  surface.set(x, y + h - 1, b.bl, color, bg);\n  surface.set(x + w - 1, y + h - 1, b.br, color, bg);\n  for (let i = 1; i < w - 1; i++) {\n    surface.set(x + i, y, b.h, color, bg);\n    surface.set(x + i, y + h - 1, b.h, color, bg);\n  }\n  for (let j = 1; j < h - 1; j++) {\n    surface.set(x, y + j, b.v, color, bg);\n    surface.set(x + w - 1, y + j, b.v, color, bg);\n  }\n  if (opts.title) placeLabel(surface, x, y, w, opts.title, opts.titleAlign, col(opts.titleColor, theme, theme.ink), bg, ATTR.BOLD);\n  if (opts.footer) placeLabel(surface, x, y + h - 1, w, opts.footer, opts.footerAlign || 'right', col(opts.footerColor, theme, theme.muted), bg, 0);\n\n  const pad = opts.padding;\n  const [pv, ph] = Array.isArray(pad) ? pad : [0, pad || 0];\n  return surface.view(x + 1 + ph, y + 1 + pv, Math.max(0, w - 2 - 2 * ph), Math.max(0, h - 2 - 2 * pv));\n}\n\n/** A horizontal rule with an optional centered/left label: ──── Label ────. */\nexport function rule(surface, x, y, w, opts = {}) {\n  const theme = themeOf(opts);\n  const color = col(opts.color, theme, theme.line);\n  const ch = opts.char || (opts.style === 'heavy' ? '━' : opts.style === 'double' ? '═' : opts.style === 'ascii' ? '-' : '─');\n  for (let i = 0; i < w; i++) surface.set(x + i, y, ch, color);\n  if (opts.label) {\n    const text = truncate(` ${opts.label} `, Math.max(0, w - 2));\n    const tw = stringWidth(text);\n    const lx = opts.align === 'left' ? x + 2 : opts.align === 'right' ? x + w - 2 - tw : x + Math.floor((w - tw) / 2);\n    surface.text(lx, y, text, col(opts.labelColor, theme, theme.muted));\n  }\n}\n",
  "prism-term/src/widgets/chart.js": "// Line charts (braille, 2×4 dots per cell) and half-block heatmaps.\n\nimport { NONE, mix } from '../color.js';\nimport { CellBuffer } from '../buffer.js';\nimport { Canvas } from '../canvas.js';\nimport { stringWidth, truncate } from '../width.js';\nimport { themeOf, col, grad, fmt, clamp01, extent, seriesColors, bayer } from './util.js';\n\nfunction normalizeSeries(series, theme, colors) {\n  if (!Array.isArray(series) || !series.length) return [];\n  const list = typeof series[0] === 'number' || series[0] === null ? [series] : series;\n  const cycle = (colors || seriesColors(theme)).map((c) => col(c, theme, theme.accent));\n  return list.map((s, i) => {\n    const o = Array.isArray(s) ? { data: s } : { ...s };\n    o.data = o.data || [];\n    o.color = col(o.color, theme, cycle[i % cycle.length]);\n    return o;\n  });\n}\n\nconst isBraille = (ch) => {\n  if (!ch) return 0;\n  const c = ch.charCodeAt(0);\n  return c > 0x2800 && c <= 0x28ff ? c - 0x2800 : 0;\n};\n\n/**\n * Multi-series line chart on a braille canvas, with y-axis ticks, optional\n * x labels, legend, grid, and a dithered gradient area fill under each line.\n * @param {Surface} surface\n * @param {number} x @param {number} y @param {number} w @param {number} h\n * @param {Array<number>|Array<Array<number>|{data:number[], color?:*, label?:string}>} series\n * @param {object} [opts]\n * @param {number} [opts.min] @param {number} [opts.max]\n * @param {Array} [opts.colors]    series color cycle\n * @param {boolean} [opts.axis=true]\n * @param {string[]} [opts.labels] x-axis labels, spread evenly (e.g. ['-5m', 'now'])\n * @param {boolean} [opts.fill=false] area fill under lines\n * @param {boolean} [opts.grid=false] dotted horizontal grid at y ticks\n * @param {boolean} [opts.legend]  default: when any series has a label\n * @param {number} [opts.ticks=5]  max y tick labels\n * @param {function} [opts.yFormat] tick value → string\n * @returns {{plotX:number, plotY:number, plotW:number, plotH:number, min:number, max:number}|undefined}\n */\nexport function lineChart(surface, x, y, w, h, series, opts = {}) {\n  const theme = themeOf(opts);\n  const s = surface.view(x, y, w, h);\n  const list = normalizeSeries(series, theme, opts.colors);\n  if (!list.length || s.width < 3 || s.height < 1) return undefined;\n  let [min, max] = extent(list.flatMap((l) => l.data), opts.min, opts.max);\n  if (max === min) { max += Math.abs(max) * 0.1 || 1; min -= opts.min !== undefined ? 0 : Math.abs(min) * 0.1 || 0; }\n\n  const axis = opts.axis !== false;\n  const legend = (opts.legend ?? list.some((l) => l.label)) && s.height >= 4;\n  const labels = Array.isArray(opts.labels) && opts.labels.length && s.height >= 4 ? opts.labels : null;\n  const top = legend ? 1 : 0;\n  const axisRow = axis && s.height >= 3 ? 1 : 0;\n  const plotH = s.height - top - axisRow - (labels ? 1 : 0);\n  if (plotH < 1) return undefined;\n  const yFmt = opts.yFormat || ((v) => fmt(v));\n\n  // y ticks: evenly spaced cell rows, labeled with the value at the row's center.\n  const nT = Math.max(1, Math.min(opts.ticks ?? 5, plotH > 1 ? Math.ceil(plotH / 2) : 1));\n  const tickRows = nT === 1 ? [0] : [...new Set(Array.from({ length: nT }, (_, k) => Math.round((k * (plotH - 1)) / (nT - 1))))];\n  const valueAtRow = (r) => (plotH === 1 ? max : max - (r / (plotH - 1)) * (max - min));\n  const tickLabels = tickRows.map((r) => yFmt(valueAtRow(r)));\n  const lw = axis ? Math.min(Math.max(...tickLabels.map(stringWidth)), Math.floor(s.width / 3)) : 0;\n  const px0 = axis ? lw + 1 : 0;\n  const plotW = s.width - px0;\n  if (plotW < 2) return undefined;\n\n  if (opts.grid) {\n    for (const r of tickRows) for (let c = 0; c < plotW; c++) s.set(px0 + c, top + r, '┈', theme.line);\n  }\n  if (axis) {\n    for (let r = 0; r < plotH; r++) s.set(px0 - 1, top + r, tickRows.includes(r) ? '┤' : '│', theme.line);\n    tickRows.forEach((r, k) => {\n      const t = truncate(tickLabels[k], lw);\n      s.text(lw - stringWidth(t), top + r, t, theme.muted);\n    });\n    if (axisRow) {\n      s.set(px0 - 1, top + plotH, '└', theme.line);\n      for (let c = 0; c < plotW; c++) s.set(px0 + c, top + plotH, '─', theme.line);\n    }\n  }\n\n  const lineCv = new Canvas(plotW, plotH, { mode: 'braille', threshold: 0 });\n  const fillCv = opts.fill ? new Canvas(plotW, plotH, { mode: 'braille', threshold: 0 }) : null;\n  const pw = lineCv.width, ph = lineCv.height;\n  const n = Math.max(2, ...list.map((l) => l.data.length));\n  const xOf = (i) => (i * (pw - 1)) / (n - 1);\n  const yOf = (v) => (1 - clamp01((v - min) / (max - min))) * (ph - 1);\n\n  for (const l of list) {\n    const pts = [];\n    l.data.forEach((v, i) => { if (Number.isFinite(v)) pts.push([xOf(i), yOf(v)]); else pts.push(null); });\n    if (fillCv) {\n      // Dimmer than the line so the line still reads on top of its own area.\n      const fc = mix(theme.bg, l.color, 0.5);\n      for (let i = 1; i < pts.length; i++) {\n        const a = pts[i - 1], b = pts[i];\n        if (!a || !b) continue;\n        for (let X = Math.round(a[0]); X <= Math.round(b[0]); X++) {\n          const t = b[0] === a[0] ? 0 : (X - a[0]) / (b[0] - a[0]);\n          const yl = a[1] + (b[1] - a[1]) * clamp01(t);\n          for (let Y = Math.ceil(yl) + 1; Y < ph; Y++) {\n            // Dot density fades quadratically from ~50% under the line to sparse at the floor.\n            const f = 1 - (Y - yl) / (ph - yl + 1);\n            const d = 0.5 * f * f + 0.03;\n            if (bayer(X, Y) < d) fillCv.set(X, Y, fc);\n          }\n        }\n      }\n    }\n    for (let i = 0; i < pts.length; i++) {\n      const p = pts[i];\n      if (!p) continue;\n      const q = i > 0 ? pts[i - 1] : null;\n      if (q) lineCv.line(q[0], q[1], p[0], p[1], l.color);\n      else lineCv.set(p[0], p[1], l.color);\n    }\n  }\n\n  // Merge at glyph level so lines stay crisp: in cells holding both, the fill's\n  // dots are OR-ed into the line glyph and take the line's color.\n  const lb = lineCv.paint(new CellBuffer(plotW, plotH));\n  const fb = fillCv ? fillCv.paint(new CellBuffer(plotW, plotH)) : null;\n  for (let r = 0; r < plotH; r++) {\n    for (let c = 0; c < plotW; c++) {\n      const i = r * plotW + c;\n      const L = isBraille(lb.ch[i]), F = fb ? isBraille(fb.ch[i]) : 0;\n      if (L) s.set(px0 + c, top + r, String.fromCharCode(0x2800 + (L | F)), lb.fg[i]);\n      else if (F) s.set(px0 + c, top + r, String.fromCharCode(0x2800 + F), fb.fg[i]);\n    }\n  }\n\n  if (labels) {\n    const row = top + plotH + axisRow;\n    let lastEnd = -1;\n    labels.forEach((lab, k) => {\n      const t = String(lab), tw = stringWidth(t);\n      const pos = labels.length === 1 ? 0 : Math.round((k * (plotW - 1)) / (labels.length - 1));\n      let lx = k === 0 ? 0 : k === labels.length - 1 ? plotW - tw : pos - Math.floor(tw / 2);\n      lx = Math.max(0, Math.min(plotW - tw, lx));\n      if (lx <= lastEnd) return;\n      s.text(px0 + lx, row, t, theme.muted);\n      lastEnd = lx + tw;\n    });\n  }\n\n  if (legend) {\n    let lx = px0;\n    for (const l of list) {\n      if (!l.label) continue;\n      if (lx >= s.width) break;\n      s.set(lx, 0, '●', l.color);\n      lx = s.text(lx + 2, 0, l.label, theme.muted) + 2;\n    }\n  }\n  return { plotX: x + px0, plotY: y + top, plotW, plotH, min, max };\n}\n\n/**\n * Heatmap with two data rows per cell (▀ with fg = upper row, bg = lower row).\n * @param {Surface} surface\n * @param {number} x @param {number} y\n * @param {number[][]} matrix  rows of values; null/NaN cells are transparent\n * @param {object} [opts] { palette='theme-heat', min, max, cellWidth=1, width, height (clip, cells) }\n * @returns {{width:number, height:number, min:number, max:number}}\n */\nexport function heatmap(surface, x, y, matrix, opts = {}) {\n  const theme = themeOf(opts);\n  const rows = (matrix || []).length;\n  const cols = Math.max(0, ...(matrix || []).map((r) => (r ? r.length : 0)));\n  const cw = Math.max(1, Math.round(opts.cellWidth || 1));\n  const W = Math.min(cols * cw, opts.width ?? Infinity), H = Math.min(Math.ceil(rows / 2), opts.height ?? Infinity);\n  const [min, max] = extent((matrix || []).flat(), opts.min, opts.max);\n  const g = grad(opts.palette ?? 'theme-heat', theme, theme.accent);\n  const colorAt = (r, c) => {\n    const v = matrix[r]?.[c];\n    if (!Number.isFinite(v)) return NONE;\n    return g(max === min ? 0.5 : clamp01((v - min) / (max - min)));\n  };\n  const s = surface.view(x, y, W, H);\n  for (let cr = 0; cr < H; cr++) {\n    for (let c = 0; c < cols; c++) {\n      const top = colorAt(cr * 2, c), bot = cr * 2 + 1 < rows ? colorAt(cr * 2 + 1, c) : NONE;\n      if (top === NONE && bot === NONE) continue;\n      for (let k = 0; k < cw; k++) {\n        const X = c * cw + k;\n        if (top === NONE) s.set(X, cr, '▄', bot);\n        else if (bot === NONE) s.set(X, cr, '▀', top);\n        else s.set(X, cr, '▀', top, bot);\n      }\n    }\n  }\n  return { width: W, height: H, min, max };\n}\n",
  "prism-term/src/widgets/gauge.js": "// Gauges: a braille arc (180°) or ring (270°) with colored zones, a needle\n// tick, and the value centered inside.\n\nimport { ATTR } from '../buffer.js';\nimport { Canvas } from '../canvas.js';\nimport { stringWidth, truncate } from '../width.js';\nimport { themeOf, col, clamp01, zoneColor, DEFAULT_ZONES } from './util.js';\n\n/**\n * @param {Surface} surface\n * @param {number} x @param {number} y @param {number} w @param {number} h\n * @param {number} value  0..1\n * @param {object} [opts]\n * @param {string} [opts.label]      caption under the gauge\n * @param {Array} [opts.thresholds]  [[fraction, color], …] zones (default [[0.7,'warn'],[0.9,'neg']])\n * @param {*} [opts.color]           base zone color (default theme.pos)\n * @param {'arc'|'ring'} [opts.style='arc']\n * @param {string} [opts.valueText]  override the centered text (default '73%')\n * @param {boolean} [opts.needle=true]\n */\nexport function gauge(surface, x, y, w, h, value, opts = {}) {\n  const theme = themeOf(opts);\n  const s = surface.view(x, y, w, h);\n  const v = clamp01(Number(value) || 0);\n  const zones = opts.thresholds || DEFAULT_ZONES;\n  const base = col(opts.color, theme, theme.pos);\n  const text = opts.valueText ?? `${Math.round(v * 100)}%`;\n  const zc = zoneColor(v, zones, theme, base);\n  const label = opts.label ? String(opts.label) : '';\n  if (s.width < 1 || s.height < 1) return;\n\n  const arcRows = s.height - (label && s.height >= 3 ? 1 : 0);\n  if (s.width < 4 || arcRows < 2) {\n    // Too small to draw an arc — degrade to colored text.\n    const t = truncate(`${text}${label && s.width > stringWidth(text) + 2 ? ' ' + label : ''}`, s.width);\n    s.text(0, 0, t, zc, undefined, ATTR.BOLD);\n    return;\n  }\n\n  const ring = opts.style === 'ring';\n  const cv = new Canvas(s.width, arcRows, { mode: 'braille', threshold: 0 });\n  const pw = cv.width, ph = cv.height;\n  const a0 = ring ? Math.PI * 1.25 : Math.PI;\n  const sweep = ring ? Math.PI * 1.5 : Math.PI;\n  const cx = (pw - 1) / 2;\n  // Size the dial to the space, then center it vertically (an arc is only half\n  // as tall as it is wide, so in tall boxes it would otherwise hug the bottom).\n  let r, cy;\n  if (ring) { r = Math.min(pw / 2 - 1, ph / 2 - 1); cy = (ph - 1) / 2; }\n  else { r = Math.min(pw / 2 - 1, ph - 2); cy = Math.min(ph - 2, Math.round((ph + r) / 2)); }\n  const thick = Math.max(1.5, r * 0.24);\n  const track = theme.line;\n\n  for (let py = 0; py < ph; py++) {\n    for (let px = 0; px < pw; px++) {\n      const dx = px - cx, dy = cy - py;\n      const d = Math.hypot(dx, dy);\n      if (d > r + 0.5 || d < r - thick) continue;\n      let da = a0 - Math.atan2(dy, dx);\n      da = ((da % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);\n      if (da > sweep + 0.02) continue;\n      const f = Math.min(1, da / sweep);\n      cv.set(px, py, f <= v ? zoneColor(f, zones, theme, base) : track);\n    }\n  }\n  if (opts.needle !== false && r > 4) {\n    const na = a0 - v * sweep, r0 = r - thick - 1, r1 = Math.max(1, r - thick - Math.max(2, r * 0.32));\n    cv.line(cx + Math.cos(na) * r1, cy - Math.sin(na) * r1, cx + Math.cos(na) * r0, cy - Math.sin(na) * r0, theme.ink);\n  }\n  cv.paint(s, 0, 0);\n\n  const textRow = Math.min(arcRows - 1, Math.floor(cy / 4));\n  const t = truncate(text, s.width);\n  s.text(Math.floor((s.width - stringWidth(t)) / 2), textRow, t, zc, undefined, ATTR.BOLD);\n  if (label && arcRows < s.height) {\n    // Arc: just under the value. Ring: in the gap at the bottom of the dial.\n    const lr = ring ? Math.min(s.height - 1, Math.floor((cy + r * 0.85) / 4)) : textRow + 1;\n    const l = truncate(label, s.width);\n    s.text(Math.floor((s.width - stringWidth(l)) / 2), Math.max(textRow + 1, lr), l, theme.muted);\n  }\n}\n",
  "prism-term/src/widgets/index.js": "// Widgets — pure drawing functions over any Surface (CellBuffer or View).\n// Each takes (surface, x, y, …, opts) and colors itself from opts.theme\n// (a theme id or a resolved theme; default: Prism's default theme).\n\nexport { box, rule, BORDERS } from './box.js';\nexport { progressBar, sparkline, barChart, meter } from './bars.js';\nexport { lineChart, heatmap } from './chart.js';\nexport { gauge } from './gauge.js';\nexport { table, logView } from './table.js';\nexport { badge, statusDot, kpi } from './badges.js';\nexport { fmt, fmtDuration, LEFT_EIGHTHS, LOWER_EIGHTHS } from './util.js';\n",
  "prism-term/src/widgets/table.js": "// Tables and log tails.\n\nimport { ATTR } from '../buffer.js';\nimport { stringWidth, truncate } from '../width.js';\nimport { themeOf, col, align } from './util.js';\n\nfunction normalizeColumns(columns, rows) {\n  if (!columns) columns = rows.length ? Object.keys(rows[0]) : [];\n  return columns.map((c) => (typeof c === 'string' ? { key: c, title: c } : { title: c.key, ...c }));\n}\n\nconst cellText = (c, row) => {\n  const v = row?.[c.key];\n  return c.format ? String(c.format(v, row)) : v === undefined || v === null ? '' : String(v);\n};\n\n/**\n * Column-aligned table with a header row and optional zebra striping.\n * Column widths: fixed `width` is honored; the rest size to content and shrink\n * (widest first) to fit `w`.\n * @param {Surface} surface\n * @param {number} x @param {number} y @param {number} w\n * @param {object[]} rows\n * @param {object} [opts]\n * @param {Array<string|{key:string, title?:string, width?:number, align?:'left'|'right'|'center', color?:*|function(value,row):*, format?:function(value,row):string}>} [opts.columns]\n * @param {boolean} [opts.header=true]\n * @param {*} [opts.headerColor] (theme.muted) @param {*} [opts.headerBg] (theme.panel2)\n * @param {boolean} [opts.zebra=true]  stripe odd rows with theme.panel\n * @param {number} [opts.height]  max lines (incl. header)\n * @param {string} [opts.separator=' ']\n * @returns {number} lines drawn\n */\nexport function table(surface, x, y, w, rows, opts = {}) {\n  const theme = themeOf(opts);\n  rows = rows || [];\n  const cols = normalizeColumns(opts.columns, rows);\n  if (!cols.length || w < 1) return 0;\n  const sep = opts.separator ?? ' ';\n  const sepW = stringWidth(sep);\n  const widths = cols.map((c) => c.width ?? Math.max(stringWidth(String(c.title ?? '')), ...rows.map((r) => stringWidth(cellText(c, r))), 1));\n  const budget = w - sepW * (cols.length - 1);\n  let total = widths.reduce((a, b) => a + b, 0);\n  while (total > budget) {\n    let k = -1;\n    for (let i = 0; i < cols.length; i++) if (cols[i].width === undefined && widths[i] > 3 && (k < 0 || widths[i] > widths[k])) k = i;\n    if (k < 0) break;\n    widths[k]--; total--;\n  }\n  const maxLines = Math.max(0, opts.height ?? Infinity);\n  const header = opts.header !== false;\n  let line = 0;\n\n  const drawRow = (getText, colorOf, bg, attr) => {\n    if (bg !== undefined) surface.fill(x, y + line, w, 1, ' ', undefined, bg, 0);\n    let cx = x;\n    cols.forEach((c, i) => {\n      if (cx >= x + w) return;\n      const cwid = Math.min(widths[i], x + w - cx);\n      const a = align(getText(c), cwid, c.align || 'left');\n      surface.text(cx + a.offset, y + line, a.text, colorOf(c), bg, attr);\n      cx += widths[i] + sepW;\n      if (i < cols.length - 1 && sep.trim() && cx - sepW < x + w) surface.text(cx - sepW, y + line, sep, theme.line, bg);\n    });\n    line++;\n  };\n\n  if (header && line < maxLines) {\n    const hb = col(opts.headerBg, theme, theme.panel2);\n    drawRow((c) => String(c.title ?? c.key), () => col(opts.headerColor, theme, theme.muted), hb, ATTR.BOLD);\n  }\n  for (let r = 0; r < rows.length && line < maxLines; r++) {\n    const row = rows[r];\n    const bg = opts.zebra !== false && r % 2 === 1 ? theme.panel : undefined;\n    drawRow(\n      (c) => cellText(c, row),\n      (c) => (typeof c.color === 'function' ? col(c.color(row?.[c.key], row), theme, theme.ink) : col(c.color, theme, theme.ink)),\n      bg,\n      0,\n    );\n  }\n  return line;\n}\n\nconst LEVELS = {\n  trace: { tag: 'TRACE', color: 'dim' },\n  debug: { tag: 'DEBUG', color: 'dim' },\n  info: { tag: 'INFO', color: 'info' },\n  notice: { tag: 'NOTE', color: 'info' },\n  warn: { tag: 'WARN', color: 'warn' },\n  warning: { tag: 'WARN', color: 'warn' },\n  error: { tag: 'ERROR', color: 'neg' },\n  err: { tag: 'ERROR', color: 'neg' },\n  crit: { tag: 'CRIT', color: 'crit', attr: ATTR.BOLD },\n  critical: { tag: 'CRIT', color: 'crit', attr: ATTR.BOLD },\n  fatal: { tag: 'FATAL', color: 'crit', attr: ATTR.BOLD },\n};\n\nfunction fmtTime(t) {\n  if (t === undefined || t === null || t === '') return '';\n  if (typeof t === 'string') return t;\n  const d = t instanceof Date ? t : new Date(t);\n  if (Number.isNaN(d.getTime())) return String(t);\n  return d.toTimeString().slice(0, 8);\n}\n\n/**\n * Severity-colored log tail: the last `h` lines, newest at the bottom.\n * @param {Surface} surface\n * @param {number} x @param {number} y @param {number} w @param {number} h\n * @param {Array<string|{time?:Date|number|string, level?:string, msg:string, source?:string}>} lines\n * @param {object} [opts] { showTime=true, align='top'|'bottom', theme }\n */\nexport function logView(surface, x, y, w, h, lines, opts = {}) {\n  const theme = themeOf(opts);\n  const s = surface.view(x, y, w, h);\n  const tail = (lines || []).slice(-Math.max(0, s.height));\n  const offset = opts.align === 'bottom' ? s.height - tail.length : 0;\n  tail.forEach((raw, i) => {\n    const l = typeof raw === 'string' ? { msg: raw } : raw || {};\n    const row = offset + i;\n    let cx = 0;\n    if (opts.showTime !== false && l.time !== undefined) cx = s.text(cx, row, fmtTime(l.time), theme.dim) + 1;\n    const lv = l.level ? LEVELS[String(l.level).toLowerCase()] || { tag: String(l.level).toUpperCase().slice(0, 5), color: 'muted' } : null;\n    if (lv) {\n      const c = col(lv.color, theme, theme.muted);\n      s.text(cx, row, lv.tag.padEnd(5), c, undefined, lv.attr || 0);\n      cx += 6;\n    }\n    if (l.source) cx = s.text(cx, row, truncate(`${l.source}`, Math.max(0, s.width - cx)), theme.muted) + 1;\n    const msgColor = lv && (lv.color === 'neg' || lv.color === 'crit') ? col(lv.color, theme, theme.ink) : theme.ink;\n    s.text(cx, row, truncate(String(l.msg ?? ''), Math.max(0, s.width - cx)), msgColor);\n  });\n}\n",
  "prism-term/src/widgets/util.js": "// Shared helpers for widgets: theme-aware color resolution, gradients from\n// \"anything\", compact number formatting, and the sub-cell block glyph tables.\n\nimport { getTheme, palette as makePalette, PALETTES } from '../palettes.js';\nimport { parse, gradient, NONE, luminance, mix } from '../color.js';\nimport { stringWidth, truncate } from '../width.js';\n\n/** Left-anchored partial blocks, index = eighths filled (0..8). */\nexport const LEFT_EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];\n/** Bottom-anchored partial blocks, index = eighths filled (0..8). */\nexport const LOWER_EIGHTHS = ['', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];\n\nconst ALIASES = {\n  ok: 'pos', good: 'pos', success: 'pos', healthy: 'pos',\n  error: 'neg', bad: 'neg', danger: 'neg', fail: 'neg',\n  warning: 'warn', critical: 'crit', text: 'ink', fg: 'ink', border: 'line',\n};\n\nexport const clamp01 = (v) => (v > 0 ? (v < 1 ? v : 1) : 0);\n\n/** Resolve opts.theme (id, resolved object, or undefined → default). */\nexport const themeOf = (opts) => getTheme(opts && opts.theme);\n\n/**\n * Resolve a color spec against a theme: packed int, theme token name\n * ('accent', 'neg', 'muted', …, plus aliases like 'ok'/'error'), any color\n * string color.parse() accepts, or undefined/null → `fallback`.\n */\nexport function col(v, theme, fallback = NONE) {\n  if (v === undefined || v === null) return fallback;\n  if (typeof v === 'number') return v;\n  const s = String(v);\n  const key = ALIASES[s] || s;\n  if (theme && typeof theme[key] === 'number') return theme[key];\n  try { return parse(s); } catch { return fallback; }\n}\n\n/**\n * Resolve a gradient spec to a function t∈[0,1] → color: a function (used\n * as-is), a stop list, a palette name, or a single color (constant).\n */\nexport function grad(g, theme, fallback) {\n  if (typeof g === 'function') return g;\n  if (Array.isArray(g)) {\n    return gradient(g.map((c) => (Array.isArray(c) && c.length === 2 && typeof c[0] === 'number' ? [c[0], col(c[1], theme, 0)] : col(c, theme, 0))));\n  }\n  if (typeof g === 'string' && (g in PALETTES || g === 'theme' || g === 'theme-heat')) return makePalette(g, theme);\n  const c = col(g, theme, fallback);\n  return () => c;\n}\n\n/** Readable text color (near-black or white) for a given background. */\nexport const contrast = (bg) => (bg !== NONE && luminance(bg) > 0.55 ? 0x111418 : 0xffffff);\n\n/** Soft tint of `c` over the theme background (for \"soft\" badges, fills). */\nexport const tint = (c, theme, k = 0.22) => mix(theme.bg, c, k);\n\n/** Compact number: 1234 → 1.2k, 0.0312 → 0.031, 12 → 12. */\nexport function fmt(v, digits = 1) {\n  if (v === null || v === undefined || Number.isNaN(v)) return '–';\n  if (typeof v !== 'number') return String(v);\n  const a = Math.abs(v);\n  const trim = (s) => (s.includes('.') ? s.replace(/\\.?0+$/, '') : s);\n  if (a >= 1e12) return trim((v / 1e12).toFixed(digits)) + 'T';\n  if (a >= 1e9) return trim((v / 1e9).toFixed(digits)) + 'B';\n  if (a >= 1e6) return trim((v / 1e6).toFixed(digits)) + 'M';\n  if (a >= 1e4) return trim((v / 1e3).toFixed(digits)) + 'k';\n  if (Number.isInteger(v)) return String(v);\n  if (a >= 100) return trim(v.toFixed(0));\n  if (a >= 10) return trim(v.toFixed(1));\n  if (a >= 1) return trim(v.toFixed(2));\n  return trim(v.toPrecision(2));\n}\n\n/** Human duration from seconds: 0.4s, 12.3s, 4m05s, 2h10m. */\nexport function fmtDuration(s) {\n  if (!Number.isFinite(s) || s < 0) return '–';\n  if (s < 10) return `${s.toFixed(1)}s`;\n  if (s < 60) return `${Math.round(s)}s`;\n  if (s < 3600) return `${Math.floor(s / 60)}m${String(Math.floor(s % 60)).padStart(2, '0')}s`;\n  return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`;\n}\n\n/**\n * Fit text into `width` columns with alignment. Returns { text, offset } where\n * offset is the column (relative to the slot) the text starts at.\n */\nexport function align(text, width, how = 'left') {\n  const s = truncate(String(text), Math.max(0, width));\n  const w = stringWidth(s);\n  const offset = how === 'right' ? width - w : how === 'center' ? Math.floor((width - w) / 2) : 0;\n  return { text: s, offset: Math.max(0, offset), width: w };\n}\n\n/** Color for a fraction f given [[threshold, color], …] zones over a base color. */\nexport function zoneColor(f, thresholds, theme, base) {\n  let c = base;\n  for (const [th, zc] of thresholds) if (f >= th) c = col(zc, theme, c);\n  return c;\n}\n\nexport const DEFAULT_ZONES = [[0.7, 'warn'], [0.9, 'neg']];\n\n/** Series color cycle used when a chart's series don't specify colors. */\nexport const seriesColors = (theme) => [theme.accent, theme.pos, theme.warn, theme.crit, theme.neg, theme.info, theme.ink];\n\n/** Min/max over finite numbers, with a fallback range. */\nexport function extent(values, min, max) {\n  let lo = Infinity, hi = -Infinity;\n  for (const v of values) if (Number.isFinite(v)) { if (v < lo) lo = v; if (v > hi) hi = v; }\n  if (min !== undefined && min !== null) lo = min;\n  if (max !== undefined && max !== null) hi = max;\n  if (!Number.isFinite(lo) || !Number.isFinite(hi)) return [0, 1];\n  return [lo, hi];\n}\n\n// 4×4 ordered-dither thresholds (0,1).\nconst BAYER4 = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map((v) => (v + 0.5) / 16);\nexport const bayer = (x, y) => BAYER4[(y & 3) * 4 + (x & 3)];\n",
  "prism-term/src/width.js": "// Terminal column width of code points. A compact take on wcwidth: zero-width\n// marks, East-Asian wide/fullwidth, and emoji presentation ranges. Everything\n// prism-term draws itself (blocks, braille, box drawing, sextants) is width 1.\n\nimport { stripAnsi } from './ansi.js';\n\nconst ZERO = [\n  [0x0300, 0x036f], [0x0483, 0x0489], [0x0591, 0x05bd], [0x0610, 0x061a], [0x064b, 0x065f],\n  [0x0e31, 0x0e31], [0x0e34, 0x0e3a], [0x0e47, 0x0e4e], [0x1ab0, 0x1aff], [0x1dc0, 0x1dff],\n  [0x200b, 0x200f], [0x2028, 0x202e], [0x2060, 0x2064], [0x20d0, 0x20ff], [0xfe00, 0xfe0f],\n  [0xfe20, 0xfe2f], [0xfeff, 0xfeff], [0x1f3fb, 0x1f3ff], [0xe0000, 0xe0fff],\n];\n\nconst WIDE = [\n  [0x1100, 0x115f], [0x231a, 0x231b], [0x2329, 0x232a], [0x23e9, 0x23ec], [0x23f0, 0x23f0],\n  [0x23f3, 0x23f3], [0x25fd, 0x25fe], [0x2614, 0x2615], [0x2648, 0x2653], [0x267f, 0x267f],\n  [0x2693, 0x2693], [0x26a1, 0x26a1], [0x26aa, 0x26ab], [0x26bd, 0x26be], [0x26c4, 0x26c5],\n  [0x26ce, 0x26ce], [0x26d4, 0x26d4], [0x26ea, 0x26ea], [0x26f2, 0x26f3], [0x26f5, 0x26f5],\n  [0x26fa, 0x26fa], [0x26fd, 0x26fd], [0x2705, 0x2705], [0x270a, 0x270b], [0x2728, 0x2728],\n  [0x274c, 0x274c], [0x274e, 0x274e], [0x2753, 0x2755], [0x2757, 0x2757], [0x2795, 0x2797],\n  [0x27b0, 0x27b0], [0x27bf, 0x27bf], [0x2b1b, 0x2b1c], [0x2b50, 0x2b50], [0x2b55, 0x2b55],\n  [0x2e80, 0x303e], [0x3041, 0x33ff], [0x3400, 0x4dbf], [0x4e00, 0x9fff], [0xa000, 0xa4cf],\n  [0xa960, 0xa97f], [0xac00, 0xd7a3], [0xf900, 0xfaff], [0xfe10, 0xfe19], [0xfe30, 0xfe6f],\n  [0xff00, 0xff60], [0xffe0, 0xffe6], [0x16fe0, 0x16fe4], [0x17000, 0x18cff], [0x1b000, 0x1b2ff],\n  [0x1f004, 0x1f004], [0x1f0cf, 0x1f0cf], [0x1f18e, 0x1f18e], [0x1f191, 0x1f19a], [0x1f200, 0x1f251],\n  [0x1f300, 0x1f320], [0x1f32d, 0x1f335], [0x1f337, 0x1f37c], [0x1f37e, 0x1f393], [0x1f3a0, 0x1f3ca],\n  [0x1f3cf, 0x1f3d3], [0x1f3e0, 0x1f3f0], [0x1f3f4, 0x1f3f4], [0x1f3f8, 0x1f43e], [0x1f440, 0x1f440],\n  [0x1f442, 0x1f4fc], [0x1f4ff, 0x1f53d], [0x1f54b, 0x1f54e], [0x1f550, 0x1f567], [0x1f57a, 0x1f57a],\n  [0x1f595, 0x1f596], [0x1f5a4, 0x1f5a4], [0x1f5fb, 0x1f64f], [0x1f680, 0x1f6c5], [0x1f6cc, 0x1f6cc],\n  [0x1f6d0, 0x1f6d2], [0x1f6d5, 0x1f6d7], [0x1f6eb, 0x1f6ec], [0x1f6f4, 0x1f6fc], [0x1f7e0, 0x1f7eb],\n  [0x1f90c, 0x1f93a], [0x1f93c, 0x1f945], [0x1f947, 0x1f9ff], [0x1fa70, 0x1faff], [0x20000, 0x3fffd],\n];\n\nfunction inTable(cp, table) {\n  let lo = 0, hi = table.length - 1;\n  while (lo <= hi) {\n    const mid = (lo + hi) >> 1;\n    if (cp < table[mid][0]) hi = mid - 1;\n    else if (cp > table[mid][1]) lo = mid + 1;\n    else return true;\n  }\n  return false;\n}\n\n/** Columns a single code point occupies: 0, 1 or 2. */\nexport function charWidth(cp) {\n  if (typeof cp === 'string') cp = cp.codePointAt(0) ?? 0;\n  if (cp < 0x300) return cp < 32 || (cp >= 0x7f && cp < 0xa0) ? 0 : 1;\n  if (inTable(cp, ZERO)) return 0;\n  if (cp === 0x200d) return 0;\n  return inTable(cp, WIDE) ? 2 : 1;\n}\n\n/** Columns a string occupies once ANSI escapes are removed. */\nexport function stringWidth(s) {\n  let w = 0;\n  for (const ch of stripAnsi(s)) w += charWidth(ch.codePointAt(0));\n  return w;\n}\n\n/** Truncate a plain string to at most `max` columns, appending `ellipsis` if cut. */\nexport function truncate(s, max, ellipsis = '…') {\n  if (stringWidth(s) <= max) return s;\n  const ew = stringWidth(ellipsis);\n  let out = '', w = 0;\n  for (const ch of s) {\n    const cw = charWidth(ch.codePointAt(0));\n    if (w + cw > max - ew) break;\n    out += ch;\n    w += cw;\n  }\n  return out + ellipsis;\n}\n\n/** Greedy word-wrap of plain text to `width` columns. Returns an array of lines. */\nexport function wrap(s, width) {\n  const out = [];\n  for (const para of String(s).split('\\n')) {\n    let line = '', w = 0;\n    for (const word of para.split(/\\s+/).filter(Boolean)) {\n      const ww = stringWidth(word);\n      if (w && w + 1 + ww > width) { out.push(line); line = ''; w = 0; }\n      if (ww > width) { out.push(truncate(word, width)); continue; }\n      line += (w ? ' ' : '') + word;\n      w += (w ? 1 : 0) + ww;\n    }\n    out.push(line);\n  }\n  return out;\n}\n"
 }
}
