Skip to content

Theme, Accent & Visual Language

dazeb edited this page Sep 17, 2026 · 2 revisions

Theme, Accent & Visual Language

termsprawl's visual language is carried by two small state modules and one component-local palette: state/theme.ts decides whether the app paints light or dark, state/accent.ts decides which accent colors are allowed to exist, and components/tesseract-palette.ts turns both into drawable channels for the boot tesseract. The TesseractSpinner is the flagship consumer — the product is named after the shape, so the boot screen is drawn with the same tokens that tint the rest of the chrome.

Module map

File Responsibility
src/renderer/src/state/theme.ts ThemeChoice type, resolveTheme, applyTheme; writes data-theme on <html> and follows OS changes while the choice is system.
src/renderer/src/state/accent.ts DEFAULT_ACCENT, ACCENT_PRESETS, hex/hue helpers, isPurple, resolveAccent — the "never purple" guard. Pure, DOM-free, unit-testable.
src/renderer/src/components/tesseract-palette.ts Channel-based palette types, tesseractPalette(accent, light), css, lerpColor. Pure.
src/renderer/src/components/TesseractSpinner.tsx Canvas drawing + requestAnimationFrame loop for the boot tesseract; honours prefers-reduced-motion.

A companion module, components/tesseract-geometry.ts, owns the 4D→3D→2D projection (STATIC_ANGLES, anglesAt, tesseractFrame) imported by the spinner. Keeping geometry and palette separate is what lets the color logic stay a pure, tested module while the spinner remains "drawing and the animation loop only".

Theme state (state/theme.ts)

Key state:

  • ThemeChoice = 'light' | 'dark' | 'system'.
  • Module-level media = window.matchMedia('(prefers-color-scheme: light)'), created once at import; null outside a browser (typeof window !== 'undefined' guard).
  • currentChoice — module state, initialised to 'system'.
  • onSystemChange — intended to hold the registered change handler so it can be replaced/removed.

Functions and call chain:

  1. resolveTheme(choice) is pure: 'system' resolves through the media query (matches → 'light', otherwise 'dark'; with no media query it resolves to 'dark'); explicit 'light'/'dark' pass through unchanged.
  2. applyTheme(choice) is the single write path. It records currentChoice, then sets document.documentElement.dataset.theme = resolveTheme(choice) — "so the stylesheet can switch variable palettes" — and, when the choice is system, installs the OS listener.
  3. While the choice is system, a change event on the media query re-enters applyTheme('system'), keeping data-theme in sync with the OS without any React state change.

Boundary note worth knowing before editing: in refreshSystemListener, the newly added listener is an anonymous closure that is never stored back into onSystemChange. Since onSystemChange stays null, the removal branch is unreachable, and each applyTheme('system') call — including those the listener itself triggers on a system change — registers another change listener. The module works, but listener bookkeeping is incomplete; assign the closure when registering if you touch this code. In non-browser contexts media is null, listener setup no-ops, and 'system' resolves to dark.

Accent selection (state/accent.ts)

  • DEFAULT_ACCENT = '#c6f135' — brand lime, also the first preset.
  • ACCENT_PRESETS — the curated six: lime (the brand signal), #02af3e green, #37d4c0 teal, #4aa8ff sky, #ffab2e amber, #ff5d5d coral. The comment is explicit: "Deliberately NO purple/violet: hues ~255-325 are banned outright."
  • isHexColor(v) accepts #rgb or #rrggbb only.
  • hexHueSat(v) expands shorthand hex and returns hue (0–360) and saturation (0–1), or null for non-hex.
  • isPurple(v) is the hard rule: hue in 255–325 and saturation above 0.15. Neutral grays and near-black/near-white tints have saturation ≈ 0 and pass.
  • resolveAccent(v) trims and lowercases, then returns the normalized hex or undefined for undefined input, non-hex junk, or any purple. undefined is the signal for "no override — fall back to brand lime".

The "never purple" defense is by construction and layered. The picker only offers the presets (none purple, pinned by accent.test.ts), and resolveAccent refuses purple arriving from any other channel — a hand-edited workspace.json, an imported bundle, or an old index — so legacy data cannot paint purple at the apply site. Note the division of labor: resolveAccent validates shape and hue, not membership in the presets (the palette test honors a raw #ff0000); preset-only selection is a picker-UI concern, while the code-level guarantee is "valid hex, never purple".

Call chain: persisted project accent → consumer render → resolveAccent → hex or undefined → ?? DEFAULT_ACCENT → CSS/canvas color.

accent.test.ts pins the contract: no purple preset; lime default passes; #861dbf, #8000ff, #e6a8ff, #f0f, #7c3aed are flagged; lime/green/sky/coral/grays are not; case normalization works; purple and junk resolve to undefined.

Boot palette (components/tesseract-palette.ts)

Colors come back as numeric channels (TesseractColor { r, g, b, a }), not CSS strings, because the spinner interpolates near/far colors per edge on every frame — that ramp is what keeps 32 overlapping lines legible.

tesseractPalette(accent, light):

  • base = resolveAccent(accent) ?? DEFAULT_ACCENT — the purple guard is re-applied here, so a purple that somehow reaches the boot screen still renders as brand lime.
  • Light theme (LIGHT_INK = '#1c1c1a', matching --fg there): far edges ink @0.2, near edges ink @0.78, vertices mix(base, ink, 0.45) @0.95, glow base @0.3. Brand lime on a near-white page is almost invisible, so the wireframe switches to dark ink and the accent survives only at the nearest vertices.
  • Dark theme: the whole figure is accent-tinted — far edges base @0.26, near edges base @0.92, vertices base @0.95, glow base @0.45, matching how the rest of the app's chrome is tinted.

Helpers: css(color) formats rgba(r, g, b, a) for canvas fill/stroke/shadow; lerpColor(from, to, t) clamps t to 0..1, rounds channels to integers and alpha to two decimals.

tesseract-palette.test.ts asserts the rationale numerically: near edges more opaque than far; on dark, edges read brighter than the page; on light, edges read darker than the page; edge colors differ per theme so lime never washes out on light; an accent override is honored; purple falls back to lime.

TesseractSpinner (components/TesseractSpinner.tsx)

  • Props: accent?: string, theme?: 'dark' | 'light' (default 'dark'). The theme prop is the resolved theme of the surface on screen, not the user's preference: the boot overlay paints var(--bg), so the wireframe must match that surface, not the OS.
  • Effect keyed on [accent, theme]: sizes the canvas backing store to 132 × min(devicePixelRatio, 2) and builds tesseractPalette(accent, theme === 'light').
  • Drawing: painter's order far edges first; a bloom pass restricted to edges with depth ≥ 0.78 (shadow blur scaled by depth, capped at 6); then a crisp pass over all edges and vertex dots whose alpha ramps from 0.22 up to the vertex color by depth. Edge width and dot radius interpolate between 0.5→1.7 and 0.7→1.9.
  • Motion: a rAF loop adds a slow spin on top of STATIC_ANGLES, the 3/4 view that reads as a solid object from the first frame. If prefers-reduced-motion: reduce matches, the loop never starts and one static frame is drawn; a change listener re-applies if the preference flips mid-boot, and cleanup cancels the frame and removes the listener.
  • Accessibility: wrapper is role="status" aria-label="Loading"; the canvas is aria-hidden; a termsprawl wordmark sits alongside.

Token flow into node chrome

flowchart LR
  CALLER["theme preference caller"] --> AT["applyTheme(choice)"]
  AT --> RT["resolveTheme(choice)"]
  MQ["prefers-color-scheme media query"] --> RT
  RT --> ATTR["html[data-theme]"]
  ATTR --> VARS["stylesheet variable palettes (--bg, --fg)"]
  VARS --> CHROME["node chrome / canvas surfaces"]
  MQ -.->|change while choice = system| AT

  STORED["persisted project accent"] --> RA{"resolveAccent(v)"}
  RA -->|valid hex and not purple| HEX["normalized hex"]
  RA -->|undefined, non-hex, or purple| LIME["DEFAULT_ACCENT #c6f135"]
  HEX --> CHROME
  LIME --> CHROME
  HEX --> TP["tesseractPalette(accent, light)"]
  LIME --> TP
  TP --> SPIN["TesseractSpinner"]
  ATTR -.->|resolved surface theme as prop| SPIN
Loading

Key nodes:

  • applyTheme is the only writer of html[data-theme]; the media query both feeds resolveTheme('system') and re-enters applyTheme on OS changes while the choice remains system.
  • resolveAccent is the single valve between persisted accent data and any paint. Its undefined return is not an error — it is the signal to use DEFAULT_ACCENT.
  • tesseractPalette re-runs the same guard, so the boot surface cannot paint purple even if a caller passes accent without resolving it first.
  • The two streams merge on chrome and nodes: theme arrives as the [data-theme] attribute driving CSS variable palettes, while accent arrives as a resolved color value consumed by surfaces (and by the spinner via props).

How the tokens land on node chrome:

  • Theme — because the switch is a DOM attribute rather than React state, themed surfaces (canvas nodes, panels, boot overlay) repaint through the stylesheet's variable palettes, the mechanism --bg/--fg in the comments depend on.
  • Accent — the project accent rides in persisted workspace data and is resolved at render time through resolveAccent (directly, or indirectly via tesseractPalette). A resolved hex — or the brand-lime fallback — is what accent-consuming surfaces paint with; purple or malformed values collapse to the fallback, so legacy data degrades to the default look instead of failing.
  • Loading and branding surfaces reuse exactly the same two inputs (resolved accent + resolved surface theme), which is what keeps the boot screen and the rest of the chrome on one palette.

Extension points & caveats

  • Adding an accent preset: append to ACCENT_PRESETS; the test loop over presets fails if the new swatch is purple. Keep brand lime as the default/first entry.
  • Tuning the purple boundary: isPurple's hue window (255–325) and saturation floor (0.15) are the single rule; changing them changes picker and runtime behavior together.
  • New themed surfaces: pass any persisted accent through resolveAccent (or tesseractPalette) instead of painting raw values; that is what keeps the ban by-construction rather than by convention.
  • New theme choices: extend ThemeChoice and add palettes under the new [data-theme] value; resolveTheme currently understands only light/dark/system.
  • New loading or branding art: reuse tesseractPalette, css, and lerpColor; keep palettes pure so they stay DOM-free and unit-testable, and follow the spinner's reduced-motion and DPR-cap patterns.
  • Theme listener bookkeeping: onSystemChange is never assigned, so listener removal is dead code and listeners accumulate across applyTheme('system') calls and successive system-change events. Assign the closure at registration to restore replacement semantics.

Sources: src/renderer/src/state/theme.ts, src/renderer/src/state/accent.ts, src/renderer/src/state/accent.test.ts, src/renderer/src/components/tesseract-palette.ts, src/renderer/src/components/tesseract-palette.test.ts, src/renderer/src/components/TesseractSpinner.tsx.

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally