-
Notifications
You must be signed in to change notification settings - Fork 0
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.
| 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".
Key state:
-
ThemeChoice = 'light' | 'dark' | 'system'. - Module-level
media = window.matchMedia('(prefers-color-scheme: light)'), created once at import;nulloutside a browser (typeof window !== 'undefined'guard). -
currentChoice— module state, initialised to'system'. -
onSystemChange— intended to hold the registeredchangehandler so it can be replaced/removed.
Functions and call chain:
-
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. -
applyTheme(choice)is the single write path. It recordscurrentChoice, then setsdocument.documentElement.dataset.theme = resolveTheme(choice)— "so the stylesheet can switch variable palettes" — and, when the choice issystem, installs the OS listener. - While the choice is
system, achangeevent on the media query re-entersapplyTheme('system'), keepingdata-themein 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.
-
DEFAULT_ACCENT = '#c6f135'— brand lime, also the first preset. -
ACCENT_PRESETS— the curated six: lime (the brand signal),#02af3egreen,#37d4c0teal,#4aa8ffsky,#ffab2eamber,#ff5d5dcoral. The comment is explicit: "Deliberately NO purple/violet: hues ~255-325 are banned outright." -
isHexColor(v)accepts#rgbor#rrggbbonly. -
hexHueSat(v)expands shorthand hex and returns hue (0–360) and saturation (0–1), ornullfor 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 orundefinedforundefinedinput, non-hex junk, or any purple.undefinedis 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.
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--fgthere): far edges ink @0.2, near edges ink @0.78, verticesmix(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.
- 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 paintsvar(--bg), so the wireframe must match that surface, not the OS. - Effect keyed on
[accent, theme]: sizes the canvas backing store to132 × min(devicePixelRatio, 2)and buildstesseractPalette(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.7and0.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. Ifprefers-reduced-motion: reducematches, the loop never starts and one static frame is drawn; achangelistener 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 isaria-hidden; atermsprawlwordmark sits alongside.
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
Key nodes:
-
applyThemeis the only writer ofhtml[data-theme]; the media query both feedsresolveTheme('system')and re-entersapplyThemeon OS changes while the choice remainssystem. -
resolveAccentis the single valve between persisted accent data and any paint. Itsundefinedreturn is not an error — it is the signal to useDEFAULT_ACCENT. -
tesseractPalettere-runs the same guard, so the boot surface cannot paint purple even if a caller passesaccentwithout 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/--fgin the comments depend on. -
Accent — the project accent rides in persisted workspace data and is resolved at render time through
resolveAccent(directly, or indirectly viatesseractPalette). 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.
-
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(ortesseractPalette) instead of painting raw values; that is what keeps the ban by-construction rather than by convention. -
New theme choices: extend
ThemeChoiceand add palettes under the new[data-theme]value;resolveThemecurrently understands only light/dark/system. -
New loading or branding art: reuse
tesseractPalette,css, andlerpColor; 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:
onSystemChangeis never assigned, so listener removal is dead code and listeners accumulate acrossapplyTheme('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.
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance