Skip to content

Commit affe5de

Browse files
authored
feat(web): let users add their own themes from ~/.thinkrail/themes (#937)
* feat(contracts): add the user theme files wire surface * feat(server): serve and watch user theme files * feat(web): install user theme files into the theme catalog * feat(web): show the custom themes folder in Appearance settings * test(e2e): cover user theme files from folder to picker * fix(themes): bound the first-paint cache and harden user theme reading * docs(specs): record user theme files as runtime data * docs(specs): move user theme files to protocol v92 * feat(web): namespace user theme ids under user. * fix(web): pick fallback and derived pair themes from bundled themes only * fix(locales): word the theme fallback notice for deleted user themes * docs(themes): record the manifest compatibility rule and the deferred theme work * fix(web): report an over-long user theme id through a locale key
1 parent 7c9150c commit affe5de

36 files changed

Lines changed: 1032 additions & 60 deletions

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ create the tools it needs based on how you actually work.
7777
Simplified Chinese and Spanish are built in. Add a language ThinkRail doesn't ship yet by dropping a
7878
translated `en.json` into `~/.thinkrail/locales` — it applies as you save. Agent replies and tool
7979
output stay as the agent wrote them.
80+
- **Your themes.** Settings → Appearance ships dark, light, minimal and high-contrast themes. Make your own
81+
by copying a bundled `*.theme.json`, changing its id and colors, and saving it to `~/.thinkrail/themes`.
8082

8183
<picture>
8284
<source media="(prefers-color-scheme: dark)" srcset=".github/readme-assets/specs-dark.png">

‎apps/web/SPEC.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ change. `dist/` therefore has no profiler timers (`actualStartTime` is absent fr
170170
the runtime graph is acyclic), `lib` (plain-HTTP-safe random page identity)
171171
- `components` → `lib` (`ErrorBoundary`'s `shallowEqualArrays` for its reset keys) and `constants` (`RunningIcon` draws the shared brand monogram) — both leaves, so any region can still wrap in it; presentational controls consume `ui`
172172
- `lib` → `themes` (the lazy highlighter uses the one generic CSS-variable Shiki registration), React (the phone-viewport hook only), and `contracts` (types only, e.g. the MCP state vocabulary's `McpServerState`)
173-
- `themes` → `constants` (the branding storage prefix scopes the first-paint hint), `clientPreferences` (native-stable hint storage)
173+
- `themes` → `constants` (the branding storage prefix scopes the first-paint hint and the user-theme cache), `clientPreferences` (native-stable hint storage); `transport` hydrates the store's user-theme snapshot and `shell`'s theme effect installs it
174174
- `i18n` → `store`, `contracts` (types), `@thinkrail/locales`; `transport` hydrates the store's local-language snapshot, `main.tsx`'s `App` mounts `useLocaleSync` once and `panels` renders the Language page from its pure derivations
175175
- **Any module may → `@thinkrail/locales/react`** (`useT`, `<T>`, `currentLocale`) for its UI text — the one cross-cutting edge besides `lib`; user-facing text is never a literal ([[architecture]] Decision 23)
176176
- leaves (`clientPreferences`, `constants`, `utils`, `styles`) → none internal
@@ -278,6 +278,8 @@ themselves.
278278
only nullable color slots (`null` retains the consumer default). A build-time glob validates the set at
279279
bootstrap (our files — a bad one fails loudly), so adding a theme changes only that file — never
280280
contracts, a label map, CSS selectors, editor imports, tests, or specs — and appears after a rebuild.
281+
A user adds a theme the same way at runtime: the same manifest in `~/.thinkrail/themes`, served by
282+
[[submodule-server-themes]] and validated here, where an unusable file is reported, not fatal.
281283
Manifests are self-contained (no inheritance), contain canonical color data only, and cannot alter
282284
layout/type/motion or inject CSS/code. The engine derives repetitive tints/effects and atomically
283285
writes the mapped custom properties before changing `[data-theme]`; `@theme inline` keeps every utility

‎apps/web/src/main.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,15 @@ import { initFilesTreePersistence } from "./panels/filesTreeState";
1212
import { initProjectExpansionPersistence } from "./panels/projectExpansion";
1313
import { AppResourceHost } from "./shell/AppResourceHost";
1414
import { Shell } from "./shell/Shell";
15-
import { applyThemePreference, initializeBundledThemes, readThemeHint } from "./themes";
15+
import { applyThemePreference, initializeThemes, readThemeHint } from "./themes";
1616
import {
1717
clientErrorReport,
1818
initTransport,
1919
installClientErrorReporting,
2020
reportClientError,
2121
} from "./transport";
2222

23-
initializeBundledThemes();
23+
initializeThemes();
2424
applyThemePreference(readThemeHint());
2525
initTransport();
2626
installClientErrorReporting();

‎apps/web/src/panels/AppearanceSettings.tsx‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,22 @@ import {
2424
} from "@thinkrail/ui/dropdown-menu";
2525
import { cn } from "@thinkrail/ui/utils";
2626
import { type ReactNode, useState, useSyncExternalStore } from "react";
27+
import { copyText } from "@/lib";
2728
import { toast, useAppStore } from "@/store";
2829
import {
2930
deriveSystemThemePair,
3031
getThemes,
32+
getUserThemeIssues,
3133
onSystemAppearanceChange,
34+
onThemesChange,
3235
readSystemAppearance,
3336
resolveTheme,
3437
resolveThemePreference,
3538
type ThemeDescriptor,
3639
type ThemeResolution,
3740
} from "@/themes";
3841
import { errorText, getTransport } from "@/transport";
42+
import { CustomThemesGroup } from "./CustomThemesSettings";
3943
import { LineWidthControl } from "./LineWidthSettings";
4044
import { SegmentedControl, type SegmentedControlOption } from "./SegmentedControl";
4145
import { SettingsGroup, SettingsPageContainer } from "./SettingsGroup";
@@ -56,7 +60,9 @@ const BUNDLED_THEME_LABELS: Readonly<Record<string, MessageKey>> = {
5660
};
5761

5862
function themeLabel(t: Translator, theme: Pick<ThemeDescriptor, "id" | "label">): string {
59-
const key = BUNDLED_THEME_LABELS[theme.id];
63+
const key = Object.hasOwn(BUNDLED_THEME_LABELS, theme.id)
64+
? BUNDLED_THEME_LABELS[theme.id]
65+
: undefined;
6066
return key ? t(key) : theme.label;
6167
}
6268

@@ -237,8 +243,11 @@ export function AppearanceSettings() {
237243
const [pending, setPending] = useState(false);
238244
const [expanded, setExpanded] = useState(false);
239245
const systemAppearance = useSyncExternalStore(onSystemAppearanceChange, readSystemAppearance);
246+
const themes = useSyncExternalStore(onThemesChange, getThemes);
247+
const userThemeIssues = useSyncExternalStore(onThemesChange, getUserThemeIssues);
248+
const userThemes = useAppStore((state) => state.userThemes);
249+
const appVersion = useAppStore((state) => state.hostAppVersion);
240250

241-
const themes = getThemes();
242251
const systemSupported = (protocolVersion ?? 0) >= THEME_SYSTEM_PROTOCOL_VERSION;
243252
const activeTheme = resolveTheme(theme);
244253
const fixedResolution = resolveThemePreference({ theme, themeMode: "fixed" });
@@ -350,6 +359,16 @@ export function AppearanceSettings() {
350359
if (next.light !== pair.light || next.dark !== pair.dark) update({ systemThemePair: next });
351360
};
352361

362+
const copyThemesFolder = async (folder: string) => {
363+
if (await copyText(folder)) toast.success(t("settings.language.folderCopied"));
364+
};
365+
366+
const revealThemesFolder = () => {
367+
getTransport()
368+
.request("themes.reveal", {})
369+
.catch((error) => toast.error(errorText(error, t("settings.language.openFolderFailed"))));
370+
};
371+
353372
const fallbacks = systemMode
354373
? [
355374
{ appearance: "light", resolution: lightResolution },
@@ -484,6 +503,13 @@ export function AppearanceSettings() {
484503
}
485504
/>
486505
</SettingsGroup>
506+
<CustomThemesGroup
507+
snapshot={userThemes}
508+
issues={userThemeIssues}
509+
appVersion={appVersion}
510+
onCopyFolder={(folder) => void copyThemesFolder(folder)}
511+
onRevealFolder={revealThemesFolder}
512+
/>
487513
<SettingsGroup title={t("settings.appearance.editorGroup")}>
488514
<LineWidthControl kind="file" />
489515
</SettingsGroup>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { expect, test } from "bun:test";
2+
import { renderToStaticMarkup } from "react-dom/server";
3+
import { CustomThemesGroup } from "./CustomThemesSettings";
4+
5+
test("shows the theme folder, the example link, and one notice per broken file", () => {
6+
const markup = renderToStaticMarkup(
7+
<CustomThemesGroup
8+
snapshot={{ dir: "/home/me/.thinkrail/themes", files: [] }}
9+
issues={[{ fileName: "copy.theme.json", error: "duplicate-id", detail: "ocean" }]}
10+
appVersion="1.2.3"
11+
onCopyFolder={() => {}}
12+
onRevealFolder={() => {}}
13+
/>,
14+
);
15+
expect(markup).toContain("/home/me/.thinkrail/themes");
16+
expect(markup).toContain('data-testid="custom-themes-open-folder"');
17+
expect(markup).toContain("/blob/v1.2.3/apps/web/src/themes/bundled/dark.theme.json");
18+
expect(markup).toContain('data-testid="custom-theme-issue-copy.theme.json"');
19+
expect(markup).toContain("Another theme file already uses this id");
20+
});
21+
22+
test("renders nothing when the host cannot serve theme files", () => {
23+
expect(
24+
renderToStaticMarkup(
25+
<CustomThemesGroup
26+
snapshot={null}
27+
issues={[]}
28+
appVersion={null}
29+
onCopyFolder={() => {}}
30+
onRevealFolder={() => {}}
31+
/>,
32+
),
33+
).toBe("");
34+
});
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { RiFileCopyLine as Copy, RiFolderOpenLine as FolderOpen } from "@remixicon/react";
2+
import type { UserThemesSnapshot } from "@thinkrail/contracts";
3+
import { T, type Translator, useT } from "@thinkrail/locales/react";
4+
import { Button } from "@thinkrail/ui/button";
5+
import type { ReactNode } from "react";
6+
import type { UserThemeIssue } from "@/themes";
7+
import { repoSourceUrl } from "./LanguageSettings";
8+
import { SettingsGroup } from "./SettingsGroup";
9+
import { SettingsNotice } from "./SettingsNotice";
10+
import { SettingsRow } from "./SettingsRow";
11+
12+
const EXAMPLE_THEME_PATH = "apps/web/src/themes/bundled/dark.theme.json";
13+
14+
function issueLabel(t: Translator, issue: UserThemeIssue): string {
15+
switch (issue.error) {
16+
case "invalid-json":
17+
return t("settings.appearance.custom.error.invalidJson");
18+
case "invalid-theme":
19+
return t("settings.appearance.custom.error.invalidTheme");
20+
case "id-too-long":
21+
return t("settings.appearance.custom.error.idTooLong");
22+
case "duplicate-id":
23+
return t("settings.appearance.custom.error.duplicateId");
24+
case "too-large":
25+
return t("settings.appearance.custom.error.tooLarge");
26+
case "too-many":
27+
return t("settings.appearance.custom.error.tooMany");
28+
case "unreadable":
29+
return t("settings.appearance.custom.error.unreadable");
30+
}
31+
}
32+
33+
function ExampleLink({ href, children }: { href: string; children?: ReactNode }) {
34+
return (
35+
<a
36+
data-testid="custom-themes-example"
37+
href={href}
38+
target="_blank"
39+
rel="noopener noreferrer"
40+
className="text-primary underline underline-offset-2 hover:opacity-80"
41+
>
42+
{children}
43+
</a>
44+
);
45+
}
46+
47+
export interface CustomThemesGroupProps {
48+
snapshot: UserThemesSnapshot | null;
49+
issues: readonly UserThemeIssue[];
50+
appVersion: string | null;
51+
onCopyFolder: (folder: string) => void;
52+
onRevealFolder: () => void;
53+
}
54+
55+
export function CustomThemesGroup({
56+
snapshot,
57+
issues,
58+
appVersion,
59+
onCopyFolder,
60+
onRevealFolder,
61+
}: CustomThemesGroupProps) {
62+
const t = useT();
63+
if (!snapshot) return null;
64+
const folder = snapshot.dir;
65+
return (
66+
<SettingsGroup title={t("settings.appearance.custom.title")} testId="custom-themes">
67+
<p className="py-12 tr-text-metadata text-text-muted">
68+
<T
69+
i18nKey="settings.appearance.custom.steps"
70+
components={[
71+
<ExampleLink key="example" href={repoSourceUrl(appVersion, EXAMPLE_THEME_PATH)} />,
72+
]}
73+
/>
74+
</p>
75+
<SettingsRow
76+
label={t("settings.appearance.custom.folder")}
77+
description={
78+
<code data-testid="custom-themes-folder-path" className="tr-code-text break-all">
79+
{folder}
80+
</code>
81+
}
82+
noBorder={issues.length === 0}
83+
control={
84+
<div className="flex gap-8">
85+
<Button variant="outline" size="sm" onClick={() => onCopyFolder(folder)}>
86+
<Copy className="size-14" />
87+
{t("settings.language.folder.copy")}
88+
</Button>
89+
<Button
90+
variant="outline"
91+
size="sm"
92+
data-testid="custom-themes-open-folder"
93+
onClick={onRevealFolder}
94+
>
95+
<FolderOpen className="size-14" />
96+
{t("settings.language.folder.open")}
97+
</Button>
98+
</div>
99+
}
100+
/>
101+
{issues.length > 0 ? (
102+
<div className="flex flex-col gap-4 py-12">
103+
{issues.map((issue) => (
104+
<SettingsNotice
105+
key={issue.fileName}
106+
variant="warning"
107+
testId={`custom-theme-issue-${issue.fileName}`}
108+
>
109+
<span className="flex flex-col gap-4">
110+
<span className="tr-code-text">{issue.fileName}</span>
111+
<span>{issueLabel(t, issue)}</span>
112+
{issue.detail ? (
113+
<span className="break-words text-text-muted">{issue.detail}</span>
114+
) : null}
115+
</span>
116+
</SettingsNotice>
117+
))}
118+
</div>
119+
) : null}
120+
</SettingsGroup>
121+
);
122+
}

‎apps/web/src/panels/LanguageSettings.tsx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,14 @@ import { SettingsRow } from "./SettingsRow";
2525
const REPO_URL = "https://github.com/JetBrains/thinkrail";
2626
export const CONTRIBUTE_TRANSLATIONS_URL = `${REPO_URL}/blob/main/CONTRIBUTING.md#translations`;
2727

28-
export function englishSourceUrl(appVersion: string | null): string {
28+
export function repoSourceUrl(appVersion: string | null, path: string): string {
2929
const released =
3030
appVersion && /^\d+\.\d+\.\d+(?:-[\w.]+)?$/.test(appVersion) && appVersion !== "0.0.0";
31-
return `${REPO_URL}/blob/${released ? `v${appVersion}` : "main"}/packages/locales/en.json`;
31+
return `${REPO_URL}/blob/${released ? `v${appVersion}` : "main"}/${path}`;
32+
}
33+
34+
export function englishSourceUrl(appVersion: string | null): string {
35+
return repoSourceUrl(appVersion, "packages/locales/en.json");
3236
}
3337

3438
function InlineLink({ href, children }: { href: string; children?: ReactNode }) {

‎apps/web/src/panels/SPEC.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -737,7 +737,12 @@ a project picker, the prompt hero, and the reused
737737
disclosed by a warning notice under the mode control — outside the disclosure, so it is always visible —
738738
naming the effective same-appearance fallback, and is never silently written back. The panel never owns
739739
a theme list, media-query logic, pair derivation, or fallback — all come from `themes`; it derives only
740-
the family/contrast counterpart the two controls move to. An **Editor** group hosts the file
740+
the family/contrast counterpart the two controls move to. A **Custom themes** group
741+
(`CustomThemesSettings.tsx`, props-driven like `LanguagePage`, hidden below
742+
`USER_THEMES_PROTOCOL_VERSION`) gives the copy-a-bundled-file steps with a version-pinned link to
743+
`dark.theme.json`, the `<dataDir>/themes` folder with **Copy path** / **Open folder** (`themes.reveal`),
744+
and one warning notice per unusable file (`themes`' issue list). The page reads the catalog and issues
745+
through `onThemesChange`, so a dropped file appears in the lists without reopening. An **Editor** group hosts the file
741746
`LineWidthControl`); **`LineWidthControl({kind})`** (`LineWidthSettings.tsx`, no page of its own — the
742747
`chat` control sits in Chat, the `file` one in Appearance → Editor — a 40–240 integer **Line width** field
743748
on the auto-save input pattern plus an independent host-synchronized **Fit to pane** switch

‎apps/web/src/shell/Shell.tsx‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,18 @@ import {
2323
} from "../store";
2424
import {
2525
applyThemePreference,
26+
clearUserThemes,
27+
installUserThemes,
2628
onSystemAppearanceChange,
2729
readThemeHint,
2830
writeThemeHint,
2931
} from "../themes";
30-
import { type ConnectionStatus, runHostUpdate, supportsHostUpdateRun } from "../transport";
32+
import {
33+
type ConnectionStatus,
34+
runHostUpdate,
35+
supportsHostUpdateRun,
36+
supportsUserThemes,
37+
} from "../transport";
3138
import { UpdateInstallSurfaces, UpdateReadyButton, UpdateSettings, useUpdates } from "../updates";
3239
import { AppShortcuts } from "./AppShortcuts";
3340
import { BrandLogo } from "./BrandLogo";
@@ -90,7 +97,11 @@ export function Shell() {
9097
const theme = useAppStore((s) => s.theme);
9198
const themeMode = useAppStore((s) => s.themeMode);
9299
const systemThemePair = useAppStore((s) => s.systemThemePair);
100+
const userThemes = useAppStore((s) => s.userThemes);
101+
const userThemesUnsupported = welcomeGeneration > 0 && !supportsUserThemes(protocolVersion);
93102
useEffect(() => {
103+
if (userThemes) installUserThemes(userThemes.files);
104+
else if (userThemesUnsupported) clearUserThemes();
94105
const preference =
95106
welcomeGeneration === 0
96107
? themeHint
@@ -99,7 +110,15 @@ export function Shell() {
99110
apply();
100111
if (welcomeGeneration > 0) writeThemeHint(preference);
101112
return preference.themeMode === "system" ? onSystemAppearanceChange(apply) : undefined;
102-
}, [themeHint, welcomeGeneration, theme, themeMode, systemThemePair]);
113+
}, [
114+
themeHint,
115+
welcomeGeneration,
116+
theme,
117+
themeMode,
118+
systemThemePair,
119+
userThemes,
120+
userThemesUnsupported,
121+
]);
103122
useGlobalHotkeys({
104123
onProjects: hasActiveWorkspace
105124
? () => {

‎apps/web/src/store/appStore.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import type {
3535
ThinkingLevel,
3636
TurnChangeSet,
3737
UserMessage,
38+
UserThemesSnapshot,
3839
WireModel,
3940
Workspace,
4041
WorkspaceFsChangedPayload,
@@ -1064,6 +1065,8 @@ interface AppState {
10641065
locale: string;
10651066
localLocales: LocalLocalesSnapshot | null;
10661067
installLocalLocales: (snapshot: LocalLocalesSnapshot | null) => void;
1068+
userThemes: UserThemesSnapshot | null;
1069+
installUserThemes: (snapshot: UserThemesSnapshot | null) => void;
10671070
customLayoutPresets: LayoutPreset[];
10681071
toasts: Toast[];
10691072
setStatus: (status: ConnectionStatus) => void;
@@ -2320,6 +2323,8 @@ export const useAppStore = create<AppState>((set, get) => ({
23202323
locale: DEFAULT_CONFIG.locale,
23212324
localLocales: null,
23222325
installLocalLocales: (snapshot) => set({ localLocales: snapshot }),
2326+
userThemes: null,
2327+
installUserThemes: (snapshot) => set({ userThemes: snapshot }),
23232328
toasts: [],
23242329
resourceSnapshots: {},
23252330
resourceRevision: 0,

0 commit comments

Comments
 (0)