Skip to content

feat(web): let users add their own themes from ~/.thinkrail/themes - #937

Merged
Rinat S (rsolmano) merged 13 commits into
mainfrom
user-custom-themes
Oct 27, 2026
Merged

Rinat S (rsolmano) merged 13 commits into
mainfrom
user-custom-themes

Conversation

@rsolmano

@rsolmano Rinat S (rsolmano) commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Themes could only be added through a source PR plus a rebuild: apps/web/src/themes/SPEC.md listed "users cannot add themes" as a deliberate non-goal. Users who want their own palette had no path at all, even though a theme is already one self-contained, declarative JSON manifest.

Approach

User themes mirror how local language files already work (~/.thinkrail/locales):

  • A user saves a theme as ~/.thinkrail/themes/<name>.theme.json in the same format as the bundled manifests (schema v2, strict, complete — copy dark.theme.json, change id, label and colours). It appears in Settings → Appearance without a restart; editing the active file re-themes live.
  • The host reads, the client validates. packages/server/src/themes only scans, size-caps, parses and watches the folder (once-a-second signature poll, same as locales) and returns the raw JSON. apps/web/src/themes validates each file with the existing parseThemeManifest, so the schema keeps one owner. Rejected: moving the validator into a shared package — more churn for no user-visible gain.
  • Bad files fail soft, built-ins stay protected. Each file is validated on its own; an unusable one is listed in Appearance with its reason while the rest load. User themes live in their own user. id namespace (a file with "id": "ocean" is catalog/config id user.ocean; bundled ids may never use the prefix), so a theme we bundle later can never capture a user's saved choice and a copied dark.theme.json is simply user.dark. Two user files with one id → duplicate-id. Fallbacks and derived system-pair slots are picked from bundled themes only. The AA/AAA contrast gates stay merge gates for our own manifests only.
  • No startup flash, with a bounded cache. Writing the first-paint hint also caches the user manifests that preference names (fixed id + system pair, ≤ 3, ≤ 64 K chars). initializeThemes installs them before React mounts. The bound matters on desktop, where that storage is Electrobun's native preference store (256 KB per value, 1 MB per document across windows): caching every user theme could have starved other desktop preferences.
  • Deleting the active theme's file falls back through the existing unavailable-id path; a host older than the user-theme protocol clears any cached user themes.

Changes

  • Protocol v92 (USER_THEMES_PROTOCOL_VERSION): themes.list → { dir, files } (each { fileName, ok: true, manifest: unknown } | { fileName, ok: false, error, detail? }), themes.reveal, and the themes.changed invalidation channel. Older hosts simply don't show the new settings group.
  • New server module packages/server/src/themes: 256 KB/file, 64 files, a string-aware nesting-depth limit (32) so a pathological file can't overflow serialization of the whole themes.list response. Its small folder poller copies the locales one rather than adding a sibling edge.
  • apps/web/src/themes: installUserThemes / clearUserThemes / getUserThemeIssues / onThemesChange; initializeBundledThemes → initializeThemes; own-property lookups so ids like constructor resolve normally.
  • Transport → store → Shell: userThemes snapshot refreshed on welcome and themes.changed; Shell installs it before re-applying the preference (re-applying republishes data-theme, so Monaco/xterm rebuild even when the active id is unchanged); supportsUserThemes gates it.
  • Settings → Appearance → Custom themes: short steps with a version-pinned link to dark.theme.json, the folder path with Copy path / Open folder, and one warning per broken file. 10 new settings.appearance.custom.* keys (English only; untranslated strings fall back to English).
  • Specs/docs: goal doc capability, architecture Decision 23, themes/SPEC.md (non-goal replaced by narrower ones), new server/src/themes/SPEC.md, web/panels/transport/contracts/server specs, README "Your themes" bullet.
  • Manifest compatibility (recorded in themes/SPEC.md): the parser accepts every schema version released for user files (v2 is the first) and migrates older ones in code; a later key must be derivable from existing ones. Forward compatibility is not promised.
  • The fallback notice now reads "Configured theme isn't available. Using …" (en, zh-CN, es), fitting a deleted user theme as well as an older app.
  • Deferred (recorded as such in themes/SPEC.md): partial overlays, external-format import (shadcn/tweakcn, VS Code), extension-provided themes, an in-app editor, contrast warnings.

Screenshots

Settings → Appearance, same viewport, with one valid user theme (ocean-dark.theme.json) and one broken file (broken.theme.json) in the themes folder.

Before (main) After — theme list (Ocean Dark from the folder, applied) After — Custom themes group
before after: theme list after: custom themes

Checklist

  • Fast gates pass: bun run lint, bun run typecheck, bun run test
  • E2E suite passes for app-affecting changes (bun run e2e, or bun run e2e:full when touching agent behavior)
  • Before/after screenshots are included for frontend changes, or marked not applicable
  • Relevant SPEC.md / top-level specs updated to reflect any boundary, contract, or behavior change
  • I have read the Contributing guide and agree to the Code of Conduct

Testing

  • bun run e2e (rebased head) → all 8 shards passed, including the new e2e/user-themes.spec.ts (dropped file listed and applied, live re-theme on edit, survives reload, duplicate-id notice, fallback and recovery after deletion).
  • bun run e2e:desktop (pre-rebase) → all theme/language specs passed; 24 unrelated failures (terminals ×20, workspace-tabs ×2, layout, model-picker) that pass 61/61 on rerun both on this branch and on its base.
  • bun run smoke:desktop (pre-rebase) → smoke OK.
  • Unit tests (rebased head): packages/server bun test → 1839 pass / 0 fail; all other packages via turbo → 21/21. bun run typecheck, biome check ., check:deps / boundaries / seams / spec-surface / i18n / compiler → green (pre-commit).
  • Manual run against an isolated data dir: theme listed/applied, ~2 s live re-theme on file edit, broken and duplicate files reported, user theme still painted with the host stopped, deletion → Dark fallback → restore.
  • Cross-review with gpt-6-astra, 3 rounds: 5 findings (unsupported host keeping stale themes, deeply nested JSON breaking the whole response, prototype-key ids, cache size vs the desktop per-value cap, then an incomplete nesting fix) — all accepted and fixed; final verdict approve.
  • Review follow-ups (namespace, bundled-only fallbacks, wording, spec rules): bun test server → 1887 pass; other packages → 21/21; bun run e2e → all 8 shards passed; bun run typecheck → 22/22 after the final rebase.

@jetbrains-air jetbrains-air Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — ready to merge.

@danyaberezun

Copy link
Copy Markdown
Collaborator

Fix before merge – mostly spec-only, but this PR turns the manifest into a format users keep on disk, so these are cheapest to settle now.

  1. Schema compatibility. A user file with schemaVersion: 3 or one extra key is rejected as invalid-theme, and the spec grows the schema by adding keys (as header was in v2), so the next bump breaks every user theme. Add a rule to apps/web/src/themes/SPEC.md: the parser accepts every released version and migrates it in code; a new key must be derivable from existing ones.
  2. Non-goals vs plan. apps/web/src/themes/SPEC.md lists external-format import and partial overlays as deliberate non-goals, while the PR description calls them deferred. Please record them as deferred.
  3. Fallback picks user themes. A copy of dark.theme.json labelled 'Arctic' (order 10, sorts before 'Dark') becomes the dark slot when System is first enabled from Light, and the fallback for a broken dark slot. Restrict fallbackTheme (apps/web/src/themes/runtime.ts:302) to bundled themes – the only ones that pass the contrast gates.
  4. Id collisions. Bundled ids always win, so once we bundle, say, nord, a user's nord becomes reserved-id and AppConfig.theme: "nord" quietly resolves to ours. Ids persist in config, so pick a namespace now (a user-id prefix or a reserved bundled prefix); later it is a migration.
  5. Fallback wording. A deleted user theme still reads 'unavailable in this app version' (packages/locales/en.json:1408-1410) – a one-line fix.

@rsolmano

Copy link
Copy Markdown
Collaborator Author

Thanks danyaberezun — all five addressed; rebased onto current main and pushed (head aaf11eaa3).

  1. Schema compatibility — agreed. apps/web/src/themes/SPEC.md (Manifest contract) now says the parser accepts every schema version released for user files (v2 is the first, so nothing to migrate yet) and migrates older ones in code before validation; a key added later must be derivable from keys every earlier version has (as header = content), or it can't be added. I recorded compatibility as backward-only: a newer schemaVersion or an unknown key stays invalid-theme rather than guessed at. → aaf11ea
  2. Non-goals vs plan — agreed. New Deferred section: partial overlays/inheritance, external-format import (shadcn/tweakcn, VS Code), extension-packaged themes (seam named), in-app editor, contrast warnings. Non-goals keeps only actual decisions. → aaf11ea
  3. Fallback picks user themes — agreed, reproduced with an order-0 "Arctic" dark copy. fallbackTheme now chooses from the bundled catalog only, which covers both the first-enable opposite slot and an unusable slot's fallback; a user theme still fills its own slot when the user picked it. Regression test fails on the old lookup. → bfcc396
  4. Id collisions — agreed; picked the user-side namespace. A user file's catalog/config id is user.<manifest id> (prefix applied by the client, so the copy-dark.theme.json-and-edit path keeps working; manifest ids capped at 59 chars), and a bundled manifest may never use user. (buildThemeCatalog throws). A future bundled nord can't capture a saved user.nord, and a file with "id": "dark" is just user.dark, so reserved-id is gone. I chose the user prefix over a reserved bundled prefix because the latter would have meant migrating every existing AppConfig.theme ("dark", "light", …). Recorded in the themes spec. → eef6181
  5. Fallback wording — now "Configured (dark/light) theme isn't available. Using {{theme}}." in en, zh-CN and es. → 210368c

Verification on the final tree: server bun test 1887 pass, all other packages 21/21, bun run e2e all 8 shards, bun run typecheck 22/22 after the last rebase.

@jetbrains-air jetbrains-air Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes — please address the blocking inline finding.

Comment thread apps/web/src/themes/runtime.ts Outdated

@jetbrains-air jetbrains-air Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — ready to merge.

@rsolmano
Rinat S (rsolmano) added this pull request to the merge queue Oct 27, 2026
Merged via the queue into main with commit affe5de Oct 27, 2026
11 checks passed
@rsolmano
Rinat S (rsolmano) deleted the user-custom-themes branch October 27, 2026 12:41
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