Skip to content

feat: theme support — Light, IntelliJ Darcula + Gruvbox, server-synced in Settings#84

Merged
Rinat S (rsolmano) merged 4 commits into
mainfrom
theme-support-light-darcula
Jul 18, 2026
Merged

feat: theme support — Light, IntelliJ Darcula + Gruvbox, server-synced in Settings#84
Rinat S (rsolmano) merged 4 commits into
mainfrom
theme-support-light-darcula

Conversation

@rsolmano

@rsolmano Rinat S (rsolmano) commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

What

Adds user-selectable UI themes, chosen in Settings → Appearance and persisted host-side so the choice follows the user across devices/browsers dialing the same host.

  • Dark — the existing ThinkRail dark (default, unchanged for current users)
  • Light — new
  • Darcula — the classic IntelliJ palette (#3c3f41 tool windows around a #2b2b2b editor, #a9b7c6 foreground, blue selection, authentic syntax colors in Monaco + shiki) with ThinkRail's violet kept as the interactive accent
  • Gruvbox — the vim / tiling-WM classic (morhetz's "retro groove"): #1d2021 editor inside #282828 tool windows, amber #ebdbb2 text, the canonical gruvbox 16-color terminal palette, gruvbox vim syntax colors — and, uniquely, the signature gruvbox orange as the interactive accent with dark-on-accent text (violet fights this palette)

Screenshots

How

Settings persistence (new) — a config.json under ~/.thinkrail, following the projects.json/workspaces.json pattern:

  • contracts: Theme const-object enum + ThemeId/THEME_IDS, AppConfig, DEFAULT_CONFIG (plain-string values → cross JSON/the wire with no casts; adding a theme id is wire-compatible — an older client falls back to Dark's :root tokens)
  • persistence: loadConfig/saveConfig
  • new settings host module: getConfig/updateConfig + a publisher seam
  • wire (PROTOCOL_VERSION 6→7): server.welcome carries config, settings.update persists a partial, settings.changed broadcasts the merged config so every client converges (same no-per-client-optimism pattern as the workspace lifecycle)

Theme mechanism (web) — the [data-theme] swap the spec already described:

  • tokens.css gains per-theme blocks overriding the semantic surface/text/status tokens + color-scheme (plus, where a theme calls for it, the accent family); @theme inline re-themes every utility for free
  • the token vocabulary carries the code surfaces too: --ansi-* (the 16 xterm colors — Light and Gruvbox ship their own palettes; dark-tuned brights wash out on white) and optional --code-* syntax colors (Darcula + Gruvbox set them; other themes get the Monaco base palette)
  • utils/theme.applyTheme + a localStorage first-paint hint (avoids a flash before server.welcome; the host stays the source of truth), store.theme fed by transport, and the Shell owns the single DOM effect
  • new Appearance settings section with the theme picker

Code surfaces track the swap: Monaco picks vs/vs-dark, observes data-theme, and rebuilds chrome + syntax rules from the tokens; shiki renders all palettes in one pass (dark/light/darcula/gruvbox — darcula is a custom registration in lib/shikiTheme.ts, gruvbox is shiki-shipped) flipped by pure-CSS [data-theme] rules, no re-highlighting; xterm rebuilds its theme incl. the ANSI palette on the same observer; mermaid already followed the tokens.

Hardening (found by a UX/theme review of the first cut)

  • Minified-CSS token reads: the built CSS serves colors in any equivalent form (#ffffff#fff, #808080gray), which Monaco/xterm reject — under Light this crashed the editor panel on any file open. All JS token reads now go through lib.cssColorToHex (canvas-canonicalized; unparseable → treated as unset), and Monaco's define degrades to the base palette instead of throwing. Regression-covered by e2e against the built bundle.
  • Contrast floors (documented in tokens.css): body ≥ 4.5:1, --muted ≥ 4.5:1 on its worst resting surface, --hint ≥ 3:1. Darcula's muted/hint were lifted (3.0 → 4.5:1 on the #3c3f41 sidebar); Light's hint + status colors darkened to ≥ 4.5:1 as text on white (they double as diff badges / "M" markers / links).
  • Terminal legibility: xterm's dark-tuned default ANSI colors washed out on Light — the 16 slots are now themed per palette.

Design note

The localStorage paint-hint is a render cache only — the host's config.json remains the source of truth for the server-synced setting.

Tests / verification

  • typecheck ✅ · lint ✅ · check:deps ✅ (husky pre-commit)
  • unit: settings module tests, store applyConfig, cssColorToHex (full suite green)
  • e2e no-agent suite: 44/44 ✅, incl. e2e/theme.spec.ts: switch + persist across reload, and open a real file under every theme against the built (minified) CSS, asserting Monaco actually re-themes (Light #ffffff → Darcula #2b2b2b → Gruvbox #1d2021 → Dark) with no editor ErrorBoundary
  • every palette verified live in the running app (measured rendered colors: Darcula #cc7832 keywords / Gruvbox #fb4934 keywords / shiki --shiki-* flips)

SPECs

Updated across contracts, persistence, new settings/SPEC.md, server, module-web (styling & theming incl. the token vocabulary + contrast floors), store, transport, panels (code-surface re-theming), lib, shell — spec-graph validates clean.

…ings

Add user-selectable UI themes, chosen in Settings → Appearance and persisted
host-side so the choice follows the user across devices.

Themes: Dark (existing default, unchanged) + a new Light theme + the classic
IntelliJ Darcula (authentic surfaces #3c3f41/#2b2b2b, #a9b7c6 text, blue
selection; ThinkRail violet kept as the interactive accent).

Settings persistence (new):
- contracts: Theme const-object enum + ThemeId/THEME_IDS, AppConfig,
  DEFAULT_CONFIG (plain-string values, no wire-boundary casts)
- persistence: loadConfig/saveConfig over config.json (~/.thinkrail)
- new `settings` host module: getConfig/updateConfig + broadcast seam
- wire (PROTOCOL_VERSION 6→7): server.welcome carries `config`,
  `settings.update` persists, `settings.changed` broadcasts so clients converge

Theme mechanism (web):
- tokens.css [data-theme="light"|"darcula"] blocks override only semantic
  surface/text/status tokens + color-scheme; @theme inline re-themes all utils
- utils/theme.applyTheme + localStorage first-paint hint (no flash; host stays
  the source of truth), store.theme fed by transport, Shell owns the DOM effect
- Appearance settings section with the theme picker

Code surfaces track the swap: Monaco picks vs/vs-dark + observes data-theme;
shiki renders dual (dark+light) palettes flipped by CSS; xterm + mermaid already
followed the tokens.

Tests: settings module + store applyConfig unit tests; e2e/theme.spec.ts
(switch + persist across reload). SPECs updated across contracts, persistence,
settings, server, module-web, store, transport, panels, shell.
… terminal ANSI + Darcula syntax

- lib.cssColorToHex: canonicalize token reads to hex (minified CSS serves
  #fff / gray equivalents; Monaco and xterm reject them). Fixes the Light-theme
  editor crash (defineTheme threw, opening any file hit the ErrorBoundary).
- Monaco: syntax rules from per-theme --code-* tokens (Darcula's cc7832/6a8759
  palette), define degrades instead of throwing.
- Terminal: 16 --ansi-* tokens (Dark+ defaults, Light+ overrides) so shell
  colors stay legible on light surfaces.
- shiki: tri-palette render (custom darcula registration in lib/shikiTheme),
  flipped by [data-theme] CSS — no re-highlighting.
- Contrast: Darcula muted/hint lifted (3.0→4.5:1 on the 3c3f41 sidebar); Light
  hint + status colors darkened to ≥4.5:1 as text on white.
- e2e: theme spec now opens a real file under each theme against the built
  (minified) CSS and asserts Monaco re-themes; SPECs updated.
The geek theme: morhetz's retro-groove palette as a fourth theme. Warm
hard-contrast darks (#1d2021 editor inside #282828 tool windows), amber
#ebdbb2 text, and — uniquely — the signature gruvbox orange as the
interactive accent with dark-on-accent text (6.5:1). Everything rides the
existing per-theme token mechanism:

- tokens.css [data-theme="gruvbox"]: surfaces/text/status + accent family,
  the canonical gruvbox 16-color ANSI palette, and gruvbox vim syntax via
  --code-* (red keywords, green strings, gray comments)
- shiki: gruvbox-dark-hard (shiki-shipped) joins the palette map; pure-CSS
  flip like the others
- contracts: Theme.Gruvbox (additive, wire-compatible — unknown ids fall
  back to Dark's :root tokens)
- e2e: the Monaco re-theme cycle now covers gruvbox

All token pairs validated ≥4.5:1 on their resting surfaces (muted 5.3,
statuses 4.8–9.7).
@rsolmano Rinat S (rsolmano) changed the title feat: theme support — Light + IntelliJ Darcula, server-synced in Settings feat: theme support — Light, IntelliJ Darcula + Gruvbox, server-synced in Settings Jul 17, 2026
@rsolmano
Rinat S (rsolmano) merged commit 09c072f into main Jul 18, 2026
4 checks passed
@rsolmano
Rinat S (rsolmano) deleted the theme-support-light-darcula branch July 18, 2026 08:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants