@humanlayer/fold-tui-theme
v0.0.5
Published
A playable OpenTUI app that renders a GitHub PR/issue browser as a cyberpunk HUD in **six swappable themes** — the two canonical candidates (RAPTURE, TACTICAL) plus four experiments (NEUROMANCER, RED ALERT, COVENANT, WINTERMUTE) — so you can decide which
Readme
fold-tui-theme
A playable OpenTUI app that renders a GitHub PR/issue browser as a cyberpunk HUD in six
swappable themes — the two canonical candidates (RAPTURE, TACTICAL) plus four
experiments (NEUROMANCER, RED ALERT, COVENANT, WINTERMUTE) — so you can decide which
aesthetic to carry into the real Fold TUI. Every theme renders exactly the same data; press
t to cycle them live and judge them side by side. This package exists to make that
decision, not to be production code — see What this is not.
For the design system itself — the palette, the glow threshold that decides which colors emit light, the typography rules, and exactly how the glitch works — see STYLE.md. It is written to be self-contained: enough to rebuild this look from scratch without reading the source.
Run it
From this directory (the repo is a Bun workspace; run bun install at the root once if you
haven't):
bun run demoThat is the zero-setup path: --demo forces the bundled fixtures, so it needs no network
and no GitHub token. Use bun run start to pull live data from GitHub instead, or bun run
<theme-id> (rapture, tactical, neuromancer, redalert, covenant, wintermute)
to start on a specific theme. The app accepts --theme <theme-id>, --repo <owner/repo>
(default humanlayer/fold), and --demo.
Live data is best-effort: the client discovers a token from GITHUB_TOKEN, GH_TOKEN, or
gh auth token, and falls back to the fixtures on any failure — no token, no network,
rate limit, private repo, 404. The network can never take the playground down. When it does
fall back, the header's RATE readout reads OFFLINE and the SOURCE panel names the reason.
Everything on screen is real
Every panel is derived from records GitHub actually returned. The rail used to carry a
spinning targeting reticle, sine-wave CPU/MEM/NET/IO gauges, fabricated LAT/LON coordinates,
and a random-hex "data stream" — all of it is gone. The rule now: if a panel can't be backed
by a function in src/github/stats.ts or a field on Feed, it doesn't belong on screen.
┌─ FOLD │ NAME + tagline │ REPO// owner/repo │ AUTH · RATE ─────────────────────────┐
├───────────────┬──────────────────────────────────────┬──────────────────────────┤
│ INDEX │ RECORD │ STATE │
│ PULLS / │ title · state chip · author · │ counts per state, │
│ ISSUES │ dates · comments · labels │ bars in semantic slots │
│ + rows, │ ──────────────────── ├──────────────────────────┤
│ glyph per │ // DESCRIPTION │ LABELS top 5 │
│ row │ scrollbox, markdown-ish body ├──────────────────────────┤
│ │ │ AUTHORS top 4 │
│ │ ├──────────────────────────┤
│ │ │ ACTIVITY 14-day spark │
│ │ ├──────────────────────────┤
│ │ │ SOURCE LIVE/FIXTURES │
├───────────────┴──────────────────────────────────────┴──────────────────────────┤
│ ↑↓/jk SELECT TAB PULLS/ISSUES T THEME Q QUIT FX// B GLOW:ON … R CRT-BAR:ON │
└───────────────────────────────────────────────────────────────────────────────────┘- INDEX — the PULLS / ISSUES list, with per-tab counts and a state glyph on each row.
Tabswitches lists;j/kmoves the cursor. - RECORD — the selected item in full: title, state chip, author / opened / updated / comments (plus the branch-ref arrow on PRs), labels, and a scrolling, lightly-marked-up body.
- STATE (
stateTallies) — counts peropen/draft/merged/closed, each bar drawn in the samesemanticslot the list uses; zero-count rows render muted. - LABELS (
labelTallies) — the top 5 labels by frequency. - AUTHORS (
authorTallies) — the top 4 authors by record count. - ACTIVITY (
updatesByDay) — a 14-day sparkline of how many records were last updated each day, with running total and peak. - SOURCE — whether the feed is
LIVEorFIXTURES; when live, the rate-limit reset countdown; when it fell back, the reason (a 404, a rate limit).offlineReasonand the reset countdown were the onlyFeedfields nothing else surfaced.
The layout is responsive: below ~118 columns the right-hand rail (STATE / LABELS / AUTHORS / ACTIVITY / SOURCE) drops; below ~84 the INDEX list narrows (to 40% of width, floored, min 24 cells). RECORD always survives.
Comparing the themes
t cycles the theme in place — same data, same layout, different palette and post-process
chain — so the comparison is instantaneous. The cycle order is RAPTURE → TACTICAL →
NEUROMANCER → RED ALERT → COVENANT → WINTERMUTE. The two canonical candidates are
deliberately pulled apart along these axes:
| Axis | RAPTURE | TACTICAL |
| --------------- | ------------------------------------------ | ---------------------------------------------------------------------- |
| Canvas | absolute black | murky brown-black, like a dirty optic |
| Neon | teal and laser purple and red | amber only; a rare cyan flash; red |
| "Injected" slot | laser purple | bright yellow (same system, running hot) |
| Frame | thin single border, cool teal | heavy border, burnt orange |
| Heading prefix | // | [ |
| Signature FX | glow + aberration + injected-colour bursts | vignette + heavier scanlines + chroma-dropout + injected-colour bursts |
| Motion | fast scan sweep, occasionally unstable | slow tube roll, constant |
With the reticle gone, the fastest reads are the border color (cool teal vs burnt
orange), the semantic state colors in STATE and INDEX (MERGED is laser purple in A, a
rare cyan in B), the "injected" slot (purple vs yellow), and the FX signature: glow
plus a glitch burst in A that separates the color layers (chromatic aberration) and snaps back,
versus a vignette + denser scanlines in B whose bursts drop chroma — the whole frame washes
toward monochrome. In both, the burst also injects corrupt color it cannot get by moving or
draining the frame: solid color blocks stamped over the logo and borders, and tinted runs of
foreground — warm/red/gray in tactical (never cool), the spliced neon palette in rapture. Dropout
alone could only ever darken B; the injection is what turns things red, burnt-orange and amber.
(glitch.chromaticAberration is pinned at 0 in tactical; chromaDropout at 0.4 plus injection
carries its corruption — an unstable analog signal, not a splice.) Both themes roll a CRT bar, but
A's is a fast thin scan sweep over a black void (only glyphs flare) and B's a slow fat tube
roll that lifts the murky canvas with it.
The names carry the intent. RAPTURE — "drowned deco // neon splice" is a sunken art-deco
city lit only from within: amber brass carries the structure, electric teal is the cold sea
pressing on the glass (borders and structural data), laser purple is the neon signage marking
anything "injected" (merged records, #123 cross-references, count badges), and piercing red
is the rare emergency — all against absolute-black water that supplies none of its own light. TACTICAL — "optic feed // nominal"
is the view through a cyborg's lens: amber and burnt orange own the whole screen, neon red is
the only loud voice, cyan is a single rare flash, and the dominant artifact is the CRT itself.
The four experimental themes
Each is a complete Theme under src/theme/, built with the same discipline (role-named
slots, a documented glow-threshold partition, a scarcity budget for the critical color, one
whole-frame glitch idiom):
- NEUROMANCER — "matrix feed // ice intact." Green phosphor owns the world (the matrix
itself); cold cyan is ICE — the countermeasure lattice — on structural data; the one warm
flash is an amber klaxon (black ICE burning). Analog chroma-dropout glitch,
doubleframe. WINTERMUTE's twin. - RED ALERT — "master alarm // all systems red." An anime mech cockpit mid-alarm: blue-shadowed razor reds on near-pure black (no rust, no orange drift), a white strobe as the critical color (the only thing brighter than a red world), one cyan sync light, hazard yellow on DRAFT. Digital chromatic-aberration glitch, urgent CRT bar.
- COVENANT — "plasma lattice // shields nominal." An alien warship's hardlight command
surface: magenta-pink plasma structure, electric shield-cyan relief, a rare relic-gold,
acid containment yellow for critical (red would mud into the pink). Aberration reads as
shield harmonics; the
roundedframe is the alien curvature tell. - WINTERMUTE — "ice lattice // self-diagnostic." A glacial orbital AI auditing itself.
Restraint is the signature: the glow gate admits exactly one token (white-ice), the glitch
fires roughly once a minute, no vignette,
doublefrost frame, and the single warm color is a thermal-amber fault light. NEUROMANCER's twin.
The theming system
This is the part that actually informs the decision: is this the right token interface to carry forward?
Themeis one flat token interface (src/theme/types.ts):name/tagline, acolorobject,chrome,semantic,fx, and two block ramps —barRamp(▏▎▍▌▋▊▉█, left-to-right) for the horizontal count bars andsparkRamp(▁▂▃▄▅▆▇█, bottom-up) for the activity sparkline.rapture.tsandtactical.tseach define a private localpaletteof raw colors and map it onto those tokens.- No hex literal exists outside
src/theme/*.ts. Every component callsuseTheme()and references a slot —color.core,color.inject,color.alert— never a color. Slots are named by role, not hue: a foundation (core/coreBright/coreDim), a cool "augmentation" pair (grid/gridDim), an "injected" slot (inject), a critical slot (alert), and a text hierarchy. Every slot has a reader — the dead ones were deleted, because a token interface carrying unused slots lies about what the aesthetic needs. Swapping themes is just swapping which raw colors sit in those roles; the components don't change. (Verified:rg '#[0-9a-fA-F]{6}' srcmatches only the two theme files.) - The post-process chain is assembled from
fxtokens (src/hud/postfx.ts).installPostFx(renderer, theme, toggles)walks thePostFxtokens in a fixed order (glow → vignette → scanlines → CRT bar → glitch) and pushes one pass per token that is both present in the theme and enabled by the runtime toggle. It returns a disposer;App.tsxtears the chain down and rebuilds it whenever the theme or a toggle changes. Every pass is independently switchable, and a pass runs only when the theme declares it and the toggle permits it — so RAPTURE, which defines no vignette, showsV VIGNETTE:--rather than pretending it is on. Everything ships on by default; the footer spells out each key and what it drives, because a toggle nobody can find is a toggle that looks broken. The scrolling CRT bar gets its own switch (r), never shared with the static vignette, since it is the only pass that animates continuously. semanticmaps GitHub states onto palette slots.open/closed/merged/drafteach name a color slot;displayState()insrc/github/types.tscollapsesstate/draft/mergedinto one token, anduseStateStyle()insrc/components/atoms.tsxturns that into a{ glyph, color, label }used by both the INDEX rows and the STATE panel. This is where the "no green, red is rare" rule lives: in both themes OPEN is not green, and red is spent only on CLOSED, target locks, and destructive arrows.
If you carry this forward, the load-bearing ideas are role-named color slots instead of hues, and an FX chain derived from tokens with per-effect runtime gates.
Gotchas a maintainer will trip on
These are real and mostly non-obvious. Do not "simplify" them away.
- Post-process
deltaTimeis milliseconds — but the bundled effects disagree.CRTRollingBarEffectdivides by 1000 internally (it wants ms), whileDistortionEffecttreats the value as seconds. Fed raw ms,DistortionEffectfires roughly every frame and expires the next, degenerating into constant static instead of occasional bursts. This is whypostfx.tshand-rolls aGlitchDirector(seconds internally) rather than reusingDistortionEffect, and passes raw ms straight toCRTRollingBarEffect. Keep it. - Do not use opentui's
BloomEffect. Its emitter test islum(fg) > thresholdand it never looks at the cell's character. A blank cell still carries a foreground, and the default foreground is white (luminance 1.0) — so on a mostly-empty HUD over a black void every empty cell becomes a maximum-intensity emitter,thresholdgoes inert, and the palette drowns in a flat white wash (measured: 48% of glyphs rendered pure white).src/hud/GlowEffect.tsreplaces it: only real glyphs emit (it readsbuffers.charand skips space/unset), and the glow lands on neighbours' backgrounds, tinted toward the emitter's foreground, never on foregrounds — so glyph colours survive exactly, which is what "outer glow" actually means. - Terminal cells are ~2:1 (tall:wide).
GlowEffect's kernel spansradius * CELL_ASPECTcells horizontally (CELL_ASPECT = 2), or every halo renders as a vertical smear. - The glow is
O(w·h·r²). That is why it is toggleable withbandradiusstays pinned at 2 — the first thing to turn off if a terminal chugs.
Verifying changes without a TTY
scripts/preview.tsx renders one frame into the test harness so you can iterate on layout and
geometry without launching the full app. It always uses the fixtures, so no token or network
is involved.
bun run scripts/preview.tsx --theme rapture --size 140x44
bun run scripts/preview.tsx --theme tactical --size 140x44 --keys tab,j,j # drive to 3rd issue
bun run scripts/preview.tsx --theme rapture --size 140x44 --spans # palette histogram
bun run scripts/preview.tsx --theme rapture --size 90x30 # narrow: rail dropsThe flags:
--theme <rapture|tactical>and--size WxH(default140x44).--keys a,b,cdrives the app through a comma-separated key sequence before the frame is captured, exactly as a user would type it. Friendly names (tab,up,pageup, …) map to their escape sequences; single letters (j,t, …) pass straight through — so--keys tab,j,jlands on the third issue and--keys tswaps to TACTICAL.--spansprints a foreground-color histogram instead of the character grid: every distinct color, how many visible (non-space) cells it paints, and its share of the frame.--spans-top Nwidens how many colors are listed before the tail is folded into one line.
captureCharFrame() — what preview prints by default — is monochrome: it proves layout
(that panels tile, that the sparkline fits its box, that text does not overflow) but says
nothing about color, which is the whole point of this package. --spans is the only way to
weigh the palette from the CLI — to answer "is red actually rare? does amber dominate?" — and
the rendered frame in a real terminal is the final arbiter.
One harness subtlety worth knowing: settleFrame() yields two macrotasks before drawing,
not one. The first lets React flush the commit queued by the key handler; but React 19
flushes passive effects (useEffect) on a later task, and App installs the post-process
chain from one — so a single yield would draw the new tree through the old FX chain (a
one-key --keys b --spans reported the glow still on).
Keybindings
Every row is sourced from App.tsx's useKeyboard:
| Key | Action |
| ---------------------- | --------------------------------- |
| ↑ / k, ↓ / j | Move selection |
| PageUp / PageDown | Jump 8 rows |
| Tab | Switch PULLS ↔ ISSUES |
| t | Cycle theme (all six) |
| b | Toggle glow (the fx.glow token) |
| s | Toggle scanlines |
| f | Toggle glitch |
| v | Toggle vignette |
| r | Toggle the scrolling CRT bar |
| q / Esc / Ctrl-C | Quit |
All effects are on by default. A toggle reads -- when the active theme declares no such
effect — RAPTURE has no vignette, so v is inert there. Above 120 columns the footer names
each key (R CRT-BAR:ON); below it, initials (R:ON).
What this is not
This is a theme playground, not production architecture. Nowhere is that clearer than
src/github/client.ts: it is plain async/fetch, with Bun.spawn for gh auth token and
Promise.allSettled across the two list calls so one disabled endpoint doesn't sink the whole
feed — deliberately not Effect, even though the rest of Fold is. The point here is the
pixels, and Effect would only add ceremony between you and the frame. The real Fold TUI will
wire its data through Effect; do not cargo-cult this client into it.
