authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-08-07 10:37:43+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-07 10:37:43+02:00
log85b1734d4d4914e743044f0ea8cb38a796de7da5
treefc415a0d4cecf8960e02fbbf10e1051f1b55013d
parent3ffe84f9688e22afea531988807a1c7faeccdc37
signature Signed by PGP key B5690EEEBB952194

feat(web): add modular theme library (#5226)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: maria-rcks <maria@kuuro.net> Co-authored-by: Julius Marminge <jmarminge@gmail.com>

77 files changed, 9326 insertions(+), 383 deletions(-)

apps/desktop/src/electron/ElectronDialog.ts+51
...@@ -23,6 +23,21 @@ export class ElectronDialogPickFolderError extends Schema.TaggedErrorClass<Elect...@@ -23,6 +23,21 @@ export class ElectronDialogPickFolderError extends Schema.TaggedErrorClass<Elect
23 }23 }
24}24}
2525
26export class ElectronDialogPickFilesError extends Schema.TaggedErrorClass<ElectronDialogPickFilesError>()(
27 "ElectronDialogPickFilesError",
28 {
29 ownerWindowId: Schema.NullOr(Schema.Number),
30 defaultPath: Schema.NullOr(Schema.String),
31 cause: Schema.Defect(),
32 },
33) {
34 override get message(): string {
35 const owner = this.ownerWindowId === null ? "the application" : `window ${this.ownerWindowId}`;
36 const defaultPath = this.defaultPath === null ? "no default path" : this.defaultPath;
37 return `Failed to open the Electron file picker for ${owner} with ${defaultPath}.`;
38 }
39}
40
26export class ElectronDialogConfirmError extends Schema.TaggedErrorClass<ElectronDialogConfirmError>()(41export class ElectronDialogConfirmError extends Schema.TaggedErrorClass<ElectronDialogConfirmError>()(
27 "ElectronDialogConfirmError",42 "ElectronDialogConfirmError",
28 {43 {
...@@ -69,6 +84,7 @@ export class ElectronDialogShowErrorBoxError extends Schema.TaggedErrorClass<Ele...@@ -69,6 +84,7 @@ export class ElectronDialogShowErrorBoxError extends Schema.TaggedErrorClass<Ele
6984
70export const ElectronDialogError = Schema.Union([85export const ElectronDialogError = Schema.Union([
71 ElectronDialogPickFolderError,86 ElectronDialogPickFolderError,
87 ElectronDialogPickFilesError,
72 ElectronDialogConfirmError,88 ElectronDialogConfirmError,
73 ElectronDialogShowMessageBoxError,89 ElectronDialogShowMessageBoxError,
74 ElectronDialogShowErrorBoxError,90 ElectronDialogShowErrorBoxError,
...@@ -81,6 +97,12 @@ export interface ElectronDialogPickFolderInput {...@@ -81,6 +97,12 @@ export interface ElectronDialogPickFolderInput {
81 readonly defaultPath: Option.Option<string>;97 readonly defaultPath: Option.Option<string>;
82}98}
8399
100export interface ElectronDialogPickFilesInput {
101 readonly owner: Option.Option<Electron.BrowserWindow>;
102 readonly defaultPath: Option.Option<string>;
103 readonly filters: readonly Electron.FileFilter[];
104}
105
84export interface ElectronDialogConfirmInput {106export interface ElectronDialogConfirmInput {
85 readonly owner: Option.Option<Electron.BrowserWindow>;107 readonly owner: Option.Option<Electron.BrowserWindow>;
86 readonly message: string;108 readonly message: string;
...@@ -92,6 +114,9 @@ export class ElectronDialog extends Context.Service<...@@ -92,6 +114,9 @@ export class ElectronDialog extends Context.Service<
92 readonly pickFolder: (114 readonly pickFolder: (
93 input: ElectronDialogPickFolderInput,115 input: ElectronDialogPickFolderInput,
94 ) => Effect.Effect<Option.Option<string>, ElectronDialogPickFolderError>;116 ) => Effect.Effect<Option.Option<string>, ElectronDialogPickFolderError>;
117 readonly pickFiles: (
118 input: ElectronDialogPickFilesInput,
119 ) => Effect.Effect<readonly string[], ElectronDialogPickFilesError>;
95 readonly confirm: (120 readonly confirm: (
96 input: ElectronDialogConfirmInput,121 input: ElectronDialogConfirmInput,
97 ) => Effect.Effect<boolean, ElectronDialogConfirmError>;122 ) => Effect.Effect<boolean, ElectronDialogConfirmError>;
...@@ -137,6 +162,32 @@ export const make = ElectronDialog.of({...@@ -137,6 +162,32 @@ export const make = ElectronDialog.of({
137 }162 }
138 return Option.fromNullishOr(result.filePaths[0]);163 return Option.fromNullishOr(result.filePaths[0]);
139 }),164 }),
165 pickFiles: Effect.fn("desktop.electron.dialog.pickFiles")(function* (input) {
166 const ownerWindowId = Option.match(input.owner, {
167 onNone: () => null,
168 onSome: (owner) => owner.id,
169 });
170 const defaultPath = Option.getOrNull(input.defaultPath);
171 const openDialogOptions: Electron.OpenDialogOptions = {
172 properties: ["openFile", "multiSelections"],
173 filters: [...input.filters],
174 ...(defaultPath === null ? {} : { defaultPath }),
175 };
176 const result = yield* Effect.tryPromise({
177 try: () =>
178 Option.match(input.owner, {
179 onNone: () => Electron.dialog.showOpenDialog(openDialogOptions),
180 onSome: (owner) => Electron.dialog.showOpenDialog(owner, openDialogOptions),
181 }),
182 catch: (cause) =>
183 new ElectronDialogPickFilesError({
184 ownerWindowId,
185 defaultPath,
186 cause,
187 }),
188 });
189 return result.canceled ? [] : result.filePaths;
190 }),
140 confirm: Effect.fn("desktop.electron.dialog.confirm")(function* (input) {191 confirm: Effect.fn("desktop.electron.dialog.confirm")(function* (input) {
141 const normalizedMessage = input.message.trim();192 const normalizedMessage = input.message.trim();
142 if (normalizedMessage.length === 0) {193 if (normalizedMessage.length === 0) {
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2
...@@ -38,6 +38,7 @@ import {...@@ -38,6 +38,7 @@ import {
38 getWindowFullscreenState,38 getWindowFullscreenState,
39 openExternal,39 openExternal,
40 pickFolder,40 pickFolder,
41 pickThemeFiles,
41 setTheme,42 setTheme,
42 showContextMenu,43 showContextMenu,
43} from "./methods/window.ts";44} from "./methods/window.ts";
...@@ -79,6 +80,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"...@@ -79,6 +80,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
79 yield* ipc.handle(setWslOnly);80 yield* ipc.handle(setWslOnly);
8081
81 yield* ipc.handle(pickFolder);82 yield* ipc.handle(pickFolder);
83 yield* ipc.handle(pickThemeFiles);
82 yield* ipc.handle(confirm);84 yield* ipc.handle(confirm);
83 yield* ipc.handle(setTheme);85 yield* ipc.handle(setTheme);
84 yield* ipc.handle(showContextMenu);86 yield* ipc.handle(showContextMenu);
apps/desktop/src/ipc/channels.ts+1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1export const PICK_FOLDER_CHANNEL = "desktop:pick-folder";1export const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
2export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files";
2export const CONFIRM_CHANNEL = "desktop:confirm";3export const CONFIRM_CHANNEL = "desktop:confirm";
3export const SET_THEME_CHANNEL = "desktop:set-theme";4export const SET_THEME_CHANNEL = "desktop:set-theme";
4export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";5export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
apps/desktop/src/ipc/methods/window.ts+51
...@@ -3,10 +3,15 @@ import {...@@ -3,10 +3,15 @@ import {
3 DesktopAppBrandingSchema,3 DesktopAppBrandingSchema,
4 DesktopEnvironmentBootstrapSchema,4 DesktopEnvironmentBootstrapSchema,
5 DesktopThemeSchema,5 DesktopThemeSchema,
6 PickedThemeFileSchema,
6 PickFolderOptionsSchema,7 PickFolderOptionsSchema,
7 PRIMARY_LOCAL_ENVIRONMENT_ID,8 PRIMARY_LOCAL_ENVIRONMENT_ID,
8 type DesktopEnvironmentBootstrap,9 type DesktopEnvironmentBootstrap,
10 type PickedThemeFile,
9} from "@t3tools/contracts";11} from "@t3tools/contracts";
12import * as NodeOS from "node:os";
13import * as FileSystem from "effect/FileSystem";
14import * as Path from "effect/Path";
10import * as Effect from "effect/Effect";15import * as Effect from "effect/Effect";
11import * as Option from "effect/Option";16import * as Option from "effect/Option";
12import * as Schema from "effect/Schema";17import * as Schema from "effect/Schema";
...@@ -268,3 +273,49 @@ export const openExternal = DesktopIpc.makeIpcMethod({...@@ -268,3 +273,49 @@ export const openExternal = DesktopIpc.makeIpcMethod({
268 return yield* shell.openExternal(url);273 return yield* shell.openExternal(url);
269 }),274 }),
270});275});
276
277/** Theme files are a few KB; anything larger returns empty text and lets the
278 * renderer reject it by size without the contents ever crossing the bridge. */
279const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024;
280
281export const pickThemeFiles = DesktopIpc.makeIpcMethod({
282 channel: IpcChannels.PICK_THEME_FILES_CHANNEL,
283 payload: Schema.Undefined,
284 result: Schema.NullOr(Schema.Array(PickedThemeFileSchema)),
285 handler: Effect.fn("desktop.ipc.window.pickThemeFiles")(function* () {
286 const dialog = yield* ElectronDialog.ElectronDialog;
287 const electronWindow = yield* ElectronWindow.ElectronWindow;
288 const fileSystem = yield* FileSystem.FileSystem;
289 const path = yield* Path.Path;
290 // The VS Code extensions directory is the same dotfolder on Windows,
291 // macOS, and Linux; when it is missing the picker opens wherever the
292 // platform would by default.
293 const extensionsDir = path.join(NodeOS.homedir(), ".vscode", "extensions");
294 const defaultPath = yield* fileSystem
295 .exists(extensionsDir)
296 .pipe(Effect.orElseSucceed(() => false));
297 const paths = yield* dialog.pickFiles({
298 owner: yield* electronWindow.focusedMainOrFirst,
299 defaultPath: defaultPath ? Option.some(extensionsDir) : Option.none(),
300 filters: [{ name: "JSON", extensions: ["json"] }],
301 });
302 if (paths.length === 0) {
303 return null;
304 }
305 return yield* Effect.forEach(paths, (filePath) => {
306 const name = path.basename(filePath);
307 return Effect.gen(function* () {
308 const info = yield* fileSystem.stat(filePath);
309 const size = Number(info.size);
310 if (size > PICKED_THEME_FILE_MAX_BYTES) {
311 return { name, size, text: "" } satisfies PickedThemeFile;
312 }
313 const text = yield* fileSystem.readFileString(filePath);
314 return { name, size, text } satisfies PickedThemeFile;
315 }).pipe(
316 // An unreadable file degrades to an entry the renderer reports.
317 Effect.orElseSucceed((): PickedThemeFile => ({ name, size: 0, text: "" })),
318 );
319 });
320 }),
321});
apps/desktop/src/preload.ts+1
...@@ -97,6 +97,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -97,6 +97,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
97 setWslDistro: (distro) => ipcRenderer.invoke(IpcChannels.SET_WSL_DISTRO_CHANNEL, distro),97 setWslDistro: (distro) => ipcRenderer.invoke(IpcChannels.SET_WSL_DISTRO_CHANNEL, distro),
98 setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled),98 setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled),
99 pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options),99 pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options),
100 pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined),
100 confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message),101 confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message),
101 setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme),102 setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme),
102 showContextMenu: (items, position) =>103 showContextMenu: (items, position) =>
apps/desktop/src/window/DesktopApplicationMenu.test.ts+1
...@@ -52,6 +52,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {...@@ -52,6 +52,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
5252
53const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, {53const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, {
54 pickFolder: () => Effect.succeed(Option.none()),54 pickFolder: () => Effect.succeed(Option.none()),
55 pickFiles: () => Effect.succeed([]),
55 confirm: () => Effect.succeed(false),56 confirm: () => Effect.succeed(false),
56 showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }),57 showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }),
57 showErrorBox: () => Effect.void,58 showErrorBox: () => Effect.void,
apps/server/src/keybindings.test.ts+1
...@@ -198,6 +198,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {...@@ -198,6 +198,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
198 assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");198 assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");
199 assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9");199 assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9");
200 assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m");200 assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m");
201 assert.equal(defaultsByCommand.get("themeEditor.toggle"), "mod+alt+shift+t");
201 assert.equal(defaultsByCommand.get("filePicker.toggle"), "mod+p");202 assert.equal(defaultsByCommand.get("filePicker.toggle"), "mod+p");
202 assert.equal(defaultsByCommand.get("projectSearch.toggle"), "mod+shift+f");203 assert.equal(defaultsByCommand.get("projectSearch.toggle"), "mod+shift+f");
203 assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b");204 assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b");
apps/web/index.html+371-13
...@@ -6,8 +6,6 @@...@@ -6,8 +6,6 @@
6 name="viewport"6 name="viewport"
7 content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"7 content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
8 />8 />
9 <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
10 <meta name="theme-color" content="#0a0a0a" media="(prefers-color-scheme: dark)" />
11 <meta name="theme-color" content="#0a0a0a" />9 <meta name="theme-color" content="#0a0a0a" />
12 <link rel="icon" href="/favicon.ico" sizes="48x48" />10 <link rel="icon" href="/favicon.ico" sizes="48x48" />
13 <link rel="apple-touch-icon" href="/apple-touch-icon.png" />11 <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
...@@ -15,23 +13,373 @@...@@ -15,23 +13,373 @@
15 (() => {13 (() => {
16 const LIGHT_BACKGROUND = "#ffffff";14 const LIGHT_BACKGROUND = "#ffffff";
17 const DARK_BACKGROUND = "#0a0a0a";15 const DARK_BACKGROUND = "#0a0a0a";
18 const themeColorMeta = document.querySelector('meta[name="theme-color"]');16 const CUSTOM_THEMES_STORAGE_KEY = "t3code:themes:v1";
17 const THEME_FOLLOW_SYSTEM_STORAGE_KEY = "t3code:theme-follow-system";
18 const THEME_APPEARANCE_MODE_STORAGE_KEY = "t3code:theme-appearance-mode";
19 const THEME_HALVES_STORAGE_KEY = "t3code:theme-halves:v1";
20 const SPLASH_COLORS = {
21 light: {
22 background: "#ffffff",
23 foreground: "#262626",
24 accent: "#4f46e5",
25 },
26 dark: {
27 background: "#0a0a0a",
28 foreground: "#f5f5f5",
29 accent: "#818cf8",
30 },
31 };
32 // These are the runtime defaults used to complete a custom palette
33 // when a stored theme omits a role. Keep them separate from the
34 // unselected splash so a partial custom theme does not flash generic
35 // app colors before React mounts.
36 const DEFAULT_THEME_PALETTES = {
37 light: {
38 background: "#fdf7fd",
39 foreground: "#501854",
40 accent: "#e33f86",
41 chrome: "#fdf7fd",
42 },
43 dark: {
44 background: "#1f1a24",
45 foreground: "#f9f8fb",
46 accent: "#a3004c",
47 chrome: "#1f1a24",
48 },
49 };
50 // Keep this small boot-time copy in sync with the built-in palettes so
51 // the selected theme is visible before the app has mounted.
52 const BUILT_IN_THEME_PALETTES = {
53 "t3-chat": {
54 light: {
55 background: "#fdf7fd",
56 foreground: "#501854",
57 accent: "#e33f86",
58 chrome: "#fdf7fd",
59 },
60 dark: {
61 background: "#1f1a24",
62 foreground: "#f9f8fb",
63 accent: "#a3004c",
64 chrome: "#1f1a24",
65 },
66 },
67 grove: {
68 light: {
69 background: "#f3f7f4",
70 foreground: "#241523",
71 accent: "#1b7d50",
72 chrome: "#f3f7f4",
73 },
74 dark: {
75 background: "#1b2821",
76 foreground: "#fffaff",
77 accent: "#69d69a",
78 chrome: "#1b2821",
79 },
80 },
81 ocean: {
82 light: {
83 background: "#f5f7f8",
84 foreground: "#241523",
85 accent: "#2672af",
86 chrome: "#f5f7f8",
87 },
88 dark: {
89 background: "#17212b",
90 foreground: "#fffaff",
91 accent: "#70b9ee",
92 chrome: "#17212b",
93 },
94 },
95 ember: {
96 light: {
97 background: "#f9f7f5",
98 foreground: "#241523",
99 accent: "#ae552a",
100 chrome: "#f9f7f5",
101 },
102 dark: {
103 background: "#291e1a",
104 foreground: "#fffaff",
105 accent: "#f09a64",
106 chrome: "#291e1a",
107 },
108 },
109 iris: {
110 light: {
111 background: "#f8f7f9",
112 foreground: "#241523",
113 accent: "#7253b9",
114 chrome: "#f8f7f9",
115 },
116 dark: {
117 background: "#1d1929",
118 foreground: "#fffaff",
119 accent: "#9d7df2",
120 chrome: "#1d1929",
121 },
122 },
123 };
124 // Keep the built-in mode list in sync with the runtime so the selected
125 // T3 Chat palette is visible before React has mounted.
126 const BUILT_IN_THEME_MODES = Object.fromEntries(
127 Object.entries(BUILT_IN_THEME_PALETTES).map(([id, palettes]) => [
128 id,
129 Object.keys(palettes),
130 ]),
131 );
132 const RESERVED_THEME_IDS = [
133 "system",
134 "light",
135 "dark",
136 "t3-chat",
137 "grove",
138 "ocean",
139 "ember",
140 "iris",
141 "t3-chat-dark",
142 "t3-grove",
143 "t3-ocean",
144 "t3-ember",
145 "t3-iris",
146 ];
147 // Update every theme-color meta so any element another layer added
148 // carries the resolved color too.
149 const setThemeColor = (color) => {
150 for (const meta of document.querySelectorAll('meta[name="theme-color"]')) {
151 meta.setAttribute("content", color);
152 }
153 };
154
155 const isHexColor = (value) =>
156 typeof value === "string" &&
157 /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value);
158 const isRecord = (value) =>
159 typeof value === "object" && value !== null && !Array.isArray(value);
160 const isThemeId = (value) =>
161 typeof value === "string" && /^[a-z0-9](?:[a-z0-9-]{0,47})$/.test(value);
162 const isThemeLabel = (value) =>
163 typeof value === "string" && value.trim().length > 0 && value.trim().length <= 48;
164 const isThemePreferenceMode = (value) =>
165 value === "light" || value === "dark" || value === "system";
166 const isThemeAppearance = (value) => value === "light" || value === "dark";
167 const isStoredCustomTheme = (value) => {
168 if (
169 !isRecord(value) ||
170 !isThemeId(value.id) ||
171 RESERVED_THEME_IDS.includes(value.id) ||
172 !isThemeLabel(value.label) ||
173 !isThemeAppearance(value.appearance) ||
174 !isRecord(value.colors)
175 ) {
176 return false;
177 }
178 if (value.variants === undefined) return true;
179 if (!isRecord(value.variants)) return false;
180 return Object.entries(value.variants).every(
181 ([appearance, colors]) =>
182 isThemeAppearance(appearance) &&
183 (appearance === value.appearance || isRecord(colors)),
184 );
185 };
186 const findCustomTheme = (themeId) => {
187 if (RESERVED_THEME_IDS.includes(themeId)) return null;
188 try {
189 const parsed = JSON.parse(
190 window.localStorage.getItem(CUSTOM_THEMES_STORAGE_KEY) ?? "null",
191 );
192 if (!Array.isArray(parsed)) return null;
193 return (
194 parsed.find((value) => isStoredCustomTheme(value) && value.id === themeId) ?? null
195 );
196 } catch {
197 return null;
198 }
199 };
200
19 try {201 try {
20 const storedTheme = window.localStorage.getItem("t3code:theme");202 const storedTheme = window.localStorage.getItem("t3code:theme");
21 const theme =
22 storedTheme === "light" || storedTheme === "dark" || storedTheme === "system"
23 ? storedTheme
24 : "system";
25 const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;203 const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
26 const isDark = theme === "dark" || (theme === "system" && prefersDark);204 // Older builds stored the dark T3 Chat palette as its own theme id
205 // and every maintainer theme under a t3- prefix; keep those
206 // preferences readable. Mirrors the runtime's legacy alias table.
207 const LEGACY_THEME_IDS = {
208 "t3-chat-dark": "t3-chat",
209 "t3-grove": "grove",
210 "t3-ocean": "ocean",
211 "t3-ember": "ember",
212 "t3-iris": "iris",
213 };
214 const isLegacyT3ChatDark = storedTheme === "t3-chat-dark";
215 const themeId =
216 storedTheme === null ? "" : (LEGACY_THEME_IDS[storedTheme] ?? storedTheme);
217 const legacyMode = isLegacyT3ChatDark ? "dark" : null;
218 const builtInModes = BUILT_IN_THEME_MODES[themeId] ?? null;
219 const customTheme = builtInModes ? null : findCustomTheme(themeId);
220 const themeModes =
221 builtInModes ??
222 (customTheme
223 ? ["light", "dark"].filter(
224 (mode) => mode === customTheme.appearance || customTheme.variants?.[mode],
225 )
226 : null);
227 // Mirrors the runtime's isKnownThemePreference.
228 const isKnownTheme =
229 storedTheme === "light" ||
230 storedTheme === "dark" ||
231 storedTheme === "system" ||
232 themeModes !== null;
233 const theme = isKnownTheme && storedTheme ? storedTheme : "system";
234 const isThemed = isKnownTheme && themeModes !== null;
235 const baseAppearance = customTheme ? customTheme.appearance : "light";
236 const systemMode = prefersDark ? "dark" : "light";
237 const storedAppearanceMode = window.localStorage.getItem(
238 THEME_APPEARANCE_MODE_STORAGE_KEY,
239 );
240 const storedFollowSystem = window.localStorage.getItem(THEME_FOLLOW_SYSTEM_STORAGE_KEY);
241 // Mirrors the runtime's getThemePreferenceMode fallback: a bare
242 // "light"/"dark" preference names its own mode.
243 const preferenceMode =
244 theme === "light" || theme === "dark" ? theme : (legacyMode ?? baseAppearance);
245 const appearanceMode = isThemePreferenceMode(storedAppearanceMode)
246 ? storedAppearanceMode
247 : storedFollowSystem === "true"
248 ? "system"
249 : storedFollowSystem === "false"
250 ? preferenceMode
251 : theme === "system"
252 ? "system"
253 : preferenceMode;
254 // A valid mix half for the wanted appearance takes over: it decides
255 // light vs dark even when the base theme cannot render that mode.
256 // Mirrors the runtime's halves-aware resolveThemeAppearance.
257 const wantedMode = appearanceMode === "system" ? systemMode : appearanceMode;
258 let halfThemeId = null;
259 let halfCustomTheme = null;
260 try {
261 const halvesRaw = JSON.parse(
262 window.localStorage.getItem(THEME_HALVES_STORAGE_KEY) ?? "null",
263 );
264 const candidate = isRecord(halvesRaw) ? halvesRaw[wantedMode] : null;
265 if (typeof candidate === "string") {
266 const halfId = LEGACY_THEME_IDS[candidate] ?? candidate;
267 const halfBuiltIn = BUILT_IN_THEME_MODES[halfId] ?? null;
268 const halfCustom = halfBuiltIn ? null : findCustomTheme(halfId);
269 const halfModes =
270 halfBuiltIn ??
271 (halfCustom
272 ? ["light", "dark"].filter(
273 (mode) => mode === halfCustom.appearance || halfCustom.variants?.[mode],
274 )
275 : null);
276 if (halfModes && halfModes.includes(wantedMode)) {
277 halfThemeId = halfId;
278 halfCustomTheme = halfCustom;
279 }
280 }
281 } catch {
282 // A malformed mix degrades to the base preference.
283 }
284 const themeMode = !isThemed
285 ? null
286 : appearanceMode === "system"
287 ? themeModes.includes(systemMode)
288 ? systemMode
289 : baseAppearance
290 : themeModes.includes(appearanceMode)
291 ? appearanceMode
292 : baseAppearance;
293 const isDark =
294 halfThemeId !== null
295 ? wantedMode === "dark"
296 : isThemed
297 ? themeMode === "dark"
298 : appearanceMode === "system"
299 ? prefersDark
300 : appearanceMode === "dark";
301 const effectiveIsThemed = halfThemeId !== null || isThemed;
302 const effectiveThemeId = halfThemeId ?? themeId;
303 const effectiveCustomTheme = halfThemeId !== null ? halfCustomTheme : customTheme;
304 const effectiveThemeMode = halfThemeId !== null ? wantedMode : themeMode;
305 const customColors =
306 effectiveIsThemed && effectiveCustomTheme
307 ? effectiveThemeMode === effectiveCustomTheme.appearance
308 ? effectiveCustomTheme.colors
309 : (effectiveCustomTheme.variants?.[effectiveThemeMode] ??
310 effectiveCustomTheme.colors)
311 : null;
312 const customDefaults =
313 effectiveCustomTheme && effectiveThemeMode
314 ? DEFAULT_THEME_PALETTES[effectiveThemeMode]
315 : null;
316 // The runtime tolerates individual malformed colors, so fall back
317 // per role rather than dropping the theme.
318 const builtInSplash =
319 effectiveIsThemed && !effectiveCustomTheme && effectiveThemeMode
320 ? (BUILT_IN_THEME_PALETTES[effectiveThemeId]?.[effectiveThemeMode] ?? null)
321 : null;
322 const fallbackSplash = isDark ? SPLASH_COLORS.dark : SPLASH_COLORS.light;
323 const customSplash = customColors
324 ? {
325 background: isHexColor(customColors.canvas)
326 ? customColors.canvas
327 : (customDefaults?.background ?? fallbackSplash.background),
328 foreground: isHexColor(customColors.text)
329 ? customColors.text
330 : (customDefaults?.foreground ?? fallbackSplash.foreground),
331 accent: isHexColor(customColors.accent)
332 ? customColors.accent
333 : (customDefaults?.accent ?? fallbackSplash.accent),
334 }
335 : null;
336 if (effectiveIsThemed) {
337 document.documentElement.dataset.themeId = effectiveCustomTheme
338 ? effectiveCustomTheme.id
339 : effectiveThemeId;
340 } else {
341 delete document.documentElement.dataset.themeId;
342 }
343 // A resolved half is a selection even when the base theme is gone
344 // (deleting the base leaves the mix pointing at a real theme).
345 const hasSelection = halfThemeId !== null || (isKnownTheme && storedTheme !== null);
346 if (hasSelection) {
347 document.documentElement.dataset.themeSelected = "true";
348 } else {
349 delete document.documentElement.dataset.themeSelected;
350 }
27 document.documentElement.classList.toggle("dark", isDark);351 document.documentElement.classList.toggle("dark", isDark);
28 const chromeColor = isDark ? DARK_BACKGROUND : LIGHT_BACKGROUND;352 const chromeColor = customColors
353 ? isHexColor(customColors.chrome)
354 ? customColors.chrome
355 : (customDefaults?.chrome ?? fallbackSplash.background)
356 : builtInSplash
357 ? builtInSplash.chrome
358 : isDark
359 ? DARK_BACKGROUND
360 : LIGHT_BACKGROUND;
29 document.documentElement.style.backgroundColor = chromeColor;361 document.documentElement.style.backgroundColor = chromeColor;
30 themeColorMeta?.setAttribute("content", chromeColor);362 if (hasSelection) {
363 const splashColors = customSplash ?? builtInSplash ?? fallbackSplash;
364 for (const [name, value] of Object.entries(splashColors)) {
365 document.documentElement.style.setProperty(`--boot-${name}`, value);
366 }
367 }
368 setThemeColor(chromeColor);
31 } catch {369 } catch {
32 document.documentElement.classList.add("dark");370 // Mirror the runtime's storage-failure fallback: follow the OS.
33 document.documentElement.style.backgroundColor = DARK_BACKGROUND;371 let prefersDark = true;
34 themeColorMeta?.setAttribute("content", DARK_BACKGROUND);372 try {
373 prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
374 } catch {
375 // Keep the dark default when even matchMedia is unavailable.
376 }
377 delete document.documentElement.dataset.themeId;
378 delete document.documentElement.dataset.themeSelected;
379 document.documentElement.classList.toggle("dark", prefersDark);
380 const fallbackColor = prefersDark ? DARK_BACKGROUND : LIGHT_BACKGROUND;
381 document.documentElement.style.backgroundColor = fallbackColor;
382 setThemeColor(fallbackColor);
35 }383 }
36 })();384 })();
37 </script>385 </script>
...@@ -56,14 +404,24 @@...@@ -56,14 +404,24 @@
56 }404 }
57405
58 #boot-shell {406 #boot-shell {
407 position: relative;
408 overflow: hidden;
59 display: flex;409 display: flex;
60 min-height: 100%;410 min-height: 100%;
61 align-items: center;411 align-items: center;
62 justify-content: center;412 justify-content: center;
63 background: inherit;413 background: inherit;
414 color: inherit;
415 }
416
417 html[data-theme-selected="true"] #boot-shell {
418 background: var(--boot-background);
419 color: var(--boot-foreground);
64 }420 }
65421
66 #boot-shell-card {422 #boot-shell-card {
423 position: relative;
424 z-index: 1;
67 display: flex;425 display: flex;
68 align-items: center;426 align-items: center;
69 justify-content: center;427 justify-content: center;
apps/web/src/components/ChatView.tsx+4-4
...@@ -5842,10 +5842,10 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5842,10 +5842,10 @@ function ChatViewContent(props: ChatViewProps) {
5842 const panelLayoutControls = (5842 const panelLayoutControls = (
5843 <div5843 <div
5844 className={cn(5844 className={cn(
5845 "workspace-titlebar-controls z-50 gap-1 [-webkit-app-region:no-drag]",5845 // One inset in both states: the controls move between containers when
5846 rightPanelOpen && !shouldUseRightPanelSheet5846 // the right panel opens, and a different right offset made them jump
5847 ? "right-2 wco:right-[var(--workspace-controls-right)]"5847 // sideways on every toggle.
5848 : "mr-px",5848 "workspace-titlebar-controls z-50 mr-px gap-1 [-webkit-app-region:no-drag]",
5849 )}5849 )}
5850 >5850 >
5851 {rightPanelOpen && !shouldUseRightPanelSheet ? (5851 {rightPanelOpen && !shouldUseRightPanelSheet ? (
apps/web/src/components/CommandPalette.logic.ts+1-1
...@@ -12,7 +12,7 @@ import { formatRelativeTimeLabel } from "../timestampFormat";...@@ -12,7 +12,7 @@ import { formatRelativeTimeLabel } from "../timestampFormat";
12import { type Project, type SidebarThreadSummary, type Thread } from "../types";12import { type Project, type SidebarThreadSummary, type Thread } from "../types";
1313
14export const RECENT_THREAD_LIMIT = 12;14export const RECENT_THREAD_LIMIT = 12;
15export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80";15export const ITEM_ICON_CLASS = "size-4 text-icon-muted";
16export const ADDON_ICON_CLASS = "size-4";16export const ADDON_ICON_CLASS = "size-4";
1717
18/**18/**
apps/web/src/components/CommandPalette.tsx+32-1
...@@ -35,6 +35,7 @@ import {...@@ -35,6 +35,7 @@ import {
35 FolderPlusIcon,35 FolderPlusIcon,
36 LinkIcon,36 LinkIcon,
37 MessageSquareIcon,37 MessageSquareIcon,
38 PaletteIcon,
38 SettingsIcon,39 SettingsIcon,
39 SquarePenIcon,40 SquarePenIcon,
40 TextSearchIcon,41 TextSearchIcon,
...@@ -57,6 +58,7 @@ import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";...@@ -57,6 +58,7 @@ import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
57import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps";58import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps";
58import { useHandleNewThread } from "../hooks/useHandleNewThread";59import { useHandleNewThread } from "../hooks/useHandleNewThread";
59import { useClientSettings } from "../hooks/useSettings";60import { useClientSettings } from "../hooks/useSettings";
61import { useTheme } from "../hooks/useTheme";
60import { readLocalApi } from "../localApi";62import { readLocalApi } from "../localApi";
61import { desktopLocalBackendId } from "../connection/desktopLocal";63import { desktopLocalBackendId } from "../connection/desktopLocal";
62import { filesystemEnvironment } from "../state/filesystem";64import { filesystemEnvironment } from "../state/filesystem";
...@@ -121,6 +123,7 @@ import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"...@@ -121,6 +123,7 @@ import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"
121import { ProjectFavicon } from "./ProjectFavicon";123import { ProjectFavicon } from "./ProjectFavicon";
122import { ProjectFilePicker } from "./files/ProjectFilePicker";124import { ProjectFilePicker } from "./files/ProjectFilePicker";
123import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog";125import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog";
126import { toggleThemeEditorForTheme } from "./settings/themeEditorStore";
124import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";127import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";
125import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server";128import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server";
126import { resolveDefaultProviderModelSelection } from "../providerInstances";129import { resolveDefaultProviderModelSelection } from "../providerInstances";
...@@ -386,6 +389,7 @@ export function CommandPalette({ children }: { children: ReactNode }) {...@@ -386,6 +389,7 @@ export function CommandPalette({ children }: { children: ReactNode }) {
386 const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []);389 const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []);
387 const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []);390 const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []);
388 const keybindings = useAtomValue(primaryServerKeybindingsAtom);391 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
392 const { theme, themeHalves, resolvedTheme } = useTheme();
389 const composerHandleRef = useRef<ChatComposerHandle | null>(null);393 const composerHandleRef = useRef<ChatComposerHandle | null>(null);
390 const routeTarget = useParams({394 const routeTarget = useParams({
391 strict: false,395 strict: false,
...@@ -428,6 +432,16 @@ export function CommandPalette({ children }: { children: ReactNode }) {...@@ -428,6 +432,16 @@ export function CommandPalette({ children }: { children: ReactNode }) {
428 previewOpen,432 previewOpen,
429 },433 },
430 });434 });
435 if (command === "themeEditor.toggle") {
436 event.preventDefault();
437 event.stopPropagation();
438 toggleThemeEditorForTheme({
439 theme,
440 themeHalves,
441 initialAppearance: resolvedTheme,
442 });
443 return;
444 }
431 const mode = overlayModeForCommand(command);445 const mode = overlayModeForCommand(command);
432 if (mode === null) {446 if (mode === null) {
433 return;447 return;
...@@ -438,7 +452,7 @@ export function CommandPalette({ children }: { children: ReactNode }) {...@@ -438,7 +452,7 @@ export function CommandPalette({ children }: { children: ReactNode }) {
438 };452 };
439 window.addEventListener("keydown", onKeyDown);453 window.addEventListener("keydown", onKeyDown);
440 return () => window.removeEventListener("keydown", onKeyDown);454 return () => window.removeEventListener("keydown", onKeyDown);
441 }, [keybindings, previewOpen, terminalOpen, toggleMode]);455 }, [keybindings, previewOpen, resolvedTheme, terminalOpen, theme, themeHalves, toggleMode]);
442456
443 useEffect(457 useEffect(
444 () =>458 () =>
...@@ -567,6 +581,7 @@ function OpenCommandPaletteDialog(props: {...@@ -567,6 +581,7 @@ function OpenCommandPaletteDialog(props: {
567 const projectOrder = useUiStateStore((store) => store.projectOrder);581 const projectOrder = useUiStateStore((store) => store.projectOrder);
568 const threads = useThreadShells();582 const threads = useThreadShells();
569 const keybindings = useAtomValue(primaryServerKeybindingsAtom);583 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
584 const { theme, themeHalves, resolvedTheme } = useTheme();
570 const providers = useAtomValue(primaryServerProvidersAtom);585 const providers = useAtomValue(primaryServerProvidersAtom);
571 const [viewStack, setViewStack] = useState<CommandPaletteView[]>([]);586 const [viewStack, setViewStack] = useState<CommandPaletteView[]>([]);
572 const currentView = viewStack.at(-1) ?? null;587 const currentView = viewStack.at(-1) ?? null;
...@@ -1463,6 +1478,22 @@ function OpenCommandPaletteDialog(props: {...@@ -1463,6 +1478,22 @@ function OpenCommandPaletteDialog(props: {
1463 });1478 });
1464 }1479 }
14651480
1481 actionItems.push({
1482 kind: "action",
1483 value: "action:theme-editor",
1484 searchTerms: ["theme", "appearance", "colors", "palette", "customize"],
1485 title: "Toggle theme editor",
1486 icon: <PaletteIcon className={ITEM_ICON_CLASS} />,
1487 shortcutCommand: "themeEditor.toggle",
1488 run: async () => {
1489 toggleThemeEditorForTheme({
1490 theme,
1491 themeHalves,
1492 initialAppearance: resolvedTheme,
1493 });
1494 },
1495 });
1496
1466 actionItems.push({1497 actionItems.push({
1467 kind: "action",1498 kind: "action",
1468 value: "action:settings",1499 value: "action:settings",
apps/web/src/components/ComposerPromptEditor.tsx+1-1
...@@ -1765,7 +1765,7 @@ function ComposerPromptEditorInner({...@@ -1765,7 +1765,7 @@ function ComposerPromptEditorInner({
1765 }1765 }
1766 placeholder={1766 placeholder={
1767 terminalContexts.length > 0 ? null : (1767 terminalContexts.length > 0 ? null : (
1768 <div className="pointer-events-none absolute inset-0 leading-relaxed text-muted-foreground/35">1768 <div className="pointer-events-none absolute inset-0 leading-relaxed text-placeholder">
1769 {placeholder}1769 {placeholder}
1770 </div>1770 </div>
1771 )1771 )
apps/web/src/components/DiffPanel.tsx+23-69
...@@ -36,6 +36,7 @@ import {...@@ -36,6 +36,7 @@ import {
36 getRenderablePatch,36 getRenderablePatch,
37 resolveDiffThemeName,37 resolveDiffThemeName,
38 resolveFileDiffPath,38 resolveFileDiffPath,
39 DIFF_SURFACE_THEME_UNSAFE_CSS,
39} from "../lib/diffRendering";40} from "../lib/diffRendering";
40import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse";41import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse";
41import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";42import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
...@@ -86,54 +87,7 @@ interface CollapsedDiffFilesState {...@@ -86,54 +87,7 @@ interface CollapsedDiffFilesState {
8687
87const EMPTY_COLLAPSED_DIFF_FILE_KEYS: ReadonlySet<string> = new Set();88const EMPTY_COLLAPSED_DIFF_FILE_KEYS: ReadonlySet<string> = new Set();
8889
89const DIFF_PANEL_UNSAFE_CSS = `90const DIFF_PANEL_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS}
90[data-diffs-header],
91[data-diff],
92[data-file],
93[data-error-wrapper],
94[data-virtualizer-buffer] {
95 --diffs-header-font-family: var(--font-sans) !important;
96 --diffs-font-family: var(--font-mono) !important;
97 --diffs-bg: var(--background) !important;
98 --diffs-light-bg: var(--background) !important;
99 --diffs-dark-bg: var(--background) !important;
100 --diffs-token-light-bg: transparent;
101 --diffs-token-dark-bg: transparent;
102
103 --diffs-bg-context-override: color-mix(in srgb, var(--background) 97%, var(--foreground));
104 --diffs-bg-hover-override: color-mix(in srgb, var(--background) 94%, var(--foreground));
105 --diffs-bg-separator-override: color-mix(in srgb, var(--background) 95%, var(--foreground));
106 --diffs-bg-buffer-override: color-mix(in srgb, var(--background) 90%, var(--foreground));
107
108 --diffs-bg-addition-override: light-dark(
109 color-mix(in srgb, var(--background) 50%, var(--success)),
110 color-mix(in srgb, var(--background) 70%, var(--success))
111 );
112 --diffs-bg-addition-number-override: light-dark(
113 color-mix(in srgb, var(--background) 35%, var(--success)),
114 color-mix(in srgb, var(--background) 60%, var(--success))
115 );
116 --diffs-bg-addition-hover-override: color-mix(in srgb, var(--background) 85%, var(--success));
117 --diffs-bg-addition-emphasis-override: color-mix(in srgb, var(--background) 80%, var(--success));
118
119 --diffs-bg-deletion-override: light-dark(
120 color-mix(in srgb, var(--background) 50%, var(--destructive)),
121 color-mix(in srgb, var(--background) 70%, var(--destructive))
122 );
123 --diffs-bg-deletion-number-override: light-dark(
124 color-mix(in srgb, var(--background) 35%, var(--destructive)),
125 color-mix(in srgb, var(--background) 60%, var(--destructive))
126 );
127 --diffs-bg-deletion-hover-override: color-mix(in srgb, var(--background) 85%, var(--destructive));
128 --diffs-bg-deletion-emphasis-override: color-mix(
129 in srgb,
130 var(--background) 80%,
131 var(--destructive)
132 );
133
134 background-color: var(--diffs-bg) !important;
135}
136
137:is(91:is(
138 [data-line],92 [data-line],
139 [data-line-annotation],93 [data-line-annotation],
...@@ -144,13 +98,13 @@ const DIFF_PANEL_UNSAFE_CSS = `...@@ -144,13 +98,13 @@ const DIFF_PANEL_UNSAFE_CSS = `
144 --diffs-line-bg: light-dark(98 --diffs-line-bg: light-dark(
145 color-mix(99 color-mix(
146 in lab,100 in lab,
147 var(--background) 88%,101 var(--code-background) 88%,
148 color-mix(in srgb, var(--background) 50%, var(--diffs-modified-base))102 color-mix(in srgb, var(--code-background) 50%, var(--diffs-modified-base))
149 ),103 ),
150 color-mix(104 color-mix(
151 in lab,105 in lab,
152 var(--background) 80%,106 var(--code-background) 80%,
153 color-mix(in srgb, var(--background) 70%, var(--diffs-modified-base))107 color-mix(in srgb, var(--code-background) 70%, var(--diffs-modified-base))
154 )108 )
155 ) !important;109 ) !important;
156}110}
...@@ -159,13 +113,13 @@ const DIFF_PANEL_UNSAFE_CSS = `...@@ -159,13 +113,13 @@ const DIFF_PANEL_UNSAFE_CSS = `
159 --diffs-line-bg: light-dark(113 --diffs-line-bg: light-dark(
160 color-mix(114 color-mix(
161 in lab,115 in lab,
162 var(--background) 91%,116 var(--code-background) 91%,
163 color-mix(in srgb, var(--background) 35%, var(--diffs-modified-base))117 color-mix(in srgb, var(--code-background) 35%, var(--diffs-modified-base))
164 ),118 ),
165 color-mix(119 color-mix(
166 in lab,120 in lab,
167 var(--background) 85%,121 var(--code-background) 85%,
168 color-mix(in srgb, var(--background) 60%, var(--diffs-modified-base))122 color-mix(in srgb, var(--code-background) 60%, var(--diffs-modified-base))
169 )123 )
170 ) !important;124 ) !important;
171}125}
...@@ -192,16 +146,16 @@ const DIFF_PANEL_UNSAFE_CSS = `...@@ -192,16 +146,16 @@ const DIFF_PANEL_UNSAFE_CSS = `
192}146}
193147
194[data-file-info] {148[data-file-info] {
195 background-color: var(--background) !important;149 background-color: var(--code-background) !important;
196 border-block-color: transparent !important;150 border-block-color: transparent !important;
197 color: var(--foreground) !important;151 color: var(--code-foreground) !important;
198}152}
199153
200[data-diffs-header] {154[data-diffs-header] {
201 position: sticky !important;155 position: sticky !important;
202 top: 0;156 top: 0;
203 z-index: 4;157 z-index: 4;
204 background-color: var(--background) !important;158 background-color: var(--code-background) !important;
205 border-bottom-color: transparent !important;159 border-bottom-color: transparent !important;
206 align-items: center !important;160 align-items: center !important;
207 font-family: var(--font-sans) !important;161 font-family: var(--font-sans) !important;
...@@ -213,13 +167,13 @@ const DIFF_PANEL_UNSAFE_CSS = `...@@ -213,13 +167,13 @@ const DIFF_PANEL_UNSAFE_CSS = `
213}167}
214168
215[data-diffs-header]:hover {169[data-diffs-header]:hover {
216 background-color: color-mix(in srgb, var(--background) 97%, var(--foreground)) !important;170 background-color: color-mix(in srgb, var(--code-background) 97%, var(--code-foreground)) !important;
217}171}
218172
219:is([data-separator="line-info"], [data-separator="line-info-basic"]) {173:is([data-separator="line-info"], [data-separator="line-info-basic"]) {
220 height: 24px !important;174 height: 24px !important;
221 margin-block: 0 !important;175 margin-block: 0 !important;
222 background-color: var(--background) !important;176 background-color: var(--code-background) !important;
223}177}
224178
225:is([data-separator="line-info"], [data-separator="line-info-basic"])179:is([data-separator="line-info"], [data-separator="line-info-basic"])
...@@ -233,7 +187,7 @@ const DIFF_PANEL_UNSAFE_CSS = `...@@ -233,7 +187,7 @@ const DIFF_PANEL_UNSAFE_CSS = `
233 gap: 8px;187 gap: 8px;
234 padding-inline: 0 !important;188 padding-inline: 0 !important;
235 background-color: transparent !important;189 background-color: transparent !important;
236 color: color-mix(in srgb, var(--foreground) 52%, var(--background)) !important;190 color: color-mix(in srgb, var(--code-foreground) 52%, var(--code-background)) !important;
237 font-family: var(--font-sans) !important;191 font-family: var(--font-sans) !important;
238 font-size: 11px !important;192 font-size: 11px !important;
239 text-decoration: none !important;193 text-decoration: none !important;
...@@ -257,7 +211,7 @@ const DIFF_PANEL_UNSAFE_CSS = `...@@ -257,7 +211,7 @@ const DIFF_PANEL_UNSAFE_CSS = `
257 height: 1px;211 height: 1px;
258 flex: 1 1 auto;212 flex: 1 1 auto;
259 content: "";213 content: "";
260 background-color: color-mix(in srgb, var(--background) 92%, var(--foreground));214 background-color: color-mix(in srgb, var(--code-background) 92%, var(--code-foreground));
261}215}
262216
263:is([data-separator="line-info"], [data-separator="line-info-basic"])[data-expand-index]217:is([data-separator="line-info"], [data-separator="line-info-basic"])[data-expand-index]
...@@ -286,7 +240,7 @@ const DIFF_PANEL_UNSAFE_CSS = `...@@ -286,7 +240,7 @@ const DIFF_PANEL_UNSAFE_CSS = `
286 [data-expand-button]240 [data-expand-button]
287 ):hover241 ):hover
288 [data-separator-content] {242 [data-separator-content] {
289 color: color-mix(in srgb, var(--foreground) 76%, var(--background)) !important;243 color: color-mix(in srgb, var(--code-foreground) 76%, var(--code-background)) !important;
290}244}
291245
292:is([data-separator="line-info"], [data-separator="line-info-basic"]):has(246:is([data-separator="line-info"], [data-separator="line-info-basic"]):has(
...@@ -297,7 +251,7 @@ const DIFF_PANEL_UNSAFE_CSS = `...@@ -297,7 +251,7 @@ const DIFF_PANEL_UNSAFE_CSS = `
297 [data-expand-button]251 [data-expand-button]
298 ):hover252 ):hover
299 [data-unmodified-lines]::after {253 [data-unmodified-lines]::after {
300 background-color: color-mix(in srgb, var(--background) 84%, var(--foreground));254 background-color: color-mix(in srgb, var(--code-background) 84%, var(--code-foreground));
301}255}
302256
303[data-diffs-header] [data-header-content] {257[data-diffs-header] [data-header-content] {
...@@ -337,7 +291,7 @@ const DIFF_PANEL_UNSAFE_CSS = `...@@ -337,7 +291,7 @@ const DIFF_PANEL_UNSAFE_CSS = `
337}291}
338292
339[data-title]:hover {293[data-title]:hover {
340 color: color-mix(in srgb, var(--foreground) 84%, var(--primary)) !important;294 color: color-mix(in srgb, var(--code-foreground) 84%, var(--primary)) !important;
341 text-decoration-color: currentColor;295 text-decoration-color: currentColor;
342}296}
343`;297`;
...@@ -796,11 +750,11 @@ export default function DiffPanel({...@@ -796,11 +750,11 @@ export default function DiffPanel({
796 <div className="flex min-w-0 flex-1 items-center gap-3 [-webkit-app-region:no-drag]">750 <div className="flex min-w-0 flex-1 items-center gap-3 [-webkit-app-region:no-drag]">
797 <DropdownMenu>751 <DropdownMenu>
798 <DropdownMenuTrigger752 <DropdownMenuTrigger
799 className="inline-flex h-6 max-w-full items-center gap-1 rounded-md bg-muted/70 px-2 text-xs font-medium text-foreground outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"753 className="inline-flex h-6 max-w-full items-center gap-1 rounded-md bg-accent px-2 text-xs font-medium text-accent-foreground outline-none transition-colors hover:bg-accent/80 focus-visible:ring-2 focus-visible:ring-ring"
800 aria-label={`Diff scope: ${selectedScopeLabel}`}754 aria-label={`Diff scope: ${selectedScopeLabel}`}
801 >755 >
802 <span className="truncate">{selectedScopeLabel}</span>756 <span className="truncate">{selectedScopeLabel}</span>
803 <ChevronDownIcon className="size-3.5 shrink-0 text-muted-foreground" />757 <ChevronDownIcon className="size-3.5 shrink-0 opacity-70" />
804 </DropdownMenuTrigger>758 </DropdownMenuTrigger>
805 <DropdownMenuContent align="start" className="w-60">759 <DropdownMenuContent align="start" className="w-60">
806 <DropdownMenuItem760 <DropdownMenuItem
...@@ -1120,7 +1074,7 @@ export default function DiffPanel({...@@ -1120,7 +1074,7 @@ export default function DiffPanel({
1120 )}1074 )}
1121 {selectedPatchError && !renderablePatch && (1075 {selectedPatchError && !renderablePatch && (
1122 <div className="px-3">1076 <div className="px-3">
1123 <p className="mb-2 text-[11px] text-red-500/80">{selectedPatchError}</p>1077 <p className="mb-2 text-[11px] text-error/80">{selectedPatchError}</p>
1124 </div>1078 </div>
1125 )}1079 )}
1126 {!renderablePatch ? (1080 {!renderablePatch ? (
apps/web/src/components/ProjectFavicon.tsx+1-1
...@@ -47,7 +47,7 @@ function ProjectFaviconFallback({...@@ -47,7 +47,7 @@ function ProjectFaviconFallback({
47 readonly className?: string | undefined;47 readonly className?: string | undefined;
48 readonly icon: ComponentType<{ className?: string }>;48 readonly icon: ComponentType<{ className?: string }>;
49}) {49}) {
50 return <Icon className={cn("size-3.5 shrink-0 text-muted-foreground/50", className)} />;50 return <Icon className={cn("size-3.5 shrink-0 text-icon-muted", className)} />;
51}51}
5252
53function ProjectFaviconImage({53function ProjectFaviconImage({
apps/web/src/components/ProjectScriptsControl.tsx+6
...@@ -343,6 +343,9 @@ export default function ProjectScriptsControl({...@@ -343,6 +343,9 @@ export default function ProjectScriptsControl({
343 variant="outline"343 variant="outline"
344 className="w-7 px-0 sm:w-6 @3xl/header-actions:w-auto! @3xl/header-actions:px-[calc(--spacing(2)-1px)]"344 className="w-7 px-0 sm:w-6 @3xl/header-actions:w-auto! @3xl/header-actions:px-[calc(--spacing(2)-1px)]"
345 aria-label={`Run ${primaryScript.name}`}345 aria-label={`Run ${primaryScript.name}`}
346 // The tooltip wrapper replaces data-slot="button", so themed
347 // toolbar styling needs its own hook.
348 data-toolbar-control=""
346 onClick={() => onRunScript(primaryScript)}349 onClick={() => onRunScript(primaryScript)}
347 />350 />
348 }351 }
...@@ -447,6 +450,9 @@ export default function ProjectScriptsControl({...@@ -447,6 +450,9 @@ export default function ProjectScriptsControl({
447 variant="outline"450 variant="outline"
448 className="w-7 px-0 sm:w-6 @3xl/header-actions:w-auto! @3xl/header-actions:px-[calc(--spacing(2)-1px)]"451 className="w-7 px-0 sm:w-6 @3xl/header-actions:w-auto! @3xl/header-actions:px-[calc(--spacing(2)-1px)]"
449 aria-label="Add action"452 aria-label="Add action"
453 // The tooltip wrapper replaces data-slot="button", so themed
454 // toolbar styling needs its own hook.
455 data-toolbar-control=""
450 onClick={openAddDialog}456 onClick={openAddDialog}
451 />457 />
452 }458 }
apps/web/src/components/Sidebar.tsx+11-13
...@@ -225,7 +225,7 @@ const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> =...@@ -225,7 +225,7 @@ const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> =
225 separate: "Keep separate",225 separate: "Keep separate",
226};226};
227const SIDEBAR_ICON_ACTION_BUTTON_CLASS =227const SIDEBAR_ICON_ACTION_BUTTON_CLASS =
228 "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-muted-foreground/60 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring";228 "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-icon-muted hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring";
229229
230function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) {230function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) {
231 useEnvironmentThread(threadRef.environmentId, threadRef.threadId);231 useEnvironmentThread(threadRef.environmentId, threadRef.threadId);
...@@ -857,9 +857,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr...@@ -857,9 +857,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
857 ) : (857 ) : (
858 <span858 <span
859 className={`text-[10px] tabular-nums ${859 className={`text-[10px] tabular-nums ${
860 isHighlighted860 isHighlighted ? "text-foreground" : "text-secondary-label"
861 ? "text-foreground/72 dark:text-foreground/82"
862 : "text-muted-foreground/40"
863 }`}861 }`}
864 >862 >
865 {formatRelativeTimeLabel(863 {formatRelativeTimeLabel(
...@@ -2245,7 +2243,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -2245,7 +2243,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
2245 }`}2243 }`}
2246 />2244 />
2247 </span>2245 </span>
2248 <ChevronRightIcon className="absolute inset-0 m-auto size-3.5 text-muted-foreground/70 opacity-0 transition-opacity duration-150 group-hover/project-header:opacity-100" />2246 <ChevronRightIcon className="absolute inset-0 m-auto size-3.5 text-icon-muted opacity-0 transition-opacity duration-150 group-hover/project-header:opacity-100" />
2249 </TooltipTrigger>2247 </TooltipTrigger>
2250 <TooltipPopup side="top">{projectStatus.label}</TooltipPopup>2248 <TooltipPopup side="top">{projectStatus.label}</TooltipPopup>
2251 </Tooltip>2249 </Tooltip>
...@@ -2262,7 +2260,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -2262,7 +2260,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
2262 {project.displayName}2260 {project.displayName}
2263 </span>2261 </span>
2264 {project.groupedProjectCount > 1 ? (2262 {project.groupedProjectCount > 1 ? (
2265 <span className="shrink-0 text-[10px] text-muted-foreground/60">2263 <span className="shrink-0 text-secondary-label text-[10px]">
2266 {project.groupedProjectCount} projects2264 {project.groupedProjectCount} projects
2267 </span>2265 </span>
2268 ) : null}2266 ) : null}
...@@ -2281,7 +2279,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -2281,7 +2279,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
2281 ? "Local sandbox project"2279 ? "Local sandbox project"
2282 : "Remote project"2280 : "Remote project"
2283 }2281 }
2284 className="pointer-events-none absolute top-1 right-1.5 inline-flex size-5 items-center justify-center rounded-md text-muted-foreground/60 transition-opacity duration-150 max-sm:right-7 group-hover/project-header:opacity-0 group-focus-within/project-header:opacity-0 max-sm:group-hover/project-header:opacity-100 max-sm:group-focus-within/project-header:opacity-100"2282 className="pointer-events-none absolute top-1 right-1.5 inline-flex size-5 items-center justify-center rounded-md text-icon-muted transition-opacity duration-150 max-sm:right-7 group-hover/project-header:opacity-0 group-focus-within/project-header:opacity-0 max-sm:group-hover/project-header:opacity-100 max-sm:group-focus-within/project-header:opacity-100"
2285 />2283 />
2286 }2284 }
2287 >2285 >
...@@ -2600,7 +2598,7 @@ function ProjectSortMenu({...@@ -2600,7 +2598,7 @@ function ProjectSortMenu({
2600 <Tooltip>2598 <Tooltip>
2601 <TooltipTrigger2599 <TooltipTrigger
2602 render={2600 render={
2603 <MenuTrigger className="inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" />2601 <MenuTrigger className="inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-icon-muted transition-colors hover:bg-accent hover:text-foreground" />
2604 }2602 }
2605 >2603 >
2606 <ArrowUpDownIcon className="size-3.5" />2604 <ArrowUpDownIcon className="size-3.5" />
...@@ -2824,7 +2822,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(...@@ -2824,7 +2822,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
2824 <SidebarContent2822 <SidebarContent
2825 className="gap-0"2823 className="gap-0"
2826 fixedHeader={2824 fixedHeader={
2827 <SidebarGroup className="px-2 pt-2 pb-1">2825 // Lifted above the stage backdrop, whose fade bleeds below the
2826 // header and would otherwise paint across the search row's outline.
2827 <SidebarGroup className="relative z-[1] px-2 pt-2 pb-1">
2828 <SidebarMenu>2828 <SidebarMenu>
2829 <SidebarMenuItem>2829 <SidebarMenuItem>
2830 <CommandDialogTrigger2830 <CommandDialogTrigger
...@@ -2891,7 +2891,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(...@@ -2891,7 +2891,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
2891 type="button"2891 type="button"
2892 aria-label="Add project"2892 aria-label="Add project"
2893 data-testid="sidebar-add-project-trigger"2893 data-testid="sidebar-add-project-trigger"
2894 className="inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"2894 className="inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-icon-muted transition-colors hover:bg-accent hover:text-foreground"
2895 onClick={openAddProject}2895 onClick={openAddProject}
2896 />2896 />
2897 }2897 }
...@@ -2977,9 +2977,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(...@@ -2977,9 +2977,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
2977 )}2977 )}
29782978
2979 {projectsLength === 0 && (2979 {projectsLength === 0 && (
2980 <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60">2980 <div className="px-2 pt-4 text-center text-secondary-label text-xs">No projects yet</div>
2981 No projects yet
2982 </div>
2983 )}2981 )}
2984 </SidebarGroup>2982 </SidebarGroup>
2985 </SidebarContent>2983 </SidebarContent>
apps/web/src/components/SidebarStageBackdrop.test.tsx+1-2
...@@ -5,7 +5,6 @@ import {...@@ -5,7 +5,6 @@ import {
5 resolveEnvironmentIdentificationPillLabel,5 resolveEnvironmentIdentificationPillLabel,
6 resolveSidebarStageBackdropVariant,6 resolveSidebarStageBackdropVariant,
7 StageBackdropArt,7 StageBackdropArt,
8 StageBackdropButtonArt,
9} from "./SidebarStageBackdrop";8} from "./SidebarStageBackdrop";
109
11describe("SidebarStageBackdrop", () => {10describe("SidebarStageBackdrop", () => {
...@@ -29,7 +28,7 @@ describe("SidebarStageBackdrop", () => {...@@ -29,7 +28,7 @@ describe("SidebarStageBackdrop", () => {
29 const markup = renderToStaticMarkup(28 const markup = renderToStaticMarkup(
30 <>29 <>
31 <StageBackdropArt variant={variant} />30 <StageBackdropArt variant={variant} />
32 <StageBackdropButtonArt variant={variant} />31 <StageBackdropArt variant={variant} />
33 </>,32 </>,
34 );33 );
35 const ids = Array.from(markup.matchAll(/\sid="([^"]+)"/g), (match) => match[1]);34 const ids = Array.from(markup.matchAll(/\sid="([^"]+)"/g), (match) => match[1]);
apps/web/src/components/SidebarStageBackdrop.tsx+4-8
...@@ -62,10 +62,6 @@ export function StageBackdropArt({ variant }: { variant: SidebarStageBackdropVar...@@ -62,10 +62,6 @@ export function StageBackdropArt({ variant }: { variant: SidebarStageBackdropVar
62 return variant === "nightly" ? <NightlySkyArt /> : <DevBlueprintArt />;62 return variant === "nightly" ? <NightlySkyArt /> : <DevBlueprintArt />;
63}63}
6464
65export function StageBackdropButtonArt({ variant }: { variant: SidebarStageBackdropVariant }) {
66 return variant === "nightly" ? <NightlySkyArt compact /> : <DevBlueprintArt compact />;
67}
68
69const NIGHTLY_STARS: ReadonlyArray<{65const NIGHTLY_STARS: ReadonlyArray<{
70 cx: number;66 cx: number;
71 cy: number;67 cy: number;
...@@ -97,7 +93,7 @@ const NIGHTLY_SPARKLES: ReadonlyArray<{ x: number; y: number }> = [...@@ -97,7 +93,7 @@ const NIGHTLY_SPARKLES: ReadonlyArray<{ x: number; y: number }> = [
97 { x: 246, y: 26 },93 { x: 246, y: 26 },
98];94];
9995
100function NightlySkyArt({ compact = false }: { compact?: boolean }) {96function NightlySkyArt() {
101 const idPrefix = useId().replaceAll(":", "");97 const idPrefix = useId().replaceAll(":", "");
102 const skyId = `${idPrefix}-stage-night-sky`;98 const skyId = `${idPrefix}-stage-night-sky`;
103 const glowId = `${idPrefix}-stage-night-glow`;99 const glowId = `${idPrefix}-stage-night-glow`;
...@@ -111,7 +107,7 @@ function NightlySkyArt({ compact = false }: { compact?: boolean }) {...@@ -111,7 +107,7 @@ function NightlySkyArt({ compact = false }: { compact?: boolean }) {
111 className="h-full w-full"107 className="h-full w-full"
112 fill="none"108 fill="none"
113 preserveAspectRatio="xMinYMin slice"109 preserveAspectRatio="xMinYMin slice"
114 viewBox={compact ? "96 0 8192 96" : STAGE_BACKDROP_VIEW_BOX}110 viewBox={STAGE_BACKDROP_VIEW_BOX}
115 xmlns="http://www.w3.org/2000/svg"111 xmlns="http://www.w3.org/2000/svg"
116 >112 >
117 <defs>113 <defs>
...@@ -195,7 +191,7 @@ function NightlySkyArt({ compact = false }: { compact?: boolean }) {...@@ -195,7 +191,7 @@ function NightlySkyArt({ compact = false }: { compact?: boolean }) {
195 );191 );
196}192}
197193
198function DevBlueprintArt({ compact = false }: { compact?: boolean }) {194function DevBlueprintArt() {
199 const idPrefix = useId().replaceAll(":", "");195 const idPrefix = useId().replaceAll(":", "");
200 const paperId = `${idPrefix}-stage-bp-paper`;196 const paperId = `${idPrefix}-stage-bp-paper`;
201 const glowId = `${idPrefix}-stage-bp-glow`;197 const glowId = `${idPrefix}-stage-bp-glow`;
...@@ -212,7 +208,7 @@ function DevBlueprintArt({ compact = false }: { compact?: boolean }) {...@@ -212,7 +208,7 @@ function DevBlueprintArt({ compact = false }: { compact?: boolean }) {
212 className="stage-blueprint h-full w-full"208 className="stage-blueprint h-full w-full"
213 fill="none"209 fill="none"
214 preserveAspectRatio="xMinYMin slice"210 preserveAspectRatio="xMinYMin slice"
215 viewBox={compact ? "64 0 8192 96" : STAGE_BACKDROP_VIEW_BOX}211 viewBox={STAGE_BACKDROP_VIEW_BOX}
216 xmlns="http://www.w3.org/2000/svg"212 xmlns="http://www.w3.org/2000/svg"
217 >213 >
218 <defs>214 <defs>
apps/web/src/components/SidebarV2.tsx+13-11
...@@ -768,7 +768,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -768,7 +768,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
768 isUnread || isWoke768 isUnread || isWoke
769 ? "text-foreground"769 ? "text-foreground"
770 : shouldRecede770 : shouldRecede
771 ? "text-muted-foreground/80"771 ? "text-secondary-label"
772 : status === "failed"772 : status === "failed"
773 ? "text-foreground/95"773 ? "text-foreground/95"
774 : "text-foreground/90",774 : "text-foreground/90",
...@@ -779,7 +779,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -779,7 +779,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
779 ? "text-foreground"779 ? "text-foreground"
780 : isUnread780 : isUnread
781 ? "text-muted-foreground"781 ? "text-muted-foreground"
782 : "text-muted-foreground/70",782 : "text-secondary-label/70",
783 ),783 ),
784 isRegeneratingTitle && "opacity-[0.55]",784 isRegeneratingTitle && "opacity-[0.55]",
785 )}785 )}
...@@ -799,8 +799,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -799,8 +799,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
799 "shrink-0 text-xs tabular-nums hover:underline",799 "shrink-0 text-xs tabular-nums hover:underline",
800 variant === "slim" && variantAction === "unsettle"800 variant === "slim" && variantAction === "unsettle"
801 ? props.isActive801 ? props.isActive
802 ? "text-muted-foreground/70"802 ? "text-secondary-label"
803 : cn("text-muted-foreground/35 transition-colors", settledPrHoverClass)803 : cn("text-secondary-label transition-colors", settledPrHoverClass)
804 : prStatus.colorClass,804 : prStatus.colorClass,
805 )}805 )}
806 aria-label={prStatus.tooltip}806 aria-label={prStatus.tooltip}
...@@ -871,7 +871,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -871,7 +871,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
871 <span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end">871 <span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end">
872 <span872 <span
873 className={cn(873 className={cn(
874 "inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity",874 "inline-flex justify-end tabular-nums text-secondary-label transition-opacity",
875 !isWoke && "group-hover/v2-row:opacity-0",875 !isWoke && "group-hover/v2-row:opacity-0",
876 )}876 )}
877 >877 >
...@@ -983,7 +983,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -983,7 +983,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
983 {props.projectTitle ? (983 {props.projectTitle ? (
984 <span984 <span
985 className={cn(985 className={cn(
986 "min-w-0 flex-1 truncate text-xs text-muted-foreground/85",986 "min-w-0 flex-1 truncate text-secondary-label text-xs",
987 shouldRecede ? "font-normal" : "font-medium",987 shouldRecede ? "font-normal" : "font-medium",
988 )}988 )}
989 >989 >
...@@ -1012,7 +1012,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -1012,7 +1012,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
1012 isWokeStatus1012 isWokeStatus
1013 ? "pointer-events-auto"1013 ? "pointer-events-auto"
1014 : "pointer-events-none group-has-[:focus-visible]/v2-status-slot:absolute group-has-[:focus-visible]/v2-status-slot:right-0 group-has-[:focus-visible]/v2-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0",1014 : "pointer-events-none group-has-[:focus-visible]/v2-status-slot:absolute group-has-[:focus-visible]/v2-status-slot:right-0 group-has-[:focus-visible]/v2-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0",
1015 "self-center justify-self-end tabular-nums text-muted-foreground/65 transition-opacity",1015 "self-center justify-self-end tabular-nums text-secondary-label transition-opacity",
1016 snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",1016 snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",
1017 )}1017 )}
1018 >1018 >
...@@ -1101,14 +1101,14 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -1101,14 +1101,14 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
1101 </span>1101 </span>
1102 ) : null}1102 ) : null}
1103 </div>1103 </div>
1104 <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground/75">1104 <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-secondary-label text-xs">
1105 {/* While working, the current plan step outranks the branch:1105 {/* While working, the current plan step outranks the branch:
1106 it's the one line that says what the thread is doing. */}1106 it's the one line that says what the thread is doing. */}
1107 {status === "working" && thread.planProgress ? (1107 {status === "working" && thread.planProgress ? (
1108 <span className="min-w-0 flex-1 truncate whitespace-nowrap">1108 <span className="min-w-0 flex-1 truncate whitespace-nowrap">
1109 {thread.planProgress.step}1109 {thread.planProgress.step}
1110 {/* Completed count, matching the transcript chip's n/m. */}1110 {/* Completed count, matching the transcript chip's n/m. */}
1111 <span className="text-muted-foreground/50 tabular-nums">1111 <span className="text-icon-muted tabular-nums">
1112 {" "}1112 {" "}
1113 {thread.planProgress.completedSteps}/{thread.planProgress.totalSteps}1113 {thread.planProgress.completedSteps}/{thread.planProgress.totalSteps}
1114 </span>1114 </span>
...@@ -2834,7 +2834,9 @@ export default function SidebarV2() {...@@ -2834,7 +2834,9 @@ export default function SidebarV2() {
2834 <SidebarContent2834 <SidebarContent
2835 className="gap-0"2835 className="gap-0"
2836 fixedHeader={2836 fixedHeader={
2837 <SidebarGroup className="gap-1 p-[var(--sidebar-content-inset)]">2837 // Lifted above the stage backdrop, whose fade bleeds below the
2838 // header and would otherwise paint across the search row's outline.
2839 <SidebarGroup className="relative z-[1] gap-1 p-[var(--sidebar-content-inset)]">
2838 <div className="flex items-center gap-1">2840 <div className="flex items-center gap-1">
2839 <div className="flex h-8 min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-sidebar-muted-foreground hover:bg-sidebar-row-hover hover:text-sidebar-foreground">2841 <div className="flex h-8 min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-sidebar-muted-foreground hover:bg-sidebar-row-hover hover:text-sidebar-foreground">
2840 <SearchIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" />2842 <SearchIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" />
...@@ -2969,7 +2971,7 @@ export default function SidebarV2() {...@@ -2969,7 +2971,7 @@ export default function SidebarV2() {
2969 type="button"2971 type="button"
2970 aria-label={`Project actions for ${project.displayName}`}2972 aria-label={`Project actions for ${project.displayName}`}
2971 title={`Project actions for ${project.displayName}`}2973 title={`Project actions for ${project.displayName}`}
2972 className="ml-auto inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground/55 outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring"2974 className="ml-auto inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-icon-muted outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
2973 onPointerDown={(event) => event.stopPropagation()}2975 onPointerDown={(event) => event.stopPropagation()}
2974 onClick={(event) => {2976 onClick={(event) => {
2975 void handleProjectActions(event, project);2977 void handleProjectActions(event, project);
apps/web/src/components/ThreadTerminalDrawer.tsx+24-7
...@@ -135,6 +135,10 @@ function normalizeComputedColor(value: string | null | undefined, fallback: stri...@@ -135,6 +135,10 @@ function normalizeComputedColor(value: string | null | undefined, fallback: stri
135 return value ?? fallback;135 return value ?? fallback;
136}136}
137137
138function readThemeColor(styles: CSSStyleDeclaration, variable: string, fallback: string): string {
139 return normalizeComputedColor(styles.getPropertyValue(variable), fallback);
140}
141
138/** The surface treats an omitted family or size as "use the built-in default". */142/** The surface treats an omitted family or size as "use the built-in default". */
139function terminalFontOptions(family: string, size: number): { family?: string; size: number } {143function terminalFontOptions(family: string, size: number): { family?: string; size: number } {
140 const trimmed = family.trim();144 const trimmed = family.trim();
...@@ -151,6 +155,7 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty...@@ -151,6 +155,7 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty
151 document.body;155 document.body;
152 const drawerStyles = getComputedStyle(drawerSurface);156 const drawerStyles = getComputedStyle(drawerSurface);
153 const bodyStyles = getComputedStyle(document.body);157 const bodyStyles = getComputedStyle(document.body);
158 const themeStyles = getComputedStyle(document.documentElement);
154 const background = normalizeComputedColor(159 const background = normalizeComputedColor(
155 drawerStyles.backgroundColor,160 drawerStyles.backgroundColor,
156 normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground),161 normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground),
...@@ -159,20 +164,32 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty...@@ -159,20 +164,32 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty
159 drawerStyles.color,164 drawerStyles.color,
160 normalizeComputedColor(bodyStyles.color, fallbackForeground),165 normalizeComputedColor(bodyStyles.color, fallbackForeground),
161 );166 );
162167 const terminalBackground = readThemeColor(themeStyles, "--terminal-background", background);
168 const terminalForeground = readThemeColor(themeStyles, "--terminal-foreground", foreground);
169 const terminalCursor = readThemeColor(
170 themeStyles,
171 "--terminal-cursor",
172 isDark ? "rgb(180, 203, 255)" : "rgb(38, 56, 78)",
173 );
174 const terminalSelection = readThemeColor(
175 themeStyles,
176 "--terminal-selection-background",
177 isDark ? "rgba(180, 203, 255, 0.25)" : "rgba(37, 63, 99, 0.2)",
178 );
163 return {179 return {
164 background: parseTerminalColor(180 background: parseTerminalColor(
165 background,181 terminalBackground,
166 isDark ? { r: 14, g: 18, b: 24 } : { r: 255, g: 255, b: 255 },182 isDark ? { r: 14, g: 18, b: 24 } : { r: 255, g: 255, b: 255 },
167 ),183 ),
168 foreground: parseTerminalColor(184 foreground: parseTerminalColor(
169 foreground,185 terminalForeground,
170 isDark ? { r: 237, g: 241, b: 247 } : { r: 28, g: 33, b: 41 },186 isDark ? { r: 237, g: 241, b: 247 } : { r: 28, g: 33, b: 41 },
171 ),187 ),
172 cursor: isDark ? { r: 180, g: 203, b: 255 } : { r: 38, g: 56, b: 78 },188 cursor: parseTerminalColor(
173 // Matches the xterm selection overlays this renderer replaced; the text189 terminalCursor,
174 // color underneath is left unchanged for contrast in both themes.190 isDark ? { r: 180, g: 203, b: 255 } : { r: 38, g: 56, b: 78 },
175 selectionBackground: isDark ? "rgba(180, 203, 255, 0.25)" : "rgba(37, 63, 99, 0.2)",191 ),
192 selectionBackground: terminalSelection,
176 };193 };
177}194}
178195
apps/web/src/components/chat/ChangedFilesTree.tsx+1
...@@ -62,6 +62,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {...@@ -62,6 +62,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
62 }62 }
63 >63 >
64 <div64 <div
65 data-changed-files-header=""
65 className={cn(66 className={cn(
66 "flex items-center justify-between gap-2 rounded-xl px-1",67 "flex items-center justify-between gap-2 rounded-xl px-1",
67 expanded &&68 expanded &&
apps/web/src/components/chat/ChatComposer.tsx+8-10
...@@ -318,8 +318,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop...@@ -318,8 +318,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
318 className={cn(318 className={cn(
319 "shrink-0 whitespace-nowrap",319 "shrink-0 whitespace-nowrap",
320 props.interactionMode === "plan"320 props.interactionMode === "plan"
321 ? "bg-blue-500/10 text-blue-400 hover:bg-blue-500/15 hover:text-blue-300"321 ? "bg-accent text-accent-foreground hover:bg-accent/80"
322 : "text-muted-foreground/70 hover:text-foreground/80",322 : "text-secondary-label hover:text-foreground",
323 )}323 )}
324 type="button"324 type="button"
325 onClick={props.onToggleInteractionMode}325 onClick={props.onToggleInteractionMode}
...@@ -420,7 +420,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(...@@ -420,7 +420,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
420 />420 />
421 ) : null}421 ) : null}
422 {props.isPreparingWorktree ? (422 {props.isPreparingWorktree ? (
423 <span className="text-muted-foreground/70 text-xs">Preparing worktree...</span>423 <span className="text-secondary-label text-xs">Preparing worktree...</span>
424 ) : null}424 ) : null}
425 <ComposerPrimaryActions425 <ComposerPrimaryActions
426 compact={props.compact}426 compact={props.compact}
...@@ -2767,9 +2767,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2767,9 +2767,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2767 type="button"2767 type="button"
2768 className={cn(2768 className={cn(
2769 "min-w-0 flex-1 truncate bg-transparent py-1.5 text-left text-sm",2769 "min-w-0 flex-1 truncate bg-transparent py-1.5 text-left text-sm",
2770 activePendingProgress?.customAnswer2770 activePendingProgress?.customAnswer ? "text-foreground" : "text-placeholder",
2771 ? "text-foreground"
2772 : "text-muted-foreground/60",
2773 !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2",2771 !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2",
2774 )}2772 )}
2775 onPointerDown={(event) => event.preventDefault()}2773 onPointerDown={(event) => event.preventDefault()}
...@@ -2814,7 +2812,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2814,7 +2812,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2814 "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none",2812 "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none",
2815 (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim())2813 (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim())
2816 ? "text-foreground"2814 ? "text-foreground"
2817 : "text-muted-foreground/35",2815 : "text-placeholder",
2818 )}2816 )}
2819 onPointerDown={(event) => event.preventDefault()}2817 onPointerDown={(event) => event.preventDefault()}
2820 onClick={expandMobileComposer}2818 onClick={expandMobileComposer}
...@@ -2828,7 +2826,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2828,7 +2826,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2828 </button>2826 </button>
2829 <button2827 <button
2830 type="button"2828 type="button"
2831 className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/90 text-primary-foreground disabled:opacity-30"2829 className="flex size-8 shrink-0 items-center justify-center rounded-full bg-message-action text-message-action-foreground hover:bg-message-action-hover disabled:opacity-30"
2832 disabled={collapsedComposerPrimaryActionDisabled}2830 disabled={collapsedComposerPrimaryActionDisabled}
2833 aria-label={collapsedComposerPrimaryActionLabel}2831 aria-label={collapsedComposerPrimaryActionLabel}
2834 onPointerDown={(event) => event.preventDefault()}2832 onPointerDown={(event) => event.preventDefault()}
...@@ -2978,7 +2976,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2978,7 +2976,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2978 />2976 />
2979 </button>2977 </button>
2980 ) : (2978 ) : (
2981 <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70">2979 <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-secondary-label">
2982 {image.name}2980 {image.name}
2983 </div>2981 </div>
2984 )}2982 )}
...@@ -3116,7 +3114,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3116,7 +3114,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3116 variant="ghost"3114 variant="ghost"
3117 disabled3115 disabled
3118 data-chat-provider-unavailable="true"3116 data-chat-provider-unavailable="true"
3119 className="shrink-0 gap-2 px-2 text-muted-foreground/70 sm:px-3"3117 className="shrink-0 gap-2 px-2 text-secondary-label sm:px-3"
3120 >3118 >
3121 <CircleAlertIcon className="size-4" />3119 <CircleAlertIcon className="size-4" />
3122 No provider available3120 No provider available
apps/web/src/components/chat/ChatHeader.tsx+1-1
...@@ -114,7 +114,7 @@ export const ChatHeader = memo(function ChatHeader({...@@ -114,7 +114,7 @@ export const ChatHeader = memo(function ChatHeader({
114 </TooltipTrigger>114 </TooltipTrigger>
115 <TooltipPopup side="top">New thread in {activeProjectName}</TooltipPopup>115 <TooltipPopup side="top">New thread in {activeProjectName}</TooltipPopup>
116 </Tooltip>116 </Tooltip>
117 <span aria-hidden className="text-muted-foreground/40">117 <span aria-hidden className="text-icon-muted">
118 /118 /
119 </span>119 </span>
120 </span>120 </span>
apps/web/src/components/chat/ComposerCommandMenu.tsx+9-9
...@@ -150,7 +150,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {...@@ -150,7 +150,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
150 {groupIndex > 0 ? <CommandSeparator className="my-0.5" /> : null}150 {groupIndex > 0 ? <CommandSeparator className="my-0.5" /> : null}
151 <CommandGroup>151 <CommandGroup>
152 {group.label ? (152 {group.label ? (
153 <CommandGroupLabel className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/55">153 <CommandGroupLabel className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-secondary-label">
154 {group.label}154 {group.label}
155 </CommandGroupLabel>155 </CommandGroupLabel>
156 ) : null}156 ) : null}
...@@ -172,10 +172,10 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {...@@ -172,10 +172,10 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
172 <div className="px-5 py-3.5">172 <div className="px-5 py-3.5">
173 {props.triggerKind === "skill" ? (173 {props.triggerKind === "skill" ? (
174 <CommandGroup>174 <CommandGroup>
175 <CommandGroupLabel className="px-0 pt-0 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/55">175 <CommandGroupLabel className="px-0 pt-0 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-secondary-label">
176 Skills176 Skills
177 </CommandGroupLabel>177 </CommandGroupLabel>
178 <p className="text-muted-foreground/70 text-xs">178 <p className="text-secondary-label text-xs">
179 {props.isLoading179 {props.isLoading
180 ? "Searching workspace skills..."180 ? "Searching workspace skills..."
181 : (props.emptyStateText ??181 : (props.emptyStateText ??
...@@ -183,7 +183,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {...@@ -183,7 +183,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
183 </p>183 </p>
184 </CommandGroup>184 </CommandGroup>
185 ) : (185 ) : (
186 <p className="text-muted-foreground/70 text-xs">186 <p className="text-secondary-label text-xs">
187 {props.isLoading187 {props.isLoading
188 ? "Searching workspace files..."188 ? "Searching workspace files..."
189 : (props.emptyStateText ??189 : (props.emptyStateText ??
...@@ -235,26 +235,26 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {...@@ -235,26 +235,26 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
235 />235 />
236 ) : null}236 ) : null}
237 {props.item.type === "slash-command" ? (237 {props.item.type === "slash-command" ? (
238 <BotIcon className="size-4 shrink-0 text-muted-foreground/80" />238 <BotIcon className="size-4 shrink-0 text-icon-muted" />
239 ) : null}239 ) : null}
240 {props.item.type === "provider-slash-command" ? (240 {props.item.type === "provider-slash-command" ? (
241 <span className="inline-flex size-4 shrink-0 items-center justify-center text-muted-foreground/80">241 <span className="inline-flex size-4 shrink-0 items-center justify-center text-icon-muted">
242 <SkillGlyph className="size-3.5" />242 <SkillGlyph className="size-3.5" />
243 </span>243 </span>
244 ) : null}244 ) : null}
245 {props.item.type === "skill" ? (245 {props.item.type === "skill" ? (
246 <span className="inline-flex size-4 shrink-0 items-center justify-center text-muted-foreground/80">246 <span className="inline-flex size-4 shrink-0 items-center justify-center text-icon-muted">
247 <SkillGlyph className="size-3.5" />247 <SkillGlyph className="size-3.5" />
248 </span>248 </span>
249 ) : null}249 ) : null}
250 <span className="flex min-w-0 flex-1 items-center gap-2">250 <span className="flex min-w-0 flex-1 items-center gap-2">
251 <span className="shrink-0">{props.item.label}</span>251 <span className="shrink-0">{props.item.label}</span>
252 <span className="min-w-0 flex-1 truncate text-muted-foreground/70 text-xs">252 <span className="min-w-0 flex-1 truncate text-secondary-label text-xs">
253 {props.item.description}253 {props.item.description}
254 </span>254 </span>
255 </span>255 </span>
256 {skillSourceLabel ? (256 {skillSourceLabel ? (
257 <span className="shrink-0 pl-2 text-muted-foreground/70 text-xs">{skillSourceLabel}</span>257 <span className="shrink-0 pl-2 text-secondary-label text-xs">{skillSourceLabel}</span>
258 ) : null}258 ) : null}
259 </CommandItem>259 </CommandItem>
260 );260 );
apps/web/src/components/chat/ComposerControl.tsx+2-2
...@@ -6,7 +6,7 @@ import { Button } from "../ui/button";...@@ -6,7 +6,7 @@ import { Button } from "../ui/button";
6import { SelectTrigger } from "../ui/select";6import { SelectTrigger } from "../ui/select";
77
8const composerControlClassName =8const composerControlClassName =
9 "h-7 min-h-7 gap-1.5 px-2.5 text-muted-foreground/70 transition-none hover:text-foreground/80 [&_svg[data-composer-control-icon]]:mx-0 [&_svg[data-composer-control-chevron]]:-mx-0.5";9 "h-7 min-h-7 gap-1.5 px-2.5 text-secondary-label transition-none hover:text-foreground [&_svg[data-composer-control-icon]]:mx-0 [&_svg[data-composer-control-chevron]]:-mx-0.5";
1010
11export function ComposerControl({11export function ComposerControl({
12 className,12 className,
...@@ -46,7 +46,7 @@ export function ComposerControlChevron() {...@@ -46,7 +46,7 @@ export function ComposerControlChevron() {
46 return (46 return (
47 <ChevronDownIcon47 <ChevronDownIcon
48 aria-hidden="true"48 aria-hidden="true"
49 className="-mx-0.5 size-3.5 shrink-0 text-muted-foreground opacity-70"49 className="-mx-0.5 size-3.5 shrink-0 text-icon-muted"
50 data-composer-control-chevron50 data-composer-control-chevron
51 strokeWidth={2.25}51 strokeWidth={2.25}
52 />52 />
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+5-5
...@@ -155,18 +155,18 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -155,18 +155,18 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
155 return (155 return (
156 <div className="px-4 py-3 sm:px-5">156 <div className="px-4 py-3 sm:px-5">
157 <div className="mb-2 flex items-center gap-3">157 <div className="mb-2 flex items-center gap-3">
158 <span className="text-[11px] font-semibold tracking-widest text-muted-foreground/55 uppercase">158 <span className="text-secondary-label text-[11px] font-semibold tracking-widest uppercase">
159 {activeQuestion.header}159 {activeQuestion.header}
160 </span>160 </span>
161 {prompt.questions.length > 1 ? (161 {prompt.questions.length > 1 ? (
162 <span className="flex h-5 items-center rounded-md bg-muted/60 px-1.5 text-[10px] font-medium tabular-nums text-muted-foreground/60">162 <span className="flex h-5 items-center rounded-md bg-muted/60 px-1.5 text-secondary-label text-[10px] font-medium tabular-nums">
163 {questionIndex + 1}/{prompt.questions.length}163 {questionIndex + 1}/{prompt.questions.length}
164 </span>164 </span>
165 ) : null}165 ) : null}
166 </div>166 </div>
167 <p className="text-sm text-foreground/90">{activeQuestion.question}</p>167 <p className="text-sm text-foreground/90">{activeQuestion.question}</p>
168 {activeQuestion.multiSelect ? (168 {activeQuestion.multiSelect ? (
169 <p className="mt-1 text-xs text-muted-foreground/65">Select one or more options.</p>169 <p className="mt-1 text-secondary-label text-xs">Select one or more options.</p>
170 ) : null}170 ) : null}
171 <div className="mt-3 space-y-1.5">171 <div className="mt-3 space-y-1.5">
172 {activeQuestion.options.map((option, index) => {172 {activeQuestion.options.map((option, index) => {
...@@ -190,7 +190,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -190,7 +190,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
190 <div className="min-w-0 flex-1 flex flex-col gap-0.5">190 <div className="min-w-0 flex-1 flex flex-col gap-0.5">
191 <span className="text-sm font-medium">{option.label}</span>191 <span className="text-sm font-medium">{option.label}</span>
192 {option.description && option.description !== option.label ? (192 {option.description && option.description !== option.label ? (
193 <span className="text-xs text-muted-foreground">{option.description}</span>193 <span className="text-secondary-label text-xs">{option.description}</span>
194 ) : null}194 ) : null}
195 </div>195 </div>
196 {isSelected ? (196 {isSelected ? (
...@@ -199,7 +199,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -199,7 +199,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
199 <kbd199 <kbd
200 className={cn(200 className={cn(
201 "flex size-5 shrink-0 items-center justify-center rounded border border-border/50 text-[11px] font-medium tabular-nums transition-colors duration-150",201 "flex size-5 shrink-0 items-center justify-center rounded border border-border/50 text-[11px] font-medium tabular-nums transition-colors duration-150",
202 "bg-background/35 text-muted-foreground/70 group-hover:border-border/70 group-hover:text-muted-foreground",202 "bg-background/35 text-secondary-label group-hover:border-border/70 group-hover:text-foreground",
203 )}203 )}
204 >204 >
205 {shortcutKey}205 {shortcutKey}
apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx+5-5
...@@ -63,13 +63,13 @@ export function ComposerPreviewAnnotationCards({...@@ -63,13 +63,13 @@ export function ComposerPreviewAnnotationCards({
63 />63 />
64 </button>64 </button>
65 ) : (65 ) : (
66 <span className="grid size-10 shrink-0 place-items-center border-r border-border/70 text-blue-500">66 <span className="grid size-10 shrink-0 place-items-center border-r border-border/70 text-message-action">
67 <MousePointerClick className="size-3.5" />67 <MousePointerClick className="size-3.5" />
68 </span>68 </span>
69 )}69 )}
70 <div className="min-w-0 px-2.5 py-2 pr-8">70 <div className="min-w-0 px-2.5 py-2 pr-8">
71 {annotation.comment.trim() ? (71 {annotation.comment.trim() ? (
72 <p className="max-w-80 truncate text-xs font-medium text-foreground/90">72 <p className="max-w-80 truncate text-foreground text-xs font-medium">
73 {annotation.comment.trim()}73 {annotation.comment.trim()}
74 </p>74 </p>
75 ) : null}75 ) : null}
...@@ -84,13 +84,13 @@ export function ComposerPreviewAnnotationCards({...@@ -84,13 +84,13 @@ export function ComposerPreviewAnnotationCards({
84 {elementLabels.slice(0, 2).map(({ id, label }) => (84 {elementLabels.slice(0, 2).map(({ id, label }) => (
85 <span85 <span
86 key={id}86 key={id}
87 className="max-w-40 truncate font-mono text-[10px] text-foreground/65"87 className="max-w-40 truncate font-mono text-secondary-label text-[10px]"
88 >88 >
89 {label}89 {label}
90 </span>90 </span>
91 ))}91 ))}
92 {elementLabels.length > 2 ? (92 {elementLabels.length > 2 ? (
93 <span className="text-[10px] text-muted-foreground">93 <span className="text-secondary-label text-[10px]">
94 +{elementLabels.length - 2}94 +{elementLabels.length - 2}
95 </span>95 </span>
96 ) : null}96 ) : null}
...@@ -131,7 +131,7 @@ export function ComposerPreviewAnnotationCards({...@@ -131,7 +131,7 @@ export function ComposerPreviewAnnotationCards({
131 <button131 <button
132 type="button"132 type="button"
133 aria-label="Remove preview annotation"133 aria-label="Remove preview annotation"
134 className="absolute right-1.5 top-1.5 grid size-5 place-items-center rounded text-muted-foreground/60 transition hover:bg-muted hover:text-foreground"134 className="absolute right-1.5 top-1.5 grid size-5 place-items-center rounded text-icon-muted transition hover:bg-muted hover:text-foreground"
135 onClick={() => onRemove(annotation.id)}135 onClick={() => onRemove(annotation.id)}
136 >136 >
137 <X className="size-3" />137 <X className="size-3" />
apps/web/src/components/chat/ComposerPrimaryActions.tsx+12-19
...@@ -1,8 +1,6 @@...@@ -1,8 +1,6 @@
1import { memo, type PointerEventHandler } from "react";1import { memo, type PointerEventHandler } from "react";
2import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react";2import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react";
3import { useEnvironmentIdentificationMode } from "~/hooks/useSettings";
4import { cn } from "~/lib/utils";3import { cn } from "~/lib/utils";
5import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../SidebarStageBackdrop";
6import { Button } from "../ui/button";4import { Button } from "../ui/button";
7import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";5import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
8import { Spinner } from "../ui/spinner";6import { Spinner } from "../ui/spinner";
...@@ -75,11 +73,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -75,11 +73,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
75 const pointerFocusProps = preserveComposerFocusOnPointerDown73 const pointerFocusProps = preserveComposerFocusOnPointerDown
76 ? { onPointerDown: preventPointerFocus }74 ? { onPointerDown: preventPointerFocus }
77 : undefined;75 : undefined;
78 const environmentIdentificationMode = useEnvironmentIdentificationMode();
79 const isSendDisabled = sendDisabledReason !== null;76 const isSendDisabled = sendDisabledReason !== null;
80 const stageBackdropVariant = useSidebarStageBackdropVariant(
81 environmentIdentificationMode === "artwork",
82 );
8377
84 if (pendingAction) {78 if (pendingAction) {
85 return (79 return (
...@@ -113,7 +107,10 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -113,7 +107,10 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
113 <Button107 <Button
114 type="submit"108 type="submit"
115 size="sm"109 size="sm"
116 className={cn("rounded-full", compact ? "px-3" : "px-4")}110 className={cn(
111 "rounded-full bg-message-action text-message-action-foreground hover:bg-message-action-hover",
112 compact ? "px-3" : "px-4",
113 )}
117 {...pointerFocusProps}114 {...pointerFocusProps}
118 disabled={115 disabled={
119 isEnvironmentUnavailable ||116 isEnvironmentUnavailable ||
...@@ -154,7 +151,10 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -154,7 +151,10 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
154 <Button151 <Button
155 type="submit"152 type="submit"
156 size="sm"153 size="sm"
157 className={cn("rounded-full", compact ? "h-9 px-3 sm:h-8" : "h-9 px-4 sm:h-8")}154 className={cn(
155 "rounded-full bg-message-action text-message-action-foreground hover:bg-message-action-hover",
156 compact ? "h-9 px-3 sm:h-8" : "h-9 px-4 sm:h-8",
157 )}
158 {...pointerFocusProps}158 {...pointerFocusProps}
159 disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable}159 disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable}
160 >160 >
...@@ -168,7 +168,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -168,7 +168,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
168 <Button168 <Button
169 type="submit"169 type="submit"
170 size="sm"170 size="sm"
171 className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8"171 className="h-9 rounded-l-full rounded-r-none bg-message-action px-4 text-message-action-foreground hover:bg-message-action-hover sm:h-8"
172 {...pointerFocusProps}172 {...pointerFocusProps}
173 disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable}173 disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable}
174 >174 >
...@@ -180,7 +180,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -180,7 +180,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
180 <Button180 <Button
181 size="sm"181 size="sm"
182 variant="default"182 variant="default"
183 className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8"183 className="h-9 rounded-l-none rounded-r-full border-l-message-action-foreground/20 bg-message-action px-2 text-message-action-foreground hover:bg-message-action-hover sm:h-8"
184 aria-label="Implementation actions"184 aria-label="Implementation actions"
185 {...pointerFocusProps}185 {...pointerFocusProps}
186 disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable}186 disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable}
...@@ -206,10 +206,8 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -206,10 +206,8 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
206 <button206 <button
207 type="submit"207 type="submit"
208 className={cn(208 className={cn(
209 "relative isolate flex h-9 w-9 items-center justify-center overflow-hidden rounded-full text-primary-foreground shadow-xs transition-all duration-150 enabled:cursor-pointer enabled:inset-shadow-[0_1px_--theme(--color-white/16%)] hover:scale-105 active:inset-shadow-[0_1px_--theme(--color-black/8%)] active:shadow-none disabled:pointer-events-none disabled:opacity-30 disabled:shadow-none disabled:hover:scale-100 sm:h-8 sm:w-8",209 "relative isolate flex h-9 w-9 items-center justify-center overflow-hidden rounded-full text-message-action-foreground shadow-xs transition-all duration-150 enabled:cursor-pointer enabled:inset-shadow-[0_1px_--theme(--color-white/16%)] hover:scale-105 active:inset-shadow-[0_1px_--theme(--color-black/8%)] active:shadow-none disabled:pointer-events-none disabled:opacity-30 disabled:shadow-none disabled:hover:scale-100 sm:h-8 sm:w-8",
210 stageBackdropVariant210 "bg-message-action enabled:shadow-message-action/24 hover:bg-message-action-hover",
211 ? "bg-transparent enabled:shadow-black/24 enabled:hover:brightness-110"
212 : "bg-primary/90 enabled:shadow-primary/24 hover:bg-primary",
213 )}211 )}
214 {...pointerFocusProps}212 {...pointerFocusProps}
215 disabled={213 disabled={
...@@ -233,11 +231,6 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -233,11 +231,6 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
233 : "Send message"231 : "Send message"
234 }232 }
235 >233 >
236 {stageBackdropVariant ? (
237 <span className="absolute inset-0 -z-10" aria-hidden="true">
238 <StageBackdropButtonArt variant={stageBackdropVariant} />
239 </span>
240 ) : null}
241 {isConnecting || isSendBusy ? (234 {isConnecting || isSendBusy ? (
242 <Spinner className="size-3.5" aria-hidden="true" />235 <Spinner className="size-3.5" aria-hidden="true" />
243 ) : (236 ) : (
apps/web/src/components/chat/ComposerStashMenu.tsx+6-6
...@@ -94,12 +94,12 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {...@@ -94,12 +94,12 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
94 <div className="dropdown-glass relative w-full overflow-hidden rounded-[20px]">94 <div className="dropdown-glass relative w-full overflow-hidden rounded-[20px]">
95 <CommandList className="max-h-72">95 <CommandList className="max-h-72">
96 <CommandGroup>96 <CommandGroup>
97 <CommandGroupLabel className="flex items-center gap-1.5 px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/55">97 <CommandGroupLabel className="flex items-center gap-1.5 px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-secondary-label">
98 <BookmarkIcon className="size-3" aria-hidden="true" />98 <BookmarkIcon className="size-3" aria-hidden="true" />
99 Stashed prompts99 Stashed prompts
100 </CommandGroupLabel>100 </CommandGroupLabel>
101 {entries.length === 0 ? (101 {entries.length === 0 ? (
102 <p className="px-3 pb-3 pt-1 text-muted-foreground/70 text-xs">102 <p className="px-3 pb-3 pt-1 text-secondary-label text-xs">
103 Nothing stashed yet. Press ⌘S with a prompt in the composer to stash it.103 Nothing stashed yet. Press ⌘S with a prompt in the composer to stash it.
104 </p>104 </p>
105 ) : (105 ) : (
...@@ -134,23 +134,23 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {...@@ -134,23 +134,23 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
134 ))}134 ))}
135 </span>135 </span>
136 ) : (136 ) : (
137 <BookmarkIcon className="size-4 shrink-0 text-muted-foreground/60" />137 <BookmarkIcon className="size-4 shrink-0 text-icon-muted" />
138 )}138 )}
139 <span className="min-w-0 flex-1 truncate text-sm">139 <span className="min-w-0 flex-1 truncate text-sm">
140 {stashEntrySnippet(entry)}140 {stashEntrySnippet(entry)}
141 </span>141 </span>
142 {entry.pendingImageCount ? (142 {entry.pendingImageCount ? (
143 <span className="shrink-0 text-[10px] text-muted-foreground/60">143 <span className="shrink-0 text-[10px] text-secondary-label">
144 saving {entry.pendingImageCount} image144 saving {entry.pendingImageCount} image
145 {entry.pendingImageCount === 1 ? "" : "s"}…145 {entry.pendingImageCount === 1 ? "" : "s"}…
146 </span>146 </span>
147 ) : missingImageCount(entry) > 0 ? (147 ) : missingImageCount(entry) > 0 ? (
148 <span className="shrink-0 text-[10px] text-amber-600">148 <span className="shrink-0 text-[10px] text-warning-foreground">
149 {missingImageCount(entry)} image149 {missingImageCount(entry)} image
150 {missingImageCount(entry) === 1 ? "" : "s"} dropped150 {missingImageCount(entry) === 1 ? "" : "s"} dropped
151 </span>151 </span>
152 ) : null}152 ) : null}
153 <span className="shrink-0 text-muted-foreground/60 text-xs">153 <span className="shrink-0 text-secondary-label text-xs">
154 {formatRelativeTimeLabel(entry.createdAt)}154 {formatRelativeTimeLabel(entry.createdAt)}
155 </span>155 </span>
156 <Button156 <Button
apps/web/src/components/chat/ContextWindowMeter.tsx+6-6
...@@ -26,7 +26,7 @@ export function ContextWindowMeter(props: {...@@ -26,7 +26,7 @@ export function ContextWindowMeter(props: {
26 const showTotalProcessed = totalProcessedTokens !== null && totalProcessedTokens > 0;26 const showTotalProcessed = totalProcessedTokens !== null && totalProcessedTokens > 0;
27 const isOverloaded = normalizedPercentage > 90;27 const isOverloaded = normalizedPercentage > 90;
28 const usageColor = isOverloaded28 const usageColor = isOverloaded
29 ? "var(--color-red-500)"29 ? "var(--color-error)"
30 : "color-mix(in oklab, var(--color-muted-foreground) 72%, transparent)";30 : "color-mix(in oklab, var(--color-muted-foreground) 72%, transparent)";
3131
32 return (32 return (
...@@ -91,7 +91,7 @@ export function ContextWindowMeter(props: {...@@ -91,7 +91,7 @@ export function ContextWindowMeter(props: {
91 <div className="flex items-center justify-between gap-3">91 <div className="flex items-center justify-between gap-3">
92 <div className="font-medium text-muted-foreground text-xs">Context Window</div>92 <div className="font-medium text-muted-foreground text-xs">Context Window</div>
93 {usage.maxTokens !== null && usedPercentage ? (93 {usage.maxTokens !== null && usedPercentage ? (
94 <div className="text-[11px] tabular-nums text-muted-foreground/70">94 <div className="text-secondary-label text-[11px] tabular-nums">
95 <span>{usedPercentage}</span>95 <span>{usedPercentage}</span>
96 <span className="mx-1">·</span>96 <span className="mx-1">·</span>
97 <span>97 <span>
...@@ -100,7 +100,7 @@ export function ContextWindowMeter(props: {...@@ -100,7 +100,7 @@ export function ContextWindowMeter(props: {
100 </span>100 </span>
101 </div>101 </div>
102 ) : (102 ) : (
103 <div className="text-[11px] tabular-nums text-muted-foreground/70">103 <div className="text-secondary-label text-[11px] tabular-nums">
104 {formatContextWindowTokens(usage.usedTokens)}104 {formatContextWindowTokens(usage.usedTokens)}
105 </div>105 </div>
106 )}106 )}
...@@ -122,14 +122,14 @@ export function ContextWindowMeter(props: {...@@ -122,14 +122,14 @@ export function ContextWindowMeter(props: {
122 ) : null}122 ) : null}
123 {showTotalProcessed ? (123 {showTotalProcessed ? (
124 <div className="flex items-center justify-between gap-3 text-[11px] leading-4">124 <div className="flex items-center justify-between gap-3 text-[11px] leading-4">
125 <span className="text-muted-foreground/60">Total processed</span>125 <span className="text-secondary-label">Total processed</span>
126 <span className="font-medium tabular-nums text-muted-foreground/80">126 <span className="font-medium tabular-nums text-secondary-label">
127 {formatContextWindowTokens(totalProcessedTokens)}127 {formatContextWindowTokens(totalProcessedTokens)}
128 </span>128 </span>
129 </div>129 </div>
130 ) : null}130 ) : null}
131 {usage.compactsAutomatically ? (131 {usage.compactsAutomatically ? (
132 <div className="mt-1 text-pretty text-[11px] font-medium text-muted-foreground/70">132 <div className="mt-1 text-pretty text-secondary-label text-[11px] font-medium">
133 {providerDisplayName ?? "It"} automatically compacts its context when needed.133 {providerDisplayName ?? "It"} automatically compacts its context when needed.
134 </div>134 </div>
135 ) : null}135 ) : null}
apps/web/src/components/chat/MessagesTimeline.test.tsx+1-1
...@@ -461,7 +461,7 @@ describe("MessagesTimeline", () => {...@@ -461,7 +461,7 @@ describe("MessagesTimeline", () => {
461461
462 expect(markup).not.toContain("Show full message");462 expect(markup).not.toContain("Show full message");
463 expect(markup).toContain('data-user-message-collapsible="false"');463 expect(markup).toContain('data-user-message-collapsible="false"');
464 expect(markup).toContain("rounded-2xl bg-accent p-3");464 expect(markup).toContain("rounded-2xl bg-message p-3");
465 });465 });
466466
467 it("renders inline terminal labels with the composer chip UI", () => {467 it("renders inline terminal labels with the composer chip UI", () => {
apps/web/src/components/chat/MessagesTimeline.tsx+31-35
...@@ -534,9 +534,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -534,9 +534,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
534 }534 }
535 return (535 return (
536 <div className="flex h-full items-center justify-center">536 <div className="flex h-full items-center justify-center">
537 <p className="text-sm text-muted-foreground/30">537 <p className="text-placeholder text-sm">Send a message to start the conversation.</p>
538 Send a message to start the conversation.
539 </p>
540 </div>538 </div>
541 );539 );
542 }540 }
...@@ -965,7 +963,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"...@@ -965,7 +963,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
965963
966 return (964 return (
967 <div className="group flex flex-col items-end gap-1">965 <div className="group flex flex-col items-end gap-1">
968 <div className="relative max-w-[80%] rounded-2xl bg-accent p-3">966 <div className="relative max-w-[80%] rounded-2xl bg-message p-3 text-message-foreground">
969 {regularImages.length > 0 && (967 {regularImages.length > 0 && (
970 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">968 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
971 {regularImages.map((image: NonNullable<TimelineMessage["attachments"]>[number]) => (969 {regularImages.map((image: NonNullable<TimelineMessage["attachments"]>[number]) => (
...@@ -991,7 +989,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"...@@ -991,7 +989,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
991 />989 />
992 </button>990 </button>
993 ) : (991 ) : (
994 <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70">992 <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-secondary-label text-[11px]">
995 {image.name}993 {image.name}
996 </div>994 </div>
997 )}995 )}
...@@ -1270,7 +1268,7 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin...@@ -1270,7 +1268,7 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin
1270 const { workingStepLabel } = use(TimelineRowActivityCtx);1268 const { workingStepLabel } = use(TimelineRowActivityCtx);
1271 return (1269 return (
1272 <div className="py-0.5 pl-1.5">1270 <div className="py-0.5 pl-1.5">
1273 <div className="flex min-w-0 items-center gap-2 pt-1 text-[11px] text-muted-foreground/70 tabular-nums">1271 <div className="flex min-w-0 items-center gap-2 pt-1 text-secondary-label text-[11px] tabular-nums">
1274 <span className="inline-flex items-center gap-[3px]">1272 <span className="inline-flex items-center gap-[3px]">
1275 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse" />1273 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse" />
1276 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:200ms]" />1274 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:200ms]" />
...@@ -1349,9 +1347,7 @@ const WorkGroupSection = memo(function WorkGroupSection({...@@ -1349,9 +1347,7 @@ const WorkGroupSection = memo(function WorkGroupSection({
1349 return (1347 return (
1350 <section className="-mx-1 space-y-0.5 px-1 py-0.5" aria-label={groupLabel}>1348 <section className="-mx-1 space-y-0.5 px-1 py-0.5" aria-label={groupLabel}>
1351 {!onlyToolEntries && (1349 {!onlyToolEntries && (
1352 <p className="px-0.5 pb-0.5 font-medium text-[11px] text-muted-foreground/65">1350 <p className="px-0.5 pb-0.5 font-medium text-secondary-label text-[11px]">{groupLabel}</p>
1353 {groupLabel}
1354 </p>
1355 )}1351 )}
1356 <div className="space-y-px">1352 <div className="space-y-px">
1357 {nonEmptyEntries.map((workEntry) => (1353 {nonEmptyEntries.map((workEntry) => (
...@@ -1391,7 +1387,7 @@ function WorkGroupToggleTimelineRow({...@@ -1391,7 +1387,7 @@ function WorkGroupToggleTimelineRow({
1391 ctx.onToggleWorkGroup(row.groupId, anchorElement);1387 ctx.onToggleWorkGroup(row.groupId, anchorElement);
1392 }}1388 }}
1393 >1389 >
1394 <span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground/65">1390 <span className="flex size-5 shrink-0 items-center justify-center text-icon-muted">
1395 <ChevronDownIcon1391 <ChevronDownIcon
1396 className={cn(1392 className={cn(
1397 "size-3.5 shrink-0 opacity-70 transition-transform duration-200",1393 "size-3.5 shrink-0 opacity-70 transition-transform duration-200",
...@@ -1400,11 +1396,11 @@ function WorkGroupToggleTimelineRow({...@@ -1400,11 +1396,11 @@ function WorkGroupToggleTimelineRow({
1400 />1396 />
1401 </span>1397 </span>
1402 {row.expanded ? (1398 {row.expanded ? (
1403 <span className="font-medium text-foreground/82">1399 <span className="font-medium text-foreground">
1404 Show fewer {row.onlyToolEntries ? "tool calls" : "log entries"}1400 Show fewer {row.onlyToolEntries ? "tool calls" : "log entries"}
1405 </span>1401 </span>
1406 ) : (1402 ) : (
1407 <span className="font-medium text-foreground/82">1403 <span className="font-medium text-foreground">
1408 +{row.hiddenCount} previous {labelNoun}1404 +{row.hiddenCount} previous {labelNoun}
1409 </span>1405 </span>
1410 )}1406 )}
...@@ -1509,7 +1505,7 @@ const UserMessageElementContextChip = memo(function UserMessageElementContextChi...@@ -1509,7 +1505,7 @@ const UserMessageElementContextChip = memo(function UserMessageElementContextChi
1509 <Tooltip>1505 <Tooltip>
1510 <TooltipTrigger1506 <TooltipTrigger
1511 render={1507 render={
1512 <span className="inline-flex max-w-full items-center gap-1 rounded-md border border-border/70 bg-background/70 px-1.5 py-0.5 text-xs text-foreground/85">1508 <span className="inline-flex max-w-full items-center gap-1 rounded-md border border-border/70 bg-background/70 px-1.5 py-0.5 text-foreground/85 text-xs">
1513 <MousePointerClickIcon className="size-3 shrink-0" />1509 <MousePointerClickIcon className="size-3 shrink-0" />
1514 <span className="truncate">{props.context.header}</span>1510 <span className="truncate">{props.context.header}</span>
1515 </span>1511 </span>
...@@ -1549,13 +1545,13 @@ function UserMessagePreviewAnnotationCard(props: {...@@ -1549,13 +1545,13 @@ function UserMessagePreviewAnnotationCard(props: {
1549 ) : null}1545 ) : null}
1550 <div className="min-w-0 px-2.5 py-2">1546 <div className="min-w-0 px-2.5 py-2">
1551 {props.annotation.comment ? (1547 {props.annotation.comment ? (
1552 <div className="max-w-80 truncate text-xs font-medium text-foreground/90">1548 <div className="max-w-80 truncate text-foreground text-xs font-medium">
1553 {props.annotation.comment}1549 {props.annotation.comment}
1554 </div>1550 </div>
1555 ) : null}1551 ) : null}
1556 <div1552 <div
1557 className={cn(1553 className={cn(
1558 "flex items-center gap-2 text-[10px] text-muted-foreground",1554 "flex items-center gap-2 text-secondary-label text-[10px]",
1559 props.annotation.comment && "mt-1",1555 props.annotation.comment && "mt-1",
1560 )}1556 )}
1561 >1557 >
...@@ -1644,7 +1640,7 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop...@@ -1644,7 +1640,7 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop
1644 aria-expanded={expanded}1640 aria-expanded={expanded}
1645 data-scroll-anchor-ignore1641 data-scroll-anchor-ignore
1646 onClick={() => setExpanded((value) => !value)}1642 onClick={() => setExpanded((value) => !value)}
1647 className="-ml-1 h-6 rounded-md px-1.5 text-xs text-muted-foreground/72 hover:bg-muted/55 hover:text-foreground/85"1643 className="-ml-1 h-6 rounded-md px-1.5 text-secondary-label text-xs hover:bg-muted/55 hover:text-message-foreground"
1648 >1644 >
1649 {expanded ? "Show less" : "Show full message"}1645 {expanded ? "Show less" : "Show full message"}
1650 </Button>1646 </Button>
...@@ -1683,7 +1679,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {...@@ -1683,7 +1679,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
1683 cwd={props.markdownCwd}1679 cwd={props.markdownCwd}
1684 threadRef={ctx.threadRef ?? undefined}1680 threadRef={ctx.threadRef ?? undefined}
1685 skills={props.skills}1681 skills={props.skills}
1686 className="text-foreground"1682 className="text-message-foreground"
1687 lineBreaks1683 lineBreaks
1688 />1684 />
1689 ) : null}1685 ) : null}
...@@ -1695,7 +1691,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {...@@ -1695,7 +1691,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
1695 const reviewCommentSegments = parseReviewCommentMessageSegments(props.text);1691 const reviewCommentSegments = parseReviewCommentMessageSegments(props.text);
1696 if (reviewCommentSegments.some((segment) => segment.kind === "review-comment")) {1692 if (reviewCommentSegments.some((segment) => segment.kind === "review-comment")) {
1697 return (1693 return (
1698 <div className="space-y-3 text-sm leading-relaxed text-foreground">1694 <div className="space-y-3 text-message-foreground text-sm leading-relaxed">
1699 {reviewCommentSegments.map((segment) =>1695 {reviewCommentSegments.map((segment) =>
1700 segment.kind === "text" ? (1696 segment.kind === "text" ? (
1701 segment.text.trim().length > 0 ? (1697 segment.text.trim().length > 0 ? (
...@@ -1705,7 +1701,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {...@@ -1705,7 +1701,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
1705 cwd={props.markdownCwd}1701 cwd={props.markdownCwd}
1706 threadRef={ctx.threadRef ?? undefined}1702 threadRef={ctx.threadRef ?? undefined}
1707 skills={props.skills}1703 skills={props.skills}
1708 className="text-foreground"1704 className="text-message-foreground"
1709 lineBreaks1705 lineBreaks
1710 />1706 />
1711 </div>1707 </div>
...@@ -1764,7 +1760,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {...@@ -1764,7 +1760,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
1764 }1760 }
17651761
1766 return (1762 return (
1767 <div className="whitespace-pre-wrap wrap-break-word text-sm leading-relaxed text-foreground">1763 <div className="whitespace-pre-wrap wrap-break-word text-message-foreground text-sm leading-relaxed">
1768 {inlineNodes}1764 {inlineNodes}
1769 </div>1765 </div>
1770 );1766 );
...@@ -1793,7 +1789,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {...@@ -1793,7 +1789,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
1793 cwd={props.markdownCwd}1789 cwd={props.markdownCwd}
1794 threadRef={ctx.threadRef ?? undefined}1790 threadRef={ctx.threadRef ?? undefined}
1795 skills={props.skills}1791 skills={props.skills}
1796 className="text-foreground"1792 className="text-message-foreground"
1797 lineBreaks1793 lineBreaks
1798 />,1794 />,
1799 );1795 );
...@@ -1802,7 +1798,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {...@@ -1802,7 +1798,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
1802 }1798 }
18031799
1804 return (1800 return (
1805 <div className="whitespace-pre-wrap wrap-break-word text-sm leading-relaxed text-foreground">1801 <div className="whitespace-pre-wrap wrap-break-word text-message-foreground text-sm leading-relaxed">
1806 {inlineNodes}1802 {inlineNodes}
1807 </div>1803 </div>
1808 );1804 );
...@@ -1818,7 +1814,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {...@@ -1818,7 +1814,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
1818 cwd={props.markdownCwd}1814 cwd={props.markdownCwd}
1819 threadRef={ctx.threadRef ?? undefined}1815 threadRef={ctx.threadRef ?? undefined}
1820 skills={props.skills}1816 skills={props.skills}
1821 className="text-foreground"1817 className="text-message-foreground"
1822 lineBreaks1818 lineBreaks
1823 />1819 />
1824 );1820 );
...@@ -1835,10 +1831,10 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte...@@ -1835,10 +1831,10 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte
1835 return (1831 return (
1836 <div className="space-y-2 rounded-lg border border-border/70 bg-background/70 p-3">1832 <div className="space-y-2 rounded-lg border border-border/70 bg-background/70 p-3">
1837 <div className="space-y-1">1833 <div className="space-y-1">
1838 <div className="text-xs font-medium text-foreground">1834 <div className="text-message-foreground text-xs font-medium">
1839 {formatWorkspaceRelativePath(comment.filePath, ctx.workspaceRoot)}1835 {formatWorkspaceRelativePath(comment.filePath, ctx.workspaceRoot)}
1840 </div>1836 </div>
1841 <div className="text-[11px] text-muted-foreground">1837 <div className="text-secondary-label text-[11px]">
1842 {comment.sectionTitle} · {comment.rangeLabel}1838 {comment.sectionTitle} · {comment.rangeLabel}
1843 </div>1839 </div>
1844 </div>1840 </div>
...@@ -1853,7 +1849,7 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte...@@ -1853,7 +1849,7 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte
1853 cwd={ctx.markdownCwd}1849 cwd={ctx.markdownCwd}
1854 threadRef={ctx.threadRef ?? undefined}1850 threadRef={ctx.threadRef ?? undefined}
1855 skills={ctx.skills}1851 skills={ctx.skills}
1856 className="text-foreground"1852 className="text-message-foreground"
1857 />1853 />
1858 )}1854 )}
1859 {renderablePatch?.kind === "files" &&1855 {renderablePatch?.kind === "files" &&
...@@ -1978,24 +1974,24 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): {...@@ -1978,24 +1974,24 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): {
1978 if (tone === "error") {1974 if (tone === "error") {
1979 return {1975 return {
1980 iconName: "circle-alert",1976 iconName: "circle-alert",
1981 className: "text-foreground/92",1977 className: "text-foreground",
1982 };1978 };
1983 }1979 }
1984 if (tone === "thinking") {1980 if (tone === "thinking") {
1985 return {1981 return {
1986 iconName: "bot",1982 iconName: "bot",
1987 className: "text-foreground/92",1983 className: "text-foreground",
1988 };1984 };
1989 }1985 }
1990 if (tone === "info") {1986 if (tone === "info") {
1991 return {1987 return {
1992 iconName: "check",1988 iconName: "check",
1993 className: "text-muted-foreground",1989 className: "text-icon-muted",
1994 };1990 };
1995 }1991 }
1996 return {1992 return {
1997 iconName: "zap",1993 iconName: "zap",
1998 className: "text-foreground/92",1994 className: "text-foreground",
1999 };1995 };
2000}1996}
20011997
...@@ -2244,14 +2240,14 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -2244,14 +2240,14 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
2244 : showDestructiveRowStyle2240 : showDestructiveRowStyle
2245 ? "text-destructive"2241 ? "text-destructive"
2246 : workEntry.tone === "tool" || showFailedIndicator2242 : workEntry.tone === "tool" || showFailedIndicator
2247 ? "text-muted-foreground/65"2243 ? "text-icon-muted"
2248 : iconConfig.className,2244 : iconConfig.className,
2249 );2245 );
2250 const headingClass = showWarningIndicator2246 const headingClass = showWarningIndicator
2251 ? "font-medium text-warning"2247 ? "font-medium text-warning"
2252 : showDestructiveRowStyle2248 : showDestructiveRowStyle
2253 ? "font-medium text-destructive"2249 ? "font-medium text-destructive"
2254 : "font-medium text-foreground/82";2250 : "font-medium text-foreground";
2255 const turnSettled = !activity.activeTurnInProgress;2251 const turnSettled = !activity.activeTurnInProgress;
2256 const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry);2252 const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry);
2257 const showSuccessIndicator =2253 const showSuccessIndicator =
...@@ -2293,11 +2289,11 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -2293,11 +2289,11 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
2293 <p className="flex min-w-0 w-full items-baseline gap-1.5 text-[12px] leading-5">2289 <p className="flex min-w-0 w-full items-baseline gap-1.5 text-[12px] leading-5">
2294 <span className={cn("min-w-0 shrink truncate", headingClass)}>{heading}</span>2290 <span className={cn("min-w-0 shrink truncate", headingClass)}>{heading}</span>
2295 {preview && (2291 {preview && (
2296 <span className="min-w-0 flex-1 truncate text-muted-foreground/55">{preview}</span>2292 <span className="min-w-0 flex-1 truncate text-secondary-label">{preview}</span>
2297 )}2293 )}
2298 </p>2294 </p>
2299 </div>2295 </div>
2300 <div className="flex shrink-0 items-center gap-px text-muted-foreground/55">2296 <div className="flex shrink-0 items-center gap-px text-icon-muted">
2301 <span2297 <span
2302 className="flex size-4 shrink-0 items-center justify-center"2298 className="flex size-4 shrink-0 items-center justify-center"
2303 aria-hidden={!canExpand}2299 aria-hidden={!canExpand}
...@@ -2362,7 +2358,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -2362,7 +2358,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
2362 onClick={stopRowToggle}2358 onClick={stopRowToggle}
2363 onPointerDown={stopRowToggle}2359 onPointerDown={stopRowToggle}
2364 >2360 >
2365 <pre className="max-h-64 cursor-text overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-muted-foreground select-text">2361 <pre className="max-h-64 cursor-text overflow-auto whitespace-pre-wrap break-words font-mono text-secondary-label text-[11px] leading-relaxed select-text">
2366 {expandedBody}2362 {expandedBody}
2367 </pre>2363 </pre>
2368 </div>2364 </div>
apps/web/src/components/chat/ModelListRow.tsx+1-1
...@@ -69,7 +69,7 @@ export const ModelListRow = memo(function ModelListRow(props: {...@@ -69,7 +69,7 @@ export const ModelListRow = memo(function ModelListRow(props: {
69 </div>69 </div>
70 {props.showNewBadge ? (70 {props.showNewBadge ? (
71 <span71 <span
72 className="shrink-0 rounded border border-amber-500/35 bg-amber-500/15 px-0.5 py-px text-[10px] font-bold uppercase leading-none tracking-wide text-amber-800 dark:border-amber-400/30 dark:bg-amber-400/12 dark:text-amber-200"72 className="shrink-0 rounded border border-update/35 bg-update/15 px-0.5 py-px text-[10px] font-bold uppercase leading-none tracking-wide text-update-foreground"
73 aria-label="New model"73 aria-label="New model"
74 >74 >
75 New75 New
apps/web/src/components/chat/ModelPickerSidebar.tsx+1-1
...@@ -29,7 +29,7 @@ const SELECTED_INDICATOR_CLASS =...@@ -29,7 +29,7 @@ const SELECTED_INDICATOR_CLASS =
29 "pointer-events-none absolute -right-1 top-1/2 z-10 h-5 w-0.75 -translate-y-1/2 rounded-l-full bg-primary";29 "pointer-events-none absolute -right-1 top-1/2 z-10 h-5 w-0.75 -translate-y-1/2 rounded-l-full bg-primary";
30const BADGE_BASE_CLASS =30const BADGE_BASE_CLASS =
31 "pointer-events-none absolute -right-0.5 top-0.5 z-10 flex size-3.5 items-center justify-center rounded-full bg-transparent shadow-sm ";31 "pointer-events-none absolute -right-0.5 top-0.5 z-10 flex size-3.5 items-center justify-center rounded-full bg-transparent shadow-sm ";
32const NEW_BADGE_CLASS = `${BADGE_BASE_CLASS} text-amber-600 dark:text-amber-300 `;32const NEW_BADGE_CLASS = `${BADGE_BASE_CLASS} text-update `;
3333
34/** Opens toward the rail so the list stays readable (not over the model names). */34/** Opens toward the rail so the list stays readable (not over the model names). */
35const PICKER_TOOLTIP_SIDE = "left" as const;35const PICKER_TOOLTIP_SIDE = "left" as const;
apps/web/src/components/chat/PierreEntryIcon.tsx+2-2
...@@ -73,9 +73,9 @@ export const PierreEntryIcon = memo(function PierreEntryIcon(props: {...@@ -73,9 +73,9 @@ export const PierreEntryIcon = memo(function PierreEntryIcon(props: {
7373
74 if (!icon) {74 if (!icon) {
75 return props.kind === "directory" ? (75 return props.kind === "directory" ? (
76 <FolderIcon className={cn("size-4 text-muted-foreground/80", props.className)} />76 <FolderIcon className={cn("size-4 text-icon-muted", props.className)} />
77 ) : (77 ) : (
78 <FileIcon className={cn("size-4 text-muted-foreground/80", props.className)} />78 <FileIcon className={cn("size-4 text-icon-muted", props.className)} />
79 );79 );
80 }80 }
8181
apps/web/src/components/files/FilePreviewPanel.tsx+12-2
...@@ -23,7 +23,7 @@ import { OpenInPicker } from "~/components/chat/OpenInPicker";...@@ -23,7 +23,7 @@ import { OpenInPicker } from "~/components/chat/OpenInPicker";
23import { useClientSettings } from "~/hooks/useSettings";23import { useClientSettings } from "~/hooks/useSettings";
24import { useTheme } from "~/hooks/useTheme";24import { useTheme } from "~/hooks/useTheme";
25import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage";25import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage";
26import { resolveDiffThemeName } from "~/lib/diffRendering";26import { DIFF_SURFACE_THEME_UNSAFE_CSS, resolveDiffThemeName } from "~/lib/diffRendering";
27import { cn } from "~/lib/utils";27import { cn } from "~/lib/utils";
28import { isPreviewSupportedInRuntime } from "~/previewStateStore";28import { isPreviewSupportedInRuntime } from "~/previewStateStore";
29import { resolvePathLinkTarget } from "~/terminal-links";29import { resolvePathLinkTarget } from "~/terminal-links";
...@@ -84,6 +84,16 @@ const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown";...@@ -84,6 +84,16 @@ const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown";
84const FILE_SAVE_DEBOUNCE_MS = 500;84const FILE_SAVE_DEBOUNCE_MS = 500;
85const FILE_LINK_REVEAL_ATTRIBUTE = "data-file-link-reveal";85const FILE_LINK_REVEAL_ATTRIBUTE = "data-file-link-reveal";
86const FILE_LINK_REVEAL_UNSAFE_CSS = `86const FILE_LINK_REVEAL_UNSAFE_CSS = `
87 ${DIFF_SURFACE_THEME_UNSAFE_CSS}
88
89 diffs-container {
90 --diffs-bg: var(--code-background, var(--background)) !important;
91 --diffs-light-bg: var(--code-background, var(--background)) !important;
92 --diffs-dark-bg: var(--code-background, var(--background)) !important;
93 background-color: var(--code-background, var(--background)) !important;
94 color: var(--code-foreground, var(--foreground)) !important;
95 }
96
87 [${FILE_LINK_REVEAL_ATTRIBUTE}][data-line] {97 [${FILE_LINK_REVEAL_ATTRIBUTE}][data-line] {
88 background-color: light-dark(98 background-color: light-dark(
89 color-mix(99 color-mix(
...@@ -959,7 +969,7 @@ export default function FilePreviewPanel({...@@ -959,7 +969,7 @@ export default function FilePreviewPanel({
959 </div>969 </div>
960 ) : null}970 ) : null}
961 {relativePath && file.data?.truncated ? (971 {relativePath && file.data?.truncated ? (
962 <div className="shrink-0 border-b border-amber-500/20 bg-amber-500/8 px-3 py-1.5 text-[11px] text-amber-700 dark:text-amber-300">972 <div className="shrink-0 border-b border-warning/20 bg-warning-surface px-3 py-1.5 text-[11px] text-warning-foreground">
963 Preview limited to the first 1 MB of a {file.data.byteLength.toLocaleString()} byte file.973 Preview limited to the first 1 MB of a {file.data.byteLength.toLocaleString()} byte file.
964 </div>974 </div>
965 ) : null}975 ) : null}
apps/web/src/components/settings/KeybindingsSettings.logic.test.ts+1
...@@ -122,6 +122,7 @@ describe("KeybindingsSettings.logic", () => {...@@ -122,6 +122,7 @@ describe("KeybindingsSettings.logic", () => {
122122
123 it("formats static and project script command labels", () => {123 it("formats static and project script command labels", () => {
124 expect(commandLabel("commandPalette.toggle")).toBe("Command Palette: Toggle");124 expect(commandLabel("commandPalette.toggle")).toBe("Command Palette: Toggle");
125 expect(commandLabel("themeEditor.toggle")).toBe("Theme Editor: Toggle");
125 expect(commandLabel("script.setup-db.run")).toBe("Run Script: Setup Db");126 expect(commandLabel("script.setup-db.run")).toBe("Run Script: Setup Db");
126 });127 });
127128
apps/web/src/components/settings/ProviderInstanceCard.tsx+1-1
...@@ -619,7 +619,7 @@ export function ProviderInstanceCard({...@@ -619,7 +619,7 @@ export function ProviderInstanceCard({
619 "size-5 rounded-sm p-0",619 "size-5 rounded-sm p-0",
620 versionAdvisory.emphasis === "strong"620 versionAdvisory.emphasis === "strong"
621 ? "text-warning hover:text-warning"621 ? "text-warning hover:text-warning"
622 : "text-primary hover:text-primary",622 : "text-update hover:text-update",
623 )}623 )}
624 aria-label="Update available — view details"624 aria-label="Update available — view details"
625 >625 >
apps/web/src/components/settings/SettingsFontPreviews.tsx+27-2
...@@ -85,6 +85,26 @@ function loadDiffPreviewHtml(theme: DiffThemeName): Promise<readonly string[]> {...@@ -85,6 +85,26 @@ function loadDiffPreviewHtml(theme: DiffThemeName): Promise<readonly string[]> {
85 return promise;85 return promise;
86}86}
8787
88// Pierre's prerendered stylesheet bakes its own light/dark surface colors
89// into the shadow root's @layer rules. These unlayered rules win the cascade
90// without !important and re-point the surfaces at the app's code tokens
91// (custom properties inherit across the shadow boundary), so the preview
92// follows the active theme exactly like the real diff panel does.
93const DIFF_PREVIEW_THEME_BRIDGE = `
94 :host {
95 color: var(--code-foreground);
96 background-color: var(--code-background);
97 --diffs-fg: var(--code-foreground);
98 --diffs-bg: var(--code-background);
99 --diffs-light-bg: var(--code-background);
100 --diffs-dark-bg: var(--code-background);
101 }
102 [data-diffs-header] {
103 background-color: var(--code-background);
104 color: var(--code-foreground);
105 }
106`;
107
88function StaticDiffHtml({ html }: { html: string }) {108function StaticDiffHtml({ html }: { html: string }) {
89 const hostRef = useRef<HTMLDivElement | null>(null);109 const hostRef = useRef<HTMLDivElement | null>(null);
90 useEffect(() => {110 useEffect(() => {
...@@ -92,6 +112,9 @@ function StaticDiffHtml({ html }: { html: string }) {...@@ -92,6 +112,9 @@ function StaticDiffHtml({ html }: { html: string }) {
92 if (host === null) return;112 if (host === null) return;
93 const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" });113 const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" });
94 shadow.innerHTML = html;114 shadow.innerHTML = html;
115 const bridge = document.createElement("style");
116 bridge.textContent = DIFF_PREVIEW_THEME_BRIDGE;
117 shadow.append(bridge);
95 }, [html]);118 }, [html]);
96 return <div ref={hostRef} />;119 return <div ref={hostRef} />;
97}120}
...@@ -158,7 +181,7 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu...@@ -158,7 +181,7 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu
158 const mountRef = useRef<HTMLDivElement>(null);181 const mountRef = useRef<HTMLDivElement>(null);
159 const surfaceRef = useRef<GhosttyTerminalSurface | null>(null);182 const surfaceRef = useRef<GhosttyTerminalSurface | null>(null);
160 const fontRef = useRef({ family, size });183 const fontRef = useRef({ family, size });
161 const { resolvedTheme } = useTheme();184 const { theme, resolvedTheme } = useTheme();
162185
163 useEffect(() => {186 useEffect(() => {
164 const current = fontRef.current;187 const current = fontRef.current;
...@@ -167,12 +190,14 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu...@@ -167,12 +190,14 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu
167 void surfaceRef.current?.setFont(previewTerminalFont(family, size));190 void surfaceRef.current?.setFont(previewTerminalFont(family, size));
168 }, [family, size]);191 }, [family, size]);
169192
193 // Re-read the terminal tokens on any theme change — switching between two
194 // palettes can leave resolvedTheme (light/dark) untouched.
170 useEffect(() => {195 useEffect(() => {
171 const mount = mountRef.current;196 const mount = mountRef.current;
172 const surface = surfaceRef.current;197 const surface = surfaceRef.current;
173 if (!mount || !surface) return;198 if (!mount || !surface) return;
174 surface.setTheme(terminalThemeFromApp(mount));199 surface.setTheme(terminalThemeFromApp(mount));
175 }, [resolvedTheme]);200 }, [theme, resolvedTheme]);
176201
177 useEffect(() => {202 useEffect(() => {
178 const mount = mountRef.current;203 const mount = mountRef.current;
apps/web/src/components/settings/SettingsPanels.tsx+109-52
...@@ -52,7 +52,13 @@ import {...@@ -52,7 +52,13 @@ import {
52} from "../SidebarStageBackdrop";52} from "../SidebarStageBackdrop";
53import { isElectron } from "../../env";53import { isElectron } from "../../env";
54import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing";54import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing";
55import { useTheme } from "../../hooks/useTheme";55import { useCustomThemes } from "../../hooks/useCustomThemes";
56import {
57 readAppearanceModePreference,
58 readThemeHalves,
59 readThemePreference,
60 useTheme,
61} from "../../hooks/useTheme";
56import { useLocalStorage } from "../../hooks/useLocalStorage";62import { useLocalStorage } from "../../hooks/useLocalStorage";
57import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";63import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";
58import { useThreadActions } from "../../hooks/useThreadActions";64import { useThreadActions } from "../../hooks/useThreadActions";
...@@ -106,6 +112,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../...@@ -106,6 +112,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../
106import { Switch } from "../ui/switch";112import { Switch } from "../ui/switch";
107import { stackedThreadToast, toastManager } from "../ui/toast";113import { stackedThreadToast, toastManager } from "../ui/toast";
108import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";114import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
115import { ThemeLibrary } from "./ThemeSettings";
109import {116import {
110 backgroundActivityOverrideSettings,117 backgroundActivityOverrideSettings,
111 backgroundActivitySharedPolicySettings,118 backgroundActivitySharedPolicySettings,
...@@ -131,21 +138,6 @@ import {...@@ -131,21 +138,6 @@ import {
131import { searchableSetting } from "./settingsSearch";138import { searchableSetting } from "./settingsSearch";
132import { ProjectFavicon } from "../ProjectFavicon";139import { ProjectFavicon } from "../ProjectFavicon";
133140
134const THEME_OPTIONS = [
135 {
136 value: "system",
137 label: "System",
138 },
139 {
140 value: "light",
141 label: "Light",
142 },
143 {
144 value: "dark",
145 label: "Dark",
146 },
147] as const;
148
149const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, string> = {141const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, string> = {
150 artwork: "Artwork",142 artwork: "Artwork",
151 pill: "Version pill",143 pill: "Version pill",
...@@ -432,7 +424,15 @@ function AboutVersionSection() {...@@ -432,7 +424,15 @@ function AboutVersionSection() {
432}424}
433425
434export function useSettingsRestore(onRestored?: () => void) {426export function useSettingsRestore(onRestored?: () => void) {
435 const { theme, setTheme } = useTheme();427 const {
428 theme,
429 setTheme,
430 followSystem,
431 setFollowSystem,
432 setThemeHalf,
433 clearThemeHalves,
434 themeHalves,
435 } = useTheme();
436 const settings = usePrimarySettings();436 const settings = usePrimarySettings();
437 const updateSettings = useUpdatePrimarySettings();437 const updateSettings = useUpdatePrimarySettings();
438438
...@@ -445,6 +445,8 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -445,6 +445,8 @@ export function useSettingsRestore(onRestored?: () => void) {
445 const changedSettingLabels = useMemo(445 const changedSettingLabels = useMemo(
446 () => [446 () => [
447 ...(theme !== "system" ? ["Theme"] : []),447 ...(theme !== "system" ? ["Theme"] : []),
448 ...(!followSystem ? ["Follow system"] : []),
449 ...(themeHalves !== null ? ["Theme mix"] : []),
448 ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []),450 ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []),
449 ...(settings.environmentIdentificationMode !==451 ...(settings.environmentIdentificationMode !==
450 DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode452 DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode
...@@ -525,6 +527,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -525,6 +527,7 @@ export function useSettingsRestore(onRestored?: () => void) {
525 settings.sidebarThreadPreviewCount,527 settings.sidebarThreadPreviewCount,
526 settings.timestampFormat,528 settings.timestampFormat,
527 settings.wordWrap,529 settings.wordWrap,
530 followSystem,
528 theme,531 theme,
529 ],532 ],
530 );533 );
...@@ -539,7 +542,57 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -539,7 +542,57 @@ export function useSettingsRestore(onRestored?: () => void) {
539 );542 );
540 if (!confirmed) return;543 if (!confirmed) return;
541544
542 setTheme("system");545 // Only touch the theme keys that are actually dirty, so a theme-storage
546 // failure cannot block restoring unrelated settings. Preferences are
547 // re-read after the confirmation dialog: they may have changed (another
548 // tab, an OS flip) while it was open, and rollback must restore the live
549 // values rather than the ones captured at render time.
550 let previousTheme = theme;
551 try {
552 previousTheme = readThemePreference();
553 } catch {
554 // Storage is unreadable; the render-time value is the best rollback.
555 }
556 // The mix may have changed while the confirmation dialog was open; both
557 // the dirty check and the rollback must see the live value.
558 const liveHalves = readThemeHalves();
559 const needsThemeReset = previousTheme !== "system";
560 const needsMixReset = liveHalves !== null;
561 // Same for the appearance mode: trusting the render-time value would skip
562 // the reset and report success while a non-system mode stayed in storage.
563 const needsFollowSystemReset = readAppearanceModePreference(previousTheme) !== "system";
564 const notifyThemeRestoreFailure = () => {
565 toastManager.add(
566 stackedThreadToast({
567 type: "error",
568 title: "Couldn’t restore theme settings",
569 description: "Try again.",
570 }),
571 );
572 };
573 // Rollback restores the base preference first (which clears any mix) and
574 // then re-applies the captured mix on top, so no failure path can leave
575 // the pair of keys half-restored.
576 const previousHalves = liveHalves;
577 const rollbackThemeState = () => {
578 if (needsThemeReset) setTheme(previousTheme);
579 if (previousHalves?.light) setThemeHalf("light", previousHalves.light);
580 if (previousHalves?.dark) setThemeHalf("dark", previousHalves.dark);
581 };
582 if (needsThemeReset && !setTheme("system")) {
583 notifyThemeRestoreFailure();
584 return;
585 }
586 if (needsMixReset && !clearThemeHalves()) {
587 rollbackThemeState();
588 notifyThemeRestoreFailure();
589 return;
590 }
591 if (needsFollowSystemReset && !setFollowSystem(true)) {
592 rollbackThemeState();
593 notifyThemeRestoreFailure();
594 return;
595 }
543 updateSettings({596 updateSettings({
544 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,597 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
545 wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,598 wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
...@@ -566,7 +619,17 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -566,7 +619,17 @@ export function useSettingsRestore(onRestored?: () => void) {
566 fontFamilyTerminal: DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal,619 fontFamilyTerminal: DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal,
567 });620 });
568 onRestored?.();621 onRestored?.();
569 }, [changedSettingLabels, onRestored, setTheme, updateSettings]);622 }, [
623 changedSettingLabels,
624 clearThemeHalves,
625 onRestored,
626 setFollowSystem,
627 setTheme,
628 setThemeHalf,
629 theme,
630 themeHalves,
631 updateSettings,
632 ]);
570633
571 return {634 return {
572 changedSettingLabels,635 changedSettingLabels,
...@@ -841,7 +904,18 @@ function BackgroundActivityAdvancedDialog({...@@ -841,7 +904,18 @@ function BackgroundActivityAdvancedDialog({
841}904}
842905
843export function AppearanceSettingsPanel() {906export function AppearanceSettingsPanel() {
844 const { theme, setTheme } = useTheme();907 const {
908 appearanceMode,
909 refreshTheme,
910 resolvedTheme,
911 setAppearanceMode,
912 setTheme,
913 setThemeHalf,
914 theme,
915 themeHalves,
916 } = useTheme();
917 const customThemes = useCustomThemes();
918 const [isImportThemeOpen, setIsImportThemeOpen] = useState(false);
845 const settings = usePrimarySettings();919 const settings = usePrimarySettings();
846 const updateSettings = useUpdatePrimarySettings();920 const updateSettings = useUpdatePrimarySettings();
847 const environmentStageLabel = useEnvironmentStageLabel();921 const environmentStageLabel = useEnvironmentStageLabel();
...@@ -857,38 +931,21 @@ export function AppearanceSettingsPanel() {...@@ -857,38 +931,21 @@ export function AppearanceSettingsPanel() {
857 return (931 return (
858 <SettingsPageContainer>932 <SettingsPageContainer>
859 <SettingsSection id="appearance" title="Appearance">933 <SettingsSection id="appearance" title="Appearance">
860 <SettingsRow934 <div id={searchableSetting("theme").id}>
861 {...searchableSetting("theme")}935 <ThemeLibrary
862 description="Choose how T3 Code looks across the app."936 appearanceMode={appearanceMode}
863 resetAction={937 customThemes={customThemes}
864 theme !== "system" ? (938 initialAppearance={resolvedTheme}
865 <SettingResetButton label="theme" onClick={() => setTheme("system")} />939 refreshTheme={refreshTheme}
866 ) : null940 isImportOpen={isImportThemeOpen}
867 }941 setAppearanceMode={setAppearanceMode}
868 control={942 setTheme={setTheme}
869 <Select943 setThemeHalf={setThemeHalf}
870 value={theme}944 theme={theme}
871 onValueChange={(value) => {945 themeHalves={themeHalves}
872 if (value === "system" || value === "light" || value === "dark") {946 onImportOpenChange={setIsImportThemeOpen}
873 setTheme(value);947 />
874 }948 </div>
875 }}
876 >
877 <SelectTrigger className="w-full sm:w-40" aria-label="Theme preference">
878 <SelectValue>
879 {THEME_OPTIONS.find((option) => option.value === theme)?.label ?? "System"}
880 </SelectValue>
881 </SelectTrigger>
882 <SelectPopup align="end" alignItemWithTrigger={false}>
883 {THEME_OPTIONS.map((option) => (
884 <SelectItem hideIndicator key={option.value} value={option.value}>
885 {option.label}
886 </SelectItem>
887 ))}
888 </SelectPopup>
889 </Select>
890 }
891 />
892949
893 <SettingsRow950 <SettingsRow
894 {...searchableSetting("setting-glass-opacity")}951 {...searchableSetting("setting-glass-opacity")}
apps/web/src/components/settings/ThemeColorPicker.tsx created+550
...@@ -0,0 +1,550 @@
1import type { KeyboardEvent, PointerEvent } from "react";
2import { memo, useCallback, useEffect, useRef, useState } from "react";
3import { isThemeColor, type ThemeColorRole } from "../../themePalette";
4import { cn } from "../../lib/utils";
5import { Input } from "../ui/input";
6import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
7export function getThemeRoleLabel(role: ThemeColorRole): string {
8 const labels: Partial<Record<ThemeColorRole, string>> = {
9 canvas: "Background",
10 toolbar: "Toolbar background",
11 toolbarForeground: "Toolbar text",
12 toolbarBorder: "Toolbar border",
13 toolbarControl: "Toolbar control",
14 toolbarControlForeground: "Toolbar control text",
15 toolbarControlHover: "Toolbar control hover",
16 accent: "Accent color",
17 errorForeground: "Error text",
18 errorSurface: "Error background",
19 warningForeground: "Warning text",
20 warningSurface: "Warning background",
21 updateForeground: "Update text",
22 updateSurface: "Update background",
23 };
24 const label = labels[role];
25 if (label) return label;
26 return role.replace(/([A-Z])/g, " $1").replace(/^./, (character) => character.toUpperCase());
27}
28
29type ThemeColorHsv = {
30 h: number;
31 s: number;
32 v: number;
33};
34
35function clampThemeColor(value: number, min = 0, max = 1) {
36 return Math.min(max, Math.max(min, value));
37}
38
39/**
40 * The picker's plane and sliders operate on opaque six-digit hex, but theme
41 * colors may carry alpha. The suffix is preserved separately and re-attached
42 * on commit so adjusting hue or brightness cannot change transparency.
43 */
44function themePickerAlphaSuffix(value: string): string {
45 const trimmed = value.trim().toLowerCase();
46 const alpha = /^#[0-9a-f]{4}$/.test(trimmed)
47 ? trimmed.slice(4).repeat(2)
48 : /^#[0-9a-f]{8}$/.test(trimmed)
49 ? trimmed.slice(7)
50 : "";
51 return alpha === "ff" ? "" : alpha;
52}
53
54function normalizeThemePickerColor(value: string): string {
55 const trimmed = value.trim();
56 if (/^#[0-9a-f]{3}$/i.test(trimmed)) {
57 return `#${trimmed
58 .slice(1)
59 .split("")
60 .map((character) => `${character}${character}`)
61 .join("")}`;
62 }
63 if (/^#[0-9a-f]{4}$/i.test(trimmed)) {
64 return `#${trimmed
65 .slice(1, 4)
66 .split("")
67 .map((character) => `${character}${character}`)
68 .join("")}`;
69 }
70 if (/^#[0-9a-f]{6}$/i.test(trimmed)) return trimmed;
71 if (/^#[0-9a-f]{8}$/i.test(trimmed)) return trimmed.slice(0, 7);
72 return "#000000";
73}
74
75function themeHexToHsv(hex: string): ThemeColorHsv {
76 const normalized = normalizeThemePickerColor(hex);
77 const numeric = Number.parseInt(normalized.slice(1), 16);
78 const red = ((numeric >> 16) & 255) / 255;
79 const green = ((numeric >> 8) & 255) / 255;
80 const blue = (numeric & 255) / 255;
81 const max = Math.max(red, green, blue);
82 const min = Math.min(red, green, blue);
83 const delta = max - min;
84
85 let hue = 0;
86 if (delta !== 0) {
87 if (max === red) {
88 hue = ((green - blue) / delta) % 6;
89 } else if (max === green) {
90 hue = (blue - red) / delta + 2;
91 } else {
92 hue = (red - green) / delta + 4;
93 }
94 hue *= 60;
95 if (hue < 0) hue += 360;
96 }
97
98 return {
99 h: hue,
100 s: max === 0 ? 0 : delta / max,
101 v: max,
102 };
103}
104
105function themeHsvToHex(hue: number, saturation: number, value: number) {
106 const normalizedHue = ((hue % 360) + 360) % 360;
107 const chroma = value * saturation;
108 const x = chroma * (1 - Math.abs(((normalizedHue / 60) % 2) - 1));
109 const match = value - chroma;
110 const [red, green, blue] =
111 normalizedHue < 60
112 ? [chroma, x, 0]
113 : normalizedHue < 120
114 ? [x, chroma, 0]
115 : normalizedHue < 180
116 ? [0, chroma, x]
117 : normalizedHue < 240
118 ? [0, x, chroma]
119 : normalizedHue < 300
120 ? [x, 0, chroma]
121 : [chroma, 0, x];
122
123 return `#${[red, green, blue]
124 .map((channel) =>
125 Math.round((channel + match) * 255)
126 .toString(16)
127 .padStart(2, "0"),
128 )
129 .join("")}`;
130}
131
132function themeHexToRgb(hex: string) {
133 const numeric = Number.parseInt(normalizeThemePickerColor(hex).slice(1), 16);
134 return [numeric >> 16, (numeric >> 8) & 255, numeric & 255] as const;
135}
136
137function themeRgbToHex(value: string): string | null {
138 const normalized = value
139 .trim()
140 .replace(/^rgb\(\s*/i, "")
141 .replace(/\s*\)$/, "");
142 const channels = normalized
143 .split(/[,\s]+/)
144 .filter(Boolean)
145 .map(Number);
146 if (
147 channels.length !== 3 ||
148 channels.some((channel) => !Number.isInteger(channel) || channel < 0 || channel > 255)
149 ) {
150 return null;
151 }
152
153 return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
154}
155
156function themeRgbValue(hex: string) {
157 return themeHexToRgb(hex).join(", ");
158}
159
160function ThemeColorPickerPanel({
161 label,
162 value,
163 onChange,
164}: {
165 label: string;
166 value: string;
167 onChange: (value: string) => void;
168}) {
169 const normalizedValue = normalizeThemePickerColor(value);
170 const alphaSuffix = themePickerAlphaSuffix(value);
171 const [hsv, setHsv] = useState(() => themeHexToHsv(normalizedValue));
172 const [hexDraft, setHexDraft] = useState(normalizedValue);
173 const [rgbDraft, setRgbDraft] = useState(() => themeRgbValue(normalizedValue));
174 const [isDragging, setIsDragging] = useState(false);
175 const isEditingTextRef = useRef(false);
176 const currentColor = themeHsvToHex(hsv.h, hsv.s, hsv.v);
177 const currentRgb = themeRgbValue(currentColor);
178
179 useEffect(() => {
180 // While a text field is focused, the incoming value may be the guided
181 // editor's readability-adjusted echo of what is being typed; rewriting the
182 // draft would fight the keystrokes. The swatch still tracks via hsv.
183 if (!isEditingTextRef.current) {
184 setHexDraft(normalizedValue);
185 setRgbDraft(themeRgbValue(normalizedValue));
186 }
187 // Keep the current hue/saturation when the incoming value is just our own
188 // change echoed back; hex → HSV is lossy for greys, white, and black.
189 setHsv((current) =>
190 themeHsvToHex(current.h, current.s, current.v) === normalizedValue
191 ? current
192 : themeHexToHsv(normalizedValue),
193 );
194 }, [normalizedValue]);
195
196 // Local state updates immediately for a smooth thumb; the parent commit
197 // (which can regenerate a whole guided palette) is batched to one call per
198 // animation frame.
199 const onChangeRef = useRef(onChange);
200 onChangeRef.current = onChange;
201 const pendingCommitRef = useRef<string | null>(null);
202 const commitFrameRef = useRef<number | null>(null);
203 // The final drag frame must not be lost when the popover closes or the
204 // pointer lifts before the animation frame fires.
205 const flushPendingCommit = useCallback(() => {
206 if (commitFrameRef.current !== null) {
207 cancelAnimationFrame(commitFrameRef.current);
208 commitFrameRef.current = null;
209 }
210 const pending = pendingCommitRef.current;
211 pendingCommitRef.current = null;
212 if (pending !== null) onChangeRef.current(pending);
213 }, []);
214 useEffect(() => () => flushPendingCommit(), [flushPendingCommit]);
215 const scheduleCommit = useCallback((color: string) => {
216 pendingCommitRef.current = color;
217 commitFrameRef.current ??= requestAnimationFrame(() => {
218 commitFrameRef.current = null;
219 const pending = pendingCommitRef.current;
220 pendingCommitRef.current = null;
221 if (pending !== null) onChangeRef.current(pending);
222 });
223 }, []);
224
225 const commitHsv = useCallback(
226 (nextHsv: ThemeColorHsv) => {
227 setHsv(nextHsv);
228 const nextColor = themeHsvToHex(nextHsv.h, nextHsv.s, nextHsv.v);
229 setHexDraft(nextColor);
230 setRgbDraft(themeRgbValue(nextColor));
231 scheduleCommit(nextColor + alphaSuffix);
232 },
233 [alphaSuffix, scheduleCommit],
234 );
235
236 const updateFromPlane = useCallback(
237 (event: PointerEvent<HTMLDivElement>) => {
238 const bounds = event.currentTarget.getBoundingClientRect();
239 const saturation = clampThemeColor((event.clientX - bounds.left) / bounds.width);
240 const value = 1 - clampThemeColor((event.clientY - bounds.top) / bounds.height);
241 commitHsv({ ...hsv, s: saturation, v: value });
242 },
243 [commitHsv, hsv],
244 );
245
246 const updateFromHue = useCallback(
247 (event: PointerEvent<HTMLDivElement>) => {
248 const bounds = event.currentTarget.getBoundingClientRect();
249 const hue = clampThemeColor((event.clientX - bounds.left) / bounds.width) * 360;
250 commitHsv({ ...hsv, h: hue });
251 },
252 [commitHsv, hsv],
253 );
254
255 const handleHueKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
256 const step = event.shiftKey ? 10 : 1;
257 const direction = event.key === "ArrowRight" || event.key === "ArrowUp" ? 1 : -1;
258 if (!["ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp"].includes(event.key)) return;
259 event.preventDefault();
260 commitHsv({ ...hsv, h: (hsv.h + direction * step + 360) % 360 });
261 };
262
263 const handlePlaneKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
264 if (!["ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp"].includes(event.key)) return;
265 event.preventDefault();
266 const step = event.shiftKey ? 0.1 : 0.02;
267 const nextHsv = { ...hsv };
268 if (event.key === "ArrowLeft") nextHsv.s = clampThemeColor(hsv.s - step);
269 if (event.key === "ArrowRight") nextHsv.s = clampThemeColor(hsv.s + step);
270 if (event.key === "ArrowUp") nextHsv.v = clampThemeColor(hsv.v + step);
271 if (event.key === "ArrowDown") nextHsv.v = clampThemeColor(hsv.v - step);
272 commitHsv(nextHsv);
273 };
274
275 const handlePointerDown = (handler: (event: PointerEvent<HTMLDivElement>) => void) => {
276 return (event: PointerEvent<HTMLDivElement>) => {
277 event.currentTarget.setPointerCapture(event.pointerId);
278 setIsDragging(true);
279 handler(event);
280 };
281 };
282
283 const stopDragging = () => {
284 setIsDragging(false);
285 flushPendingCommit();
286 };
287
288 // Thumbs travel inside the control by half their own size so they never
289 // clip at the extremes; movement only animates for keyboard steps and
290 // click-to-jump, never while dragging.
291 const thumbTransition = isDragging
292 ? undefined
293 : "left 80ms linear, top 80ms linear, background-color 80ms linear";
294
295 const handleHexChange = (nextValue: string) => {
296 setHexDraft(nextValue);
297 if (!/^#[0-9a-f]{6}$/i.test(nextValue)) return;
298 const nextHsv = themeHexToHsv(nextValue);
299 setHsv(nextHsv);
300 setRgbDraft(themeRgbValue(nextValue));
301 onChange(nextValue.toLowerCase());
302 };
303
304 const handleRgbChange = (nextValue: string) => {
305 setRgbDraft(nextValue);
306 const nextColor = themeRgbToHex(nextValue);
307 if (!nextColor) return;
308 setHsv(themeHexToHsv(nextColor));
309 setHexDraft(nextColor);
310 // RGB cannot express alpha, so a commit keeps the incoming suffix just
311 // like the plane and hue controls do.
312 onChange(nextColor + alphaSuffix);
313 };
314
315 return (
316 <div className="w-72 bg-popover">
317 <div className="flex items-center justify-between border-b border-border/70 px-4 py-3">
318 <div className="min-w-0">
319 <p className="truncate text-xs font-semibold text-foreground">{label}</p>
320 <p className="text-[11px] text-muted-foreground">Choose a color</p>
321 </div>
322 <span
323 className="size-7 shrink-0 rounded-full shadow-sm"
324 style={{ backgroundColor: currentColor }}
325 />
326 </div>
327 <div className="grid gap-3 px-3 pb-3 pt-3">
328 <div
329 aria-label={`${label} saturation and brightness`}
330 aria-valuetext={`saturation ${Math.round(hsv.s * 100)}%, brightness ${Math.round(hsv.v * 100)}%`}
331 className="relative h-32 cursor-crosshair touch-none overflow-hidden rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-popover"
332 role="slider"
333 style={{
334 backgroundColor: `hsl(${hsv.h} 100% 50%)`,
335 backgroundImage:
336 "linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent)",
337 }}
338 tabIndex={0}
339 onKeyDown={handlePlaneKeyDown}
340 onLostPointerCapture={stopDragging}
341 onPointerDown={handlePointerDown(updateFromPlane)}
342 onPointerMove={(event) => {
343 if (event.currentTarget.hasPointerCapture(event.pointerId)) updateFromPlane(event);
344 }}
345 onPointerUp={stopDragging}
346 >
347 <span
348 className="pointer-events-none absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgb(0_0_0/0.4)]"
349 style={{
350 left: `calc(${hsv.s} * (100% - 0.75rem) + 0.375rem)`,
351 top: `calc(${1 - hsv.v} * (100% - 0.75rem) + 0.375rem)`,
352 transition: thumbTransition,
353 }}
354 />
355 </div>
356 <div
357 aria-label={`${label} hue`}
358 aria-valuemax={360}
359 aria-valuemin={0}
360 aria-valuenow={Math.round(hsv.h)}
361 className="relative flex h-6 cursor-pointer touch-none items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-popover"
362 role="slider"
363 tabIndex={0}
364 onKeyDown={handleHueKeyDown}
365 onLostPointerCapture={stopDragging}
366 onPointerDown={handlePointerDown(updateFromHue)}
367 onPointerMove={(event) => {
368 if (event.currentTarget.hasPointerCapture(event.pointerId)) updateFromHue(event);
369 }}
370 onPointerUp={stopDragging}
371 >
372 <span
373 aria-hidden
374 className="h-2.5 w-full rounded-full shadow-[inset_0_0_0_1px_rgb(0_0_0_/_12%)]"
375 style={{
376 background: "linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)",
377 }}
378 />
379 <span
380 className="pointer-events-none absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgb(0_0_0/0.4)]"
381 style={{
382 left: `calc(${hsv.h / 360} * (100% - 1rem) + 0.5rem)`,
383 // The ball shows the pure hue so it stays visually anchored to
384 // the track; the header swatch carries the full current color.
385 backgroundColor: `hsl(${hsv.h} 100% 50%)`,
386 transition: thumbTransition,
387 }}
388 />
389 </div>
390 <div className="grid grid-cols-[1fr_1.2fr] gap-2">
391 <label className="grid min-w-0 gap-1">
392 <span className="px-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
393 HEX
394 </span>
395 <span className="flex min-w-0 items-center gap-2 rounded-lg border border-input bg-background px-2 focus-within:border-ring">
396 <span
397 className="size-3.5 shrink-0 rounded-full"
398 style={{ backgroundColor: currentColor }}
399 />
400 <input
401 aria-label={`${label} picker hex value`}
402 className="h-8 min-w-0 flex-1 bg-transparent font-mono text-xs text-foreground outline-none"
403 onBlur={() => {
404 isEditingTextRef.current = false;
405 setHexDraft(currentColor);
406 setRgbDraft(currentRgb);
407 }}
408 onChange={(event) => handleHexChange(event.currentTarget.value)}
409 onFocus={() => {
410 isEditingTextRef.current = true;
411 }}
412 spellCheck={false}
413 value={hexDraft}
414 />
415 </span>
416 </label>
417 <label className="grid min-w-0 gap-1">
418 <span className="px-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
419 RGB
420 </span>
421 <span className="flex min-w-0 items-center rounded-lg border border-input bg-background px-2 focus-within:border-ring">
422 <input
423 aria-label={`${label} picker RGB value`}
424 className="h-8 min-w-0 flex-1 bg-transparent font-mono text-xs text-foreground outline-none"
425 onBlur={() => {
426 isEditingTextRef.current = false;
427 setHexDraft(currentColor);
428 setRgbDraft(currentRgb);
429 }}
430 onChange={(event) => handleRgbChange(event.currentTarget.value)}
431 onFocus={() => {
432 isEditingTextRef.current = true;
433 }}
434 spellCheck={false}
435 value={rgbDraft}
436 />
437 </span>
438 </label>
439 </div>
440 </div>
441 </div>
442 );
443}
444
445function ThemeColorPicker({
446 label,
447 value,
448 onChange,
449 onInteract,
450}: {
451 label: string;
452 value: string;
453 onChange: (value: string) => void;
454 onInteract?: () => void;
455}) {
456 return (
457 <Popover>
458 <PopoverTrigger
459 render={
460 <button
461 aria-label={`Choose ${label} color`}
462 className="relative flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-full border border-foreground/30 transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
463 onFocus={onInteract}
464 onPointerDown={onInteract}
465 title={`Choose ${label} color`}
466 type="button"
467 >
468 <span
469 className="absolute inset-0 rounded-full shadow-sm"
470 style={{ backgroundColor: value }}
471 />
472 </button>
473 }
474 />
475 <PopoverPopup
476 align="end"
477 className="overflow-hidden rounded-2xl border border-border/70 p-0 shadow-2xl [--viewport-inline-padding:0px] [&_[data-slot=popover-viewport]]:p-0"
478 data-theme-editor-panel=""
479 side="bottom"
480 sideOffset={10}
481 >
482 <ThemeColorPickerPanel label={label} onChange={onChange} value={value} />
483 </PopoverPopup>
484 </Popover>
485 );
486}
487
488export const ThemeColorField = memo(function ThemeColorField({
489 role,
490 value,
491 onChange,
492 onSelect,
493 onToggleSelected,
494 selected = false,
495 label: customLabel,
496}: {
497 role: ThemeColorRole;
498 value: string;
499 onChange: (role: ThemeColorRole, value: string) => void;
500 onSelect?: (role: ThemeColorRole) => void;
501 onToggleSelected?: (role: ThemeColorRole) => void;
502 selected?: boolean;
503 label?: string;
504}) {
505 const label = customLabel ?? getThemeRoleLabel(role);
506 const isColorValue = isThemeColor(value);
507 const swatchValue = isColorValue ? value : "#000000";
508
509 return (
510 <div
511 className={cn(
512 "flex min-h-11 min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 transition-[background-color,box-shadow]",
513 selected && "bg-accent/60 shadow-[inset_0_0_0_1px_var(--ring)]",
514 )}
515 data-theme-color-role={role}
516 >
517 <button
518 aria-label={`${selected ? "Hide" : "Show"} ${label} usage`}
519 aria-pressed={selected}
520 className="flex min-w-0 flex-1 cursor-pointer items-center rounded-md text-left text-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
521 onClick={() => onToggleSelected?.(role)}
522 title={`${selected ? "Hide" : "Show"} where ${label} is used`}
523 type="button"
524 >
525 <span className="min-w-0 flex-1 truncate">{label}</span>
526 </button>
527 <div className="ml-auto flex shrink-0 items-center gap-2">
528 <ThemeColorPicker
529 label={label}
530 onChange={(nextValue) => onChange(role, nextValue)}
531 onInteract={() => onSelect?.(role)}
532 value={swatchValue}
533 />
534 <Input
535 aria-invalid={!isColorValue}
536 aria-label={`${label} hex value`}
537 className="w-28 shrink-0 rounded-md border-0 bg-black/10 font-mono text-xs text-foreground shadow-none focus-within:bg-black/15 focus-within:ring-0 dark:bg-black/20 dark:focus-within:bg-black/25 [&_[data-slot=input]]:text-right"
538 id={`${role}-hex`}
539 nativeInput
540 onChange={(event) => onChange(role, event.currentTarget.value)}
541 onFocus={() => onSelect?.(role)}
542 onPointerDown={() => onSelect?.(role)}
543 size="sm"
544 unstyled
545 value={value}
546 />
547 </div>
548 </div>
549 );
550});
apps/web/src/components/settings/ThemeEditorHost.tsx created+114
...@@ -0,0 +1,114 @@
1import { useCallback } from "react";
2
3import { useTheme } from "../../hooks/useTheme";
4import { getThemeDefinition, type ThemeAppearance, type ThemeDefinition } from "../../themePalette";
5import { stackedThreadToast, toastManager } from "../ui/toast";
6import { ThemeEditorPanel } from "./ThemeEditorPanel";
7import { useThemeEditorStore } from "./themeEditorStore";
8
9/**
10 * Renders the theme editor above the router. The editor paints its draft on
11 * the live app, so it has to outlive the settings route: the point is to walk
12 * through threads, panels, and pages while the colors are being tuned.
13 */
14export function ThemeEditorHost() {
15 const session = useThemeEditorStore((store) => store.session);
16 const closeThemeEditor = useThemeEditorStore((store) => store.closeThemeEditor);
17 const { theme, setTheme, themeHalves, refreshTheme } = useTheme();
18
19 // The panel reports which path it actually took: a theme removed while its
20 // editor is open resolves to null there, so the save becomes a create even
21 // though the session still names it.
22 const handleSaved = useCallback(
23 (
24 savedTheme: ThemeDefinition,
25 { created, mergedAppearance }: { created: boolean; mergedAppearance?: ThemeAppearance },
26 ) => {
27 // A merge completed an existing theme's light/dark pair; activating the
28 // whole theme shows the new palette right away.
29 if (mergedAppearance) {
30 if (!setTheme(savedTheme.id)) {
31 toastManager.add(
32 stackedThreadToast({
33 type: "error",
34 title: "Could not save your theme",
35 description: "Browser storage is unavailable, so the change was not kept.",
36 }),
37 );
38 return false;
39 }
40 toastManager.add(
41 stackedThreadToast({
42 type: "success",
43 title: `${savedTheme.label} updated`,
44 description: `Its ${mergedAppearance} palette was added.`,
45 }),
46 );
47 return true;
48 }
49 if (!created) {
50 // The edited theme may be showing through the base preference or either
51 // half of the mix; the preference itself is untouched (a setTheme here
52 // would clear the mix), the palette just needs re-applying.
53 const wasActive =
54 getThemeDefinition(theme)?.id === savedTheme.id ||
55 themeHalves?.light === savedTheme.id ||
56 themeHalves?.dark === savedTheme.id;
57 if (wasActive) refreshTheme();
58 toastManager.add(
59 stackedThreadToast({
60 type: "success",
61 title: `${savedTheme.label} saved`,
62 description: wasActive ? "Your changes are now active." : "Your changes are saved.",
63 }),
64 );
65 return true;
66 }
67
68 if (!setTheme(savedTheme.id)) {
69 toastManager.add(
70 stackedThreadToast({
71 type: "error",
72 title: "Could not save your theme",
73 description: "Browser storage is unavailable, so the change was not kept.",
74 }),
75 );
76 return false;
77 }
78 toastManager.add(
79 stackedThreadToast({
80 type: "success",
81 title: `${savedTheme.label} created`,
82 description: "It’s now active.",
83 }),
84 );
85 return true;
86 },
87 [refreshTheme, setTheme, theme, themeHalves],
88 );
89
90 if (!session) return null;
91
92 // Resolve on every render: an edit or import can change the stored
93 // definitions while a session is open.
94 const editingTheme = session.editingThemeId
95 ? (getThemeDefinition(session.editingThemeId) ?? null)
96 : null;
97 const seedTheme = session.seedThemeId ? (getThemeDefinition(session.seedThemeId) ?? null) : null;
98
99 return (
100 <ThemeEditorPanel
101 editingTheme={editingTheme}
102 initialAppearance={session.initialAppearance}
103 key={session.id}
104 onOpenChange={(open) => {
105 if (!open) closeThemeEditor();
106 }}
107 onSaved={handleSaved}
108 open
109 restoreTheme={refreshTheme}
110 seedName={session.seedName ?? undefined}
111 seedTheme={seedTheme}
112 />
113 );
114}
apps/web/src/components/settings/ThemeEditorPanel.tsx created+1143
...@@ -0,0 +1,1143 @@
1import { ChevronDownIcon, ChevronUpIcon, MousePointer2Icon, PlusIcon, XIcon } from "lucide-react";
2import {
3 useCallback,
4 useEffect,
5 useRef,
6 useState,
7 type PointerEvent as ReactPointerEvent,
8} from "react";
9import {
10 applyThemeColorPreview,
11 THEME_COLOR_ROLES,
12 THEME_FILE_VERSION,
13 createVividThemeColors,
14 getCustomThemes,
15 getStandardThemeColors,
16 getThemeColorsForMode,
17 getThemeModes,
18 installCustomTheme,
19 isThemeColor,
20 parseThemeFile,
21 removeCustomTheme,
22 themeIdFromName,
23 updateCustomTheme,
24 type ThemeAppearance,
25 type ThemeColorRole,
26 type ThemeDefinition,
27} from "../../themePalette";
28import { cn } from "../../lib/utils";
29import { Button } from "../ui/button";
30import { Input } from "../ui/input";
31import { Switch } from "../ui/switch";
32import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
33import { getThemeRoleLabel, ThemeColorField } from "./ThemeColorPicker";
34import {
35 clearThemeInspectorHover,
36 clearThemeInspectorHighlights,
37 highlightThemeRoleUsage,
38 inspectThemeRoleAtElement,
39 inspectThemeRoleFromUtilitiesAtElement,
40 refreshThemeInspectorSpotlight,
41 showThemeInspectorHover,
42 type ThemeElementInspection,
43} from "./themeInspector";
44
45const THEME_EDITOR_PRIMARY_ROLES: ReadonlyArray<ThemeColorRole> = [
46 "canvas",
47 "chrome",
48 "sidebar",
49 "surface",
50 "text",
51 "textMuted",
52 "placeholder",
53 "secondaryLabel",
54 "iconMuted",
55 "accent",
56 "messageSurface",
57 "messageAction",
58];
59
60const THEME_EDITOR_SIMPLE_ROLES: ReadonlyArray<ThemeColorRole> = ["canvas", "accent"];
61
62const THEME_EDITOR_STATUS_ROLES: ReadonlyArray<ThemeColorRole> = [
63 "error",
64 "errorForeground",
65 "errorSurface",
66 "warning",
67 "warningForeground",
68 "warningSurface",
69 "update",
70 "updateForeground",
71 "updateSurface",
72];
73
74const THEME_EDITOR_ADVANCED_ROLES = THEME_COLOR_ROLES.filter(
75 (role) => !THEME_EDITOR_PRIMARY_ROLES.includes(role) && !THEME_EDITOR_STATUS_ROLES.includes(role),
76);
77
78const THEME_EDITOR_ROLE_GROUPS: ReadonlyArray<{
79 id: string;
80 title: string;
81 roles: ReadonlyArray<ThemeColorRole>;
82}> = [
83 {
84 id: "main",
85 title: "Main colors",
86 roles: THEME_EDITOR_PRIMARY_ROLES,
87 },
88 {
89 id: "status",
90 title: "Status colors",
91 roles: THEME_EDITOR_STATUS_ROLES,
92 },
93 {
94 id: "additional",
95 title: "Other colors",
96 roles: THEME_EDITOR_ADVANCED_ROLES,
97 },
98];
99
100type ThemeEditorColors = Record<ThemeColorRole, string>;
101type ThemeEditorColorsByAppearance = Record<ThemeAppearance, ThemeEditorColors>;
102
103// A draft with no source theme starts as the standard T3 Code look — the
104// palette on screen when no theme is installed — so creating from the default
105// theme changes nothing until the user edits a color.
106function getThemeEditorDefaults(appearance: ThemeAppearance): ThemeEditorColors {
107 return { ...getStandardThemeColors(appearance) };
108}
109
110function getThemeEditorColorsByAppearance(): ThemeEditorColorsByAppearance {
111 return {
112 light: getThemeEditorDefaults("light"),
113 dark: getThemeEditorDefaults("dark"),
114 };
115}
116
117function isThemeEditorColor(value: string): boolean {
118 return isThemeColor(value.trim());
119}
120
121function getManagedEditorColors(
122 appearance: ThemeAppearance,
123 colors: ThemeEditorColors,
124): ThemeEditorColors {
125 const defaults = getStandardThemeColors(appearance);
126 // The editor keeps the user's exact picks and derives the rest through the
127 // perceptual vivid engine, so a two-color theme carries its own identity.
128 return createVividThemeColors(
129 appearance,
130 isThemeEditorColor(colors.canvas) ? colors.canvas : defaults.canvas,
131 isThemeEditorColor(colors.accent) ? colors.accent : defaults.accent,
132 );
133}
134
135export function ThemeEditorPanel({
136 open,
137 onOpenChange,
138 onSaved,
139 editingTheme,
140 initialAppearance,
141 seedTheme,
142 seedName,
143 restoreTheme,
144}: {
145 open: boolean;
146 onOpenChange: (open: boolean) => void;
147 onSaved: (
148 theme: ThemeDefinition,
149 context: {
150 created: boolean;
151 /** Set when a create merged its palette into an existing theme. */
152 mergedAppearance?: ThemeAppearance;
153 },
154 ) => boolean;
155 editingTheme: ThemeDefinition | null;
156 initialAppearance: ThemeAppearance;
157 /** The theme a new theme starts from, so tuning what you already use is a
158 * matter of editing rather than rebuilding. Null starts from the defaults. */
159 seedTheme?: ThemeDefinition | null;
160 /** Prefilled name for an explicit duplicate; a plain create stays unnamed. */
161 seedName?: string | undefined;
162 /** Reapplies the stored theme once the draft stops being previewed. */
163 restoreTheme: () => void;
164}) {
165 const isEditing = editingTheme !== null;
166 const [name, setName] = useState("");
167 const [activeAppearance, setActiveAppearance] = useState<ThemeAppearance>(initialAppearance);
168 const [isAdvanced, setIsAdvanced] = useState(false);
169 const [colorsByAppearance, setColorsByAppearance] = useState<ThemeEditorColorsByAppearance>(() =>
170 getThemeEditorColorsByAppearance(),
171 );
172 const [simpleColorsDirtyByAppearance, setSimpleColorsDirtyByAppearance] = useState<
173 Record<ThemeAppearance, boolean>
174 >({ light: false, dark: false });
175 const [error, setError] = useState<string | null>(null);
176 const [isMinimized, setIsMinimized] = useState(false);
177 const [roleQuery, setRoleQuery] = useState("");
178 const [isInspecting, setIsInspecting] = useState(false);
179 const [selectedRole, setSelectedRole] = useState<ThemeColorRole | null>(null);
180 const [usageCount, setUsageCount] = useState<number | null>(null);
181 // Null parks the panel at its default corner; a value is a dragged spot,
182 // kept clamped so the header can always be grabbed again.
183 const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
184 // Null keeps the responsive default size; a value is a corner-grip resize.
185 const [size, setSize] = useState<{ width: number; height: number } | null>(null);
186 const panelRef = useRef<HTMLDivElement>(null);
187 const dragOffsetRef = useRef<{ dx: number; dy: number } | null>(null);
188 const resizeStartRef = useRef<{
189 pointerX: number;
190 pointerY: number;
191 // Where the panel's top-left sits: the grip only moves the opposite
192 // corner, so the room to grow is measured from here.
193 left: number;
194 top: number;
195 width: number;
196 height: number;
197 } | null>(null);
198 useEffect(() => {
199 if (!open) return;
200 // A panel sized wider than the window can no longer be clamped back into
201 // view by position alone -- its right edge (close, minimize, the grip)
202 // stays off screen. So the size shrinks to fit first, then the position
203 // is re-clamped against the new size.
204 const clamp = () => {
205 const margin = 8;
206 let clampedWidth: number | undefined;
207 let clampedHeight: number | undefined;
208 setSize((current) => {
209 if (!current) return current;
210 clampedWidth = Math.max(280, Math.min(current.width, window.innerWidth - margin * 2));
211 clampedHeight = Math.max(220, Math.min(current.height, window.innerHeight - margin * 2));
212 return { width: clampedWidth, height: clampedHeight };
213 });
214 setPosition((current) => {
215 if (!current) return current;
216 const clamped = clampPosition(current.x, current.y, clampedWidth);
217 // Dragging may park the panel with only its header showing, but a
218 // window resize should pull the whole thing back into view when it
219 // fits -- otherwise the grip ends up below the fold. Minimized, the
220 // stored height is not applied (the panel hugs its header), so the
221 // rendered height is what has to fit.
222 const height = isMinimized
223 ? (panelRef.current?.offsetHeight ?? 0)
224 : (clampedHeight ?? panelRef.current?.offsetHeight ?? 0);
225 const maxY = Math.max(margin, window.innerHeight - height - margin);
226 return { x: clamped.x, y: Math.min(clamped.y, maxY) };
227 });
228 };
229 window.addEventListener("resize", clamp);
230 return () => window.removeEventListener("resize", clamp);
231 // oxlint-disable-next-line exhaustive-deps -- clampPosition reads live layout only.
232 }, [isMinimized, open]);
233
234 // The draft only reaches the live app once this open has been seeded;
235 // previewing in the seeding commit would paint the previous session's
236 // colors for a frame.
237 const [isDraftSeeded, setIsDraftSeeded] = useState(false);
238 const previousOpenRef = useRef(false);
239
240 useEffect(() => {
241 if (open && !previousOpenRef.current) {
242 // Editing works on the theme itself; creating starts from the theme
243 // that is currently in use, so tuning what you already run is an edit
244 // away instead of a rebuild from the defaults.
245 const sourceTheme = editingTheme ?? seedTheme ?? null;
246 const nextColors = getThemeEditorColorsByAppearance();
247 const nextAppearance = sourceTheme
248 ? getThemeColorsForMode(sourceTheme, initialAppearance)
249 ? initialAppearance
250 : sourceTheme.appearance
251 : initialAppearance;
252 if (sourceTheme) {
253 nextColors[sourceTheme.appearance] = { ...sourceTheme.colors };
254 for (const appearance of ["light", "dark"] as const) {
255 const variantColors = sourceTheme.variants?.[appearance];
256 if (variantColors) nextColors[appearance] = { ...variantColors };
257 }
258 }
259
260 setName(editingTheme?.label ?? seedName ?? "");
261 setActiveAppearance(nextAppearance);
262 // Themes saved by the guided editor carry the managed flag; anything
263 // else (imports, hand-edited files, older saves) opens in advanced mode
264 // so guided regeneration cannot silently discard hand-tuned colors. A
265 // seeded new theme follows the same rule: its palette is only safe to
266 // regenerate when the guided editor produced it.
267 setIsAdvanced(sourceTheme !== null && sourceTheme.managed !== true);
268 setSimpleColorsDirtyByAppearance({ light: false, dark: false });
269 setColorsByAppearance(nextColors);
270 setSelectedRole(null);
271 setUsageCount(null);
272 setIsInspecting(false);
273 setError(null);
274 setIsDraftSeeded(true);
275 }
276 if (!open && isDraftSeeded) setIsDraftSeeded(false);
277 previousOpenRef.current = open;
278 }, [editingTheme, initialAppearance, isDraftSeeded, open, seedName, seedTheme]);
279
280 // A name an installed theme already uses combines instead of failing:
281 // creating adds the new palette to that theme, and renaming an existing
282 // theme onto it folds the edited palette in and retires the old entry —
283 // light "My Theme" plus a dark "My Theme" become one theme with both modes.
284 // Labels are matched as well as derived ids: a rename keeps a theme's
285 // original id, so its label is the only name a user can see and retype.
286 const nameTargetId = themeIdFromName(name);
287 const normalizedName = name.trim().toLowerCase();
288 const mergeTarget =
289 normalizedName === ""
290 ? null
291 : (getCustomThemes().find(
292 (theme) =>
293 theme.id !== editingTheme?.id &&
294 (theme.id === nameTargetId || theme.label.trim().toLowerCase() === normalizedName),
295 ) ?? null);
296 const takenAppearances = mergeTarget ? getThemeModes(mergeTarget) : [];
297 const editableAppearances = editingTheme ? getThemeModes(editingTheme) : null;
298
299 // The appearance a mode button would produce can be blocked two ways: the
300 // merge target already has that palette, or the theme being edited never
301 // had it (adding one is a create-with-same-name away).
302 const appearanceLockReason = (appearance: ThemeAppearance): string | null => {
303 if (editableAppearances && !editableAppearances.includes(appearance)) {
304 return `“${editingTheme?.label}” has no ${appearance} palette. Create a theme with the same name to add one.`;
305 }
306 if (!isEditing && takenAppearances.includes(appearance)) {
307 return `“${mergeTarget?.label}” already has a ${appearance} palette.`;
308 }
309 return null;
310 };
311
312 // Typing a name whose theme already owns the selected appearance flips the
313 // draft to the free side, so the merge affordance works without a manual
314 // toggle. Both sides taken leaves the selection alone; save is blocked with
315 // an explanation instead.
316 const mergeTargetId = mergeTarget?.id ?? null;
317 const takenAppearancesKey = takenAppearances.join(",");
318 useEffect(() => {
319 if (isEditing || mergeTargetId === null) return;
320 const taken = takenAppearancesKey.split(",").filter(Boolean) as ThemeAppearance[];
321 if (taken.length !== 1) return;
322 setActiveAppearance((current) => {
323 if (!taken.includes(current)) return current;
324 return taken[0] === "light" ? "dark" : "light";
325 });
326 }, [isEditing, mergeTargetId, takenAppearancesKey]);
327
328 // The whole app wears the draft while the editor is open, so a role change
329 // is judged on the real interface rather than a miniature. The stored theme
330 // comes back when the editor closes, including on cancel.
331 useEffect(() => {
332 if (!open || !isDraftSeeded) return;
333 applyThemeColorPreview(colorsByAppearance[activeAppearance], activeAppearance);
334 }, [activeAppearance, colorsByAppearance, isDraftSeeded, open]);
335
336 useEffect(() => {
337 if (!open) return;
338 return () => {
339 restoreTheme();
340 };
341 }, [open, restoreTheme]);
342
343 const updateColor = useCallback(
344 (role: ThemeColorRole, value: string) => {
345 setColorsByAppearance((current) => {
346 const nextColors = { ...current[activeAppearance], [role]: value };
347 const shouldManageColors =
348 !isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(role) && isThemeEditorColor(value);
349
350 return {
351 ...current,
352 [activeAppearance]: shouldManageColors
353 ? getManagedEditorColors(activeAppearance, nextColors)
354 : nextColors,
355 };
356 });
357 if (!isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(role) && isThemeEditorColor(value)) {
358 setSimpleColorsDirtyByAppearance((current) => ({
359 ...current,
360 [activeAppearance]: true,
361 }));
362 }
363 },
364 [activeAppearance, isAdvanced],
365 );
366
367 const selectThemeRole = useCallback((role: ThemeColorRole, reveal = false) => {
368 setSelectedRole(role);
369 if (!THEME_EDITOR_SIMPLE_ROLES.includes(role)) {
370 setIsAdvanced(true);
371 setRoleQuery("");
372 }
373 if (!reveal) return;
374
375 requestAnimationFrame(() => {
376 panelRef.current
377 ?.querySelector(`[data-theme-color-role="${role}"]`)
378 ?.scrollIntoView({ behavior: "smooth", block: "nearest" });
379 });
380 }, []);
381
382 const toggleThemeRole = useCallback((role: ThemeColorRole) => {
383 setSelectedRole((current) => (current === role ? null : role));
384 }, []);
385
386 const clearInspectorSelection = useCallback(() => {
387 setSelectedRole(null);
388 setUsageCount(null);
389 setIsInspecting(false);
390 }, []);
391
392 const selectedHighlightRoles = selectedRole
393 ? !isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(selectedRole)
394 ? THEME_COLOR_ROLES.filter(
395 (role) =>
396 colorsByAppearance[activeAppearance][role].trim().toLowerCase() ===
397 colorsByAppearance[activeAppearance][selectedRole].trim().toLowerCase(),
398 )
399 : [selectedRole]
400 : [];
401 const selectedHighlightRolesKey = selectedHighlightRoles.join(",");
402
403 useEffect(() => {
404 clearThemeInspectorHighlights();
405 if (!open || selectedRole === null) {
406 setUsageCount(null);
407 return;
408 }
409 // Picking a new element needs the unobscured app, so suspend the existing
410 // spotlight while the picker is armed.
411 if (isInspecting) return;
412
413 const highlightedRoles = selectedHighlightRolesKey.split(",") as Array<ThemeColorRole>;
414 const refreshHighlights = () => setUsageCount(highlightThemeRoleUsage(highlightedRoles));
415 refreshHighlights();
416 // A refresh snapshots computed styles for the whole tree twice, so it is
417 // throttled rather than run per frame: a streaming reply or a virtualized
418 // list mutates the DOM continuously and would otherwise stall the main
419 // thread for as long as the inspector is open.
420 const MIN_REFRESH_INTERVAL_MS = 500;
421 let refreshFrame: number | null = null;
422 let refreshTimer: ReturnType<typeof setTimeout> | null = null;
423 let lastRefreshAt = performance.now();
424 const scheduleRefresh = () => {
425 if (refreshFrame !== null || refreshTimer !== null) return;
426 const wait = Math.max(0, MIN_REFRESH_INTERVAL_MS - (performance.now() - lastRefreshAt));
427 const run = () => {
428 refreshFrame = null;
429 refreshTimer = null;
430 lastRefreshAt = performance.now();
431 refreshHighlights();
432 };
433 if (wait === 0) refreshFrame = requestAnimationFrame(run);
434 else refreshTimer = setTimeout(run, wait);
435 };
436 const observer = new MutationObserver((mutations) => {
437 if (
438 mutations.every(
439 (mutation) =>
440 mutation.target instanceof Element &&
441 (mutation.target.closest("#theme-inspector-spotlight") ||
442 mutation.target.closest("[data-theme-editor-panel]")),
443 )
444 ) {
445 return;
446 }
447 scheduleRefresh();
448 });
449 observer.observe(document.body, { childList: true, subtree: true });
450 let spotlightFrame: number | null = null;
451 const scheduleSpotlightRefresh = () => {
452 spotlightFrame ??= requestAnimationFrame(() => {
453 spotlightFrame = null;
454 refreshThemeInspectorSpotlight();
455 });
456 };
457 window.addEventListener("resize", scheduleSpotlightRefresh);
458 window.addEventListener("scroll", scheduleSpotlightRefresh, true);
459 return () => {
460 observer.disconnect();
461 if (refreshFrame !== null) cancelAnimationFrame(refreshFrame);
462 if (refreshTimer !== null) clearTimeout(refreshTimer);
463 if (spotlightFrame !== null) cancelAnimationFrame(spotlightFrame);
464 window.removeEventListener("resize", scheduleSpotlightRefresh);
465 window.removeEventListener("scroll", scheduleSpotlightRefresh, true);
466 clearThemeInspectorHighlights();
467 };
468 }, [isInspecting, open, selectedHighlightRolesKey, selectedRole]);
469
470 useEffect(() => {
471 if (!open || !isInspecting) {
472 clearThemeInspectorHover();
473 return;
474 }
475
476 let shouldDisarmAfterClick = false;
477 let hoverTarget: Element | null = null;
478 let hoverInspection: ThemeElementInspection | null = null;
479 let hoverTimer: number | null = null;
480 let hoverFrame: number | null = null;
481 const clearHoverTimer = () => {
482 if (hoverTimer === null) return;
483 window.clearTimeout(hoverTimer);
484 hoverTimer = null;
485 };
486 const clearHover = () => {
487 clearHoverTimer();
488 hoverTarget = null;
489 hoverInspection = null;
490 clearThemeInspectorHover();
491 };
492 const showInspection = (inspection: ThemeElementInspection) => {
493 hoverInspection = inspection;
494 showThemeInspectorHover(inspection, getThemeRoleLabel(inspection.role));
495 };
496 const handlePointerOver = (event: PointerEvent) => {
497 const target = event.target;
498 if (!(target instanceof Element) || target.closest("[data-theme-editor-panel]")) {
499 clearHover();
500 return;
501 }
502
503 clearHoverTimer();
504 hoverTarget = target;
505 hoverInspection = null;
506 const utilityInspection = inspectThemeRoleFromUtilitiesAtElement(target);
507 if (utilityInspection) {
508 showInspection(utilityInspection);
509 return;
510 }
511
512 clearThemeInspectorHover();
513 hoverTimer = window.setTimeout(() => {
514 hoverTimer = null;
515 if (hoverTarget !== target || !target.isConnected) return;
516 const inspection = inspectThemeRoleAtElement(target);
517 if (inspection) showInspection(inspection);
518 }, 140);
519 };
520 const handlePointerOut = (event: PointerEvent) => {
521 if (event.relatedTarget === null) clearHover();
522 };
523 const handlePointerDown = (event: PointerEvent) => {
524 const target = event.target;
525 if (!(target instanceof Element) || target.closest("[data-theme-editor-panel]")) return;
526 event.preventDefault();
527 event.stopPropagation();
528 clearHoverTimer();
529 const inspection =
530 hoverTarget === target && hoverInspection
531 ? hoverInspection
532 : inspectThemeRoleAtElement(target);
533 if (!inspection) return;
534 clearHover();
535 selectThemeRole(inspection.role, true);
536 shouldDisarmAfterClick = true;
537 };
538 const blockInspectedClick = (event: MouseEvent) => {
539 const target = event.target;
540 if (!(target instanceof Element) || target.closest("[data-theme-editor-panel]")) return;
541 event.preventDefault();
542 event.stopPropagation();
543 if (shouldDisarmAfterClick) setIsInspecting(false);
544 shouldDisarmAfterClick = false;
545 };
546 const cancelInspection = (event: KeyboardEvent) => {
547 if (event.key !== "Escape") return;
548 event.preventDefault();
549 clearHover();
550 clearInspectorSelection();
551 };
552 const refreshHover = () => {
553 if (!hoverInspection) return;
554 hoverFrame ??= requestAnimationFrame(() => {
555 hoverFrame = null;
556 if (hoverInspection) {
557 showThemeInspectorHover(hoverInspection, getThemeRoleLabel(hoverInspection.role));
558 }
559 });
560 };
561 const clearHoverOnScroll = () => clearHover();
562
563 document.addEventListener("pointerover", handlePointerOver, true);
564 document.addEventListener("pointerout", handlePointerOut, true);
565 document.addEventListener("pointerdown", handlePointerDown, true);
566 document.addEventListener("click", blockInspectedClick, true);
567 document.addEventListener("keydown", cancelInspection, true);
568 window.addEventListener("resize", refreshHover);
569 window.addEventListener("scroll", clearHoverOnScroll, true);
570 return () => {
571 document.removeEventListener("pointerover", handlePointerOver, true);
572 document.removeEventListener("pointerout", handlePointerOut, true);
573 document.removeEventListener("pointerdown", handlePointerDown, true);
574 document.removeEventListener("click", blockInspectedClick, true);
575 document.removeEventListener("keydown", cancelInspection, true);
576 window.removeEventListener("resize", refreshHover);
577 window.removeEventListener("scroll", clearHoverOnScroll, true);
578 clearHoverTimer();
579 if (hoverFrame !== null) cancelAnimationFrame(hoverFrame);
580 clearThemeInspectorHover();
581 };
582 }, [clearInspectorSelection, isInspecting, open, selectThemeRole]);
583
584 const handleAdvancedChange = useCallback(
585 (checked: boolean) => {
586 setIsAdvanced(checked);
587 if (checked) return;
588 if (selectedRole && !THEME_EDITOR_SIMPLE_ROLES.includes(selectedRole)) {
589 setSelectedRole(null);
590 }
591
592 // Regenerate every appearance the theme will save, not just the visible
593 // one, so the palettes shown after toggling match what gets saved.
594 const managedAppearances: ReadonlyArray<ThemeAppearance> =
595 editingTheme && getThemeModes(editingTheme).length > 1
596 ? ["light", "dark"]
597 : [activeAppearance];
598 setSimpleColorsDirtyByAppearance((current) => {
599 const next = { ...current };
600 for (const appearance of managedAppearances) next[appearance] = true;
601 return next;
602 });
603 setColorsByAppearance((current) => {
604 const next = { ...current };
605 for (const appearance of managedAppearances) {
606 next[appearance] = getManagedEditorColors(appearance, current[appearance]);
607 }
608 return next;
609 });
610 },
611 [activeAppearance, editingTheme, selectedRole],
612 );
613
614 const handleSubmit = useCallback(() => {
615 if (!name.trim()) {
616 setError("Name your theme first.");
617 return;
618 }
619
620 try {
621 // Only regenerate palettes the user actually touched in guided mode, so
622 // untouched appearances save exactly what the editor displayed.
623 const colorsForSave = !isAdvanced
624 ? {
625 light: simpleColorsDirtyByAppearance.light
626 ? getManagedEditorColors("light", colorsByAppearance.light)
627 : colorsByAppearance.light,
628 dark: simpleColorsDirtyByAppearance.dark
629 ? getManagedEditorColors("dark", colorsByAppearance.dark)
630 : colorsByAppearance.dark,
631 }
632 : colorsByAppearance;
633
634 let savedTheme: ThemeDefinition;
635 let mergedAppearance: ThemeAppearance | null = null;
636 let retiredTheme: ThemeDefinition | null = null;
637 if (editingTheme && mergeTarget) {
638 // Renamed onto another installed theme: this theme's palettes fold
639 // into it and the edited entry retires, so both cards become one.
640 // Colliding palettes cannot merge — neither side should be silently
641 // overwritten.
642 const editedModes = getThemeModes(editingTheme);
643 const collision = editedModes.find((mode) => takenAppearances.includes(mode));
644 if (collision) {
645 setError(`“${mergeTarget.label}” already has a ${collision} palette. Pick another name.`);
646 return;
647 }
648 mergedAppearance = editedModes[0] ?? null;
649 savedTheme = updateCustomTheme(
650 parseThemeFile({
651 version: THEME_FILE_VERSION,
652 id: mergeTarget.id,
653 name: mergeTarget.label,
654 appearance: mergeTarget.appearance,
655 colors: mergeTarget.colors,
656 variants: {
657 ...mergeTarget.variants,
658 ...Object.fromEntries(editedModes.map((mode) => [mode, colorsForSave[mode]])),
659 },
660 ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}),
661 }),
662 );
663 retiredTheme = editingTheme;
664 try {
665 removeCustomTheme(editingTheme.id);
666 } catch (cause) {
667 // The merge already persisted. Leaving it while the edited theme
668 // survives would collide on every retry, so the target goes back to
669 // its pre-merge palettes before the failure surfaces.
670 try {
671 updateCustomTheme(mergeTarget);
672 } catch {
673 // Storage is failing wholesale; the rethrow below reports it.
674 }
675 throw cause;
676 }
677 } else if (editingTheme) {
678 const baseAppearance = editingTheme.appearance;
679 const variantAppearance = baseAppearance === "light" ? "dark" : "light";
680 savedTheme = updateCustomTheme(
681 parseThemeFile({
682 version: THEME_FILE_VERSION,
683 id: editingTheme.id,
684 name,
685 appearance: baseAppearance,
686 colors: colorsForSave[baseAppearance],
687 ...(getThemeModes(editingTheme).length > 1
688 ? { variants: { [variantAppearance]: colorsForSave[variantAppearance] } }
689 : {}),
690 ...(isAdvanced ? {} : { managed: true }),
691 }),
692 );
693 } else if (mergeTarget) {
694 if (takenAppearances.includes(activeAppearance)) {
695 setError(
696 `“${mergeTarget.label}” already has light and dark palettes. Pick another name.`,
697 );
698 return;
699 }
700 // The new palette joins the existing theme as its other mode; its
701 // stored palettes are untouched. The guided (managed) flag only
702 // survives when every palette in the theme came from the guided
703 // editor.
704 mergedAppearance = activeAppearance;
705 savedTheme = updateCustomTheme(
706 parseThemeFile({
707 version: THEME_FILE_VERSION,
708 id: mergeTarget.id,
709 name: mergeTarget.label,
710 appearance: mergeTarget.appearance,
711 colors: mergeTarget.colors,
712 variants: {
713 ...mergeTarget.variants,
714 [activeAppearance]: colorsForSave[activeAppearance],
715 },
716 ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}),
717 }),
718 );
719 } else {
720 savedTheme = installCustomTheme(
721 parseThemeFile({
722 version: THEME_FILE_VERSION,
723 name,
724 appearance: activeAppearance,
725 colors: colorsForSave[activeAppearance],
726 ...(isAdvanced ? {} : { managed: true }),
727 }),
728 );
729 }
730 if (
731 !onSaved(savedTheme, {
732 created: editingTheme === null && mergedAppearance === null,
733 ...(mergedAppearance ? { mergedAppearance } : {}),
734 })
735 ) {
736 if (!editingTheme && mergedAppearance === null) {
737 // Roll the install back so a retry can run it again instead of
738 // failing on the already-taken theme id.
739 try {
740 removeCustomTheme(savedTheme.id);
741 } catch {
742 // Storage is failing wholesale; the error below covers it.
743 }
744 } else if (mergeTarget && mergedAppearance !== null) {
745 // Put the pre-merge definitions back for the same reason.
746 try {
747 updateCustomTheme(mergeTarget);
748 if (retiredTheme) installCustomTheme(retiredTheme);
749 } catch {
750 // Storage is failing wholesale; the error below covers it.
751 }
752 }
753 setError("Theme saved, but it could not be made active. Try again.");
754 return;
755 }
756 onOpenChange(false);
757 } catch (cause) {
758 setError(
759 cause instanceof Error
760 ? cause.message
761 : isEditing
762 ? "Could not save the theme."
763 : "Could not create the theme.",
764 );
765 }
766 }, [
767 activeAppearance,
768 colorsByAppearance,
769 editingTheme,
770 isAdvanced,
771 isEditing,
772 mergeTarget,
773 name,
774 onOpenChange,
775 onSaved,
776 simpleColorsDirtyByAppearance,
777 takenAppearances,
778 ]);
779
780 const renderNameField = () => (
781 <label className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] items-center gap-3">
782 <span className="text-sm font-medium">Theme name</span>
783 <Input
784 autoFocus
785 onChange={(event) => {
786 setName(event.currentTarget.value);
787 // Most save failures are name collisions; retyping is the fix, so
788 // the stale message goes with the old name.
789 setError(null);
790 }}
791 placeholder={isEditing ? "Theme name" : "e.g. Aurora"}
792 value={name}
793 />
794 </label>
795 );
796
797 const renderAppearanceButton = (appearance: ThemeAppearance) => {
798 const isActive = activeAppearance === appearance;
799 const lockReason = appearanceLockReason(appearance);
800 // A locked mode stays hoverable so the tooltip can say why it is off;
801 // a real disabled attribute would swallow the pointer events.
802 const button = (
803 <Button
804 aria-disabled={lockReason !== null}
805 aria-pressed={isActive}
806 className={lockReason !== null ? "opacity-50" : undefined}
807 style={isActive ? { boxShadow: "inset 0 0 0 1px var(--ring)" } : undefined}
808 variant={isActive ? "secondary" : "outline"}
809 onClick={() => {
810 if (lockReason === null) setActiveAppearance(appearance);
811 }}
812 >
813 {appearance === "light" ? "Light" : "Dark"}
814 </Button>
815 );
816 if (lockReason === null) return button;
817 return (
818 <Tooltip>
819 <TooltipTrigger render={button} />
820 <TooltipPopup>{lockReason}</TooltipPopup>
821 </Tooltip>
822 );
823 };
824
825 const renderAppearanceButtons = () => (
826 <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] items-center gap-3">
827 <span className="text-sm font-medium">Appearance</span>
828 <div aria-label="Theme appearance" className="grid grid-cols-2 gap-2" role="group">
829 {renderAppearanceButton("light")}
830 {renderAppearanceButton("dark")}
831 </div>
832 </div>
833 );
834
835 const renderColorsHeader = () => (
836 <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] items-start gap-3">
837 <div>
838 <h3 className="text-sm font-medium">Colors</h3>
839 {isAdvanced ? null : (
840 <p className="text-xs text-muted-foreground">Two colors, rest derived</p>
841 )}
842 </div>
843 <div className="flex min-w-0 items-start gap-3">
844 {isAdvanced ? (
845 <Input
846 aria-label="Filter colors"
847 className="min-w-0 flex-1"
848 onChange={(event) => setRoleQuery(event.currentTarget.value)}
849 placeholder="Filter colors"
850 size="sm"
851 value={roleQuery}
852 />
853 ) : null}
854 <label className="ml-auto flex shrink-0 cursor-pointer items-center gap-2 pt-0.5 text-sm font-medium">
855 <span>Advanced</span>
856 <Switch
857 aria-label="Use advanced theme colors"
858 checked={isAdvanced}
859 onCheckedChange={(checked) => handleAdvancedChange(Boolean(checked))}
860 />
861 </label>
862 </div>
863 </div>
864 );
865
866 const renderRoleFields = (
867 roles: ReadonlyArray<ThemeColorRole>,
868 gridClassName = "grid gap-2 sm:grid-cols-2",
869 ) => (
870 <div className={gridClassName}>
871 {roles.map((role) => (
872 <ThemeColorField
873 key={role}
874 onChange={updateColor}
875 onSelect={selectThemeRole}
876 onToggleSelected={toggleThemeRole}
877 role={role}
878 selected={selectedRole === role}
879 value={colorsByAppearance[activeAppearance][role]}
880 />
881 ))}
882 </div>
883 );
884
885 const renderColorFields = () => {
886 const query = roleQuery.trim().toLowerCase();
887 const groups = THEME_EDITOR_ROLE_GROUPS.map((group) => ({
888 ...group,
889 roles: group.roles.filter(
890 (role) => !query || getThemeRoleLabel(role).toLowerCase().includes(query),
891 ),
892 })).filter((group) => group.roles.length > 0);
893 return isAdvanced ? (
894 <div className="space-y-5">
895 {groups.map((group) => (
896 <section className="space-y-2" key={group.id}>
897 <h4 className="text-sm font-medium text-foreground">{group.title}</h4>
898 {renderRoleFields(group.roles, "grid gap-1")}
899 </section>
900 ))}
901 {groups.length === 0 ? <p className="text-xs text-muted-foreground">No matches.</p> : null}
902 </div>
903 ) : (
904 <div className="grid gap-1">
905 {THEME_EDITOR_SIMPLE_ROLES.map((role) => (
906 <ThemeColorField
907 key={role}
908 onChange={updateColor}
909 onSelect={selectThemeRole}
910 onToggleSelected={toggleThemeRole}
911 role={role}
912 label={role === "canvas" ? "Background" : "Accent"}
913 selected={selectedRole === role}
914 value={colorsByAppearance[activeAppearance][role]}
915 />
916 ))}
917 </div>
918 );
919 };
920
921 const clampPosition = (x: number, y: number, widthOverride?: number) => {
922 const panel = panelRef.current;
923 const margin = 8;
924 // The caller passes a width when it has just shrunk the panel: the DOM
925 // still reports the old one until React commits.
926 const width = widthOverride ?? panel?.offsetWidth ?? 0;
927 return {
928 x: Math.min(Math.max(x, margin), Math.max(margin, window.innerWidth - width - margin)),
929 // Keep at least the header on screen even when dragged far down.
930 y: Math.min(Math.max(y, margin), Math.max(margin, window.innerHeight - 48)),
931 };
932 };
933
934 const handleDragPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
935 // Buttons in the header keep their own behavior.
936 if ((event.target as HTMLElement).closest("button, input, a")) return;
937 const rect = panelRef.current?.getBoundingClientRect();
938 if (!rect) return;
939 dragOffsetRef.current = { dx: event.clientX - rect.x, dy: event.clientY - rect.y };
940 event.currentTarget.setPointerCapture(event.pointerId);
941 };
942
943 const handleDragPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
944 const offset = dragOffsetRef.current;
945 if (!offset) return;
946 setPosition(clampPosition(event.clientX - offset.dx, event.clientY - offset.dy));
947 };
948
949 const endDrag = () => {
950 dragOffsetRef.current = null;
951 };
952
953 const handleResizePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
954 const rect = panelRef.current?.getBoundingClientRect();
955 if (!rect) return;
956 event.preventDefault();
957 // The grip drags the bottom-right corner, so the top-left must hold
958 // still; the default parking spot is anchored bottom-right and would
959 // slide, so it converts to an explicit position first.
960 if (position === null) setPosition(clampPosition(rect.x, rect.y));
961 resizeStartRef.current = {
962 pointerX: event.clientX,
963 pointerY: event.clientY,
964 left: rect.x,
965 top: rect.y,
966 width: rect.width,
967 height: rect.height,
968 };
969 event.currentTarget.setPointerCapture(event.pointerId);
970 };
971
972 const handleResizePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
973 const start = resizeStartRef.current;
974 if (!start) return;
975 const margin = 8;
976 const MIN_WIDTH = 280;
977 const MIN_HEIGHT = 220;
978 // Grow only into the space right of and below the panel's own corner,
979 // otherwise a panel parked away from the top-left pushes its far edges
980 // (and this grip) off screen.
981 const maxWidth = Math.max(MIN_WIDTH, window.innerWidth - margin - start.left);
982 const maxHeight = Math.max(MIN_HEIGHT, window.innerHeight - margin - start.top);
983 setSize({
984 width: Math.min(Math.max(start.width + event.clientX - start.pointerX, MIN_WIDTH), maxWidth),
985 height: Math.min(
986 Math.max(start.height + event.clientY - start.pointerY, MIN_HEIGHT),
987 maxHeight,
988 ),
989 });
990 };
991
992 const endResize = () => {
993 resizeStartRef.current = null;
994 };
995
996 return (
997 <div
998 aria-label={isEditing ? "Edit theme" : "Create theme"}
999 className={cn(
1000 "dialog-glass fixed z-[110] flex max-h-[min(42rem,calc(100dvh-6rem))] w-[min(26rem,calc(100vw-2rem))] flex-col overflow-hidden rounded-xl border text-popover-foreground",
1001 position === null && "bottom-4 right-4",
1002 isMinimized && "max-h-none",
1003 )}
1004 data-theme-editor-panel
1005 ref={panelRef}
1006 role="dialog"
1007 style={{
1008 ...(position ? { left: position.x, top: position.y } : {}),
1009 ...(size ? { width: size.width } : {}),
1010 // A chosen height only applies expanded; minimized keeps hugging the
1011 // header. The viewport stays the ceiling either way.
1012 ...(size && !isMinimized ? { height: size.height, maxHeight: "calc(100dvh - 1rem)" } : {}),
1013 }}
1014 >
1015 <div
1016 className="flex cursor-grab touch-none select-none items-center gap-1 border-b border-border/70 px-3 py-2 active:cursor-grabbing"
1017 onPointerCancel={endDrag}
1018 onPointerDown={handleDragPointerDown}
1019 onPointerMove={handleDragPointerMove}
1020 onPointerUp={endDrag}
1021 >
1022 <div className="flex min-w-0 flex-1 items-baseline gap-2">
1023 <h2 className="shrink-0 truncate text-sm font-medium">
1024 {isEditing ? "Edit theme" : "Create theme"}
1025 </h2>
1026 {isMinimized ? null : (
1027 <p className="truncate text-xs text-muted-foreground">
1028 {isInspecting
1029 ? "Select an element · Esc to cancel"
1030 : selectedRole
1031 ? `${getThemeRoleLabel(selectedRole)} · ${usageCount ?? 0} ${usageCount === 1 ? "use" : "uses"}`
1032 : "Select a color below"}
1033 </p>
1034 )}
1035 </div>
1036 <Tooltip>
1037 <TooltipTrigger
1038 render={
1039 <Button
1040 aria-label={isInspecting ? "Cancel inspecting app colors" : "Inspect app colors"}
1041 aria-pressed={isInspecting}
1042 size="xs"
1043 variant={isInspecting ? "secondary" : "ghost"}
1044 onClick={() => {
1045 if (isInspecting) {
1046 clearInspectorSelection();
1047 return;
1048 }
1049 setIsInspecting(true);
1050 }}
1051 >
1052 <MousePointer2Icon />
1053 {isInspecting ? "Cancel" : "Inspect"}
1054 </Button>
1055 }
1056 />
1057 <TooltipPopup data-theme-editor-panel="">
1058 {isInspecting ? "Cancel and clear the selection" : "Pick a color from the app"}
1059 </TooltipPopup>
1060 </Tooltip>
1061 <Button
1062 aria-label={isMinimized ? "Expand the theme editor" : "Minimize the theme editor"}
1063 size="icon-xs"
1064 variant="ghost"
1065 onClick={() => setIsMinimized(!isMinimized)}
1066 >
1067 {isMinimized ? <ChevronUpIcon /> : <ChevronDownIcon />}
1068 </Button>
1069 <Button
1070 aria-label="Close the theme editor"
1071 size="icon-xs"
1072 variant="ghost"
1073 onClick={() => onOpenChange(false)}
1074 >
1075 <XIcon />
1076 </Button>
1077 </div>
1078
1079 {isMinimized ? null : (
1080 <>
1081 <div className="min-h-0 flex-1 space-y-3 overflow-y-auto py-3 pl-3 pr-1.5">
1082 {renderNameField()}
1083 {/* Inline and above the color list: the panel scrolls, and an
1084 error parked below every role would go unseen. */}
1085 {error ? (
1086 <p aria-live="polite" className="text-sm text-destructive">
1087 {error}
1088 </p>
1089 ) : null}
1090 {renderAppearanceButtons()}
1091 <div className="space-y-3">
1092 {renderColorsHeader()}
1093 {renderColorFields()}
1094 </div>
1095 </div>
1096 <div className="flex items-center justify-end gap-2 border-t border-border/70 px-3 py-2">
1097 <Button size="sm" variant="ghost" onClick={() => onOpenChange(false)}>
1098 Cancel
1099 </Button>
1100 <Button disabled={!name.trim()} size="sm" onClick={handleSubmit}>
1101 {isEditing ? (
1102 mergeTarget ? (
1103 `Merge into “${mergeTarget.label}”`
1104 ) : (
1105 "Save changes"
1106 )
1107 ) : mergeTarget ? (
1108 <>
1109 <PlusIcon />
1110 {`Add ${activeAppearance} palette`}
1111 </>
1112 ) : (
1113 <>
1114 <PlusIcon />
1115 Create theme
1116 </>
1117 )}
1118 </Button>
1119 </div>
1120 <div
1121 aria-hidden
1122 className="absolute bottom-0 right-0 z-10 flex size-5 cursor-se-resize touch-none select-none items-end justify-end p-1 text-muted-foreground/70"
1123 onPointerCancel={endResize}
1124 onPointerDown={handleResizePointerDown}
1125 onPointerMove={handleResizePointerMove}
1126 onPointerUp={endResize}
1127 >
1128 <svg
1129 className="size-2.5"
1130 fill="none"
1131 stroke="currentColor"
1132 strokeLinecap="round"
1133 strokeWidth="1.2"
1134 viewBox="0 0 8 8"
1135 >
1136 <path d="M7 1 1 7M7 4.5 4.5 7" />
1137 </svg>
1138 </div>
1139 </>
1140 )}
1141 </div>
1142 );
1143}
apps/web/src/components/settings/ThemeImportDialog.test.ts created+21
...@@ -0,0 +1,21 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { describeOversizedThemeFile, MAX_THEME_FILE_BYTES } from "./ThemeImportDialog";
4
5describe("theme import size guard", () => {
6 it("accepts anything a theme file could plausibly be", () => {
7 for (const bytes of [0, 4_096, MAX_THEME_FILE_BYTES]) {
8 expect(describeOversizedThemeFile(bytes)).toBeNull();
9 }
10 });
11
12 it("rejects a file too large to be a theme and names its size", () => {
13 const message = describeOversizedThemeFile(100 * 1024 * 1024);
14 expect(message).toContain("100.0 MB");
15 expect(message).toContain("256 KB");
16 });
17
18 it("reports sizes just past the limit in KB", () => {
19 expect(describeOversizedThemeFile(MAX_THEME_FILE_BYTES + 1)).toContain("256 KB");
20 });
21});
apps/web/src/components/settings/ThemeImportDialog.tsx created+537
...@@ -0,0 +1,537 @@
1import { PlusIcon, UploadIcon } from "lucide-react";
2import type { ChangeEvent, DragEvent, UIEvent } from "react";
3import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4import { cn } from "../../lib/utils";
5import {
6 getCustomThemes,
7 installCustomTheme,
8 parseThemeFile,
9 removeCustomTheme,
10 THEME_FILE_VERSION,
11 updateCustomTheme,
12 type ThemeDefinition,
13} from "../../themePalette";
14import {
15 humanizeThemeName,
16 isVsCodeThemeFile,
17 pairVsCodeThemes,
18 parseVsCodeThemeFile,
19 resolveThemeLabelCollisions,
20} from "../../vscodeThemeImport";
21import { Alert } from "../ui/alert";
22import { Button } from "../ui/button";
23import {
24 Dialog,
25 DialogFooter,
26 DialogHeader,
27 DialogPanel,
28 DialogPopup,
29 DialogTitle,
30} from "../ui/dialog";
31
32/**
33 * A full theme export is a few KB, so anything past this is not a theme file.
34 * The guard runs on the size before the bytes are ever read: a large file
35 * would otherwise be pulled into memory, highlighted, and rendered, which
36 * locks the UI for as long as that takes.
37 */
38export const MAX_THEME_FILE_BYTES = 256 * 1024;
39
40/** Highlighting rebuilds the whole markup on every keystroke, so oversized
41 * pastes fall back to plain text instead of freezing the editor. */
42const MAX_HIGHLIGHTED_JSON_LENGTH = 20_000;
43
44function formatByteSize(bytes: number): string {
45 if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
46 if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
47 return `${bytes} bytes`;
48}
49
50/** Returns the error to show for a file too large to be a theme, else null. */
51export function describeOversizedThemeFile(bytes: number): string | null {
52 if (bytes <= MAX_THEME_FILE_BYTES) return null;
53 return `That file is ${formatByteSize(bytes)}. Theme files are only a few KB, so this one was not read (limit ${formatByteSize(MAX_THEME_FILE_BYTES)}).`;
54}
55
56function escapeJsonHtml(value: string): string {
57 return value.replace(
58 /[&<>"']/g,
59 (character) =>
60 ({
61 "&": "&amp;",
62 "<": "&lt;",
63 ">": "&gt;",
64 '"': "&quot;",
65 "'": "&#39;",
66 })[character] ?? character,
67 );
68}
69
70function highlightJson(value: string): string {
71 const tokenPattern =
72 /"(?:\\.|[^"\\])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null/g;
73 let highlighted = "";
74 let cursor = 0;
75
76 for (const match of value.matchAll(tokenPattern)) {
77 const token = match[0];
78 const index = match.index ?? 0;
79 highlighted += escapeJsonHtml(value.slice(cursor, index));
80
81 let tokenClass = "theme-json-number";
82 if (token.startsWith('"')) {
83 tokenClass = /^\s*:/.test(value.slice(index + token.length))
84 ? "theme-json-key"
85 : "theme-json-string";
86 } else if (token === "true" || token === "false" || token === "null") {
87 tokenClass = "theme-json-constant";
88 }
89 highlighted += `<span class="${tokenClass}">${escapeJsonHtml(token)}</span>`;
90 cursor = index + token.length;
91 }
92
93 return highlighted + escapeJsonHtml(value.slice(cursor));
94}
95
96function ThemeJsonEditor({
97 id,
98 value,
99 onChange,
100}: {
101 id: string;
102 value: string;
103 onChange: (value: string) => void;
104}) {
105 const highlightRef = useRef<HTMLPreElement>(null);
106 const isPlainText = value.length > MAX_HIGHLIGHTED_JSON_LENGTH;
107 const highlightedJson = useMemo(
108 () => (value.length > MAX_HIGHLIGHTED_JSON_LENGTH ? "" : highlightJson(value)),
109 [value],
110 );
111
112 const syncScroll = useCallback((event: UIEvent<HTMLTextAreaElement>) => {
113 const highlightElement = highlightRef.current;
114 if (!highlightElement) return;
115 highlightElement.scrollTop = event.currentTarget.scrollTop;
116 highlightElement.scrollLeft = event.currentTarget.scrollLeft;
117 }, []);
118
119 return (
120 <div className="relative overflow-hidden rounded-xl border border-input bg-background shadow-xs/5 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/24">
121 {isPlainText ? null : (
122 <pre
123 ref={highlightRef}
124 aria-hidden
125 className="pointer-events-none absolute inset-0 m-0 overflow-hidden whitespace-pre-wrap break-words p-3 font-mono text-[12px] leading-5 text-foreground"
126 >
127 <code dangerouslySetInnerHTML={{ __html: highlightedJson }} />
128 </pre>
129 )}
130 <textarea
131 aria-label="Theme JSON"
132 className={cn(
133 "relative z-10 block min-h-72 w-full resize-y overflow-auto bg-transparent p-3 font-mono text-[12px] leading-5 caret-foreground outline-none placeholder:text-muted-foreground selection:bg-accent/30",
134 isPlainText ? "text-foreground" : "text-transparent selection:text-transparent",
135 )}
136 id={id}
137 onChange={(event) => onChange(event.currentTarget.value)}
138 onScroll={syncScroll}
139 placeholder={
140 '{\n "version": 1,\n "name": "Aurora",\n "appearance": "light",\n "colors": { ... }\n}'
141 }
142 spellCheck={false}
143 value={value}
144 />
145 </div>
146 );
147}
148
149/** What the import pipeline needs from a file; DOM File satisfies it. */
150type ImportableThemeFile = { name: string; size: number; text: () => Promise<string> };
151
152export function ThemeImportDialog({
153 open,
154 onOpenChange,
155 onImported,
156 onImportedMany,
157}: {
158 open: boolean;
159 onOpenChange: (open: boolean) => void;
160 onImported: (theme: ThemeDefinition) => boolean;
161 /** Batch imports install without activating; the caller reports them. */
162 onImportedMany: (themes: ReadonlyArray<ThemeDefinition>, context: { updated: boolean }) => void;
163}) {
164 const fileInputRef = useRef<HTMLInputElement>(null);
165 const [json, setJson] = useState("");
166 const [fileName, setFileName] = useState<string | null>(null);
167 const [error, setError] = useState<string | null>(null);
168 const [isReading, setIsReading] = useState(false);
169 const [isDropTarget, setIsDropTarget] = useState(false);
170 // Imports whose id is already installed wait here for an update-or-copy
171 // decision instead of failing.
172 const [conflicts, setConflicts] = useState<ReadonlyArray<ThemeDefinition> | null>(null);
173 const importRequestRef = useRef(0);
174
175 useEffect(() => {
176 importRequestRef.current += 1;
177 // Reset on close too: a dialog dismissed mid-drag would otherwise reopen
178 // still wearing the drop highlight.
179 setIsDropTarget(false);
180 if (!open) return;
181 setJson("");
182 setFileName(null);
183 setError(null);
184 setIsReading(false);
185 setConflicts(null);
186 }, [open]);
187
188 const readThemeFile = useCallback(async (file: ImportableThemeFile) => {
189 // Check the size first: reading a large file is what locks the UI, so it
190 // never gets read at all.
191 const oversized = describeOversizedThemeFile(file.size);
192 if (oversized) {
193 setError(oversized);
194 return;
195 }
196
197 const requestId = ++importRequestRef.current;
198 setIsReading(true);
199 try {
200 const fileText = await file.text();
201 if (requestId !== importRequestRef.current) return;
202 setJson(fileText);
203 setFileName(file.name);
204 setError(null);
205 } catch {
206 if (requestId !== importRequestRef.current) return;
207 setError("Could not read that file. Paste the JSON below instead.");
208 } finally {
209 if (requestId === importRequestRef.current) setIsReading(false);
210 }
211 }, []);
212
213 // Several files at once import as a batch: VS Code families pair their
214 // light and dark variants, everything installs without activating, and the
215 // single-file flow keeps filling the editor for review.
216 const readThemeBatch = useCallback(
217 async (files: ReadonlyArray<ImportableThemeFile>) => {
218 const requestId = ++importRequestRef.current;
219 setIsReading(true);
220 const failures: string[] = [];
221 const parsed: Array<{ theme: ThemeDefinition; sourceName: string }> = [];
222 try {
223 for (const file of files) {
224 const oversized = describeOversizedThemeFile(file.size);
225 if (oversized) {
226 failures.push(`${file.name}: too large`);
227 continue;
228 }
229 try {
230 const value: unknown = JSON.parse(await file.text());
231 parsed.push({
232 sourceName: file.name,
233 theme: isVsCodeThemeFile(value) ? parseVsCodeThemeFile(value) : parseThemeFile(value),
234 });
235 } catch (cause) {
236 failures.push(
237 `${file.name}: ${cause instanceof Error ? cause.message : "not a theme file"}`,
238 );
239 }
240 }
241 if (requestId !== importRequestRef.current) return;
242 const installed: ThemeDefinition[] = [];
243 const conflicting: ThemeDefinition[] = [];
244 for (const theme of pairVsCodeThemes(resolveThemeLabelCollisions(parsed))) {
245 if (getCustomThemes().some((existing) => existing.id === theme.id)) {
246 conflicting.push(theme);
247 continue;
248 }
249 try {
250 installed.push(installCustomTheme(theme));
251 } catch (cause) {
252 failures.push(
253 `${theme.label}: ${cause instanceof Error ? cause.message : "could not install"}`,
254 );
255 }
256 }
257 if (installed.length > 0) onImportedMany(installed, { updated: false });
258 if (failures.length > 0) {
259 setError(failures.join(" — "));
260 } else if (conflicting.length > 0) {
261 setConflicts(conflicting);
262 } else if (installed.length > 0) {
263 onOpenChange(false);
264 }
265 } finally {
266 if (requestId === importRequestRef.current) setIsReading(false);
267 }
268 },
269 [onImportedMany, onOpenChange],
270 );
271
272 const readThemeFiles = useCallback(
273 (files: ReadonlyArray<ImportableThemeFile>) => {
274 if (files.length === 0) return;
275 if (files.length === 1) void readThemeFile(files[0]!);
276 else void readThemeBatch(files);
277 },
278 [readThemeBatch, readThemeFile],
279 );
280
281 // On desktop the native picker opens in ~/.vscode/extensions (when it
282 // exists) and reads the files in the main process; the browser input is
283 // the fallback everywhere else.
284 const openFilePicker = useCallback(() => {
285 const bridge = window.desktopBridge;
286 if (bridge?.pickThemeFiles) {
287 void bridge.pickThemeFiles().then((picked) => {
288 if (!picked || picked.length === 0) return;
289 readThemeFiles(
290 picked.map((file) => ({
291 name: file.name,
292 size: file.size,
293 text: () => Promise.resolve(file.text),
294 })),
295 );
296 });
297 return;
298 }
299 fileInputRef.current?.click();
300 }, [readThemeFiles]);
301
302 const handleFileChange = useCallback(
303 (event: ChangeEvent<HTMLInputElement>) => {
304 const files = [...(event.currentTarget.files ?? [])];
305 event.currentTarget.value = "";
306 readThemeFiles(files);
307 },
308 [readThemeFiles],
309 );
310
311 const handleDrop = useCallback(
312 (event: DragEvent<HTMLDivElement>) => {
313 event.preventDefault();
314 setIsDropTarget(false);
315 readThemeFiles([...event.dataTransfer.files]);
316 },
317 [readThemeFiles],
318 );
319
320 /** Copy of an already-installed theme under the source file's name when
321 * that differs (Dracula Soft), else the next free "Name (1)". */
322 const versionedCopy = (
323 theme: ThemeDefinition,
324 preferredName?: string | null,
325 ): ThemeDefinition => {
326 if (preferredName && preferredName.toLowerCase() !== theme.label.toLowerCase()) {
327 const candidate = parseThemeFile({
328 version: THEME_FILE_VERSION,
329 name: preferredName.slice(0, 48),
330 appearance: theme.appearance,
331 colors: theme.colors,
332 ...(theme.variants ? { variants: theme.variants } : {}),
333 ...(theme.managed ? { managed: true } : {}),
334 });
335 if (!getCustomThemes().some((existing) => existing.id === candidate.id)) return candidate;
336 }
337 for (let copy = 1; copy < 100; copy += 1) {
338 const candidate = parseThemeFile({
339 version: THEME_FILE_VERSION,
340 name: `${theme.label.slice(0, 48 - ` (${copy})`.length)} (${copy})`,
341 appearance: theme.appearance,
342 colors: theme.colors,
343 ...(theme.variants ? { variants: theme.variants } : {}),
344 ...(theme.managed ? { managed: true } : {}),
345 });
346 if (getCustomThemes().some((existing) => existing.id === candidate.id)) continue;
347 return candidate;
348 }
349 throw new Error(`Too many copies of "${theme.label}".`);
350 };
351
352 const resolveConflicts = useCallback(
353 (mode: "update" | "copy") => {
354 if (!conflicts) return;
355 const resolved: ThemeDefinition[] = [];
356 const failures: string[] = [];
357 const preferredName =
358 conflicts.length === 1 && fileName
359 ? humanizeThemeName(fileName.replace(/\.[^.]+$/, ""))
360 : null;
361 for (const theme of conflicts) {
362 try {
363 resolved.push(
364 mode === "update"
365 ? updateCustomTheme(theme)
366 : installCustomTheme(versionedCopy(theme, preferredName)),
367 );
368 } catch (cause) {
369 failures.push(`${theme.label}: ${cause instanceof Error ? cause.message : "failed"}`);
370 }
371 }
372 if (resolved.length > 0) onImportedMany(resolved, { updated: mode === "update" });
373 setConflicts(null);
374 if (failures.length > 0) setError(failures.join(" — "));
375 else onOpenChange(false);
376 },
377 [conflicts, fileName, onImportedMany, onOpenChange],
378 );
379
380 const handleSubmit = useCallback(() => {
381 // Pasted text bypasses the file guard, so the same limit applies here.
382 const oversized = describeOversizedThemeFile(json.length);
383 if (oversized) {
384 setError(oversized);
385 return;
386 }
387 try {
388 const parsed: unknown = JSON.parse(json);
389 // VS Code themes are converted on the way in; anything else has to be
390 // one of our own files.
391 const theme = isVsCodeThemeFile(parsed)
392 ? parseVsCodeThemeFile(parsed)
393 : parseThemeFile(parsed);
394 if (getCustomThemes().some((existing) => existing.id === theme.id)) {
395 setError(null);
396 setConflicts([theme]);
397 return;
398 }
399 const installedTheme = installCustomTheme(theme);
400 if (!onImported(installedTheme)) {
401 // Roll the install back so a retry can run it again instead of
402 // failing on the already-taken theme id.
403 try {
404 removeCustomTheme(installedTheme.id);
405 } catch {
406 // Storage is failing wholesale; the error below covers it.
407 }
408 setError("Theme added, but it could not be selected. Try again.");
409 return;
410 }
411 onOpenChange(false);
412 } catch (cause) {
413 setError(cause instanceof Error ? cause.message : "That theme file is invalid.");
414 }
415 }, [json, onImported, onOpenChange]);
416
417 return (
418 <Dialog
419 open={open}
420 onOpenChange={(nextOpen) => {
421 if (!nextOpen) setError(null);
422 onOpenChange(nextOpen);
423 }}
424 >
425 <DialogPopup className="max-w-3xl overflow-hidden">
426 <DialogHeader>
427 <DialogTitle>Add a theme</DialogTitle>
428 </DialogHeader>
429 <DialogPanel className="space-y-4">
430 {(() => {
431 const dropHandlers = {
432 onDragEnter: (event: DragEvent<HTMLDivElement>) => {
433 event.preventDefault();
434 setIsDropTarget(true);
435 },
436 onDragOver: (event: DragEvent<HTMLDivElement>) => {
437 event.preventDefault();
438 setIsDropTarget(true);
439 },
440 onDragLeave: (event: DragEvent<HTMLDivElement>) => {
441 // Ignore moves between children of the drop zone.
442 if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
443 setIsDropTarget(false);
444 },
445 onDrop: handleDrop,
446 };
447 const fileInput = (
448 <input
449 ref={fileInputRef}
450 accept=".json,application/json"
451 className="sr-only"
452 onChange={handleFileChange}
453 multiple
454 type="file"
455 />
456 );
457 const chooseButton = (label = "Choose files") => (
458 <Button disabled={isReading} size="sm" variant="outline" onClick={openFilePicker}>
459 <UploadIcon />
460 {isReading ? "Reading…" : label}
461 </Button>
462 );
463 const editorSection = () => (
464 <div className="space-y-2">
465 <div className="flex items-baseline justify-between gap-3">
466 <label className="text-sm font-medium" htmlFor="theme-json-editor">
467 Theme JSON
468 </label>
469 </div>
470 <ThemeJsonEditor id="theme-json-editor" onChange={setJson} value={json} />
471 </div>
472 );
473 if (conflicts) {
474 return (
475 <div className="space-y-3">
476 <div className="rounded-xl border border-border/70 bg-muted/20 p-3">
477 <p className="text-sm font-medium">Already installed</p>
478 <p className="mt-1 text-xs text-muted-foreground">
479 {conflicts.map((theme) => theme.label).join(", ")}
480 </p>
481 </div>
482 <div className="flex flex-wrap items-center gap-2">
483 <Button size="sm" onClick={() => resolveConflicts("update")}>
484 Update existing
485 </Button>
486 <Button size="sm" variant="outline" onClick={() => resolveConflicts("copy")}>
487 Keep both
488 </Button>
489 <Button size="sm" variant="ghost" onClick={() => setConflicts(null)}>
490 Back
491 </Button>
492 </div>
493 </div>
494 );
495 }
496 return (
497 <div className="space-y-4">
498 <div
499 className={cn(
500 "flex flex-wrap items-center justify-between gap-3 rounded-xl border border-dashed px-3 py-3 transition-colors",
501 isDropTarget ? "border-ring bg-accent/20" : "border-border/80 bg-muted/20",
502 )}
503 {...dropHandlers}
504 >
505 <div className="min-w-0">
506 <p className="text-sm font-medium">Theme file</p>
507 <p className="truncate text-xs text-muted-foreground">
508 {fileName ?? "Drop T3 Code or VS Code .json files"}
509 </p>
510 </div>
511 {chooseButton()}
512 {fileInput}
513 </div>
514 {editorSection()}
515 </div>
516 );
517 })()}
518
519 {error ? (
520 <Alert aria-live="polite" variant="error">
521 {error}
522 </Alert>
523 ) : null}
524 </DialogPanel>
525 <DialogFooter>
526 <Button variant="ghost" onClick={() => onOpenChange(false)}>
527 Cancel
528 </Button>
529 <Button disabled={!json.trim() || isReading} onClick={handleSubmit}>
530 <PlusIcon />
531 Add theme
532 </Button>
533 </DialogFooter>
534 </DialogPopup>
535 </Dialog>
536 );
537}
apps/web/src/components/settings/ThemePreviewCircles.tsx created+224
...@@ -0,0 +1,224 @@
1import { MoonIcon, SunIcon } from "lucide-react";
2import type { CSSProperties } from "react";
3import { cn } from "../../lib/utils";
4import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
5import {
6 getThemeColorsForMode,
7 getThemeModes,
8 type ThemeAppearance,
9 type ThemeDefinition,
10} from "../../themePalette";
11
12const THEME_PREVIEW_ROLES = [
13 "sidebar",
14 "canvas",
15 "surface",
16 "accentSurface",
17 "accent",
18 "messageSurface",
19 "messageAction",
20] as const;
21type ThemePreviewRole = (typeof THEME_PREVIEW_ROLES)[number];
22type ThemeCardPreview = {
23 mode: ThemeAppearance;
24 colors: Readonly<Record<ThemePreviewRole, string>>;
25};
26export type ThemeCardDefinition = {
27 id: string;
28 label: string;
29 previews: ReadonlyArray<ThemeCardPreview>;
30};
31export type ThemeMode = ThemeAppearance | "system";
32export type ThemeCardPreviewColors = ThemeCardPreview["colors"];
33
34const STANDARD_THEME_PREVIEW_COLORS: Record<
35 ThemeAppearance,
36 Readonly<Record<ThemePreviewRole, string>>
37> = {
38 light: {
39 sidebar: "#fafafa",
40 canvas: "#fcfcfc",
41 surface: "#ffffff",
42 accentSurface: "#f4f4f5",
43 accent: "#f4f4f5",
44 messageSurface: "#e4e4e7",
45 messageAction: "#4f46e5",
46 },
47 dark: {
48 sidebar: "#0f0f10",
49 canvas: "#0a0a0a",
50 surface: "#121212",
51 accentSurface: "#27272a",
52 accent: "#1c1c1f",
53 messageSurface: "#27272a",
54 messageAction: "#8b9cff",
55 },
56};
57
58export const STANDARD_THEME_CARDS: ReadonlyArray<ThemeCardDefinition> = [
59 {
60 id: "default",
61 label: "T3 Code",
62 previews: (["light", "dark"] as const).map((mode) => ({
63 mode,
64 colors: STANDARD_THEME_PREVIEW_COLORS[mode],
65 })),
66 },
67];
68
69export function previewColorsOf(
70 card: ThemeCardDefinition,
71 mode: ThemeAppearance,
72): ThemeCardPreviewColors | null {
73 return card.previews.find((preview) => preview.mode === mode)?.colors ?? null;
74}
75
76export function getThemeCardDefinition(theme: ThemeDefinition): ThemeCardDefinition {
77 return {
78 id: theme.id,
79 label: theme.label,
80 previews: getThemeModes(theme).map((mode) => {
81 const colors = getThemeColorsForMode(theme, mode) ?? theme.colors;
82 return {
83 mode,
84 colors: {
85 sidebar: colors.sidebar,
86 canvas: colors.canvas,
87 surface: colors.surface,
88 accentSurface: colors.accentSurface,
89 accent: colors.accent,
90 messageSurface: colors.messageSurface,
91 messageAction: colors.messageAction,
92 },
93 };
94 }),
95 };
96}
97
98// Interpolating in oklab keeps the glow falloff perceptually even (no gray
99// mid-tones or banding rings), and premultiplied alpha keeps the fade to
100// transparent clean.
101function getThemePreviewStyle(
102 colors: ThemeCardPreviewColors,
103 mode: ThemeAppearance,
104): CSSProperties {
105 const isDark = mode === "dark";
106 // The canvas carries the ball's light/dark identity, so it stays dominant:
107 // a near-true base with a contained accent glow, instead of an accent wash
108 // that makes both modes read alike.
109 const modeBase = isDark
110 ? `color-mix(in oklab, ${colors.canvas} 80%, #09090b)`
111 : `color-mix(in oklab, ${colors.canvas} 80%, #ffffff)`;
112 const accentPosition = isDark ? "28% 78%" : "72% 22%";
113 const actionPosition = isDark ? "82% 18%" : "18% 82%";
114 const accentFade = isDark ? 62 : 72;
115 return {
116 backgroundColor: modeBase,
117 backgroundImage: [
118 `radial-gradient(circle at ${accentPosition} in oklab, ${colors.accent} 0%, color-mix(in oklab, ${colors.accent} ${accentFade}%, transparent) 28%, transparent 58%)`,
119 // The action color is a soft tint from the opposite corner, not a second
120 // light source — two bright hotspots read as headlights.
121 `radial-gradient(circle at ${actionPosition} in oklab, color-mix(in oklab, ${colors.messageAction} 45%, transparent) 0%, transparent 55%)`,
122 ].join(", "),
123 };
124}
125
126// The gradient halves of each ball can match the card surface, so every ball
127// carries a faint mode-appropriate inner ring to keep its silhouette legible.
128function themePreviewEdgeShadow(mode: ThemeAppearance): string {
129 return mode === "dark"
130 ? "inset 0 0 0 1px rgb(255 255 255 / 0.14), 0 1px 2px rgb(0 0 0 / 0.18)"
131 : "inset 0 0 0 1px rgb(0 0 0 / 0.10), 0 1px 2px rgb(0 0 0 / 0.08)";
132}
133
134export function ThemePreviewCircle({
135 colors,
136 mode,
137}: {
138 colors: ThemeCardPreviewColors;
139 mode: ThemeAppearance;
140}) {
141 return (
142 <span
143 aria-hidden
144 className="relative block size-14 shrink-0 overflow-hidden rounded-full border-2 border-background"
145 style={{ boxShadow: themePreviewEdgeShadow(mode) }}
146 >
147 <span
148 className="absolute inset-0 scale-110 rounded-full blur-[3px]"
149 style={getThemePreviewStyle(colors, mode)}
150 />
151 </span>
152 );
153}
154
155/**
156 * A theme card's light and dark balls. Clicking a ball assigns that theme to
157 * that half of the appearance mix; assigned balls carry a ring and a sun or
158 * moon badge.
159 */
160export function ThemePreviewCircles({
161 label,
162 activeModes,
163 onSelectMode,
164 previews,
165}: {
166 label: string;
167 activeModes: ReadonlyArray<ThemeMode>;
168 onSelectMode: (mode: ThemeMode) => void;
169 previews: ThemeCardDefinition["previews"];
170}) {
171 return (
172 <div className="flex min-h-16 items-center justify-center gap-2.5 px-3 pt-3">
173 {previews.map((preview) => {
174 const mode = preview.mode;
175 const isPicked = activeModes.includes(mode);
176 return (
177 <Tooltip key={mode}>
178 <TooltipTrigger
179 render={
180 <button
181 aria-label={`Use ${label} ${mode} mode`}
182 aria-pressed={isPicked}
183 className={cn(
184 "relative flex size-[68px] shrink-0 transform-gpu cursor-pointer items-center justify-center rounded-full p-1 outline-none transition-transform hover:scale-105 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card",
185 isPicked && "hover:scale-100",
186 )}
187 onClick={(event) => {
188 event.stopPropagation();
189 onSelectMode(mode);
190 }}
191 type="button"
192 >
193 <ThemePreviewCircle colors={preview.colors} mode={mode} />
194 {isPicked ? (
195 <>
196 <span
197 aria-hidden
198 className="pointer-events-none absolute inset-0 rounded-full"
199 style={{ boxShadow: "inset 0 0 0 2px var(--ring)" }}
200 />
201 <span
202 aria-hidden
203 className="pointer-events-none absolute bottom-0.5 right-0.5 flex size-5 items-center justify-center rounded-full border border-border/70 bg-background text-foreground shadow-sm"
204 >
205 {mode === "light" ? (
206 <SunIcon className="size-3" />
207 ) : (
208 <MoonIcon className="size-3" />
209 )}
210 </span>
211 </>
212 ) : null}
213 </button>
214 }
215 />
216 <TooltipPopup>
217 {mode === "light" ? "Use for light mode only" : "Use for dark mode only"}
218 </TooltipPopup>
219 </Tooltip>
220 );
221 })}
222 </div>
223 );
224}
apps/web/src/components/settings/ThemeSettings.tsx created+658
...@@ -0,0 +1,658 @@
1import {
2 CopyIcon,
3 DownloadIcon,
4 PenLineIcon,
5 PlusIcon,
6 Trash2Icon,
7 UploadIcon,
8} from "lucide-react";
9import { useCallback, useEffect, useRef, useState } from "react";
10import { cn } from "../../lib/utils";
11import {
12 getThemeDefinition,
13 getThemeModes,
14 removeCustomTheme,
15 serializeThemeFile,
16 type ThemeAppearance,
17 type ThemeDefinition,
18 type ThemeHalves,
19 T3_CHAT_THEME,
20 EMBER_THEME,
21 GROVE_THEME,
22 IRIS_THEME,
23 OCEAN_THEME,
24} from "../../themePalette";
25import {
26 AlertDialog,
27 AlertDialogClose,
28 AlertDialogDescription,
29 AlertDialogFooter,
30 AlertDialogHeader,
31 AlertDialogPopup,
32 AlertDialogTitle,
33} from "../ui/alert-dialog";
34import { Button } from "../ui/button";
35import { stackedThreadToast, toastManager } from "../ui/toast";
36import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
37import { ThemeImportDialog } from "./ThemeImportDialog";
38import { useThemeEditorStore } from "./themeEditorStore";
39import {
40 STANDARD_THEME_CARDS,
41 getThemeCardDefinition,
42 previewColorsOf,
43 ThemePreviewCircles,
44 type ThemeCardDefinition,
45 type ThemeMode,
46} from "./ThemePreviewCircles";
47import { ThemeWireframe } from "./ThemeWireframe";
48
49const MAINTAINER_THEMES: ReadonlyArray<ThemeDefinition> = [
50 T3_CHAT_THEME,
51 GROVE_THEME,
52 OCEAN_THEME,
53 EMBER_THEME,
54 IRIS_THEME,
55];
56
57function downloadThemeFile(filename: string, contents: string): void {
58 const url = URL.createObjectURL(new Blob([contents], { type: "application/json" }));
59 const anchor = document.createElement("a");
60 anchor.href = url;
61 anchor.download = filename;
62 anchor.click();
63 // Revoking synchronously can abort the download in some browsers; give the
64 // browser time to open the stream first.
65 setTimeout(() => URL.revokeObjectURL(url), 30_000);
66}
67
68function ThemeLibraryCard({
69 theme,
70 isActive,
71 onUse,
72 onUseMode,
73 activeModes,
74 onEdit,
75 onDuplicate,
76 onDownload,
77 onRemove,
78}: {
79 theme: ThemeCardDefinition;
80 isActive: boolean;
81 onUse: () => void;
82 onUseMode: (mode: ThemeMode) => void;
83 activeModes: ReadonlyArray<ThemeMode>;
84 onEdit?: () => void;
85 onDuplicate?: () => void;
86 onDownload?: () => void;
87 onRemove?: () => void;
88}) {
89 // A one-appearance theme can only take its own side of the mix, so the card
90 // tooltip promises exactly what clicking it does.
91 const cardModes = theme.previews.map((preview) => preview.mode);
92 return (
93 // The card surface stays a plain div (buttons cannot nest inside a button
94 // role); the title button and mode circles carry the accessible actions,
95 // while the card click is a pointer-only convenience.
96 <Tooltip>
97 <TooltipTrigger
98 render={
99 <div
100 className={cn(
101 "cursor-pointer overflow-hidden rounded-xl border border-border/70 bg-card/60 transition-colors hover:bg-accent/10",
102 isActive && "bg-accent/30",
103 )}
104 data-theme-library-card={theme.id}
105 onClick={onUse}
106 style={isActive ? { boxShadow: "inset 0 0 0 1px var(--ring)" } : undefined}
107 >
108 <ThemePreviewCircles
109 label={theme.label}
110 activeModes={activeModes}
111 onSelectMode={onUseMode}
112 previews={theme.previews}
113 />
114 <div className="flex items-center gap-2 px-3 pb-3 pt-2">
115 <div className="min-w-0 flex-1">
116 <div className="flex items-center gap-1.5">
117 <button
118 aria-label={`Use ${theme.label} theme${isActive ? ", currently active" : ""}`}
119 aria-pressed={isActive}
120 className="min-w-0 cursor-pointer truncate rounded-sm text-left text-sm font-medium text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
121 type="button"
122 onClick={(event) => {
123 event.stopPropagation();
124 onUse();
125 }}
126 >
127 {theme.label}
128 </button>
129 </div>
130 </div>
131 {onEdit || onDuplicate || onDownload || onRemove ? (
132 <div className="flex shrink-0 items-center gap-1">
133 {onDuplicate ? (
134 <Tooltip>
135 <TooltipTrigger
136 render={
137 <Button
138 aria-label={`Duplicate ${theme.label}`}
139 size="icon-xs"
140 variant="ghost"
141 onClick={(event) => {
142 event.stopPropagation();
143 onDuplicate();
144 }}
145 >
146 <CopyIcon />
147 </Button>
148 }
149 />
150 <TooltipPopup>Duplicate theme</TooltipPopup>
151 </Tooltip>
152 ) : null}
153 {onEdit ? (
154 <Tooltip>
155 <TooltipTrigger
156 render={
157 <Button
158 aria-label={`Edit ${theme.label}`}
159 size="icon-xs"
160 variant="ghost"
161 onClick={(event) => {
162 event.stopPropagation();
163 onEdit();
164 }}
165 >
166 <PenLineIcon />
167 </Button>
168 }
169 />
170 <TooltipPopup>Edit theme</TooltipPopup>
171 </Tooltip>
172 ) : null}
173 {onDownload ? (
174 <Tooltip>
175 <TooltipTrigger
176 render={
177 <Button
178 aria-label={`Export ${theme.label}`}
179 size="icon-xs"
180 variant="ghost"
181 onClick={(event) => {
182 event.stopPropagation();
183 onDownload();
184 }}
185 >
186 <DownloadIcon />
187 </Button>
188 }
189 />
190 <TooltipPopup>Export theme file</TooltipPopup>
191 </Tooltip>
192 ) : null}
193 {onRemove ? (
194 <Tooltip>
195 <TooltipTrigger
196 render={
197 <Button
198 aria-label={`Remove ${theme.label}`}
199 size="icon-xs"
200 variant="ghost"
201 className="text-muted-foreground hover:text-destructive"
202 onClick={(event) => {
203 event.stopPropagation();
204 onRemove();
205 }}
206 >
207 <Trash2Icon />
208 </Button>
209 }
210 />
211 <TooltipPopup>Remove theme</TooltipPopup>
212 </Tooltip>
213 ) : null}
214 </div>
215 ) : null}
216 </div>
217 </div>
218 }
219 />
220 <TooltipPopup>
221 {cardModes.length > 1 ? "Use for both light and dark" : `Use for ${cardModes[0]} mode only`}
222 </TooltipPopup>
223 </Tooltip>
224 );
225}
226
227export function ThemeLibrary({
228 theme,
229 setTheme,
230 appearanceMode,
231 setAppearanceMode,
232 customThemes,
233 initialAppearance,
234 refreshTheme,
235 isImportOpen,
236 onImportOpenChange,
237 themeHalves,
238 setThemeHalf,
239}: {
240 theme: string;
241 setTheme: (theme: string) => boolean;
242 appearanceMode: ThemeMode;
243 setAppearanceMode: (mode: ThemeMode) => boolean;
244 customThemes: ReadonlyArray<ThemeDefinition>;
245 initialAppearance: ThemeAppearance;
246 refreshTheme: () => void;
247 isImportOpen: boolean;
248 onImportOpenChange: (open: boolean) => void;
249 themeHalves: ThemeHalves | null;
250 setThemeHalf: (appearance: ThemeAppearance, themeId: string | null) => boolean;
251}) {
252 const openThemeEditor = useThemeEditorStore((store) => store.openThemeEditor);
253 const [themeToRemove, setThemeToRemove] = useState<ThemeDefinition | null>(null);
254 // Keep the last removal target so the dialog title stays populated while the
255 // close animation plays after confirming.
256 const lastThemeToRemoveRef = useRef<ThemeDefinition | null>(null);
257 useEffect(() => {
258 if (themeToRemove) lastThemeToRemoveRef.current = themeToRemove;
259 }, [themeToRemove]);
260 const removeDialogTheme = themeToRemove ?? lastThemeToRemoveRef.current;
261
262 const notifyThemeSaveFailure = useCallback(() => {
263 toastManager.add(
264 stackedThreadToast({
265 type: "error",
266 title: "Couldn’t save theme selection",
267 description: "Try again.",
268 }),
269 );
270 }, []);
271
272 const notifyThemeRemovalFailure = useCallback(() => {
273 toastManager.add(
274 stackedThreadToast({
275 type: "error",
276 title: "Couldn’t remove theme",
277 description: "Try again.",
278 }),
279 );
280 }, []);
281
282 const persistTheme = useCallback(
283 (nextTheme: string) => {
284 const didSave = setTheme(nextTheme);
285 if (!didSave) notifyThemeSaveFailure();
286 return didSave;
287 },
288 [notifyThemeSaveFailure, setTheme],
289 );
290
291 const handleRemoveTheme = useCallback((customTheme: ThemeDefinition) => {
292 setThemeToRemove(customTheme);
293 }, []);
294
295 const handleConfirmRemoveTheme = useCallback(() => {
296 if (!themeToRemove) return;
297 const removesBase = getThemeDefinition(theme)?.id === themeToRemove.id;
298 // Keep the theme installed if we cannot move the selection off it; the
299 // dialog stays open so the user can retry or cancel.
300 if (removesBase && !persistTheme(appearanceMode === "system" ? "system" : appearanceMode)) {
301 return;
302 }
303 for (const appearance of ["light", "dark"] as const) {
304 const half = themeHalves?.[appearance];
305 if (half === undefined) continue;
306 // Writing a base preference clears the whole mix, so halves that name a
307 // surviving theme are written back; halves on the removed theme fall
308 // back to the base.
309 const next = half === themeToRemove.id ? null : removesBase ? half : undefined;
310 if (next !== undefined && !setThemeHalf(appearance, next)) {
311 notifyThemeRemovalFailure();
312 return;
313 }
314 }
315 try {
316 removeCustomTheme(themeToRemove.id);
317 } catch {
318 notifyThemeRemovalFailure();
319 return;
320 }
321 setThemeToRemove(null);
322 }, [
323 appearanceMode,
324 notifyThemeRemovalFailure,
325 persistTheme,
326 setThemeHalf,
327 theme,
328 themeHalves,
329 themeToRemove,
330 ]);
331
332 // ----- Automatic-mode mixing -------------------------------------------
333 // The pair model: one theme owns light, one owns dark, and the global
334 // appearance mode (light / dark / auto) decides which is showing.
335 const baseCardId = getThemeDefinition(theme)?.id ?? null;
336 const lightOwner = themeHalves?.light ?? baseCardId;
337 const darkOwner = themeHalves?.dark ?? baseCardId;
338
339 const assignHalf = useCallback(
340 (appearance: ThemeAppearance, cardId: string | null) => {
341 const otherAppearance = appearance === "light" ? "dark" : "light";
342 // Picking the default over a themed base cannot be stored as a half:
343 // the base would still own that appearance. Convert the base into an
344 // explicit half on the other side so this side falls back to default.
345 if (cardId === null && baseCardId !== null) {
346 const otherOwner = themeHalves?.[otherAppearance] ?? baseCardId;
347 if (!persistTheme(appearanceMode === "system" ? "system" : appearanceMode)) return;
348 if (!setThemeHalf(otherAppearance, otherOwner)) {
349 // Best-effort rollback: restore the whole-theme selection rather
350 // than leaving the user with no theme at all.
351 setTheme(theme);
352 notifyThemeSaveFailure();
353 }
354 return;
355 }
356 if (!setThemeHalf(appearance, cardId)) {
357 notifyThemeSaveFailure();
358 }
359 },
360 [
361 appearanceMode,
362 baseCardId,
363 notifyThemeSaveFailure,
364 persistTheme,
365 setTheme,
366 setThemeHalf,
367 theme,
368 themeHalves,
369 ],
370 );
371
372 // "Create theme" starts from whatever is on screen for the appearance being
373 // edited, so tuning the theme you already use never means rebuilding it.
374 const activeThemeForAppearance =
375 getThemeDefinition((initialAppearance === "light" ? lightOwner : darkOwner) ?? "") ?? null;
376
377 const cardDefById = (id: string | null): ThemeCardDefinition => {
378 if (id === null) return STANDARD_THEME_CARDS[0]!;
379 const definition = getThemeDefinition(id);
380 return definition ? getThemeCardDefinition(definition) : STANDARD_THEME_CARDS[0]!;
381 };
382
383 const pickColors = (id: string | null, appearance: ThemeAppearance) => {
384 const card = cardDefById(id);
385 return previewColorsOf(card, appearance) ?? card.previews[0]!.colors;
386 };
387
388 const setMode = (mode: ThemeMode) => {
389 if (!setAppearanceMode(mode)) notifyThemeSaveFailure();
390 };
391
392 // ----- Wireframe tiles on top, two-ball cards below --------------------
393 const handlePairPick = (cardId: string | null) => (mode: ThemeMode) => {
394 if (mode === "system") return;
395 assignHalf(mode, cardId);
396 };
397
398 // Rings always show the effective owner of each appearance: an unpicked
399 // half belongs to the default card (a null owner), so a fresh install
400 // shows T3 Code selected instead of nothing.
401 const pickedModesFor = (cardId: string | null): ThemeMode[] => {
402 const rings: ThemeMode[] = [];
403 if (lightOwner === cardId) rings.push("light");
404 if (darkOwner === cardId) rings.push("dark");
405 return rings;
406 };
407
408 const wireframeColors = (appearance: ThemeAppearance) =>
409 pickColors(appearance === "light" ? lightOwner : darkOwner, appearance);
410
411 const renderWireframe = (mode: ThemeMode) => (
412 <ThemeWireframe
413 className="h-[8.75rem]"
414 panes={
415 mode === "system"
416 ? [
417 { clip: "left", colors: wireframeColors("light") },
418 { clip: "right", colors: wireframeColors("dark") },
419 ]
420 : [{ colors: wireframeColors(mode === "dark" ? "dark" : "light") }]
421 }
422 />
423 );
424
425 const renderModeTiles = () => (
426 <div
427 aria-label="Appearance mode"
428 className="mx-auto grid w-full max-w-[56rem] grid-cols-3 gap-3 px-3 sm:px-4"
429 role="group"
430 >
431 {(["system", "light", "dark"] as const).map((mode) => {
432 const isActive = appearanceMode === mode;
433 return (
434 <button
435 aria-label={mode === "system" ? "Follow the system appearance" : `Use ${mode} mode`}
436 aria-pressed={isActive}
437 className={cn(
438 "flex cursor-pointer flex-col items-stretch gap-1.5 rounded-xl border p-2 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
439 isActive
440 ? "border-transparent bg-accent/30"
441 : "border-border/70 bg-card/60 hover:bg-accent/10",
442 )}
443 key={mode}
444 style={isActive ? { boxShadow: "inset 0 0 0 1px var(--ring)" } : undefined}
445 onClick={() => setMode(mode)}
446 type="button"
447 >
448 {renderWireframe(mode)}
449 <span
450 className={cn(
451 "flex items-center justify-center text-xs font-medium",
452 isActive ? "text-foreground" : "text-muted-foreground",
453 )}
454 >
455 {mode === "system" ? "System" : mode === "light" ? "Light" : "Dark"}
456 </span>
457 </button>
458 );
459 })}
460 </div>
461 );
462
463 const renderPairGrid = () => (
464 // One shared provider so every tooltip in the grid hands off instantly to
465 // the next hovered trigger instead of stacking on top of it. The card
466 // tooltip briefly showing while crossing between a card's two circles is
467 // accepted — scoping the group tighter makes the handoffs feel sluggish.
468 <TooltipProvider>
469 <div
470 className="mx-auto grid w-full max-w-[56rem] gap-2 px-3 sm:px-4"
471 style={{ gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 17rem), 1fr))" }}
472 >
473 {STANDARD_THEME_CARDS.map((standardTheme) => (
474 <ThemeLibraryCard
475 activeModes={pickedModesFor(null)}
476 isActive={false}
477 key={standardTheme.id}
478 onUse={() => persistTheme(appearanceMode === "system" ? "system" : appearanceMode)}
479 onUseMode={handlePairPick(null)}
480 theme={standardTheme}
481 />
482 ))}
483 {MAINTAINER_THEMES.map((maintainerTheme) => {
484 const card = getThemeCardDefinition(maintainerTheme);
485 return (
486 <ThemeLibraryCard
487 activeModes={pickedModesFor(maintainerTheme.id)}
488 isActive={false}
489 key={maintainerTheme.id}
490 onDuplicate={() =>
491 openThemeEditor({
492 editingThemeId: null,
493 seedThemeId: maintainerTheme.id,
494 seedName: `${maintainerTheme.label} copy`,
495 initialAppearance,
496 })
497 }
498 onUse={() => persistTheme(maintainerTheme.id)}
499 onUseMode={handlePairPick(maintainerTheme.id)}
500 theme={card}
501 />
502 );
503 })}
504 {customThemes.map((customTheme) => {
505 const card = getThemeCardDefinition(customTheme);
506 return (
507 <ThemeLibraryCard
508 activeModes={pickedModesFor(customTheme.id)}
509 isActive={false}
510 key={customTheme.id}
511 onDuplicate={() =>
512 openThemeEditor({
513 editingThemeId: null,
514 seedThemeId: customTheme.id,
515 seedName: `${customTheme.label} copy`,
516 initialAppearance,
517 })
518 }
519 onEdit={() =>
520 openThemeEditor({
521 editingThemeId: customTheme.id,
522 seedThemeId: null,
523 seedName: null,
524 initialAppearance,
525 })
526 }
527 onDownload={() =>
528 downloadThemeFile(`${customTheme.id}.json`, serializeThemeFile(customTheme))
529 }
530 onRemove={() => handleRemoveTheme(customTheme)}
531 onUse={() => {
532 const modes = getThemeModes(customTheme);
533 if (modes.length === 1) assignHalf(modes[0]!, customTheme.id);
534 else persistTheme(customTheme.id);
535 }}
536 onUseMode={handlePairPick(customTheme.id)}
537 theme={card}
538 />
539 );
540 })}
541 </div>
542 </TooltipProvider>
543 );
544
545 return (
546 <div className="space-y-3">
547 <p className="px-3 text-[13px] leading-[1.45] text-muted-foreground/80 sm:px-4">
548 Choose how T3 Code looks. Use a built-in theme or make your own.
549 </p>
550 <h3 className="px-3 text-sm font-medium tracking-[-0.005em] text-foreground sm:px-4">
551 Color scheme
552 </h3>
553 {renderModeTiles()}
554 <div className="flex min-h-8 flex-wrap items-center justify-between gap-3 px-3 pt-2 sm:px-4">
555 <h3 className="text-sm font-medium tracking-[-0.005em] text-foreground">Themes</h3>
556 <div className="flex flex-wrap items-center justify-end gap-3">
557 <Button
558 className="h-7 rounded-md border border-border/70 bg-muted/30 px-2 text-xs font-medium text-foreground shadow-none hover:bg-accent/40"
559 size="xs"
560 variant="ghost"
561 onClick={() =>
562 openThemeEditor({
563 editingThemeId: null,
564 seedThemeId: activeThemeForAppearance?.id ?? null,
565 seedName: null,
566 initialAppearance,
567 })
568 }
569 >
570 <PlusIcon />
571 Create theme
572 </Button>
573 <Button size="xs" variant="ghost" onClick={() => onImportOpenChange(true)}>
574 <UploadIcon />
575 Import theme
576 </Button>
577 </div>
578 </div>
579 {renderPairGrid()}
580 <ThemeImportDialog
581 onImportedMany={(importedThemes, { updated }) => {
582 // An updated theme that is showing (as the base or either half)
583 // needs its palette re-applied.
584 if (
585 updated &&
586 importedThemes.some(
587 (imported) =>
588 getThemeDefinition(theme)?.id === imported.id ||
589 themeHalves?.light === imported.id ||
590 themeHalves?.dark === imported.id,
591 )
592 ) {
593 refreshTheme();
594 }
595 const verb = updated ? "updated" : "added";
596 toastManager.add(
597 stackedThreadToast({
598 type: "success",
599 title:
600 importedThemes.length === 1
601 ? `${importedThemes[0]!.label} ${verb}`
602 : `${importedThemes.length} themes ${verb}`,
603 description: importedThemes.map((imported) => imported.label).join(", "),
604 }),
605 );
606 }}
607 onImported={(importedTheme) => {
608 // Same rule as clicking the card: a one-appearance theme takes its
609 // side of the mix instead of becoming the base for both.
610 const modes = getThemeModes(importedTheme);
611 if (modes.length === 1) {
612 assignHalf(modes[0]!, importedTheme.id);
613 toastManager.add(
614 stackedThreadToast({
615 type: "success",
616 title: `${importedTheme.label} added`,
617 description: `It’s now your ${modes[0]!} theme.`,
618 }),
619 );
620 return true;
621 }
622 if (!persistTheme(importedTheme.id)) return false;
623 toastManager.add(
624 stackedThreadToast({
625 type: "success",
626 title: `${importedTheme.label} added`,
627 description: "It’s now active.",
628 }),
629 );
630 return true;
631 }}
632 onOpenChange={onImportOpenChange}
633 open={isImportOpen}
634 />
635 <AlertDialog
636 open={themeToRemove !== null}
637 onOpenChange={(open) => {
638 if (!open) setThemeToRemove(null);
639 }}
640 >
641 <AlertDialogPopup>
642 <AlertDialogHeader>
643 <AlertDialogTitle>Remove “{removeDialogTheme?.label}”?</AlertDialogTitle>
644 <AlertDialogDescription>
645 You can bring it back anytime by importing its JSON file.
646 </AlertDialogDescription>
647 </AlertDialogHeader>
648 <AlertDialogFooter>
649 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
650 <Button variant="destructive" onClick={handleConfirmRemoveTheme}>
651 Remove theme
652 </Button>
653 </AlertDialogFooter>
654 </AlertDialogPopup>
655 </AlertDialog>
656 </div>
657 );
658}
apps/web/src/components/settings/ThemeWireframe.tsx created+136
...@@ -0,0 +1,136 @@
1import { cn } from "../../lib/utils";
2import type { ThemeCardPreviewColors } from "./ThemePreviewCircles";
3
4// A simple miniature of the app: sidebar, a short conversation, the
5// composer, and the orchestrator panel floating over the interface as an
6// island with horizontal agent rows.
7export function ThemeWireframePane({
8 colors,
9 clip,
10}: {
11 colors: ThemeCardPreviewColors;
12 clip?: "left" | "right" | undefined;
13}) {
14 const line = "rgb(127 127 127 / 0.25)";
15 return (
16 <span
17 className="absolute inset-0"
18 style={
19 clip === undefined
20 ? undefined
21 : {
22 clipPath:
23 clip === "left"
24 ? "polygon(0 0, calc(50% - 1px) 0, calc(50% - 1px) 100%, 0 100%)"
25 : "polygon(calc(50% + 1px) 0, 100% 0, 100% 100%, calc(50% + 1px) 100%)",
26 }
27 }
28 >
29 <span className="absolute inset-0" style={{ backgroundColor: colors.canvas }} />
30 <span
31 className="absolute inset-y-0 left-0 w-[22%]"
32 style={{ backgroundColor: colors.sidebar, boxShadow: `inset -1px 0 0 ${line}` }}
33 />
34
35 {/* Sidebar: search, then thread rows */}
36 <span
37 className="absolute left-[3%] top-[8%] h-[8%] w-[16%] rounded-md"
38 style={{ backgroundColor: colors.surface, boxShadow: `inset 0 0 0 1px ${line}` }}
39 />
40 <span
41 className="absolute left-[3%] top-[22%] h-[7%] w-[16%] rounded-md"
42 style={{ backgroundColor: colors.accentSurface }}
43 />
44 <span
45 className="absolute left-[3%] top-[32%] h-[7%] w-[16%] rounded-md"
46 style={{ backgroundColor: colors.messageSurface, opacity: 0.7 }}
47 />
48 <span
49 className="absolute left-[3%] top-[42%] h-[7%] w-[16%] rounded-md"
50 style={{ backgroundColor: colors.messageSurface, opacity: 0.5 }}
51 />
52
53 {/* Conversation */}
54 <span
55 className="absolute right-[28%] top-[11%] h-[9%] w-[24%] rounded-lg"
56 style={{ backgroundColor: colors.messageSurface }}
57 />
58 <span
59 className="absolute left-[27%] top-[28%] h-[5%] w-[34%] rounded-sm"
60 style={{ backgroundColor: line }}
61 />
62 <span
63 className="absolute left-[27%] top-[38%] h-[5%] w-[26%] rounded-sm"
64 style={{ backgroundColor: line }}
65 />
66
67 {/* Composer */}
68 <span
69 className="absolute bottom-[8%] left-[26%] right-[6%] flex h-[15%] items-center justify-between rounded-md px-[2.5%]"
70 style={{
71 backgroundColor: colors.surface,
72 boxShadow: `inset 0 0 0 1px ${line}`,
73 }}
74 >
75 <span
76 className="block h-[26%] w-[34%] rounded-full"
77 style={{ backgroundColor: line, opacity: 0.7 }}
78 />
79 <span
80 className="block aspect-square h-[58%] rounded-full"
81 style={{ backgroundColor: colors.messageAction }}
82 />
83 </span>
84
85 {/* Orchestrator island floating over the composer */}
86 <span
87 className="absolute right-[5%] top-[8%] h-[46%] w-[20%] rounded-lg"
88 style={{
89 backgroundColor: colors.surface,
90 boxShadow: `inset 0 0 0 1px ${line}, 0 2px 5px rgb(0 0 0 / 0.14)`,
91 }}
92 >
93 {[0, 1, 2].map((row) => (
94 <span
95 className="absolute left-[11%] right-[11%] flex items-center gap-[5%]"
96 key={row}
97 style={{ top: `${10 + row * 30}%`, height: "20%" }}
98 >
99 <span
100 className="block aspect-square h-[26%] rounded-full"
101 style={{
102 backgroundColor:
103 row === 0 ? "#34d399" : row === 1 ? colors.messageAction : "#fbbf24",
104 opacity: 0.55,
105 }}
106 />
107 <span className="block h-[30%] w-[52%] rounded-sm" style={{ backgroundColor: line }} />
108 </span>
109 ))}
110 </span>
111 </span>
112 );
113}
114
115export function ThemeWireframe({
116 className,
117 panes,
118}: {
119 /** Sizing (height) for the frame; the pane geometry is percentage based. */
120 className?: string;
121 panes: ReadonlyArray<{ colors: ThemeCardPreviewColors; clip?: "left" | "right" }>;
122}) {
123 return (
124 <span
125 aria-hidden
126 className={cn(
127 "relative block w-full overflow-hidden rounded-lg border border-border/60",
128 className,
129 )}
130 >
131 {panes.map((pane) => (
132 <ThemeWireframePane clip={pane.clip} colors={pane.colors} key={pane.clip ?? "pane"} />
133 ))}
134 </span>
135 );
136}
apps/web/src/components/settings/settingsSearch.ts+11-1
...@@ -37,10 +37,20 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {...@@ -37,10 +37,20 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {
37 * here once instead of separately in the panel and the index.37 * here once instead of separately in the panel and the index.
38 */38 */
39export const SETTINGS_SEARCH_ITEMS = [39export const SETTINGS_SEARCH_ITEMS = [
40 {
41 id: "color-scheme",
42 title: "Color scheme",
43 to: "/settings/appearance",
44 // The scheme tiles sit at the top of the Appearance section.
45 targetId: "appearance",
46 },
40 {47 {
41 id: "theme",48 id: "theme",
42 title: "Theme",49 title: "Themes",
43 to: "/settings/appearance",50 to: "/settings/appearance",
51 // Theme cards live directly under the scheme tiles; the section is the
52 // stable scroll destination for both.
53 targetId: "appearance",
44 },54 },
45 {55 {
46 // Prefixed because the slider control already owns the `glass-opacity` id.56 // Prefixed because the slider control already owns the `glass-opacity` id.
apps/web/src/components/settings/themeEditorStore.test.ts created+40
...@@ -0,0 +1,40 @@
1import { afterEach, describe, expect, it } from "vite-plus/test";
2
3import { OCEAN_THEME_ID, T3_CHAT_THEME_ID } from "../../themePalette";
4import { toggleThemeEditorForTheme, useThemeEditorStore } from "./themeEditorStore";
5
6afterEach(() => {
7 useThemeEditorStore.getState().closeThemeEditor();
8});
9
10describe("toggleThemeEditorForTheme", () => {
11 it("opens a new editor seeded from the theme active for the current appearance", () => {
12 toggleThemeEditorForTheme({
13 theme: T3_CHAT_THEME_ID,
14 themeHalves: { dark: OCEAN_THEME_ID },
15 initialAppearance: "dark",
16 });
17
18 expect(useThemeEditorStore.getState().session).toMatchObject({
19 editingThemeId: null,
20 seedThemeId: OCEAN_THEME_ID,
21 seedName: null,
22 initialAppearance: "dark",
23 });
24 });
25
26 it("closes an open editor", () => {
27 toggleThemeEditorForTheme({
28 theme: T3_CHAT_THEME_ID,
29 themeHalves: null,
30 initialAppearance: "light",
31 });
32 toggleThemeEditorForTheme({
33 theme: T3_CHAT_THEME_ID,
34 themeHalves: null,
35 initialAppearance: "light",
36 });
37
38 expect(useThemeEditorStore.getState().session).toBeNull();
39 });
40});
apps/web/src/components/settings/themeEditorStore.ts created+68
...@@ -0,0 +1,68 @@
1import { create } from "zustand";
2
3import {
4 getThemeDefinition,
5 type ThemeAppearance,
6 type ThemeHalves,
7 type ThemePreference,
8} from "../../themePalette";
9
10/**
11 * The theme editor lives above the router so a draft survives navigation: the
12 * palette is painted on the live app, and judging it means browsing the app
13 * while the editor stays open. Themes are referenced by id because the stored
14 * definitions change underneath an open session (an edit, an import).
15 */
16export type ThemeEditorSessionInput = {
17 /** Set when editing an installed theme; null creates a new one. */
18 editingThemeId: string | null;
19 /** Theme a new theme starts from: the active one, or a duplicate target. */
20 seedThemeId: string | null;
21 /** Prefilled name, used by duplicate. */
22 seedName: string | null;
23 initialAppearance: ThemeAppearance;
24};
25
26export type ThemeEditorSession = ThemeEditorSessionInput & {
27 /**
28 * Distinguishes two sessions that name the same themes, so asking for a
29 * fresh draft while the panel is already open still reseeds it.
30 */
31 id: number;
32};
33
34type ThemeEditorStore = {
35 session: ThemeEditorSession | null;
36 openThemeEditor: (session: ThemeEditorSessionInput) => void;
37 closeThemeEditor: () => void;
38};
39
40let nextSessionId = 0;
41
42export const useThemeEditorStore = create<ThemeEditorStore>((set) => ({
43 session: null,
44 openThemeEditor: (session) => set({ session: { ...session, id: ++nextSessionId } }),
45 closeThemeEditor: () => set({ session: null }),
46}));
47
48export function toggleThemeEditorForTheme(input: {
49 theme: ThemePreference;
50 themeHalves: ThemeHalves | null;
51 initialAppearance: ThemeAppearance;
52}): void {
53 const store = useThemeEditorStore.getState();
54 if (store.session) {
55 store.closeThemeEditor();
56 return;
57 }
58
59 const baseThemeId = getThemeDefinition(input.theme)?.id ?? null;
60 const activeThemeId = input.themeHalves?.[input.initialAppearance] ?? baseThemeId;
61 const seedThemeId = activeThemeId ? (getThemeDefinition(activeThemeId)?.id ?? null) : null;
62 store.openThemeEditor({
63 editingThemeId: null,
64 seedThemeId,
65 seedName: null,
66 initialAppearance: input.initialAppearance,
67 });
68}
apps/web/src/components/settings/themeInspector.test.ts created+42
...@@ -0,0 +1,42 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import {
4 changedThemePaintKinds,
5 themeRoleFromUtilityClass,
6 type ThemePaintSnapshot,
7} from "./themeInspector";
8
9const WHITE_PAINT: ThemePaintSnapshot = {
10 background: "rgb(255, 255, 255)\nnone",
11 border: "rgb(255, 255, 255)\nnone",
12 foreground: "rgb(255, 255, 255)\nnone",
13};
14
15describe("changedThemePaintKinds", () => {
16 it("does not match an identical hardcoded color", () => {
17 expect(changedThemePaintKinds(WHITE_PAINT, { ...WHITE_PAINT })).toEqual([]);
18 });
19
20 it("reports only paint that changed with the probed token", () => {
21 expect(
22 changedThemePaintKinds(WHITE_PAINT, {
23 ...WHITE_PAINT,
24 background: "rgb(1, 254, 167)\nnone",
25 foreground: "rgb(1, 254, 167)\nnone",
26 }),
27 ).toEqual(["background", "foreground"]);
28 });
29});
30
31describe("themeRoleFromUtilityClass", () => {
32 it("maps semantic utilities to their actual palette role", () => {
33 expect(themeRoleFromUtilityClass("text-foreground", "foreground")).toBe("text");
34 expect(themeRoleFromUtilityClass("bg-primary/90", "background")).toBe("messageAction");
35 expect(themeRoleFromUtilityClass("ring-ring", "border")).toBe("focus");
36 });
37
38 it("does not infer hardcoded or potentially inactive colors", () => {
39 expect(themeRoleFromUtilityClass("bg-white", "background")).toBeNull();
40 expect(themeRoleFromUtilityClass("hover:bg-accent", "background")).toBeNull();
41 });
42});
apps/web/src/components/settings/themeInspector.ts created+501
...@@ -0,0 +1,501 @@
1import { THEME_COLOR_ROLES, getThemeColorVariable, type ThemeColorRole } from "../../themePalette";
2
3export type ThemePaintKind = "background" | "border" | "foreground";
4
5export type ThemePaintSnapshot = Readonly<Record<ThemePaintKind, string>>;
6export type ThemeElementInspection = Readonly<{
7 element: Element;
8 role: ThemeColorRole;
9}>;
10
11const THEME_PAINT_KIND_ORDER: ReadonlyArray<ThemePaintKind> = [
12 "background",
13 "border",
14 "foreground",
15];
16
17export const THEME_INSPECTOR_MATCH_ATTRIBUTE = "data-theme-inspector-match";
18
19const THEME_TOKEN_PROBE_ATTRIBUTE = "data-theme-token-probe";
20const THEME_TOKEN_PROBE_COLOR = "#01fea7";
21const THEME_TOKEN_ALTERNATE_PROBE_COLOR = "#fe01a7";
22const THEME_SPOTLIGHT_ID = "theme-inspector-spotlight";
23const THEME_SPOTLIGHT_MASK_ID = "theme-inspector-spotlight-mask";
24const THEME_SPOTLIGHT_GLOW_ID = "theme-inspector-spotlight-glow";
25const THEME_HOVER_ID = "theme-inspector-hover";
26const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
27
28const THEME_UTILITY_ROLES: Readonly<Partial<Record<string, ThemeColorRole>>> = {
29 background: "canvas",
30 foreground: "text",
31 card: "surface",
32 "card-foreground": "text",
33 popover: "surfaceOverlay",
34 "popover-foreground": "text",
35 "surface-raised": "surfaceRaised",
36 primary: "messageAction",
37 "primary-foreground": "messageActionForeground",
38 secondary: "secondary",
39 "secondary-foreground": "secondaryForeground",
40 muted: "muted",
41 "muted-foreground": "textMuted",
42 placeholder: "placeholder",
43 "secondary-label": "secondaryLabel",
44 "icon-muted": "iconMuted",
45 accent: "accentSurface",
46 "accent-foreground": "accentSurfaceForeground",
47 destructive: "error",
48 "destructive-foreground": "errorForeground",
49 error: "error",
50 "error-foreground": "errorForeground",
51 "error-surface": "errorSurface",
52 warning: "warning",
53 "warning-foreground": "warningForeground",
54 "warning-surface": "warningSurface",
55 update: "update",
56 "update-foreground": "updateForeground",
57 "update-surface": "updateSurface",
58 message: "messageSurface",
59 "message-foreground": "messageForeground",
60 "message-action": "messageAction",
61 "message-action-foreground": "messageActionForeground",
62 "message-action-hover": "messageActionHover",
63 sidebar: "sidebar",
64 "sidebar-foreground": "sidebarForeground",
65 "sidebar-muted-foreground": "sidebarMutedForeground",
66 "sidebar-control-surface": "sidebarControlSurface",
67 "sidebar-row-hover": "sidebarRowHover",
68 "sidebar-row-active": "sidebarRowActive",
69 "sidebar-row-selected": "sidebarRowSelected",
70 "sidebar-border": "sidebarBorder",
71 border: "border",
72 input: "input",
73 ring: "focus",
74};
75
76const THEME_UTILITY_PREFIXES: Readonly<Record<ThemePaintKind, ReadonlyArray<string>>> = {
77 background: ["bg-"],
78 border: ["border-", "outline-", "ring-"],
79 foreground: ["text-", "caret-", "fill-", "stroke-"],
80};
81
82function clearThemeInspectorAttribute(attribute: string): void {
83 document
84 .querySelectorAll(`[${attribute}]`)
85 .forEach((element) => element.removeAttribute(attribute));
86}
87
88export function clearThemeInspectorHighlights(): void {
89 clearThemeInspectorAttribute(THEME_INSPECTOR_MATCH_ATTRIBUTE);
90 document.getElementById(THEME_SPOTLIGHT_ID)?.remove();
91}
92
93export function clearThemeInspectorHover(): void {
94 document.getElementById(THEME_HOVER_ID)?.remove();
95}
96
97function svgElement<Name extends keyof SVGElementTagNameMap>(
98 name: Name,
99): SVGElementTagNameMap[Name] {
100 return document.createElementNS(SVG_NAMESPACE, name);
101}
102
103function spotlightRect(element: Element): {
104 x: number;
105 y: number;
106 width: number;
107 height: number;
108 radius: number;
109} | null {
110 const bounds = element.getBoundingClientRect();
111 if (
112 bounds.width <= 0 ||
113 bounds.height <= 0 ||
114 bounds.right < 0 ||
115 bounds.bottom < 0 ||
116 bounds.left > window.innerWidth ||
117 bounds.top > window.innerHeight
118 ) {
119 return null;
120 }
121
122 const padding = 5;
123 // Keep the true bounds when an element is partially offscreen. Clamping its
124 // rectangle to the viewport would draw a fake glow edge along the crop.
125 const x = bounds.left - padding;
126 const y = bounds.top - padding;
127 const elementRadius =
128 Number.parseFloat(window.getComputedStyle(element).borderTopLeftRadius) || 0;
129 return {
130 x,
131 y,
132 width: bounds.width + padding * 2,
133 height: bounds.height + padding * 2,
134 radius: Math.min(18, Math.max(7, elementRadius + padding)),
135 };
136}
137
138export function showThemeInspectorHover(inspection: ThemeElementInspection, label: string): void {
139 const rectangle = spotlightRect(inspection.element);
140 if (!rectangle) {
141 clearThemeInspectorHover();
142 return;
143 }
144
145 let hover = document.getElementById(THEME_HOVER_ID);
146 if (!hover) {
147 hover = document.createElement("div");
148 hover.id = THEME_HOVER_ID;
149 hover.setAttribute("aria-hidden", "true");
150 const tokenLabel = document.createElement("span");
151 tokenLabel.dataset.themeInspectorHoverLabel = "";
152 hover.append(tokenLabel);
153 document.body.append(hover);
154 }
155
156 hover.style.left = `${rectangle.x}px`;
157 hover.style.top = `${rectangle.y}px`;
158 hover.style.width = `${rectangle.width}px`;
159 hover.style.height = `${rectangle.height}px`;
160 hover.style.borderRadius = `${rectangle.radius}px`;
161 hover.dataset.placement = rectangle.y < 32 ? "below" : "above";
162 const tokenLabel = hover.querySelector<HTMLElement>("[data-theme-inspector-hover-label]");
163 if (tokenLabel) tokenLabel.textContent = label;
164}
165
166function renderThemeInspectorSpotlight(elements: ReadonlyArray<Element>): void {
167 const rectangles = new Map<string, NonNullable<ReturnType<typeof spotlightRect>>>();
168 for (const element of elements) {
169 const rectangle = spotlightRect(element);
170 if (!rectangle) continue;
171 const key = [rectangle.x, rectangle.y, rectangle.width, rectangle.height]
172 .map((value) => Math.round(value))
173 .join(":");
174 rectangles.set(key, rectangle);
175 }
176
177 if (rectangles.size === 0) {
178 document.getElementById(THEME_SPOTLIGHT_ID)?.remove();
179 return;
180 }
181
182 let spotlight = document.getElementById(THEME_SPOTLIGHT_ID) as SVGSVGElement | null;
183 if (!spotlight) {
184 spotlight = svgElement("svg");
185 spotlight.id = THEME_SPOTLIGHT_ID;
186 spotlight.setAttribute("aria-hidden", "true");
187 spotlight.setAttribute("focusable", "false");
188 document.body.append(spotlight);
189 }
190 spotlight.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`);
191
192 const definitions = svgElement("defs");
193 const mask = svgElement("mask");
194 mask.id = THEME_SPOTLIGHT_MASK_ID;
195 mask.setAttribute("maskUnits", "userSpaceOnUse");
196 const maskSurface = svgElement("rect");
197 maskSurface.setAttribute("width", String(window.innerWidth));
198 maskSurface.setAttribute("height", String(window.innerHeight));
199 maskSurface.setAttribute("fill", "white");
200 mask.append(maskSurface);
201
202 const glowFilter = svgElement("filter");
203 glowFilter.id = THEME_SPOTLIGHT_GLOW_ID;
204 glowFilter.setAttribute("x", "-50%");
205 glowFilter.setAttribute("y", "-50%");
206 glowFilter.setAttribute("width", "200%");
207 glowFilter.setAttribute("height", "200%");
208 const blur = svgElement("feGaussianBlur");
209 blur.setAttribute("stdDeviation", "5");
210 blur.setAttribute("result", "blur");
211 const merge = svgElement("feMerge");
212 const blurredGlow = svgElement("feMergeNode");
213 blurredGlow.setAttribute("in", "blur");
214 const crispGlow = svgElement("feMergeNode");
215 crispGlow.setAttribute("in", "SourceGraphic");
216 merge.append(blurredGlow, crispGlow);
217 glowFilter.append(blur, merge);
218 definitions.append(mask, glowFilter);
219
220 const glowGroup = svgElement("g");
221 for (const rectangle of rectangles.values()) {
222 const hole = svgElement("rect");
223 hole.setAttribute("x", String(rectangle.x));
224 hole.setAttribute("y", String(rectangle.y));
225 hole.setAttribute("width", String(rectangle.width));
226 hole.setAttribute("height", String(rectangle.height));
227 hole.setAttribute("rx", String(rectangle.radius));
228 hole.setAttribute("fill", "black");
229 mask.append(hole);
230
231 const glow = hole.cloneNode(false) as SVGRectElement;
232 glow.removeAttribute("fill");
233 glow.setAttribute("class", "theme-inspector-spotlight-glow");
234 glow.setAttribute("filter", `url(#${THEME_SPOTLIGHT_GLOW_ID})`);
235 glowGroup.append(glow);
236 }
237
238 const dimmer = svgElement("rect");
239 dimmer.setAttribute("class", "theme-inspector-spotlight-dimmer");
240 dimmer.setAttribute("width", String(window.innerWidth));
241 dimmer.setAttribute("height", String(window.innerHeight));
242 dimmer.setAttribute("mask", `url(#${THEME_SPOTLIGHT_MASK_ID})`);
243 spotlight.replaceChildren(definitions, dimmer, glowGroup);
244}
245
246export function refreshThemeInspectorSpotlight(): void {
247 renderThemeInspectorSpotlight([
248 ...document.querySelectorAll(`[${THEME_INSPECTOR_MATCH_ATTRIBUTE}]`),
249 ]);
250}
251
252function elementHasVisibleText(element: Element): boolean {
253 if (element.matches("input, textarea, select, option")) return true;
254 return Array.from(element.childNodes).some(
255 (node) => node.nodeType === Node.TEXT_NODE && Boolean(node.textContent?.trim()),
256 );
257}
258
259function getThemePaintSnapshot(
260 element: Element,
261 { forHitTest = false }: { forHitTest?: boolean } = {},
262): ThemePaintSnapshot | null {
263 const style = window.getComputedStyle(element);
264 if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) {
265 return null;
266 }
267
268 const borderPaints: Array<string> = [];
269 for (const [color, borderStyle, width] of [
270 [style.borderTopColor, style.borderTopStyle, style.borderTopWidth],
271 [style.borderRightColor, style.borderRightStyle, style.borderRightWidth],
272 [style.borderBottomColor, style.borderBottomStyle, style.borderBottomWidth],
273 [style.borderLeftColor, style.borderLeftStyle, style.borderLeftWidth],
274 ] as const) {
275 if (borderStyle !== "none" && Number.parseFloat(width) > 0) borderPaints.push(color);
276 }
277 if (style.outlineStyle !== "none" && Number.parseFloat(style.outlineWidth) > 0) {
278 borderPaints.push(style.outlineColor);
279 }
280 if (style.borderImageSource !== "none") borderPaints.push(style.borderImageSource);
281 if (style.boxShadow !== "none") borderPaints.push(style.boxShadow);
282
283 const foregroundPaints: Array<string> = [];
284 if (forHitTest || elementHasVisibleText(element)) foregroundPaints.push(style.color);
285 if (element instanceof SVGElement) foregroundPaints.push(style.fill, style.stroke);
286 if (element.matches("input, textarea")) foregroundPaints.push(style.caretColor);
287 if (style.textDecorationLine !== "none") foregroundPaints.push(style.textDecorationColor);
288 if (style.textShadow !== "none") foregroundPaints.push(style.textShadow);
289
290 return {
291 background: [style.backgroundColor, style.backgroundImage].join("\n"),
292 border: borderPaints.join("\n"),
293 foreground: foregroundPaints.join("\n"),
294 };
295}
296
297export function changedThemePaintKinds(
298 before: ThemePaintSnapshot,
299 after: ThemePaintSnapshot,
300): ReadonlyArray<ThemePaintKind> {
301 return THEME_PAINT_KIND_ORDER.filter((kind) => before[kind] !== after[kind]);
302}
303
304function withThemeTokenProbeSession<Result>(run: () => Result): Result {
305 const root = document.documentElement;
306 const wasAlreadyProbing = root.hasAttribute(THEME_TOKEN_PROBE_ATTRIBUTE);
307 if (!wasAlreadyProbing) root.setAttribute(THEME_TOKEN_PROBE_ATTRIBUTE, "");
308 try {
309 return run();
310 } finally {
311 // Flush every restored token while transitions are still suppressed. The
312 // browser never gets a paint opportunity between the probe and restore.
313 void window.getComputedStyle(root).color;
314 if (!wasAlreadyProbing) root.removeAttribute(THEME_TOKEN_PROBE_ATTRIBUTE);
315 }
316}
317
318function applyThemeTokenProbe(role: ThemeColorRole): () => void {
319 const root = document.documentElement;
320 const variable = getThemeColorVariable(role);
321 const originalValue = root.style.getPropertyValue(variable);
322 const originalPriority = root.style.getPropertyPriority(variable);
323 const resolvedValue = originalValue.trim().toLowerCase();
324 const probeColor =
325 resolvedValue === THEME_TOKEN_PROBE_COLOR
326 ? THEME_TOKEN_ALTERNATE_PROBE_COLOR
327 : THEME_TOKEN_PROBE_COLOR;
328 root.style.setProperty(variable, probeColor, "important");
329
330 return () => {
331 if (originalValue) {
332 root.style.setProperty(variable, originalValue, originalPriority);
333 } else {
334 root.style.removeProperty(variable);
335 }
336 };
337}
338
339function applyThemeTokenProbes(roles: ReadonlyArray<ThemeColorRole>): () => void {
340 const restores = roles.map(applyThemeTokenProbe);
341 return () => {
342 for (const restore of restores.toReversed()) restore();
343 };
344}
345
346function themeInspectorCandidates(): ReadonlyArray<Element> {
347 return [document.body, ...document.body.querySelectorAll("*")].filter(
348 (element) =>
349 !element.closest("[data-theme-editor-panel]") &&
350 !element.closest(`#${THEME_SPOTLIGHT_ID}, #${THEME_HOVER_ID}`),
351 );
352}
353
354export function themeRoleFromUtilityClass(
355 className: string,
356 kind: ThemePaintKind,
357): ThemeColorRole | null {
358 // Stateful variants such as hover: and disabled: may not be contributing to
359 // the current paint. The computed-style probe below handles those exactly.
360 if (className.includes(":")) return null;
361 for (const prefix of THEME_UTILITY_PREFIXES[kind]) {
362 if (!className.startsWith(prefix)) continue;
363 const colorName = className.slice(prefix.length).split("/", 1)[0] ?? "";
364 return THEME_UTILITY_ROLES[colorName] ?? null;
365 }
366 return null;
367}
368
369function themeRoleFromUtilities(element: Element): ThemeColorRole | null {
370 for (const kind of THEME_PAINT_KIND_ORDER) {
371 for (const className of element.classList) {
372 const role = themeRoleFromUtilityClass(className, kind);
373 if (role) return role;
374 }
375 }
376 return null;
377}
378
379function themeInspectionCandidates(initialElement: Element): ReadonlyArray<Element> {
380 const candidates: Array<Element> = [];
381 let element: Element | null = initialElement;
382 while (element && element !== document.documentElement) {
383 if (!element.closest("[data-theme-editor-panel]")) candidates.push(element);
384 element = element.parentElement;
385 }
386 return candidates;
387}
388
389export function inspectThemeRoleFromUtilitiesAtElement(
390 initialElement: Element,
391): ThemeElementInspection | null {
392 if (initialElement.closest("[data-theme-editor-panel]")) return null;
393 for (const candidate of themeInspectionCandidates(initialElement)) {
394 const role = themeRoleFromUtilities(candidate);
395 if (role) return { element: candidate, role };
396 }
397 return null;
398}
399
400/**
401 * Finds actual dependency on a theme token instead of comparing final colors.
402 * A token is synchronously replaced with a sentinel, computed paint is read,
403 * and the original value is restored before the browser can render a frame.
404 */
405export function highlightThemeRoleUsage(roles: ReadonlyArray<ThemeColorRole>): number {
406 const startedAt = performance.now();
407 clearThemeInspectorAttribute(THEME_INSPECTOR_MATCH_ATTRIBUTE);
408 const candidates = themeInspectorCandidates();
409 const probeStartedAt = performance.now();
410 const matches = withThemeTokenProbeSession(() => {
411 const baseline = new Map<Element, ThemePaintSnapshot>();
412 for (const element of candidates) {
413 const snapshot = getThemePaintSnapshot(element);
414 if (snapshot) baseline.set(element, snapshot);
415 }
416
417 const restore = applyThemeTokenProbes(roles);
418 const changed = new Set<Element>();
419 try {
420 for (const [element, before] of baseline) {
421 const after = getThemePaintSnapshot(element);
422 if (after && changedThemePaintKinds(before, after).length > 0) changed.add(element);
423 }
424 } finally {
425 restore();
426 }
427 return changed;
428 });
429 const probeDuration = performance.now() - probeStartedAt;
430
431 const highlightedElements = new Set<Element>();
432 for (const element of matches) {
433 highlightedElements.add(
434 element instanceof SVGElement ? (element.closest("svg") ?? element) : element,
435 );
436 }
437 for (const element of highlightedElements) {
438 element.setAttribute(THEME_INSPECTOR_MATCH_ATTRIBUTE, "");
439 }
440 renderThemeInspectorSpotlight([...highlightedElements]);
441 if (import.meta.env.DEV) {
442 const spotlight = document.getElementById(THEME_SPOTLIGHT_ID);
443 if (spotlight) {
444 spotlight.dataset.themeInspectorCandidates = String(candidates.length);
445 spotlight.dataset.themeInspectorProbeMs = probeDuration.toFixed(2);
446 spotlight.dataset.themeInspectorTotalMs = (performance.now() - startedAt).toFixed(2);
447 }
448 }
449 return highlightedElements.size;
450}
451
452/** Resolves the nearest painted token at a touched element. */
453export function inspectThemeRoleAtElement(initialElement: Element): ThemeElementInspection | null {
454 if (initialElement.closest("[data-theme-editor-panel]")) return null;
455
456 const candidates = themeInspectionCandidates(initialElement);
457
458 // Most app paint comes from Tailwind's semantic color utilities. Those class
459 // names express the token dependency directly and avoid dozens of forced
460 // style recalculations on every pointer-down. Custom CSS still falls through
461 // to the exact computed-style probe.
462 const utilityInspection = inspectThemeRoleFromUtilitiesAtElement(initialElement);
463 if (utilityInspection) return utilityInspection;
464
465 const rolesByElement = withThemeTokenProbeSession(() => {
466 const baseline = new Map<Element, ThemePaintSnapshot>();
467 const roles = new Map<Element, Partial<Record<ThemePaintKind, ThemeColorRole>>>();
468 for (const candidate of candidates) {
469 const snapshot = getThemePaintSnapshot(candidate, { forHitTest: true });
470 if (snapshot) baseline.set(candidate, snapshot);
471 }
472
473 for (const role of THEME_COLOR_ROLES) {
474 const restore = applyThemeTokenProbe(role);
475 try {
476 for (const [candidate, before] of baseline) {
477 const after = getThemePaintSnapshot(candidate, { forHitTest: true });
478 if (!after) continue;
479 const candidateRoles = roles.get(candidate) ?? {};
480 for (const kind of changedThemePaintKinds(before, after)) {
481 candidateRoles[kind] ??= role;
482 }
483 roles.set(candidate, candidateRoles);
484 }
485 } finally {
486 restore();
487 }
488 }
489 return roles;
490 });
491
492 for (const candidate of candidates) {
493 const roles = rolesByElement.get(candidate);
494 if (!roles) continue;
495 for (const kind of THEME_PAINT_KIND_ORDER) {
496 const role = roles[kind];
497 if (role) return { element: candidate, role };
498 }
499 }
500 return null;
501}
apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx+1-1
...@@ -13,7 +13,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";...@@ -13,7 +13,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1313
14const PROVIDER_UPDATE_PILL_STYLES = {14const PROVIDER_UPDATE_PILL_STYLES = {
15 loading:15 loading:
16 "bg-primary/15 text-primary group-has-[button.provider-update-main:hover]/provider-update:bg-primary/22",16 "bg-update-surface text-update group-has-[button.provider-update-main:hover]/provider-update:bg-update/22",
17 success:17 success:
18 "bg-success/12 text-success group-has-[button.provider-update-main:hover]/provider-update:bg-success/18",18 "bg-success/12 text-success group-has-[button.provider-update-main:hover]/provider-update:bg-success/18",
19 warning:19 warning:
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+3-3
...@@ -159,11 +159,11 @@ export function SidebarUpdatePill() {...@@ -159,11 +159,11 @@ export function SidebarUpdatePill() {
159 )}159 )}
160 {visible && (160 {visible && (
161 <div161 <div
162 className={`group/update relative flex h-7 w-full items-center rounded-lg bg-primary/15 text-xs font-medium text-primary ${162 className={`group/update relative flex h-7 w-full items-center rounded-lg bg-update-surface text-xs font-medium text-update ${
163 disabled ? " cursor-not-allowed opacity-60" : ""163 disabled ? " cursor-not-allowed opacity-60" : ""
164 }`}164 }`}
165 >165 >
166 <div className="pointer-events-none absolute inset-0 rounded-lg transition-colors group-has-[button.update-main:hover]/update:bg-primary/22" />166 <div className="pointer-events-none absolute inset-0 rounded-lg transition-colors group-has-[button.update-main:hover]/update:bg-update/12" />
167 <Tooltip>167 <Tooltip>
168 <TooltipTrigger168 <TooltipTrigger
169 render={169 render={
...@@ -224,7 +224,7 @@ export function SidebarUpdatePill() {...@@ -224,7 +224,7 @@ export function SidebarUpdatePill() {
224 <button224 <button
225 type="button"225 type="button"
226 aria-label="Dismiss update"226 aria-label="Dismiss update"
227 className="mr-1 inline-flex size-5 items-center justify-center rounded-md text-primary/60 transition-colors hover:text-primary"227 className="mr-1 inline-flex size-5 items-center justify-center rounded-md text-update/60 transition-colors hover:text-update"
228 onClick={() => setDismissed(true)}228 onClick={() => setDismissed(true)}
229 >229 >
230 <XIcon className="size-3.5" />230 <XIcon className="size-3.5" />
apps/web/src/components/ui/alert.tsx+3-2
...@@ -12,10 +12,11 @@ const alertVariants = cva("relative rounded-xl border px-3.5 py-3 text-card-fore...@@ -12,10 +12,11 @@ const alertVariants = cva("relative rounded-xl border px-3.5 py-3 text-card-fore
12 variant: {12 variant: {
13 default: "bg-transparent dark:bg-input/32 [&_svg]:text-muted-foreground",13 default: "bg-transparent dark:bg-input/32 [&_svg]:text-muted-foreground",
14 error:14 error:
15 "border-destructive/32 bg-destructive/4 text-destructive-foreground [&_[data-slot=alert-description]]:text-destructive-foreground/80 [&_svg]:text-destructive",15 "border-error/32 bg-error-surface text-error-foreground [&_[data-slot=alert-description]]:text-error-foreground/80 [&_svg]:text-error",
16 info: "border-info/32 bg-info/4 [&_svg]:text-info",16 info: "border-info/32 bg-info/4 [&_svg]:text-info",
17 success: "border-success/32 bg-success/4 [&_svg]:text-success",17 success: "border-success/32 bg-success/4 [&_svg]:text-success",
18 warning: "border-warning/32 bg-warning/4 [&_svg]:text-warning",18 warning:
19 "border-warning/32 bg-warning-surface text-warning-foreground [&_[data-slot=alert-description]]:text-warning-foreground/80 [&_svg]:text-warning",
19 },20 },
20 },21 },
21});22});
apps/web/src/components/ui/command.tsx+3-3
...@@ -112,12 +112,12 @@ function CommandInput({...@@ -112,12 +112,12 @@ function CommandInput({
112 <AutocompleteInput112 <AutocompleteInput
113 autoFocus113 autoFocus
114 className={cn(114 className={cn(
115 "border-transparent! bg-transparent! shadow-none before:hidden has-focus-visible:ring-0 placeholder:text-muted-foreground/80 *:data-[slot=autocomplete-input]:ps-9! sm:*:data-[slot=autocomplete-input]:ps-[calc(var(--command-shell-inset)+1.5rem)]!",115 "border-transparent! bg-transparent! shadow-none before:hidden has-focus-visible:ring-0 placeholder:text-placeholder *:data-[slot=autocomplete-input]:ps-9! sm:*:data-[slot=autocomplete-input]:ps-[calc(var(--command-shell-inset)+1.5rem)]!",
116 className,116 className,
117 )}117 )}
118 placeholder={placeholder}118 placeholder={placeholder}
119 size="lg"119 size="lg"
120 startAddon={<SearchIcon className="translate-x-0.5 text-muted-foreground" />}120 startAddon={<SearchIcon className="translate-x-0.5 text-icon-muted" />}
121 {...props}121 {...props}
122 />122 />
123 </div>123 </div>
...@@ -203,7 +203,7 @@ function CommandShortcut({ className, ...props }: React.ComponentProps<"kbd">) {...@@ -203,7 +203,7 @@ function CommandShortcut({ className, ...props }: React.ComponentProps<"kbd">) {
203 return (203 return (
204 <kbd204 <kbd
205 className={cn(205 className={cn(
206 "ms-auto font-medium font-sans text-muted-foreground/70 text-xs tracking-widest",206 "ms-auto font-medium font-sans text-secondary-label text-xs tracking-widest",
207 className,207 className,
208 )}208 )}
209 data-slot="command-shortcut"209 data-slot="command-shortcut"
apps/web/src/components/ui/input.tsx+1-1
...@@ -19,7 +19,7 @@ function Input({...@@ -19,7 +19,7 @@ function Input({
19 ...props19 ...props
20}: InputProps) {20}: InputProps) {
21 const inputClassName = cn(21 const inputClassName = cn(
22 "h-8.5 w-full min-w-0 rounded-[inherit] px-[calc(--spacing(3)-1px)] leading-8.5 outline-none placeholder:text-muted-foreground/72 sm:h-7.5 sm:leading-7.5 [transition:background-color_5000000s_ease-in-out_0s]",22 "h-8.5 w-full min-w-0 rounded-[inherit] px-[calc(--spacing(3)-1px)] leading-8.5 outline-none placeholder:text-placeholder sm:h-7.5 sm:leading-7.5 [transition:background-color_5000000s_ease-in-out_0s]",
23 size === "sm" && "h-7.5 px-[calc(--spacing(2.5)-1px)] leading-7.5 sm:h-6.5 sm:leading-6.5",23 size === "sm" && "h-7.5 px-[calc(--spacing(2.5)-1px)] leading-7.5 sm:h-6.5 sm:leading-6.5",
24 size === "lg" && "h-9.5 leading-9.5 sm:h-8.5 sm:leading-8.5",24 size === "lg" && "h-9.5 leading-9.5 sm:h-8.5 sm:leading-8.5",
25 props.type === "search" &&25 props.type === "search" &&
apps/web/src/components/ui/menu.tsx+1-1
...@@ -203,7 +203,7 @@ function MenuShortcut({ className, ...props }: React.ComponentProps<"kbd">) {...@@ -203,7 +203,7 @@ function MenuShortcut({ className, ...props }: React.ComponentProps<"kbd">) {
203 return (203 return (
204 <kbd204 <kbd
205 className={cn(205 className={cn(
206 "ms-auto font-medium font-sans text-muted-foreground/72 text-xs tracking-widest",206 "ms-auto font-medium font-sans text-secondary-label text-xs tracking-widest",
207 className,207 className,
208 )}208 )}
209 data-slot="menu-shortcut"209 data-slot="menu-shortcut"
apps/web/src/components/ui/select.tsx+4-6
...@@ -21,9 +21,9 @@ const selectTriggerVariants = cva(...@@ -21,9 +21,9 @@ const selectTriggerVariants = cva(
21 variants: {21 variants: {
22 variant: {22 variant: {
23 default:23 default:
24 "w-full min-w-36 border-input bg-background not-dark:bg-clip-padding text-foreground shadow-xs/5 ring-ring/24 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-data-disabled:not-focus-visible:not-aria-invalid:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 focus-visible:border-ring focus-visible:ring-[3px] aria-invalid:border-destructive/36 focus-visible:aria-invalid:border-destructive/64 focus-visible:aria-invalid:ring-destructive/16 dark:bg-input/32 dark:aria-invalid:ring-destructive/24 dark:not-data-disabled:not-focus-visible:not-aria-invalid:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='text-'])]:text-muted-foreground [[data-disabled],:focus-visible,[aria-invalid],[data-pressed]]:shadow-none",24 "w-full min-w-36 border-input bg-background not-dark:bg-clip-padding text-foreground shadow-xs/5 ring-ring/24 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-data-disabled:not-focus-visible:not-aria-invalid:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 focus-visible:border-ring focus-visible:ring-[3px] aria-invalid:border-destructive/36 focus-visible:aria-invalid:border-destructive/64 focus-visible:aria-invalid:ring-destructive/16 dark:bg-input/32 dark:aria-invalid:ring-destructive/24 dark:not-data-disabled:not-focus-visible:not-aria-invalid:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='text-'])]:text-icon-muted [[data-disabled],:focus-visible,[aria-invalid],[data-pressed]]:shadow-none",
25 ghost:25 ghost:
26 "border-transparent text-muted-foreground/70 focus-visible:ring-2 focus-visible:ring-ring data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent [:hover,[data-pressed]]:text-foreground/80",26 "border-transparent text-secondary-label focus-visible:ring-2 focus-visible:ring-ring data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent [:hover,[data-pressed]]:text-foreground",
27 },27 },
28 size: {28 size: {
29 default: "min-h-9 px-[calc(--spacing(3)-1px)] sm:min-h-8",29 default: "min-h-9 px-[calc(--spacing(3)-1px)] sm:min-h-8",
...@@ -50,9 +50,7 @@ function SelectButton({ className, size, variant, render, children, ...props }:...@@ -50,9 +50,7 @@ function SelectButton({ className, size, variant, render, children, ...props }:
50 const defaultProps = {50 const defaultProps = {
51 children: (51 children: (
52 <>52 <>
53 <span className="flex-1 truncate in-data-placeholder:text-muted-foreground/72">53 <span className="flex-1 truncate in-data-placeholder:text-placeholder">{children}</span>
54 {children}
55 </span>
56 {variant === "ghost" ? (54 {variant === "ghost" ? (
57 <ChevronDownIcon className="-me-1 size-3 opacity-50" />55 <ChevronDownIcon className="-me-1 size-3 opacity-50" />
58 ) : (56 ) : (
...@@ -98,7 +96,7 @@ function SelectTrigger({...@@ -98,7 +96,7 @@ function SelectTrigger({
98function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {96function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
99 return (97 return (
100 <SelectPrimitive.Value98 <SelectPrimitive.Value
101 className={cn("flex-1 truncate data-placeholder:text-muted-foreground", className)}99 className={cn("flex-1 truncate data-placeholder:text-placeholder", className)}
102 data-slot="select-value"100 data-slot="select-value"
103 {...props}101 {...props}
104 />102 />
apps/web/src/hooks/useCustomThemes.ts created+9
...@@ -0,0 +1,9 @@
1import { useSyncExternalStore } from "react";
2
3import { getCustomThemes, subscribeToCustomThemes } from "../themePalette";
4
5const EMPTY_CUSTOM_THEMES: readonly [] = [];
6
7export function useCustomThemes() {
8 return useSyncExternalStore(subscribeToCustomThemes, getCustomThemes, () => EMPTY_CUSTOM_THEMES);
9}
apps/web/src/hooks/useTheme.test.ts+16-3
...@@ -70,6 +70,18 @@ describe("theme failure handling", () => {...@@ -70,6 +70,18 @@ describe("theme failure handling", () => {
70 }70 }
71 });71 });
7272
73 it("reads the persisted T3 Chat theme preference", async () => {
74 vi.stubGlobal("window", {
75 localStorage: createStorage({
76 getItem: () => "t3-chat",
77 }),
78 });
79
80 const { readThemePreference } = await import("./useTheme");
81
82 expect(readThemePreference()).toBe("t3-chat");
83 });
84
73 it("falls back during initial theme application and logs only safe attributes", async () => {85 it("falls back during initial theme application and logs only safe attributes", async () => {
74 const cause = new Error("private browsing storage failure");86 const cause = new Error("private browsing storage failure");
75 const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});87 const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
...@@ -104,9 +116,10 @@ describe("theme failure handling", () => {...@@ -104,9 +116,10 @@ describe("theme failure handling", () => {
104116
105 it("retries a failed storage read only after a relevant storage event", async () => {117 it("retries a failed storage read only after a relevant storage event", async () => {
106 const cause = new Error("persistent storage failure");118 const cause = new Error("persistent storage failure");
107 const getItem = vi.fn(() => {119 const themeGetItem = vi.fn((): string | null => {
108 throw cause;120 throw cause;
109 });121 });
122 const getItem = vi.fn((key: string) => (key === "t3code:theme" ? themeGetItem() : null));
110 const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});123 const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
111 let readSnapshot: (() => unknown) | undefined;124 let readSnapshot: (() => unknown) | undefined;
112 let subscribeToTheme: ((listener: () => void) => () => void) | undefined;125 let subscribeToTheme: ((listener: () => void) => () => void) | undefined;
...@@ -141,14 +154,14 @@ describe("theme failure handling", () => {...@@ -141,14 +154,14 @@ describe("theme failure handling", () => {
141 readSnapshot?.();154 readSnapshot?.();
142 readSnapshot?.();155 readSnapshot?.();
143156
144 expect(getItem).toHaveBeenCalledTimes(1);157 expect(themeGetItem).toHaveBeenCalledTimes(1);
145 expect(errorLog).toHaveBeenCalledTimes(1);158 expect(errorLog).toHaveBeenCalledTimes(1);
146159
147 const unsubscribe = subscribeToTheme?.(() => undefined);160 const unsubscribe = subscribeToTheme?.(() => undefined);
148 storageHandler?.({ key: "t3code:theme" } as StorageEvent);161 storageHandler?.({ key: "t3code:theme" } as StorageEvent);
149 readSnapshot?.();162 readSnapshot?.();
150163
151 expect(getItem).toHaveBeenCalledTimes(2);164 expect(themeGetItem).toHaveBeenCalledTimes(2);
152 expect(errorLog).toHaveBeenCalledTimes(2);165 expect(errorLog).toHaveBeenCalledTimes(2);
153 unsubscribe?.();166 unsubscribe?.();
154 });167 });
apps/web/src/hooks/useTheme.ts+369-60
...@@ -2,12 +2,33 @@ import type { DesktopBridge } from "@t3tools/contracts";...@@ -2,12 +2,33 @@ import type { DesktopBridge } from "@t3tools/contracts";
2import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";2import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
3import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
4import { useCallback, useEffect, useSyncExternalStore } from "react";4import { useCallback, useEffect, useSyncExternalStore } from "react";
55import {
6const ThemePreference = Schema.Literals(["light", "dark", "system"]);6 applyThemePalette,
7type Theme = typeof ThemePreference.Type;7 CUSTOM_THEMES_STORAGE_KEY,
8 invalidateCustomThemes,
9 canonicalThemePreference,
10 isKnownThemePreference,
11 getThemePreferenceMode,
12 parseThemeHalves,
13 resolveDesktopTheme,
14 resolveThemeAppearance,
15 resolveThemeHalf,
16 THEME_APPEARANCE_MODE_STORAGE_KEY,
17 THEME_FOLLOW_SYSTEM_STORAGE_KEY,
18 THEME_HALVES_STORAGE_KEY,
19 ThemePreference,
20 type ThemeAppearance,
21 type ThemeHalves,
22 type ThemePreferenceMode,
23} from "../themePalette";
24
25type Theme = ThemePreference;
8type ThemeSnapshot = {26type ThemeSnapshot = {
9 theme: Theme;27 theme: Theme;
10 systemDark: boolean;28 systemDark: boolean;
29 followSystem: boolean;
30 appearanceMode: ThemePreferenceMode;
31 themeHalves: ThemeHalves | null;
11};32};
1233
13type DesktopThemeBridge = Pick<DesktopBridge, "setTheme">;34type DesktopThemeBridge = Pick<DesktopBridge, "setTheme">;
...@@ -17,7 +38,29 @@ const MEDIA_QUERY = "(prefers-color-scheme: dark)";...@@ -17,7 +38,29 @@ const MEDIA_QUERY = "(prefers-color-scheme: dark)";
17const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = {38const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = {
18 theme: "system",39 theme: "system",
19 systemDark: false,40 systemDark: false,
41 followSystem: true,
42 appearanceMode: "system",
43 themeHalves: null,
20};44};
45
46/** Live read of the stored appearance mix, for callers that must not rely on
47 * a render-time snapshot (for example rollback after an async dialog). */
48export function readThemeHalves(): ThemeHalves | null {
49 return readStoredThemeHalves();
50}
51
52function readStoredThemeHalves(): ThemeHalves | null {
53 if (typeof window === "undefined") return null;
54 try {
55 return parseThemeHalves(window.localStorage.getItem(THEME_HALVES_STORAGE_KEY));
56 } catch {
57 return null;
58 }
59}
60
61function themeHalvesSignature(halves: ThemeHalves | null): string {
62 return `${halves?.light ?? ""}|${halves?.dark ?? ""}`;
63}
21const THEME_COLOR_META_NAME = "theme-color";64const THEME_COLOR_META_NAME = "theme-color";
22const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`;65const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`;
2366
...@@ -53,11 +96,13 @@ export const isDesktopThemeSyncError = Schema.is(DesktopThemeSyncError);...@@ -53,11 +96,13 @@ export const isDesktopThemeSyncError = Schema.is(DesktopThemeSyncError);
5396
54let listeners: Array<() => void> = [];97let listeners: Array<() => void> = [];
55let lastSnapshot: ThemeSnapshot | null = null;98let lastSnapshot: ThemeSnapshot | null = null;
56let lastDesktopTheme: Theme | null = null;99let snapshotStale = true;
100let lastDesktopTheme: "light" | "dark" | "system" | null = null;
57let lastAppliedTheme: ThemeSnapshot | null = null;101let lastAppliedTheme: ThemeSnapshot | null = null;
58let themeStorageReadFailure: ThemeStorageError | null = null;102let themeStorageReadFailure: ThemeStorageError | null = null;
59103
60function emitChange() {104function emitChange() {
105 snapshotStale = true;
61 for (const listener of listeners) listener();106 for (const listener of listeners) listener();
62}107}
63108
...@@ -69,6 +114,53 @@ function getSystemDark() {...@@ -69,6 +114,53 @@ function getSystemDark() {
69 );114 );
70}115}
71116
117function readStoredFollowSystem(theme: Theme): boolean {
118 if (typeof window === "undefined") return theme === "system";
119
120 try {
121 const raw = window.localStorage.getItem(THEME_FOLLOW_SYSTEM_STORAGE_KEY);
122 if (raw === "true") return true;
123 if (raw === "false") return false;
124 } catch {
125 // Fall back to the legacy theme value when the separate preference is unavailable.
126 }
127
128 return theme === "system";
129}
130
131function isThemePreferenceMode(value: string | null): value is ThemePreferenceMode {
132 return value === "light" || value === "dark" || value === "system";
133}
134
135export function readAppearanceModePreference(theme: Theme): ThemePreferenceMode {
136 if (typeof window !== "undefined") {
137 try {
138 const raw = window.localStorage.getItem(THEME_APPEARANCE_MODE_STORAGE_KEY);
139 if (isThemePreferenceMode(raw)) return raw;
140 } catch {
141 // Fall back to the legacy preference below when storage is unavailable.
142 }
143 }
144
145 if (readStoredFollowSystem(theme)) return "system";
146 return getThemePreferenceMode(theme) ?? "light";
147}
148
149function writeAppearanceModePreference(appearanceMode: ThemePreferenceMode): void {
150 if (typeof window === "undefined") return;
151 try {
152 // The legacy follow-system flag is read-only migration input now; the
153 // mode key is the single source of truth.
154 window.localStorage.setItem(THEME_APPEARANCE_MODE_STORAGE_KEY, appearanceMode);
155 } catch (cause) {
156 throw new ThemeStorageError({
157 operation: "write",
158 storageKey: THEME_APPEARANCE_MODE_STORAGE_KEY,
159 cause,
160 });
161 }
162}
163
72export function readThemePreference(): Theme {164export function readThemePreference(): Theme {
73 if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT.theme;165 if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT.theme;
74 let raw: string | null;166 let raw: string | null;
...@@ -81,7 +173,9 @@ export function readThemePreference(): Theme {...@@ -81,7 +173,9 @@ export function readThemePreference(): Theme {
81 cause,173 cause,
82 });174 });
83 }175 }
84 if (raw === "light" || raw === "dark" || raw === "system") return raw;176 if (raw !== null && isKnownThemePreference(raw)) {
177 return canonicalThemePreference(raw);
178 }
85 return DEFAULT_THEME_SNAPSHOT.theme;179 return DEFAULT_THEME_SNAPSHOT.theme;
86}180}
87181
...@@ -161,34 +255,66 @@ function resolveBrowserChromeSurface(): HTMLElement {...@@ -161,34 +255,66 @@ function resolveBrowserChromeSurface(): HTMLElement {
161255
162export function syncBrowserChromeTheme() {256export function syncBrowserChromeTheme() {
163 if (typeof document === "undefined" || typeof getComputedStyle === "undefined") return;257 if (typeof document === "undefined" || typeof getComputedStyle === "undefined") return;
258 const rootStyles = getComputedStyle(document.documentElement);
259 const themeChromeColor = document.documentElement.dataset.themeId
260 ? normalizeThemeColor(rootStyles.getPropertyValue("--app-chrome-background"))
261 : null;
164 const surfaceColor = normalizeThemeColor(262 const surfaceColor = normalizeThemeColor(
165 getComputedStyle(resolveBrowserChromeSurface()).backgroundColor,263 getComputedStyle(resolveBrowserChromeSurface()).backgroundColor,
166 );264 );
167 const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor);265 const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor);
168 const backgroundColor = surfaceColor ?? fallbackColor;266 const backgroundColor = themeChromeColor ?? surfaceColor ?? fallbackColor;
169 if (!backgroundColor) return;267 if (!backgroundColor) return;
170268
171 document.documentElement.style.backgroundColor = backgroundColor;269 document.documentElement.style.backgroundColor = backgroundColor;
172 document.body.style.backgroundColor = backgroundColor;270 document.body.style.backgroundColor = backgroundColor;
173 ensureThemeColorMetaTag().setAttribute("content", backgroundColor);271 // Update every theme-color meta so any element another layer added (for
272 // example a media-scoped one) carries the resolved color too.
273 const themeColorMetas = document.querySelectorAll<HTMLMetaElement>(
274 `meta[name="${THEME_COLOR_META_NAME}"]`,
275 );
276 if (themeColorMetas.length === 0) {
277 ensureThemeColorMetaTag().setAttribute("content", backgroundColor);
278 return;
279 }
280 for (const element of themeColorMetas) {
281 element.setAttribute("content", backgroundColor);
282 }
174}283}
175284
176function applyTheme(theme: Theme, suppressTransitions = false) {285function applyTheme(theme: Theme, suppressTransitions = false) {
177 if (typeof document === "undefined" || typeof window === "undefined") return;286 if (typeof document === "undefined" || typeof window === "undefined") return;
178 const systemDark = theme === "system" ? getSystemDark() : false;287 const appearanceMode = readAppearanceModePreference(theme);
179 if (lastAppliedTheme?.theme === theme && lastAppliedTheme.systemDark === systemDark) {288 const followSystem = appearanceMode === "system";
180 syncDesktopTheme(theme);289 const systemDark = followSystem ? getSystemDark() : false;
290 const themeHalves = readStoredThemeHalves();
291 if (
292 lastAppliedTheme?.theme === theme &&
293 lastAppliedTheme.systemDark === systemDark &&
294 lastAppliedTheme.followSystem === followSystem &&
295 lastAppliedTheme.appearanceMode === appearanceMode &&
296 themeHalvesSignature(lastAppliedTheme.themeHalves) === themeHalvesSignature(themeHalves)
297 ) {
298 syncDesktopTheme(theme, followSystem, appearanceMode);
181 return;299 return;
182 }300 }
183301
184 if (suppressTransitions) {302 if (suppressTransitions) {
185 document.documentElement.classList.add("no-transitions");303 document.documentElement.classList.add("no-transitions");
186 }304 }
187 const isDark = theme === "dark" || (theme === "system" && systemDark);305 const resolvedAppearance = resolveThemeAppearance(
306 theme,
307 systemDark,
308 followSystem,
309 appearanceMode,
310 themeHalves,
311 );
312 applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance);
313 const isDark = resolvedAppearance === "dark";
188 document.documentElement.classList.toggle("dark", isDark);314 document.documentElement.classList.toggle("dark", isDark);
189 lastAppliedTheme = { theme, systemDark };315 lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves };
190 syncBrowserChromeTheme();316 syncBrowserChromeTheme();
191 syncDesktopTheme(theme);317 syncDesktopTheme(theme, followSystem, appearanceMode);
192 if (suppressTransitions) {318 if (suppressTransitions) {
193 // Force a reflow so the no-transitions class takes effect before removal319 // Force a reflow so the no-transitions class takes effect before removal
194 // oxlint-disable-next-line no-unused-expressions320 // oxlint-disable-next-line no-unused-expressions
...@@ -202,34 +328,45 @@ function applyTheme(theme: Theme, suppressTransitions = false) {...@@ -202,34 +328,45 @@ function applyTheme(theme: Theme, suppressTransitions = false) {
202export async function syncDesktopThemePreference(328export async function syncDesktopThemePreference(
203 bridge: DesktopThemeBridge,329 bridge: DesktopThemeBridge,
204 theme: Theme,330 theme: Theme,
331 followSystem?: boolean,
332 appearanceMode?: ThemePreferenceMode,
333 halves: ThemeHalves | null = readStoredThemeHalves(),
205): Promise<void> {334): Promise<void> {
206 try {335 try {
207 await bridge.setTheme(theme);336 await bridge.setTheme(resolveDesktopTheme(theme, followSystem, appearanceMode, halves));
208 } catch (cause) {337 } catch (cause) {
209 throw new DesktopThemeSyncError({ theme, cause });338 throw new DesktopThemeSyncError({ theme, cause });
210 }339 }
211}340}
212341
213export function syncDesktopTheme(theme: Theme) {342export function syncDesktopTheme(
343 theme: Theme,
344 followSystem?: boolean,
345 appearanceMode?: ThemePreferenceMode,
346) {
214 if (typeof window === "undefined") return;347 if (typeof window === "undefined") return;
215 const bridge = window.desktopBridge;348 const bridge = window.desktopBridge;
216 if (!bridge || typeof bridge.setTheme !== "function" || lastDesktopTheme === theme) {349 const halves = readStoredThemeHalves();
350 const desktopTheme = resolveDesktopTheme(theme, followSystem, appearanceMode, halves);
351 if (!bridge || typeof bridge.setTheme !== "function" || lastDesktopTheme === desktopTheme) {
217 return;352 return;
218 }353 }
219354
220 lastDesktopTheme = theme;355 lastDesktopTheme = desktopTheme;
221 void syncDesktopThemePreference(bridge, theme).catch((cause: unknown) => {356 void syncDesktopThemePreference(bridge, theme, followSystem, appearanceMode, halves).catch(
222 const error = isDesktopThemeSyncError(cause)357 (cause: unknown) => {
223 ? cause358 const error = isDesktopThemeSyncError(cause)
224 : new DesktopThemeSyncError({ theme, cause });359 ? cause
225 console.error(error.message, {360 : new DesktopThemeSyncError({ theme, cause });
226 theme: error.theme,361 console.error(error.message, {
227 ...safeErrorLogAttributes(error),362 theme: error.theme,
228 });363 ...safeErrorLogAttributes(error),
229 if (lastDesktopTheme === theme) {364 });
230 lastDesktopTheme = null;365 if (lastDesktopTheme === desktopTheme) {
231 }366 lastDesktopTheme = null;
232 });367 }
368 },
369 );
233}370}
234371
235// Apply immediately on module load to prevent flash372// Apply immediately on module load to prevent flash
...@@ -239,14 +376,28 @@ if (typeof document !== "undefined" && typeof window !== "undefined") {...@@ -239,14 +376,28 @@ if (typeof document !== "undefined" && typeof window !== "undefined") {
239376
240function getSnapshot(): ThemeSnapshot {377function getSnapshot(): ThemeSnapshot {
241 if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT;378 if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT;
379 // Reading the preference hits localStorage, so only recompute after a
380 // change was signalled; useTheme consumers call this on every render.
381 if (!snapshotStale && lastSnapshot) return lastSnapshot;
382 snapshotStale = false;
242 const theme = getStored();383 const theme = getStored();
243 const systemDark = theme === "system" ? getSystemDark() : false;384 const appearanceMode = readAppearanceModePreference(theme);
385 const followSystem = appearanceMode === "system";
386 const systemDark = followSystem ? getSystemDark() : false;
387 const themeHalves = readStoredThemeHalves();
244388
245 if (lastSnapshot && lastSnapshot.theme === theme && lastSnapshot.systemDark === systemDark) {389 if (
390 lastSnapshot &&
391 lastSnapshot.theme === theme &&
392 lastSnapshot.systemDark === systemDark &&
393 lastSnapshot.followSystem === followSystem &&
394 lastSnapshot.appearanceMode === appearanceMode &&
395 themeHalvesSignature(lastSnapshot.themeHalves) === themeHalvesSignature(themeHalves)
396 ) {
246 return lastSnapshot;397 return lastSnapshot;
247 }398 }
248399
249 lastSnapshot = { theme, systemDark };400 lastSnapshot = { theme, systemDark, followSystem, appearanceMode, themeHalves };
250 return lastSnapshot;401 return lastSnapshot;
251}402}
252403
...@@ -254,32 +405,56 @@ function getServerSnapshot() {...@@ -254,32 +405,56 @@ function getServerSnapshot() {
254 return DEFAULT_THEME_SNAPSHOT;405 return DEFAULT_THEME_SNAPSHOT;
255}406}
256407
408function handleSystemAppearanceChange() {
409 const storedTheme = getStored();
410 if (readAppearanceModePreference(storedTheme) === "system") applyTheme(storedTheme, true);
411 emitChange();
412}
413
414function handleStorageChange(e: StorageEvent) {
415 if (e.key === STORAGE_KEY) {
416 themeStorageReadFailure = null;
417 applyTheme(getStored(), true);
418 emitChange();
419 } else if (e.key === THEME_FOLLOW_SYSTEM_STORAGE_KEY) {
420 applyTheme(getStored(), true);
421 emitChange();
422 } else if (e.key === THEME_APPEARANCE_MODE_STORAGE_KEY || e.key === THEME_HALVES_STORAGE_KEY) {
423 applyTheme(getStored(), true);
424 emitChange();
425 } else if (e.key === CUSTOM_THEMES_STORAGE_KEY || e.key === null) {
426 if (e.key === null) themeStorageReadFailure = null;
427 invalidateCustomThemes();
428 lastAppliedTheme = null;
429 applyTheme(getStored(), true);
430 emitChange();
431 }
432}
433
434let removeWindowListeners: (() => void) | null = null;
435
257function subscribe(listener: () => void): () => void {436function subscribe(listener: () => void): () => void {
258 if (typeof window === "undefined") return () => {};437 if (typeof window === "undefined") return () => {};
259 listeners.push(listener);438 listeners.push(listener);
260439
261 // Listen for system preference changes440 // The system-preference and cross-tab listeners are shared by all
262 const mq = typeof window.matchMedia === "function" ? window.matchMedia(MEDIA_QUERY) : null;441 // subscribers; each event applies the theme once and notifies everyone.
263 const handleChange = () => {442 if (!removeWindowListeners) {
264 if (getStored() === "system") applyTheme("system", true);443 const mq = typeof window.matchMedia === "function" ? window.matchMedia(MEDIA_QUERY) : null;
265 emitChange();444 mq?.addEventListener("change", handleSystemAppearanceChange);
266 };445 window.addEventListener("storage", handleStorageChange);
267 mq?.addEventListener("change", handleChange);446 removeWindowListeners = () => {
268447 mq?.removeEventListener("change", handleSystemAppearanceChange);
269 // Listen for storage changes from other tabs448 window.removeEventListener("storage", handleStorageChange);
270 const handleStorage = (e: StorageEvent) => {449 };
271 if (e.key === STORAGE_KEY) {450 }
272 themeStorageReadFailure = null;
273 applyTheme(getStored(), true);
274 emitChange();
275 }
276 };
277 window.addEventListener("storage", handleStorage);
278451
279 return () => {452 return () => {
280 listeners = listeners.filter((l) => l !== listener);453 listeners = listeners.filter((l) => l !== listener);
281 mq?.removeEventListener("change", handleChange);454 if (listeners.length === 0) {
282 window.removeEventListener("storage", handleStorage);455 removeWindowListeners?.();
456 removeWindowListeners = null;
457 }
283 };458 };
284}459}
285460
...@@ -287,13 +462,34 @@ export function useTheme() {...@@ -287,13 +462,34 @@ export function useTheme() {
287 const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);462 const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
288 const theme = snapshot.theme;463 const theme = snapshot.theme;
289464
290 const resolvedTheme: "light" | "dark" =465 const resolvedTheme: "light" | "dark" = resolveThemeAppearance(
291 theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme;466 theme,
467 snapshot.systemDark,
468 snapshot.followSystem,
469 snapshot.appearanceMode,
470 snapshot.themeHalves,
471 );
292472
293 const setTheme = useCallback((next: Theme) => {473 const setTheme = useCallback((next: Theme): boolean => {
294 if (typeof window === "undefined") return;474 if (typeof window === "undefined") return false;
295 try {475 try {
296 writeThemePreference(next);476 // Choosing a whole theme replaces any automatic-mode mix. The mix is
477 // captured first so a failed preference write can put it back instead
478 // of erasing it or leaving it attached to the new theme.
479 const previousHalvesRaw = window.localStorage.getItem(THEME_HALVES_STORAGE_KEY);
480 window.localStorage.removeItem(THEME_HALVES_STORAGE_KEY);
481 try {
482 writeThemePreference(next);
483 } catch (cause) {
484 if (previousHalvesRaw !== null) {
485 try {
486 window.localStorage.setItem(THEME_HALVES_STORAGE_KEY, previousHalvesRaw);
487 } catch {
488 // Storage is failing wholesale; the outer handler reports it.
489 }
490 }
491 throw cause;
492 }
297 } catch (cause) {493 } catch (cause) {
298 const error = isThemeStorageError(cause)494 const error = isThemeStorageError(cause)
299 ? cause495 ? cause
...@@ -309,16 +505,129 @@ export function useTheme() {...@@ -309,16 +505,129 @@ export function useTheme() {
309 theme: next,505 theme: next,
310 ...safeErrorLogAttributes(error),506 ...safeErrorLogAttributes(error),
311 });507 });
312 return;508 return false;
313 }509 }
314 applyTheme(next, true);510 applyTheme(next, true);
315 emitChange();511 emitChange();
512 return true;
513 }, []);
514
515 const setAppearanceMode = useCallback((nextAppearanceMode: ThemePreferenceMode): boolean => {
516 if (typeof window === "undefined") return false;
517 try {
518 writeAppearanceModePreference(nextAppearanceMode);
519 } catch (cause) {
520 const error = isThemeStorageError(cause)
521 ? cause
522 : new ThemeStorageError({
523 operation: "write",
524 storageKey: THEME_APPEARANCE_MODE_STORAGE_KEY,
525 cause,
526 });
527 console.error(error.message, {
528 operation: error.operation,
529 storageKey: error.storageKey,
530 ...safeErrorLogAttributes(error),
531 });
532 return false;
533 }
534 themeStorageReadFailure = null;
535 applyTheme(getStored(), true);
536 emitChange();
537 return true;
538 }, []);
539
540 const setFollowSystem = useCallback(
541 (nextFollowSystem: boolean): boolean => {
542 const currentMode = readAppearanceModePreference(theme);
543 const nextMode = nextFollowSystem
544 ? "system"
545 : currentMode === "system"
546 ? (getThemePreferenceMode(theme) ?? "light")
547 : currentMode;
548 return setAppearanceMode(nextMode);
549 },
550 [setAppearanceMode, theme],
551 );
552
553 const setThemeHalf = useCallback(
554 (appearance: ThemeAppearance, themeId: string | null): boolean => {
555 if (typeof window === "undefined") return false;
556 try {
557 const current = readStoredThemeHalves() ?? {};
558 const next: { light?: string; dark?: string } = { ...current };
559 if (themeId === null) delete next[appearance];
560 else next[appearance] = themeId;
561 if (next.light === undefined && next.dark === undefined) {
562 window.localStorage.removeItem(THEME_HALVES_STORAGE_KEY);
563 } else {
564 window.localStorage.setItem(THEME_HALVES_STORAGE_KEY, JSON.stringify(next));
565 }
566 } catch (cause) {
567 const error = new ThemeStorageError({
568 operation: "write",
569 storageKey: THEME_HALVES_STORAGE_KEY,
570 cause,
571 });
572 console.error(error.message, {
573 operation: error.operation,
574 storageKey: error.storageKey,
575 ...safeErrorLogAttributes(error),
576 });
577 return false;
578 }
579 applyTheme(getStored(), true);
580 emitChange();
581 return true;
582 },
583 [],
584 );
585
586 const clearThemeHalves = useCallback((): boolean => {
587 if (typeof window === "undefined") return false;
588 try {
589 window.localStorage.removeItem(THEME_HALVES_STORAGE_KEY);
590 } catch (cause) {
591 const error = new ThemeStorageError({
592 operation: "write",
593 storageKey: THEME_HALVES_STORAGE_KEY,
594 cause,
595 });
596 console.error(error.message, {
597 operation: error.operation,
598 storageKey: error.storageKey,
599 ...safeErrorLogAttributes(error),
600 });
601 return false;
602 }
603 applyTheme(getStored(), true);
604 emitChange();
605 return true;
606 }, []);
607
608 const refreshTheme = useCallback(() => {
609 if (typeof window === "undefined") return;
610 lastAppliedTheme = null;
611 applyTheme(getStored(), true);
612 emitChange();
316 }, []);613 }, []);
317614
318 // Keep DOM in sync on mount/change615 // Keep DOM in sync on mount/change
319 useEffect(() => {616 useEffect(() => {
320 applyTheme(theme);617 applyTheme(theme);
321 }, [theme]);618 }, [snapshot.appearanceMode, theme]);
322619
323 return { theme, setTheme, resolvedTheme } as const;620 return {
621 theme,
622 setTheme,
623 setAppearanceMode,
624 setFollowSystem,
625 setThemeHalf,
626 clearThemeHalves,
627 refreshTheme,
628 followSystem: snapshot.followSystem,
629 appearanceMode: snapshot.appearanceMode,
630 resolvedTheme,
631 themeHalves: snapshot.themeHalves,
632 } as const;
324}633}
apps/web/src/index.css+433-11
...@@ -148,6 +148,13 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -148,6 +148,13 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
148 --animate-status-ping: status-ping 2s infinite;148 --animate-status-ping: status-ping 2s infinite;
149 --color-warning-foreground: var(--warning-foreground);149 --color-warning-foreground: var(--warning-foreground);
150 --color-warning: var(--warning);150 --color-warning: var(--warning);
151 --color-warning-surface: var(--warning-surface);
152 --color-error-foreground: var(--error-foreground);
153 --color-error: var(--error);
154 --color-error-surface: var(--error-surface);
155 --color-update-foreground: var(--update-foreground);
156 --color-update: var(--update);
157 --color-update-surface: var(--update-surface);
151 --color-success-foreground: var(--success-foreground);158 --color-success-foreground: var(--success-foreground);
152 --color-success: var(--success);159 --color-success: var(--success);
153 --color-info-foreground: var(--info-foreground);160 --color-info-foreground: var(--info-foreground);
...@@ -161,6 +168,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -161,6 +168,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
161 --color-accent: var(--accent);168 --color-accent: var(--accent);
162 --color-muted-foreground: var(--muted-foreground);169 --color-muted-foreground: var(--muted-foreground);
163 --color-muted: var(--muted);170 --color-muted: var(--muted);
171 --color-placeholder: var(--placeholder);
172 --color-secondary-label: var(--secondary-label);
173 --color-icon-muted: var(--icon-muted);
164 --color-secondary-foreground: var(--secondary-foreground);174 --color-secondary-foreground: var(--secondary-foreground);
165 --color-secondary: var(--secondary);175 --color-secondary: var(--secondary);
166 --color-primary-foreground: var(--primary-foreground);176 --color-primary-foreground: var(--primary-foreground);
...@@ -172,6 +182,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -172,6 +182,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
172 --color-foreground: var(--foreground);182 --color-foreground: var(--foreground);
173 --color-background: var(--background);183 --color-background: var(--background);
174 --color-surface-raised: var(--surface-raised);184 --color-surface-raised: var(--surface-raised);
185 --color-message: var(--message-surface);
186 --color-message-foreground: var(--message-foreground);
187 --color-message-action: var(--message-action);
188 --color-message-action-foreground: var(--message-action-foreground);
189 --color-message-action-hover: var(--message-action-hover);
175 --color-sidebar: var(--sidebar);190 --color-sidebar: var(--sidebar);
176 --color-sidebar-foreground: var(--sidebar-foreground);191 --color-sidebar-foreground: var(--sidebar-foreground);
177 --color-sidebar-muted-foreground: var(--sidebar-muted-foreground);192 --color-sidebar-muted-foreground: var(--sidebar-muted-foreground);
...@@ -833,7 +848,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -833,7 +848,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
833@utility surface-grain {848@utility surface-grain {
834 background-image: var(--surface-grain);849 background-image: var(--surface-grain);
835 background-repeat: repeat;850 background-repeat: repeat;
836 background-size: 256px 256px;851 background-size: var(--surface-grain-size);
837}852}
838853
839/* Suppress all transitions during theme changes */854/* Suppress all transitions during theme changes */
...@@ -850,6 +865,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -850,6 +865,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
850 --radius: 0.625rem;865 --radius: 0.625rem;
851 --background: var(--color-zinc-25);866 --background: var(--color-zinc-25);
852 --app-chrome-background: var(--background);867 --app-chrome-background: var(--background);
868 --toolbar-background: var(--app-chrome-background);
869 --toolbar-foreground: var(--foreground);
870 --toolbar-border: var(--border);
871 --toolbar-control: var(--popover);
872 --toolbar-control-foreground: var(--foreground);
873 --toolbar-control-hover: var(--accent);
853 --surface-raised: color-mix(in srgb, var(--card) 20%, transparent);874 --surface-raised: color-mix(in srgb, var(--card) 20%, transparent);
854 --foreground: var(--color-zinc-800);875 --foreground: var(--color-zinc-800);
855 --card: var(--color-white);876 --card: var(--color-white);
...@@ -862,19 +883,34 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -862,19 +883,34 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
862 --secondary-foreground: var(--color-zinc-800);883 --secondary-foreground: var(--color-zinc-800);
863 --muted: var(--color-zinc-50);884 --muted: var(--color-zinc-50);
864 --muted-foreground: var(--color-zinc-500);885 --muted-foreground: var(--color-zinc-500);
886 --placeholder: var(--muted-foreground);
887 --secondary-label: var(--muted-foreground);
888 --icon-muted: var(--muted-foreground);
889 --message-surface: var(--accent);
890 --message-foreground: var(--foreground);
891 --message-action: var(--primary);
892 --message-action-foreground: var(--primary-foreground);
893 --message-action-hover: color-mix(in srgb, var(--primary) 90%, var(--background));
865 --accent: var(--color-zinc-100);894 --accent: var(--color-zinc-100);
866 --accent-foreground: var(--color-zinc-900);895 --accent-foreground: var(--color-zinc-900);
867 --destructive: var(--color-red-500);896 --error: var(--color-red-500);
897 --error-foreground: var(--color-red-700);
898 --error-surface: color-mix(in srgb, var(--error) 8%, transparent);
899 --destructive: var(--error);
868 --border: var(--color-zinc-200);900 --border: var(--color-zinc-200);
869 --input: var(--color-zinc-300);901 --input: var(--color-zinc-300);
870 --ring: oklch(0.488 0.217 264);902 --ring: oklch(0.488 0.217 264);
871 --destructive-foreground: var(--color-red-700);903 --destructive-foreground: var(--error-foreground);
872 --info: var(--color-blue-500);904 --info: var(--color-blue-500);
873 --info-foreground: var(--color-blue-700);905 --info-foreground: var(--color-blue-700);
874 --success: var(--color-emerald-500);906 --success: var(--color-emerald-500);
875 --success-foreground: var(--color-emerald-700);907 --success-foreground: var(--color-emerald-700);
876 --warning: var(--color-amber-500);908 --warning: var(--color-amber-500);
877 --warning-foreground: var(--color-amber-700);909 --warning-foreground: var(--color-amber-700);
910 --warning-surface: color-mix(in srgb, var(--warning) 8%, transparent);
911 --update: var(--primary);
912 --update-foreground: var(--primary);
913 --update-surface: color-mix(in srgb, var(--update) 12%, transparent);
878 /* Keep every sidebar primitive on the same light surface hierarchy, including914 /* Keep every sidebar primitive on the same light surface hierarchy, including
879 portaled mobile sheets and settings navigation outside the app sidebar. */915 portaled mobile sheets and settings navigation outside the app sidebar. */
880 --sidebar: var(--color-zinc-50);916 --sidebar: var(--color-zinc-50);
...@@ -886,6 +922,14 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -886,6 +922,14 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
886 --sidebar-row-selected: var(--color-white);922 --sidebar-row-selected: var(--color-white);
887 --sidebar-border: var(--border);923 --sidebar-border: var(--border);
888 --sidebar-stage-fade: var(--sidebar);924 --sidebar-stage-fade: var(--sidebar);
925 --code-background: color-mix(in srgb, var(--card) 90%, var(--background));
926 --code-foreground: var(--foreground);
927 --terminal-background: var(--background);
928 --terminal-foreground: var(--foreground);
929 --terminal-cursor: rgb(38 56 78);
930 --terminal-selection-background: rgb(37 63 99 / 20%);
931 --terminal-scrollbar: rgb(0 0 0 / 15%);
932 --terminal-scrollbar-hover: rgb(0 0 0 / 25%);
889933
890 @variant dark {934 @variant dark {
891 color-scheme: dark;935 color-scheme: dark;
...@@ -905,19 +949,34 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -905,19 +949,34 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
905 --secondary-foreground: var(--color-neutral-100);949 --secondary-foreground: var(--color-neutral-100);
906 --muted: --alpha(var(--color-white) / 4%);950 --muted: --alpha(var(--color-white) / 4%);
907 --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white));951 --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white));
952 --placeholder: var(--muted-foreground);
953 --secondary-label: var(--muted-foreground);
954 --icon-muted: var(--muted-foreground);
955 --message-surface: var(--accent);
956 --message-foreground: var(--foreground);
957 --message-action: var(--primary);
958 --message-action-foreground: var(--primary-foreground);
959 --message-action-hover: color-mix(in srgb, var(--primary) 90%, var(--background));
908 --accent: --alpha(var(--color-white) / 4%);960 --accent: --alpha(var(--color-white) / 4%);
909 --accent-foreground: var(--color-neutral-100);961 --accent-foreground: var(--color-neutral-100);
910 --destructive: color-mix(in srgb, var(--color-red-500) 90%, var(--color-white));962 --error: color-mix(in srgb, var(--color-red-500) 90%, var(--color-white));
963 --error-foreground: var(--color-red-400);
964 --error-surface: color-mix(in srgb, var(--error) 16%, transparent);
965 --destructive: var(--error);
911 --border: --alpha(var(--color-white) / 6%);966 --border: --alpha(var(--color-white) / 6%);
912 --input: --alpha(var(--color-white) / 8%);967 --input: --alpha(var(--color-white) / 8%);
913 --ring: oklch(0.588 0.217 264);968 --ring: oklch(0.588 0.217 264);
914 --destructive-foreground: var(--color-red-400);969 --destructive-foreground: var(--error-foreground);
915 --info: var(--color-blue-500);970 --info: var(--color-blue-500);
916 --info-foreground: var(--color-blue-400);971 --info-foreground: var(--color-blue-400);
917 --success: var(--color-emerald-500);972 --success: var(--color-emerald-500);
918 --success-foreground: var(--color-emerald-400);973 --success-foreground: var(--color-emerald-400);
919 --warning: var(--color-amber-500);974 --warning: var(--color-amber-500);
920 --warning-foreground: var(--color-amber-400);975 --warning-foreground: var(--color-amber-400);
976 --warning-surface: color-mix(in srgb, var(--warning) 16%, transparent);
977 --update: var(--primary);
978 --update-foreground: var(--primary);
979 --update-surface: color-mix(in srgb, var(--update) 18%, transparent);
921 --sidebar: var(--card);980 --sidebar: var(--card);
922 --sidebar-foreground: var(--foreground);981 --sidebar-foreground: var(--foreground);
923 --sidebar-muted-foreground: var(--muted-foreground);982 --sidebar-muted-foreground: var(--muted-foreground);
...@@ -927,6 +986,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -927,6 +986,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
927 --sidebar-row-selected: var(--muted);986 --sidebar-row-selected: var(--muted);
928 --sidebar-border: var(--border);987 --sidebar-border: var(--border);
929 --sidebar-stage-fade: var(--card);988 --sidebar-stage-fade: var(--card);
989 --terminal-background: var(--background);
990 --terminal-foreground: var(--foreground);
991 --terminal-cursor: rgb(180 203 255);
992 --terminal-selection-background: rgb(180 203 255 / 25%);
993 --terminal-scrollbar: rgb(255 255 255 / 10%);
994 --terminal-scrollbar-hover: rgb(255 255 255 / 18%);
930 }995 }
931}996}
932997
...@@ -984,6 +1049,335 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -984,6 +1049,335 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
984 --sidebar-stage-fade: var(--card);1049 --sidebar-stage-fade: var(--card);
985}1050}
9861051
1052/* Theme files are expressed in app color roles and mapped to the existing
1053 semantic surface tokens here. Keep this block after the sidebar-version
1054 compatibility overrides so both navigation implementations receive the
1055 same palette. Success, info, provider, and channel identity colors remain
1056 independent; error, warning, and update roles are themeable below. */
1057html[data-theme-id],
1058html.dark[data-theme-id] {
1059 --background: var(--app-theme-canvas);
1060 --app-chrome-background: var(--app-theme-chrome);
1061 --toolbar-background: var(--app-theme-toolbar);
1062 --toolbar-foreground: var(--app-theme-toolbar-foreground);
1063 --toolbar-border: var(--app-theme-toolbar-border);
1064 --toolbar-control: var(--app-theme-toolbar-control);
1065 --toolbar-control-foreground: var(--app-theme-toolbar-control-foreground);
1066 --toolbar-control-hover: var(--app-theme-toolbar-control-hover);
1067 --surface-raised: var(--app-theme-surface-raised);
1068 --foreground: var(--app-theme-text);
1069 --card: var(--app-theme-surface);
1070 --card-foreground: var(--app-theme-text);
1071 --popover: var(--app-theme-surface-overlay);
1072 --popover-foreground: var(--app-theme-text);
1073 /* Solid controls (CTAs, switches, sliders, the send button) all share the
1074 theme's action color so every "act now" affordance reads consistently;
1075 the accent keeps driving surfaces, selection, and focus. Palettes without
1076 a distinct action color make these identical. */
1077 --primary: var(--app-theme-message-action);
1078 --primary-foreground: var(--app-theme-message-action-foreground);
1079 --secondary: var(--app-theme-secondary);
1080 --secondary-foreground: var(--app-theme-secondary-foreground);
1081 --muted: var(--app-theme-muted);
1082 --muted-foreground: var(--app-theme-text-muted);
1083 --placeholder: var(--app-theme-placeholder);
1084 --secondary-label: var(--app-theme-secondary-label);
1085 --icon-muted: var(--app-theme-icon-muted);
1086 --accent: var(--app-theme-accent-surface);
1087 --accent-foreground: var(--app-theme-accent-surface-foreground);
1088 --message-surface: var(--app-theme-message-surface);
1089 --message-foreground: var(--app-theme-message-foreground);
1090 --message-action: var(--app-theme-message-action);
1091 --message-action-foreground: var(--app-theme-message-action-foreground);
1092 --message-action-hover: var(--app-theme-message-action-hover);
1093 --error: var(--app-theme-error);
1094 --error-foreground: var(--app-theme-error-foreground);
1095 --error-surface: var(--app-theme-error-surface);
1096 --destructive: var(--error);
1097 --destructive-foreground: var(--error-foreground);
1098 --warning: var(--app-theme-warning);
1099 --warning-foreground: var(--app-theme-warning-foreground);
1100 --warning-surface: var(--app-theme-warning-surface);
1101 --update: var(--app-theme-update);
1102 --update-foreground: var(--app-theme-update-foreground);
1103 --update-surface: var(--app-theme-update-surface);
1104 --border: var(--app-theme-border);
1105 --input: var(--app-theme-input);
1106 --ring: var(--app-theme-focus);
1107 --sidebar: var(--app-theme-sidebar);
1108 --sidebar-foreground: var(--app-theme-sidebar-foreground);
1109 --sidebar-muted-foreground: var(--app-theme-sidebar-muted-foreground);
1110 --sidebar-control-surface: var(--app-theme-sidebar-control-surface);
1111 --sidebar-row-hover: var(--app-theme-sidebar-row-hover);
1112 --sidebar-row-active: var(--app-theme-sidebar-row-active);
1113 --sidebar-row-selected: var(--app-theme-sidebar-row-selected);
1114 --sidebar-border: var(--app-theme-sidebar-border);
1115 --sidebar-stage-fade: var(--app-theme-sidebar);
1116 --code-background: var(--app-theme-code-background);
1117 --code-foreground: var(--app-theme-code-foreground);
1118 --terminal-background: var(--app-theme-terminal-background);
1119 --terminal-foreground: var(--app-theme-terminal-foreground);
1120 --terminal-cursor: var(--app-theme-terminal-cursor);
1121 --terminal-selection-background: var(--app-theme-terminal-selection-background);
1122 --terminal-scrollbar: var(--app-theme-terminal-scrollbar);
1123 --terminal-scrollbar-hover: var(--app-theme-terminal-scrollbar-hover);
1124}
1125
1126/* T3 Chat's composer is a translucent lift over --chat-background. Route its
1127 measured flattened color through the raised-surface role instead of deriving
1128 another tint from the canvas, which made the dark composer too red. */
1129html[data-theme-id] .chat-composer-glass-shell {
1130 --chat-composer-glass-surface: var(--app-theme-surface-raised);
1131}
1132
1133html[data-theme-id]:not(.dark) .chat-composer-glass-shell {
1134 --chat-composer-outline: var(--app-theme-toolbar-border);
1135}
1136
1137html.dark[data-theme-id] .chat-composer-glass-shell {
1138 --chat-composer-outline: color-mix(in srgb, var(--app-theme-input) 30%, var(--background));
1139 --chat-composer-highlight: color-mix(in srgb, var(--app-theme-input) 12%, transparent);
1140}
1141
1142html.dark[data-theme-id="t3-chat"] .chat-composer-glass-shell {
1143 /* T3 Chat's visible composer edge is a dark plum, not the stock translucent
1144 white outline. Its highlight is derived from --chat-input-gradient. */
1145 --chat-composer-outline: #241e28;
1146 --chat-composer-highlight: color-mix(in srgb, #432d48 12%, transparent);
1147}
1148
1149html[data-theme-id]:not(.dark) {
1150 color-scheme: light;
1151}
1152
1153html.dark[data-theme-id] {
1154 color-scheme: dark;
1155}
1156
1157html[data-theme-id] body {
1158 background-color: var(--app-chrome-background);
1159 color: var(--foreground);
1160}
1161
1162/* Theme-token dependency probes are restored synchronously, before paint. Keep
1163 transitions from observing the temporary sentinel color in between. */
1164html[data-theme-token-probe],
1165html[data-theme-token-probe] *,
1166html[data-theme-token-probe] *::before,
1167html[data-theme-token-probe] *::after {
1168 transition: none !important;
1169}
1170
1171#theme-inspector-spotlight {
1172 position: fixed;
1173 z-index: 100;
1174 inset: 0;
1175 width: 100vw;
1176 height: 100dvh;
1177 overflow: visible;
1178 pointer-events: none;
1179}
1180
1181[data-slot="popover-positioner"]:has([data-theme-editor-panel]),
1182[data-slot="tooltip-positioner"]:has([data-theme-editor-panel]) {
1183 z-index: 120;
1184}
1185
1186.theme-inspector-spotlight-dimmer {
1187 fill: rgb(2 3 8 / 54%);
1188}
1189
1190.theme-inspector-spotlight-glow {
1191 fill: color-mix(in oklab, var(--ring) 9%, transparent);
1192 stroke: color-mix(in oklab, var(--ring) 72%, white);
1193 stroke-width: 2px;
1194 opacity: 0.92;
1195 vector-effect: non-scaling-stroke;
1196}
1197
1198#theme-inspector-hover {
1199 position: fixed;
1200 z-index: 101;
1201 pointer-events: none;
1202 background: color-mix(in oklab, var(--ring) 7%, transparent);
1203 border: 1px solid color-mix(in oklab, var(--ring) 70%, white);
1204 box-shadow:
1205 0 0 0 3px color-mix(in oklab, var(--ring) 16%, transparent),
1206 0 0 18px color-mix(in oklab, var(--ring) 38%, transparent);
1207}
1208
1209#theme-inspector-hover [data-theme-inspector-hover-label] {
1210 position: absolute;
1211 left: 0;
1212 max-width: 12rem;
1213 overflow: hidden;
1214 padding: 0.2rem 0.45rem;
1215 border: 1px solid color-mix(in oklab, var(--ring) 48%, var(--border));
1216 border-radius: 0.4rem;
1217 background: var(--popover);
1218 box-shadow: 0 4px 16px rgb(0 0 0 / 24%);
1219 color: var(--popover-foreground);
1220 font-size: 0.6875rem;
1221 font-weight: 600;
1222 line-height: 1rem;
1223 text-overflow: ellipsis;
1224 white-space: nowrap;
1225}
1226
1227#theme-inspector-hover[data-placement="above"] [data-theme-inspector-hover-label] {
1228 bottom: calc(100% + 0.35rem);
1229}
1230
1231#theme-inspector-hover[data-placement="below"] [data-theme-inspector-hover-label] {
1232 top: calc(100% + 0.35rem);
1233}
1234
1235/* Workspace actions use a small, separate chrome hierarchy. Keeping these
1236 roles explicit prevents the thread toolbar from falling back to the default
1237 dark-mode whites while preserving the same theme file across clients. */
1238html[data-theme-id] [data-chat-header] {
1239 background-color: var(--toolbar-background);
1240 color: var(--toolbar-foreground);
1241}
1242
1243html[data-theme-id] [data-chat-header] [data-slot="button"],
1244html[data-theme-id] [data-chat-header] [data-slot="menu-trigger"],
1245html[data-theme-id] [data-chat-header] [data-toolbar-control] {
1246 --control-icon-color: var(--toolbar-control-foreground);
1247 background-color: var(--toolbar-control);
1248 border-color: var(--toolbar-border);
1249 color: var(--toolbar-control-foreground);
1250}
1251
1252/* The panel layout toggles stay ghost: they render both inside the header and
1253 in the titlebar strip, so filling them would make them change appearance as
1254 the panel opens. They only take the themed foreground; hover and pressed
1255 keep the base ghost accent. The tooltip trigger's data-slot wins over the
1256 toggle's when the trigger renders the toggle, so match both. */
1257html[data-theme-id] [data-panel-layout-controls] [data-slot="toggle"],
1258html[data-theme-id] [data-panel-layout-controls] [data-slot="tooltip-trigger"],
1259html[data-theme-id] .workspace-titlebar-controls [data-slot="toggle"],
1260html[data-theme-id] .workspace-titlebar-controls [data-slot="tooltip-trigger"] {
1261 --control-icon-color: var(--toolbar-foreground);
1262 color: var(--toolbar-foreground);
1263}
1264
1265html[data-theme-id] [data-chat-header] [data-slot="button"]:hover,
1266html[data-theme-id] [data-chat-header] [data-slot="button"][data-pressed],
1267html[data-theme-id] [data-chat-header] [data-slot="menu-trigger"]:hover,
1268html[data-theme-id] [data-chat-header] [data-slot="menu-trigger"][data-pressed],
1269html[data-theme-id] [data-chat-header] [data-toolbar-control]:hover,
1270html[data-theme-id] [data-chat-header] [data-toolbar-control][data-pressed] {
1271 background-color: var(--toolbar-control-hover);
1272}
1273
1274html[data-theme-id] [data-chat-header] [data-slot="separator"] {
1275 background-color: var(--toolbar-border);
1276}
1277
1278/* Chat code blocks join the other code surfaces on the code tokens. The
1279 default look keeps its secondary/input tints; themed palettes tint those
1280 surfaces with the accent, which fights syntax highlighting. */
1281html[data-theme-id] .chat-markdown .chat-markdown-codeblock {
1282 background-color: var(--code-background);
1283 border-color: var(--border);
1284 color: var(--code-foreground);
1285}
1286
1287html[data-theme-id] .chat-markdown pre {
1288 background-color: var(--code-background);
1289 color: var(--code-foreground);
1290}
1291
1292html[data-theme-id] .chat-markdown .chat-markdown-codeblock pre {
1293 background-color: transparent;
1294}
1295
1296html[data-theme-id] .chat-markdown .chat-markdown-codeblock-header,
1297html[data-theme-id] .chat-markdown .chat-markdown-chrome-action {
1298 color: color-mix(in srgb, var(--code-foreground) 72%, transparent);
1299}
1300
1301/* T3 Chat renders inline code and compact chat artifacts with its translucent
1302 secondary surface flattened over the light chat canvas. The raw muted and
1303 secondary tokens are substantially darker than those visible pixels. */
1304html[data-theme-id="t3-chat"]:not(.dark) .chat-markdown :not(pre) > code,
1305html[data-theme-id="t3-chat"]:not(.dark) [data-changed-files-state],
1306html[data-theme-id="t3-chat"]:not(.dark) [data-changed-files-header] {
1307 background-color: var(--message-surface);
1308}
1309
1310html[data-theme-id="t3-chat"]:not(.dark) .chat-markdown :not(pre) > code,
1311html[data-theme-id="t3-chat"]:not(.dark) [data-changed-files-state] {
1312 border-color: transparent;
1313}
1314
1315html[data-theme-id="t3-chat"]:not(.dark) .chat-markdown :not(pre) > code {
1316 color: var(--message-foreground);
1317}
1318
1319html[data-theme-id] .chat-markdown .chat-markdown-chrome-action:hover,
1320html[data-theme-id] .chat-markdown .chat-markdown-chrome-action[aria-pressed="true"] {
1321 color: var(--code-foreground);
1322}
1323
1324html[data-theme-id] [data-sidebar-version="v1"],
1325html[data-theme-id] [data-sidebar-version="v2"] {
1326 --background: var(--app-theme-canvas);
1327 --foreground: var(--app-theme-text);
1328 --card: var(--app-theme-surface);
1329 --card-foreground: var(--app-theme-text);
1330 --accent: var(--app-theme-accent-surface);
1331 --accent-foreground: var(--app-theme-accent-surface-foreground);
1332 --muted: var(--app-theme-muted);
1333 --muted-foreground: var(--app-theme-text-muted);
1334 --border: var(--app-theme-border);
1335 --input: var(--app-theme-input);
1336 --sidebar: var(--app-theme-sidebar);
1337 --sidebar-foreground: var(--app-theme-sidebar-foreground);
1338 --sidebar-muted-foreground: var(--app-theme-sidebar-muted-foreground);
1339 --sidebar-control-surface: var(--app-theme-sidebar-control-surface);
1340 --sidebar-row-hover: var(--app-theme-sidebar-row-hover);
1341 --sidebar-row-active: var(--app-theme-sidebar-row-active);
1342 --sidebar-row-selected: var(--app-theme-sidebar-row-selected);
1343 --sidebar-border: var(--app-theme-sidebar-border);
1344 --sidebar-stage-fade: var(--app-theme-sidebar);
1345 background-color: var(--sidebar);
1346}
1347
1348/* Keep the navigation edge as quiet as the standard palettes. Theme files may
1349 still use sidebarBorder for controls and internal separators, but the outer
1350 divider should not become more prominent just because a palette is vivid. */
1351html[data-theme-id] [data-app-sidebar] {
1352 border-color: color-mix(in srgb, var(--sidebar-foreground) 10%, transparent);
1353}
1354
1355html.dark[data-theme-id] [data-app-sidebar] {
1356 border-color: color-mix(in srgb, var(--sidebar-foreground) 8%, transparent);
1357}
1358
1359/* T3 Chat's panel divider is deliberately pink, and its resize affordance
1360 keeps that color while hovered. Do not neutralize this branded edge. */
1361html.dark[data-theme-id="t3-chat"] [data-app-sidebar] {
1362 border-color: var(--sidebar-border);
1363}
1364
1365.theme-json-key {
1366 color: var(--app-theme-accent, var(--color-blue-600));
1367}
1368
1369.theme-json-string {
1370 color: var(--app-theme-message-action, var(--color-emerald-600));
1371}
1372
1373.theme-json-number {
1374 color: var(--app-theme-secondary-foreground, var(--color-amber-600));
1375}
1376
1377.theme-json-constant {
1378 color: var(--app-theme-accent-surface-foreground, var(--color-violet-600));
1379}
1380
987body {1381body {
988 /* Reference the theme token (not a literal stack) so the Settings ->1382 /* Reference the theme token (not a literal stack) so the Settings ->
989 Appearance runtime override of --font-sans reaches all interface text. */1383 Appearance runtime override of --font-sans reaches all interface text. */
...@@ -1028,12 +1422,21 @@ body {...@@ -1028,12 +1422,21 @@ body {
1028 grain's subtle brightening and stand out against the body. */1422 grain's subtle brightening and stand out against the body. */
1029:root {1423:root {
1030 --surface-grain: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.035'/%3E%3C/svg%3E");1424 --surface-grain: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.035'/%3E%3C/svg%3E");
1425 --surface-grain-size: 256px 256px;
1426}
1427
1428/* The live T3 Chat texture is a 128px grayscale tile with a constant 7/255
1429 alpha. Keep our procedural texture cheap while matching that scale and
1430 strength for the branded preset. */
1431html[data-theme-id="t3-chat"] {
1432 --surface-grain: url("data:image/svg+xml,%3Csvg viewBox='0 0 128 128' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.02745'/%3E%3C/svg%3E");
1433 --surface-grain-size: 128px 128px;
1031}1434}
10321435
1033body {1436body {
1034 background-image: var(--surface-grain);1437 background-image: var(--surface-grain);
1035 background-repeat: repeat;1438 background-repeat: repeat;
1036 background-size: 256px 256px;1439 background-size: var(--surface-grain-size);
1037}1440}
10381441
1039pre,1442pre,
...@@ -1584,15 +1987,34 @@ label:has(> select#reasoning-effort) select {...@@ -1584,15 +1987,34 @@ label:has(> select#reasoning-effort) select {
15841987
1585/* Diffs theme bridge (match diff surfaces to app palette) */1988/* Diffs theme bridge (match diff surfaces to app palette) */
1586.diff-panel-viewport {1989.diff-panel-viewport {
1587 background: color-mix(in srgb, var(--background) 94%, var(--card));1990 background: var(--background);
1991}
1992
1993/* Diffs live directly on the panel canvas. Normal chat code blocks may use a
1994 raised code surface, but carrying that fill into the diff creates a card-like
1995 rectangle that does not belong in the panel. */
1996.diff-render-surface {
1997 --code-background: var(--background);
1588}1998}
15891999
1590.diff-render-file,2000.diff-render-file,
1591.diff-render-surface > diffs-container {2001.diff-render-surface diffs-container {
1592 border: 1px solid var(--border);2002 border: 0;
1593 border-radius: 0.5rem;2003 border-radius: 0;
1594 overflow: clip;2004 overflow: clip;
1595 background: color-mix(in srgb, var(--card) 92%, var(--background));2005 background: var(--code-background);
2006 color: var(--code-foreground);
2007}
2008
2009/* Pierre's file renderer owns its own surface tokens. Keep the root of a file
2010 preview on the same themeable code surface as the diff panel. */
2011.diff-render-surface diffs-container,
2012.file-preview-virtualizer diffs-container {
2013 --diffs-bg: var(--code-background) !important;
2014 --diffs-light-bg: var(--code-background) !important;
2015 --diffs-dark-bg: var(--code-background) !important;
2016 background-color: var(--code-background) !important;
2017 color: var(--code-foreground) !important;
1596}2018}
15972019
1598@keyframes ultrathink-rainbow {2020@keyframes ultrathink-rainbow {
apps/web/src/keybindings.test.ts+23
...@@ -128,6 +128,10 @@ const DEFAULT_BINDINGS = compile([...@@ -128,6 +128,10 @@ const DEFAULT_BINDINGS = compile([
128 command: "projectSearch.toggle",128 command: "projectSearch.toggle",
129 whenAst: whenNot(whenIdentifier("terminalFocus")),129 whenAst: whenNot(whenIdentifier("terminalFocus")),
130 },130 },
131 {
132 shortcut: modShortcut("t", { altKey: true, shiftKey: true }),
133 command: "themeEditor.toggle",
134 },
131 {135 {
132 shortcut: modShortcut("m", { shiftKey: true }),136 shortcut: modShortcut("m", { shiftKey: true }),
133 command: "modelPicker.toggle",137 command: "modelPicker.toggle",
...@@ -566,6 +570,25 @@ describe("chat/editor shortcuts", () => {...@@ -566,6 +570,25 @@ describe("chat/editor shortcuts", () => {
566 );570 );
567 });571 });
568572
573 it("matches themeEditor.toggle on macOS and Windows", () => {
574 assert.strictEqual(
575 resolveShortcutCommand(
576 event({ key: "t", metaKey: true, altKey: true, shiftKey: true }),
577 DEFAULT_BINDINGS,
578 { platform: "MacIntel" },
579 ),
580 "themeEditor.toggle",
581 );
582 assert.strictEqual(
583 resolveShortcutCommand(
584 event({ key: "t", ctrlKey: true, altKey: true, shiftKey: true }),
585 DEFAULT_BINDINGS,
586 { platform: "Win32" },
587 ),
588 "themeEditor.toggle",
589 );
590 });
591
569 it("matches diff.toggle shortcut outside terminal focus", () => {592 it("matches diff.toggle shortcut outside terminal focus", () => {
570 assert.isTrue(593 assert.isTrue(
571 isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, {594 isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, {
apps/web/src/lib/diffRendering.ts+71
...@@ -172,3 +172,74 @@ export function getDiffCollapseIconClassName(fileDiff: FileDiffMetadata): string...@@ -172,3 +172,74 @@ export function getDiffCollapseIconClassName(fileDiff: FileDiffMetadata): string
172 return "text-muted-foreground/80";172 return "text-muted-foreground/80";
173 }173 }
174}174}
175
176/**
177 * Maps every diff/file surface the @pierre/diffs renderer paints onto the
178 * app's code tokens, so themed palettes reach the code body, gutter, and
179 * row tints instead of the renderer's bundled colors. Shared by the diff
180 * panel and the file preview.
181 */
182export const DIFF_SURFACE_THEME_UNSAFE_CSS = `
183[data-diffs-header],
184[data-diff],
185[data-file],
186[data-error-wrapper],
187[data-virtualizer-buffer] {
188 --diffs-header-font-family: var(--font-sans) !important;
189 --diffs-font-family: var(--font-mono) !important;
190 --diffs-bg: var(--code-background) !important;
191 --diffs-light-bg: var(--code-background) !important;
192 --diffs-dark-bg: var(--code-background) !important;
193 --diffs-token-light-bg: transparent;
194 --diffs-token-dark-bg: transparent;
195
196 /* Gutter, context, and row tints all derive from the code surface the diff
197 body sits on — mixing from the canvas leaves the gutter looking unthemed
198 when a palette separates the two. */
199 --diffs-bg-context-override: color-mix(in srgb, var(--code-background) 97%, var(--code-foreground));
200 --diffs-bg-hover-override: color-mix(in srgb, var(--code-background) 94%, var(--code-foreground));
201 --diffs-bg-separator-override: color-mix(
202 in srgb,
203 var(--code-background) 95%,
204 var(--code-foreground)
205 );
206 --diffs-bg-buffer-override: color-mix(in srgb, var(--code-background) 90%, var(--code-foreground));
207
208 --diffs-bg-addition-override: light-dark(
209 color-mix(in srgb, var(--code-background) 50%, var(--success)),
210 color-mix(in srgb, var(--code-background) 70%, var(--success))
211 );
212 --diffs-bg-addition-number-override: light-dark(
213 color-mix(in srgb, var(--code-background) 35%, var(--success)),
214 color-mix(in srgb, var(--code-background) 60%, var(--success))
215 );
216 --diffs-bg-addition-hover-override: color-mix(in srgb, var(--code-background) 85%, var(--success));
217 --diffs-bg-addition-emphasis-override: color-mix(
218 in srgb,
219 var(--code-background) 80%,
220 var(--success)
221 );
222
223 --diffs-bg-deletion-override: light-dark(
224 color-mix(in srgb, var(--code-background) 50%, var(--destructive)),
225 color-mix(in srgb, var(--code-background) 70%, var(--destructive))
226 );
227 --diffs-bg-deletion-number-override: light-dark(
228 color-mix(in srgb, var(--code-background) 35%, var(--destructive)),
229 color-mix(in srgb, var(--code-background) 60%, var(--destructive))
230 );
231 --diffs-bg-deletion-hover-override: color-mix(
232 in srgb,
233 var(--code-background) 85%,
234 var(--destructive)
235 );
236 --diffs-bg-deletion-emphasis-override: color-mix(
237 in srgb,
238 var(--code-background) 80%,
239 var(--destructive)
240 );
241
242 background-color: var(--diffs-bg) !important;
243 color: var(--code-foreground) !important;
244}
245`;
apps/web/src/routes/__root.tsx+4
...@@ -19,6 +19,7 @@ import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstall...@@ -19,6 +19,7 @@ import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstall
19import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";19import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";
20import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";20import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
21import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";21import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";
22import { ThemeEditorHost } from "../components/settings/ThemeEditorHost";
22import { Button } from "../components/ui/button";23import { Button } from "../components/ui/button";
23import {24import {
24 AnchoredToastProvider,25 AnchoredToastProvider,
...@@ -139,6 +140,9 @@ function RootRouteView() {...@@ -139,6 +140,9 @@ function RootRouteView() {
139 {primaryEnvironmentAuthenticated ? <EventRouter /> : null}140 {primaryEnvironmentAuthenticated ? <EventRouter /> : null}
140 {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null}141 {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null}
141 {appShell}142 {appShell}
143 {/* Above the router: a theme draft is judged by walking the app, so the
144 editor has to survive navigation away from settings. */}
145 <ThemeEditorHost />
142 </AnchoredToastProvider>146 </AnchoredToastProvider>
143 </ToastProvider>147 </ToastProvider>
144 );148 );
apps/web/src/themeBoot.test.ts created+455
...@@ -0,0 +1,455 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2
3import indexHtml from "../index.html?raw";
4import {
5 CUSTOM_THEMES_STORAGE_KEY,
6 getDefaultThemeColors,
7 getThemeColorsForMode,
8 invalidateCustomThemes,
9 isKnownThemePreference,
10 resolveThemeAppearance,
11 T3_CHAT_THEME,
12 EMBER_THEME,
13 GROVE_THEME,
14 IRIS_THEME,
15 OCEAN_THEME,
16 THEME_APPEARANCE_MODE_STORAGE_KEY,
17 THEME_FOLLOW_SYSTEM_STORAGE_KEY,
18} from "./themePalette";
19
20const THEME_STORAGE_KEY = "t3code:theme";
21// A custom theme that omits chrome falls back to the runtime default, so the
22// boot copy of that default stays derived from the real palette.
23const DEFAULT_DARK_CHROME = getDefaultThemeColors("dark").chrome;
24
25const bootScript = (() => {
26 const match = indexHtml.match(/<script>([\s\S]*?)<\/script>/);
27 if (!match?.[1]) throw new Error("Could not find the inline boot script in index.html");
28 return match[1];
29})();
30
31type BootResult = {
32 isDark: boolean;
33 themeId: string | undefined;
34 themeSelected: string | undefined;
35 backgroundColor: string;
36 bootVariables: Record<string, string>;
37 metaContent: string | null;
38};
39
40function runBootScript(options: {
41 storage?: Record<string, string>;
42 storageThrows?: boolean;
43 prefersDark: boolean;
44}): BootResult {
45 const classes = new Set<string>();
46 const bootVariables: Record<string, string> = {};
47 const meta = {
48 content: null as string | null,
49 setAttribute(_name: string, value: string) {
50 this.content = value;
51 },
52 };
53 const documentElement = {
54 dataset: {} as Record<string, string | undefined>,
55 classList: {
56 add: (name: string) => void classes.add(name),
57 remove: (name: string) => void classes.delete(name),
58 toggle: (name: string, force?: boolean) => {
59 const next = force ?? !classes.has(name);
60 if (next) classes.add(name);
61 else classes.delete(name);
62 return next;
63 },
64 },
65 style: {
66 backgroundColor: "",
67 setProperty: (name: string, value: string) => {
68 bootVariables[name] = value;
69 },
70 },
71 };
72 const fakeDocument = {
73 documentElement,
74 querySelectorAll: (selector: string) => (selector === 'meta[name="theme-color"]' ? [meta] : []),
75 };
76 const fakeWindow = {
77 localStorage: {
78 getItem: (key: string): string | null => {
79 if (options.storageThrows) throw new Error("storage blocked");
80 return options.storage?.[key] ?? null;
81 },
82 },
83 matchMedia: () => ({ matches: options.prefersDark }),
84 };
85
86 new Function("window", "document", bootScript)(fakeWindow, fakeDocument);
87
88 return {
89 isDark: classes.has("dark"),
90 themeId: documentElement.dataset.themeId,
91 themeSelected: documentElement.dataset.themeSelected,
92 backgroundColor: documentElement.style.backgroundColor,
93 bootVariables,
94 metaContent: meta.content,
95 };
96}
97
98/** Mirrors getStored + readAppearanceModePreference + resolveThemeAppearance from the runtime. */
99function runtimeResolvedAppearance(
100 storage: Record<string, string>,
101 prefersDark: boolean,
102): "light" | "dark" {
103 vi.stubGlobal("window", {
104 localStorage: { getItem: (key: string) => storage[key] ?? null },
105 matchMedia: () => ({ matches: prefersDark }),
106 });
107 invalidateCustomThemes();
108 try {
109 const raw = storage[THEME_STORAGE_KEY] ?? null;
110 const theme = raw !== null && isKnownThemePreference(raw) ? raw : "system";
111 const followRaw = storage[THEME_FOLLOW_SYSTEM_STORAGE_KEY] ?? null;
112 const appearanceRaw = storage[THEME_APPEARANCE_MODE_STORAGE_KEY] ?? null;
113 const appearanceMode =
114 appearanceRaw === "light" || appearanceRaw === "dark" || appearanceRaw === "system"
115 ? appearanceRaw
116 : followRaw === "true"
117 ? "system"
118 : followRaw === "false"
119 ? null
120 : theme === "system"
121 ? "system"
122 : null;
123 const followSystem = appearanceMode === "system";
124 return resolveThemeAppearance(theme, prefersDark, followSystem, appearanceMode ?? undefined);
125 } finally {
126 vi.unstubAllGlobals();
127 invalidateCustomThemes();
128 }
129}
130
131const AURORA_DUAL = {
132 id: "aurora",
133 label: "Aurora",
134 appearance: "light",
135 colors: { canvas: "#f8fbff", text: "#10243d", accent: "#5b6cff" },
136 variants: { dark: { canvas: "#101827", text: "#eef5ff", accent: "#7c93ff" } },
137};
138const CHARCOAL_DARK_ONLY = {
139 id: "charcoal",
140 label: "Charcoal",
141 appearance: "dark",
142 colors: { canvas: "#1c1210", text: "#ffe8d9", accent: "#ff7a45" },
143};
144
145describe("index.html boot script", () => {
146 const parityCases: ReadonlyArray<{
147 name: string;
148 storage: Record<string, string>;
149 prefersDark: boolean;
150 }> = [
151 { name: "no stored preference on a dark OS", storage: {}, prefersDark: true },
152 {
153 name: "T3 Chat follows a dark OS",
154 storage: { [THEME_STORAGE_KEY]: "t3-chat", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true" },
155 prefersDark: true,
156 },
157 {
158 name: "an explicit global dark mode applies to T3 Chat",
159 storage: {
160 [THEME_STORAGE_KEY]: "t3-chat",
161 [THEME_APPEARANCE_MODE_STORAGE_KEY]: "dark",
162 [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "false",
163 },
164 prefersDark: false,
165 },
166 {
167 name: "Grove follows a dark OS",
168 storage: { [THEME_STORAGE_KEY]: "grove", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true" },
169 prefersDark: true,
170 },
171 {
172 name: "Ocean follows a dark OS",
173 storage: { [THEME_STORAGE_KEY]: "ocean", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true" },
174 prefersDark: true,
175 },
176 {
177 name: "Ember follows a dark OS",
178 storage: { [THEME_STORAGE_KEY]: "ember", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true" },
179 prefersDark: true,
180 },
181 {
182 name: "Iris follows a dark OS",
183 storage: { [THEME_STORAGE_KEY]: "iris", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true" },
184 prefersDark: true,
185 },
186 {
187 name: "a legacy t3-grove preference resolves through the alias",
188 storage: { [THEME_STORAGE_KEY]: "t3-grove", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true" },
189 prefersDark: true,
190 },
191 {
192 name: "legacy t3-chat-dark resolves to dark T3 Chat",
193 storage: { [THEME_STORAGE_KEY]: "t3-chat-dark" },
194 prefersDark: true,
195 },
196 {
197 name: "a dual-mode custom theme follows the OS",
198 storage: {
199 [THEME_STORAGE_KEY]: "aurora",
200 [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true",
201 [CUSTOM_THEMES_STORAGE_KEY]: JSON.stringify([AURORA_DUAL]),
202 },
203 prefersDark: true,
204 },
205 {
206 name: "a dark-only custom theme stays dark on a light OS",
207 storage: {
208 [THEME_STORAGE_KEY]: "charcoal",
209 [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true",
210 [CUSTOM_THEMES_STORAGE_KEY]: JSON.stringify([CHARCOAL_DARK_ONLY]),
211 },
212 prefersDark: false,
213 },
214 {
215 name: "a legacy mode-suffixed preference is treated as unknown",
216 storage: {
217 [THEME_STORAGE_KEY]: "aurora:dark",
218 [CUSTOM_THEMES_STORAGE_KEY]: JSON.stringify([AURORA_DUAL]),
219 },
220 prefersDark: true,
221 },
222 {
223 name: "a removed custom theme falls back to system",
224 storage: { [THEME_STORAGE_KEY]: "gone-theme" },
225 prefersDark: true,
226 },
227 {
228 name: "a corrupted follow-system value falls back to inference",
229 storage: { [THEME_STORAGE_KEY]: "system", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "1" },
230 prefersDark: true,
231 },
232 {
233 name: "follow-system off keeps an explicit light preference on a dark OS",
234 storage: { [THEME_STORAGE_KEY]: "light", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "false" },
235 prefersDark: true,
236 },
237 {
238 name: "a bare dark preference stays dark on a light OS",
239 storage: { [THEME_STORAGE_KEY]: "dark" },
240 prefersDark: false,
241 },
242 {
243 name: "follow-system off keeps a bare dark preference on a light OS",
244 storage: { [THEME_STORAGE_KEY]: "dark", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "false" },
245 prefersDark: false,
246 },
247 ];
248
249 it.each(parityCases)("matches the runtime appearance: $name", ({ storage, prefersDark }) => {
250 const boot = runBootScript({ storage, prefersDark });
251 expect(boot.isDark).toBe(runtimeResolvedAppearance(storage, prefersDark) === "dark");
252 });
253
254 it("marks built-in and custom themes on the document element", () => {
255 const chat = runBootScript({
256 storage: { [THEME_STORAGE_KEY]: "t3-chat", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true" },
257 prefersDark: true,
258 });
259 expect(chat.themeId).toBe("t3-chat");
260 expect(chat.themeSelected).toBe("true");
261 expect(chat.isDark).toBe(true);
262
263 const aurora = runBootScript({
264 storage: {
265 [THEME_STORAGE_KEY]: "aurora",
266 [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true",
267 [CUSTOM_THEMES_STORAGE_KEY]: JSON.stringify([AURORA_DUAL]),
268 },
269 prefersDark: true,
270 });
271 expect(aurora.themeId).toBe("aurora");
272 expect(aurora.isDark).toBe(true);
273 expect(aurora.backgroundColor).toBe(DEFAULT_DARK_CHROME);
274 expect(aurora.bootVariables["--boot-background"]).toBe(AURORA_DUAL.variants.dark.canvas);
275 expect(aurora.metaContent).toBe(DEFAULT_DARK_CHROME);
276 });
277
278 // Asserting against the real palette definitions (not literals) turns the
279 // boot script's hand-maintained copy into a CI-enforced contract: any
280 // palette change breaks this test until the copy in index.html is updated.
281 it("keeps every built-in boot splash in sync with the real palettes", () => {
282 for (const theme of [T3_CHAT_THEME, GROVE_THEME, OCEAN_THEME, EMBER_THEME, IRIS_THEME]) {
283 // The boot script resolves every built-in from a light base appearance.
284 expect(theme.appearance).toBe("light");
285 for (const mode of ["light", "dark"] as const) {
286 const colors = getThemeColorsForMode(theme, mode);
287 expect(colors).not.toBeNull();
288 const boot = runBootScript({
289 storage: {
290 [THEME_STORAGE_KEY]: theme.id,
291 [THEME_APPEARANCE_MODE_STORAGE_KEY]: mode,
292 },
293 prefersDark: mode === "dark",
294 });
295 expect(boot.themeId).toBe(theme.id);
296 expect(boot.isDark).toBe(mode === "dark");
297 expect(boot.bootVariables["--boot-background"]).toBe(colors!.canvas);
298 expect(boot.bootVariables["--boot-foreground"]).toBe(colors!.text);
299 expect(boot.bootVariables["--boot-accent"]).toBe(colors!.accent);
300 expect(boot.backgroundColor).toBe(colors!.chrome);
301 expect(boot.metaContent).toBe(colors!.chrome);
302 }
303 }
304 });
305
306 it("applies the matching half of an automatic mix to the splash", () => {
307 const storage = {
308 [THEME_STORAGE_KEY]: "t3-chat",
309 [THEME_APPEARANCE_MODE_STORAGE_KEY]: "system",
310 "t3code:theme-halves:v1": JSON.stringify({ dark: GROVE_THEME.id }),
311 };
312
313 const dark = runBootScript({ storage, prefersDark: true });
314 expect(dark.isDark).toBe(true);
315 expect(dark.themeId).toBe(GROVE_THEME.id);
316 expect(dark.bootVariables["--boot-background"]).toBe(
317 getThemeColorsForMode(GROVE_THEME, "dark")!.canvas,
318 );
319
320 const light = runBootScript({ storage, prefersDark: false });
321 expect(light.isDark).toBe(false);
322 expect(light.themeId).toBe("t3-chat");
323 expect(light.bootVariables["--boot-background"]).toBe(
324 getThemeColorsForMode(T3_CHAT_THEME, "light")!.canvas,
325 );
326 });
327
328 it("lets a dark half go dark when the light-only base cannot", () => {
329 const boot = runBootScript({
330 storage: {
331 [THEME_STORAGE_KEY]: "paper",
332 [THEME_APPEARANCE_MODE_STORAGE_KEY]: "system",
333 [CUSTOM_THEMES_STORAGE_KEY]: JSON.stringify([
334 {
335 id: "paper",
336 label: "Paper",
337 appearance: "light",
338 colors: { canvas: "#f8fbff", text: "#10243d", accent: "#5b6cff" },
339 },
340 ]),
341 "t3code:theme-halves:v1": JSON.stringify({ dark: GROVE_THEME.id }),
342 },
343 prefersDark: true,
344 });
345 expect(boot.isDark).toBe(true);
346 expect(boot.themeId).toBe(GROVE_THEME.id);
347 });
348
349 it("paints the half's splash when the base theme no longer exists", () => {
350 const boot = runBootScript({
351 storage: {
352 [THEME_STORAGE_KEY]: "gone-theme",
353 [THEME_APPEARANCE_MODE_STORAGE_KEY]: "system",
354 "t3code:theme-halves:v1": JSON.stringify({ dark: GROVE_THEME.id }),
355 },
356 prefersDark: true,
357 });
358 expect(boot.isDark).toBe(true);
359 expect(boot.themeId).toBe(GROVE_THEME.id);
360 expect(boot.themeSelected).toBe("true");
361 expect(boot.bootVariables["--boot-background"]).toBe(
362 getThemeColorsForMode(GROVE_THEME, "dark")!.canvas,
363 );
364 });
365
366 it("resolves a legacy-prefixed mix half onto the renamed theme", () => {
367 const boot = runBootScript({
368 storage: {
369 [THEME_STORAGE_KEY]: "t3-chat",
370 [THEME_APPEARANCE_MODE_STORAGE_KEY]: "system",
371 "t3code:theme-halves:v1": JSON.stringify({ dark: "t3-grove" }),
372 },
373 prefersDark: true,
374 });
375 expect(boot.isDark).toBe(true);
376 expect(boot.themeId).toBe(GROVE_THEME.id);
377 expect(boot.bootVariables["--boot-background"]).toBe(
378 getThemeColorsForMode(GROVE_THEME, "dark")!.canvas,
379 );
380 });
381
382 it("ignores a mix half that names an unknown theme", () => {
383 const boot = runBootScript({
384 storage: {
385 [THEME_STORAGE_KEY]: "t3-chat",
386 [THEME_APPEARANCE_MODE_STORAGE_KEY]: "system",
387 "t3code:theme-halves:v1": JSON.stringify({ dark: "gone-theme" }),
388 },
389 prefersDark: true,
390 });
391 expect(boot.themeId).toBe("t3-chat");
392 expect(boot.isDark).toBe(true);
393 });
394
395 it("uses runtime defaults for malformed custom roles", () => {
396 const boot = runBootScript({
397 storage: {
398 [THEME_STORAGE_KEY]: "partial",
399 [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true",
400 [CUSTOM_THEMES_STORAGE_KEY]: JSON.stringify([
401 {
402 id: "partial",
403 label: "Partial",
404 appearance: "dark",
405 colors: { canvas: "not-a-color", text: "#fffaff", accent: "nope" },
406 },
407 ]),
408 },
409 prefersDark: true,
410 });
411
412 expect(boot.themeId).toBe("partial");
413 expect(boot.bootVariables["--boot-background"]).toBe("#1f1a24");
414 expect(boot.bootVariables["--boot-foreground"]).toBe("#fffaff");
415 expect(boot.bootVariables["--boot-accent"]).toBe("#a3004c");
416 expect(boot.backgroundColor).toBe(DEFAULT_DARK_CHROME);
417 expect(boot.metaContent).toBe(DEFAULT_DARK_CHROME);
418 });
419
420 it("ignores malformed custom theme entries before applying a splash", () => {
421 const boot = runBootScript({
422 storage: {
423 [THEME_STORAGE_KEY]: "broken",
424 [CUSTOM_THEMES_STORAGE_KEY]: JSON.stringify([
425 { id: "broken", label: "Broken", appearance: "light", colors: "bad" },
426 ]),
427 },
428 prefersDark: false,
429 });
430
431 expect(boot.themeId).toBeUndefined();
432 expect(boot.themeSelected).toBeUndefined();
433 expect(boot.backgroundColor).toBe("#ffffff");
434 expect(boot.metaContent).toBe("#ffffff");
435 });
436
437 it("leaves unknown preferences unthemed so the runtime default applies", () => {
438 const boot = runBootScript({
439 storage: { [THEME_STORAGE_KEY]: "gone-theme" },
440 prefersDark: true,
441 });
442 expect(boot.themeId).toBeUndefined();
443 expect(boot.themeSelected).toBeUndefined();
444 expect(boot.isDark).toBe(true);
445 });
446
447 it("follows the OS appearance when storage is unavailable", () => {
448 const light = runBootScript({ storageThrows: true, prefersDark: false });
449 expect(light.isDark).toBe(false);
450 expect(light.themeId).toBeUndefined();
451
452 const dark = runBootScript({ storageThrows: true, prefersDark: true });
453 expect(dark.isDark).toBe(true);
454 });
455});
apps/web/src/themePalette.test.ts created+509
...@@ -0,0 +1,509 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 getThemeColorsForMode,
5 getThemeDefinition,
6 getThemeModes,
7 getThemePreferenceMode,
8 isKnownThemePreference,
9 getCustomThemes,
10 invalidateCustomThemes,
11 installCustomTheme,
12 canonicalThemePreference,
13 parseThemeFile,
14 parseThemeHalves,
15 resolveDesktopTheme,
16 resolveThemeAppearance,
17 serializeThemeFile,
18 subscribeToCustomThemes,
19 T3_CHAT_THEME,
20 EMBER_THEME,
21 GROVE_THEME,
22 IRIS_THEME,
23 OCEAN_THEME,
24 updateCustomTheme,
25 CUSTOM_THEMES_STORAGE_KEY,
26 createManagedThemeColors,
27 createVividThemeColors,
28 getDefaultThemeColors,
29 THEME_FILE_VERSION,
30} from "./themePalette";
31
32function contrastRatio(first: string, second: string): number {
33 const toRgb = (value: string) => {
34 const hex = value.slice(1);
35 return [0, 1, 2].map(
36 (channel) => Number.parseInt(hex.slice(channel * 2, channel * 2 + 2), 16) / 255,
37 );
38 };
39 const luminance = (value: string) =>
40 toRgb(value)
41 .map((channel) => (channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4))
42 .reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index]!, 0);
43 const lighter = Math.max(luminance(first), luminance(second));
44 const darker = Math.min(luminance(first), luminance(second));
45 return (lighter + 0.05) / (darker + 0.05);
46}
47
48describe("theme files", () => {
49 it("derives a readable palette from extreme simple-editor colors", () => {
50 const light = createManagedThemeColors("light", "#111827", "#ffff00");
51 const dark = createManagedThemeColors("dark", "#ffffff", "#ffff00");
52 const darkDefaults = getDefaultThemeColors("dark");
53
54 expect(light.canvas).not.toBe("#111827");
55 expect(dark.canvas).not.toBe("#ffffff");
56 expect(contrastRatio(light.accent, light.canvas)).toBeGreaterThanOrEqual(4.5);
57 expect(contrastRatio(dark.accent, dark.canvas)).toBeGreaterThanOrEqual(4.5);
58 expect(contrastRatio(light.textMuted, light.canvas)).toBeGreaterThanOrEqual(4.5);
59 expect(contrastRatio(dark.textMuted, dark.canvas)).toBeGreaterThanOrEqual(4.5);
60 expect(contrastRatio(light.textMuted, light.canvas)).toBeLessThan(5.5);
61 expect(contrastRatio(dark.textMuted, dark.canvas)).toBeLessThan(5.5);
62 expect(contrastRatio(light.textMuted, light.canvas)).toBeCloseTo(4.705, 1);
63 expect(contrastRatio(dark.textMuted, dark.canvas)).toBeCloseTo(5.082, 1);
64 expect(light.secondaryLabel).toBe(light.textMuted);
65 expect(dark.secondaryLabel).toBe(dark.textMuted);
66 expect(contrastRatio(light.accentForeground, light.accent)).toBeGreaterThanOrEqual(4.5);
67 expect(contrastRatio(dark.accentForeground, dark.accent)).toBeGreaterThanOrEqual(4.5);
68 // Status colors fall back to T3 Code's standard red and amber rather than
69 // the flagship palette's, so no generated theme inherits a brand tint.
70 const channels = (value: string) =>
71 [1, 3, 5].map((index) => Number.parseInt(value.slice(index, index + 2), 16)) as [
72 number,
73 number,
74 number,
75 ];
76 for (const colors of [light, dark]) {
77 const [errorRed, errorGreen, errorBlue] = channels(colors.error);
78 // Red leads by a wide margin; the old default was a pink whose blue sat
79 // close behind its red.
80 expect(errorRed).toBeGreaterThan(errorGreen * 2);
81 expect(errorRed).toBeGreaterThan(errorBlue * 2);
82 expect(contrastRatio(colors.error, "#ffffff")).toBeGreaterThanOrEqual(2.5);
83 expect(contrastRatio(colors.errorForeground, colors.errorSurface)).toBeGreaterThanOrEqual(
84 4.5,
85 );
86 const [warnRed, warnGreen, warnBlue] = channels(colors.warning);
87 expect(warnRed).toBeGreaterThan(warnBlue);
88 expect(warnGreen).toBeGreaterThan(warnBlue);
89 }
90 expect(dark.error).not.toBe(darkDefaults.error);
91 });
92
93 it("derives readable, distinctive vivid palettes from exact seeds", () => {
94 const seeds: ReadonlyArray<["light" | "dark", string, string]> = [
95 ["light", "#f4f9f2", "#1d8a4e"],
96 ["dark", "#101a2c", "#4f8fe8"],
97 ["dark", "#211a23", "#df5398"],
98 ["light", "#fdf6ec", "#c2571b"],
99 // Inverted canvases: the palette follows the picked color, not the slot.
100 ["light", "#111827", "#8ab4f8"],
101 ["dark", "#f5ecf5", "#a84370"],
102 ];
103 for (const [appearance, canvas, accent] of seeds) {
104 const colors = createVividThemeColors(appearance, canvas, accent);
105 // Exact seeds are honored.
106 expect(colors.canvas).toBe(canvas);
107 expect(colors.accent).toBe(accent);
108 // Readability is solved per surface.
109 expect(contrastRatio(colors.text, colors.canvas)).toBeGreaterThanOrEqual(7);
110 expect(contrastRatio(colors.textMuted, colors.canvas)).toBeGreaterThanOrEqual(4.5);
111 expect(contrastRatio(colors.textMuted, colors.canvas)).toBeLessThan(5.5);
112 expect(colors.secondaryLabel).toBe(colors.textMuted);
113 expect(contrastRatio(colors.accentForeground, colors.accent)).toBeGreaterThanOrEqual(4.5);
114 expect(
115 contrastRatio(colors.messageActionForeground, colors.messageAction),
116 ).toBeGreaterThanOrEqual(4.5);
117 expect(contrastRatio(colors.messageForeground, colors.messageSurface)).toBeGreaterThanOrEqual(
118 4.5,
119 );
120 expect(contrastRatio(colors.secondaryForeground, colors.secondary)).toBeGreaterThanOrEqual(
121 4.5,
122 );
123 expect(contrastRatio(colors.sidebarForeground, colors.sidebar)).toBeGreaterThanOrEqual(4.5);
124 // The companion action is a distinct voice, not the accent again.
125 expect(colors.messageAction).not.toBe(colors.accent);
126 // Update family follows the theme, not the default palette.
127 expect(colors.update).toBe(accent);
128 }
129 });
130
131 it("keys status colors off the canvas, not the appearance slot", () => {
132 // Inverted seeds: a dark canvas in the light slot must still get the dark
133 // status pair, or the alert foreground lands on a dark surface unreadable.
134 const inverted = [
135 createVividThemeColors("light", "#111827", "#8ab4f8"),
136 createVividThemeColors("dark", "#f5ecf5", "#a84370"),
137 createManagedThemeColors("light", "#0d1117", "#69b1ff", { exactSeeds: true }),
138 createManagedThemeColors("dark", "#fdfdfd", "#c2571b", { exactSeeds: true }),
139 ];
140 for (const colors of inverted) {
141 expect(contrastRatio(colors.errorForeground, colors.errorSurface)).toBeGreaterThanOrEqual(
142 4.5,
143 );
144 expect(contrastRatio(colors.warningForeground, colors.warningSurface)).toBeGreaterThanOrEqual(
145 4.5,
146 );
147 }
148 });
149
150 it("merges a small user file onto the matching contrast-safe base palette", () => {
151 const theme = parseThemeFile({
152 version: THEME_FILE_VERSION,
153 name: "Ocean dusk",
154 appearance: "dark",
155 colors: {
156 canvas: "#07152f",
157 accent: "#67c2ff",
158 },
159 });
160
161 expect(theme).toMatchObject({
162 id: "ocean-dusk",
163 label: "Ocean dusk",
164 appearance: "dark",
165 colors: {
166 canvas: "#07152f",
167 accent: "#67c2ff",
168 placeholder: "#8f8699",
169 },
170 });
171 });
172
173 it("rejects unknown roles and invalid color values", () => {
174 expect(() =>
175 parseThemeFile({
176 version: THEME_FILE_VERSION,
177 name: "Broken",
178 appearance: "light",
179 colors: { background: "#ffffff" },
180 }),
181 ).toThrow('"background" is not a supported theme color role.');
182
183 expect(() =>
184 parseThemeFile({
185 version: THEME_FILE_VERSION,
186 name: "Broken",
187 appearance: "light",
188 colors: { accent: "var(--danger)" },
189 }),
190 ).toThrow('The color for "accent" must be a hex color');
191 });
192
193 it("serializes a theme back into the importable file shape", () => {
194 const serialized = serializeThemeFile(T3_CHAT_THEME);
195 expect(JSON.parse(serialized)).toMatchObject({
196 version: THEME_FILE_VERSION,
197 id: T3_CHAT_THEME.id,
198 name: T3_CHAT_THEME.label,
199 appearance: "light",
200 });
201 });
202
203 it("keeps optional light and dark palettes under one theme id", () => {
204 const theme = parseThemeFile({
205 version: THEME_FILE_VERSION,
206 id: "aurora",
207 name: "Aurora",
208 appearance: "light",
209 colors: { canvas: "#f8fbff", text: "#10243d" },
210 variants: {
211 dark: { canvas: "#101827", text: "#eef5ff" },
212 },
213 });
214
215 expect(getThemeModes(theme)).toEqual(["light", "dark"]);
216 expect(getThemeColorsForMode(theme, "dark")).toMatchObject({
217 canvas: "#101827",
218 text: "#eef5ff",
219 });
220 expect(getThemeModes(T3_CHAT_THEME)).toEqual(["light", "dark"]);
221 expect(resolveThemeAppearance(T3_CHAT_THEME.id, true, true)).toBe("dark");
222 expect(resolveDesktopTheme(T3_CHAT_THEME.id, true)).toBe("system");
223 expect(resolveThemeAppearance(T3_CHAT_THEME.id, false, false, "dark")).toBe("dark");
224 expect(resolveDesktopTheme(T3_CHAT_THEME.id, false, "dark")).toBe("dark");
225 expect(JSON.parse(serializeThemeFile(theme)).variants.dark).toMatchObject({
226 canvas: "#101827",
227 text: "#eef5ff",
228 });
229 });
230
231 it("keeps the T3 Chat palette faithful and readable", () => {
232 expect(T3_CHAT_THEME.colors).toMatchObject({
233 canvas: "#fdf7fd",
234 chrome: "#fdf7fd",
235 toolbarBorder: "#efbdeb",
236 toolbarControl: "#f3e6f5",
237 toolbarControlHover: "#eccfe3",
238 surfaceRaised: "#fdfafd",
239 input: "#e7c1dc",
240 focus: "#db2777",
241 messageSurface: "#f7def2",
242 codeBackground: "#f5ecf9",
243 codeForeground: "#673c8b",
244 accentSurface: "#f3e6f5",
245 sidebar: "#f2e1f4",
246 });
247 expect(T3_CHAT_THEME.variants?.dark).toMatchObject({
248 canvas: "#1f1a24",
249 chrome: "#1f1a24",
250 surface: "#29232d",
251 surfaceRaised: "#2c2631",
252 input: "#302029",
253 focus: "#db2777",
254 messageSurface: "#2b2431",
255 codeBackground: "#1f1a24",
256 sidebar: "#171018",
257 sidebarBorder: "#322028",
258 });
259
260 for (const mode of ["light", "dark"] as const) {
261 const colors = getThemeColorsForMode(T3_CHAT_THEME, mode)!;
262 expect(contrastRatio(colors.text, colors.canvas)).toBeGreaterThanOrEqual(7);
263 expect(contrastRatio(colors.textMuted, colors.canvas)).toBeGreaterThanOrEqual(4.5);
264 expect(contrastRatio(colors.messageForeground, colors.messageSurface)).toBeGreaterThanOrEqual(
265 4.5,
266 );
267 expect(contrastRatio(colors.secondaryForeground, colors.secondary)).toBeGreaterThanOrEqual(
268 4.5,
269 );
270 expect(contrastRatio(colors.sidebarForeground, colors.sidebar)).toBeGreaterThanOrEqual(4.5);
271 // The live light primary is intended for filled controls and clears the
272 // non-text UI-component threshold rather than normal-text AA.
273 expect(contrastRatio(colors.accentForeground, colors.accent)).toBeGreaterThanOrEqual(3);
274 }
275 });
276
277 it("includes the dual-mode maintainer themes", () => {
278 for (const theme of [T3_CHAT_THEME, GROVE_THEME, OCEAN_THEME, EMBER_THEME, IRIS_THEME]) {
279 expect(getThemeDefinition(theme.id)).toBe(theme);
280 expect(getThemeModes(theme)).toEqual(["light", "dark"]);
281 expect(theme.colors.accent).toMatch(/^#[0-9a-f]{6}$/i);
282 expect(theme.variants?.dark?.accent).toMatch(/^#[0-9a-f]{6}$/i);
283
284 for (const mode of ["light", "dark"] as const) {
285 const colors = getThemeColorsForMode(theme, mode);
286 expect(colors).not.toBeNull();
287 expect(contrastRatio(colors!.text, colors!.canvas)).toBeGreaterThanOrEqual(4.5);
288 expect(contrastRatio(colors!.textMuted, colors!.canvas)).toBeGreaterThanOrEqual(4.5);
289 if (theme !== T3_CHAT_THEME) {
290 expect(contrastRatio(colors!.textMuted, colors!.canvas)).toBeLessThan(5.5);
291 expect(contrastRatio(colors!.textMuted, colors!.canvas)).toBeCloseTo(
292 mode === "dark" ? 5.082 : 4.705,
293 1,
294 );
295 }
296 expect(contrastRatio(colors!.accentForeground, colors!.accent)).toBeGreaterThanOrEqual(
297 theme === T3_CHAT_THEME ? 3 : 4.5,
298 );
299 expect(
300 contrastRatio(colors!.toolbarControlForeground, colors!.toolbarControl),
301 ).toBeGreaterThanOrEqual(4.5);
302 expect(
303 contrastRatio(colors!.messageForeground, colors!.messageSurface),
304 ).toBeGreaterThanOrEqual(4.5);
305 expect(
306 contrastRatio(colors!.messageActionForeground, colors!.messageAction),
307 ).toBeGreaterThanOrEqual(theme === T3_CHAT_THEME ? 3 : 4.5);
308 }
309 }
310 });
311
312 it("rejects a variant that repeats the base appearance", () => {
313 expect(() =>
314 parseThemeFile({
315 version: THEME_FILE_VERSION,
316 name: "Duplicate light",
317 appearance: "light",
318 colors: { canvas: "#f8fbff" },
319 variants: { light: { canvas: "#101827" } },
320 }),
321 ).toThrow('Theme variants must not repeat the base appearance "light".');
322 });
323
324 it("keeps a single-mode theme on its only palette", () => {
325 const theme = parseThemeFile({
326 version: THEME_FILE_VERSION,
327 id: "midnight-slate",
328 name: "Midnight Slate",
329 appearance: "dark",
330 colors: { canvas: "#111827", messageAction: "#2563eb" },
331 });
332
333 expect(getThemeModes(theme)).toEqual(["dark"]);
334 expect(getThemeColorsForMode(theme, "dark")).toMatchObject({ canvas: "#111827" });
335 expect(getThemeColorsForMode(theme, "light")).toBeNull();
336 });
337
338 it("invalidates cached themes when another tab clears localStorage", () => {
339 let storedThemes: string | null = JSON.stringify([
340 {
341 id: "ocean-dusk",
342 label: "Ocean dusk",
343 appearance: "dark",
344 colors: { canvas: "#07152f" },
345 },
346 ]);
347 let storageHandler: ((event: StorageEvent) => void) | undefined;
348 vi.stubGlobal("window", {
349 addEventListener: (type: string, listener: (event: StorageEvent) => void) => {
350 if (type === "storage") storageHandler = listener;
351 },
352 removeEventListener: vi.fn(),
353 localStorage: {
354 getItem: (key: string) => (key === CUSTOM_THEMES_STORAGE_KEY ? storedThemes : null),
355 },
356 });
357
358 invalidateCustomThemes();
359 expect(getCustomThemes()).toHaveLength(1);
360 const listener = vi.fn();
361 const unsubscribe = subscribeToCustomThemes(listener);
362
363 storedThemes = null;
364 storageHandler?.({ key: null } as StorageEvent);
365
366 expect(listener).toHaveBeenCalledTimes(1);
367 expect(getCustomThemes()).toEqual([]);
368 unsubscribe();
369 invalidateCustomThemes();
370 vi.unstubAllGlobals();
371 });
372
373 it("updates a personal theme without changing its id", () => {
374 const stored = new Map<string, string>();
375 vi.stubGlobal("window", {
376 localStorage: {
377 getItem: (key: string) => stored.get(key) ?? null,
378 setItem: (key: string, value: string) => stored.set(key, value),
379 },
380 });
381
382 invalidateCustomThemes();
383 const createdTheme = installCustomTheme(
384 parseThemeFile({
385 version: THEME_FILE_VERSION,
386 id: "aurora",
387 name: "Aurora",
388 appearance: "light",
389 colors: { canvas: "#f8fbff", accent: "#5b6cff" },
390 }),
391 );
392 const updatedTheme = updateCustomTheme({
393 ...createdTheme,
394 label: "Aurora Night",
395 colors: { ...createdTheme.colors, accent: "#7c3aed" },
396 });
397
398 expect(updatedTheme).toMatchObject({ id: "aurora", label: "Aurora Night" });
399 expect(getCustomThemes()).toEqual([updatedTheme]);
400 expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")[0]).toMatchObject({
401 id: "aurora",
402 label: "Aurora Night",
403 });
404
405 vi.unstubAllGlobals();
406 invalidateCustomThemes();
407 });
408});
409
410describe("stored theme preferences", () => {
411 it("lets a configured half unlock an appearance the base theme lacks", () => {
412 // Light-only base: without halves, dark requests fall back to light.
413 const lightOnly = parseThemeFile({
414 version: THEME_FILE_VERSION,
415 id: "paper",
416 name: "Paper",
417 appearance: "light",
418 colors: { canvas: "#f8fbff" },
419 });
420 vi.stubGlobal("window", {
421 localStorage: {
422 getItem: (key: string) =>
423 key === CUSTOM_THEMES_STORAGE_KEY ? JSON.stringify([lightOnly]) : null,
424 },
425 });
426 invalidateCustomThemes();
427 try {
428 expect(resolveThemeAppearance("paper", true, true)).toBe("light");
429 const halves = { dark: GROVE_THEME.id };
430 expect(resolveThemeAppearance("paper", true, true, undefined, halves)).toBe("dark");
431 expect(resolveThemeAppearance("paper", false, false, "dark", halves)).toBe("dark");
432 expect(resolveDesktopTheme("paper", true, undefined, halves)).toBe("system");
433 } finally {
434 vi.unstubAllGlobals();
435 invalidateCustomThemes();
436 }
437 });
438
439 it("resolves the legacy t3-chat-dark preference to dark T3 Chat", () => {
440 expect(getThemeDefinition("t3-chat-dark")).toBe(T3_CHAT_THEME);
441 expect(getThemePreferenceMode("t3-chat-dark")).toBe("dark");
442 expect(resolveThemeAppearance("t3-chat-dark", true, false)).toBe("dark");
443 expect(resolveDesktopTheme("t3-chat-dark", false)).toBe("dark");
444 expect(isKnownThemePreference("t3-chat-dark")).toBe(true);
445 });
446
447 it("resolves legacy t3-prefixed ids onto the renamed themes", () => {
448 for (const [legacy, theme] of [
449 ["t3-grove", GROVE_THEME],
450 ["t3-ocean", OCEAN_THEME],
451 ["t3-ember", EMBER_THEME],
452 ["t3-iris", IRIS_THEME],
453 ] as const) {
454 expect(getThemeDefinition(legacy)).toBe(theme);
455 expect(isKnownThemePreference(legacy)).toBe(true);
456 expect(canonicalThemePreference(legacy)).toBe(theme.id);
457 }
458 // The dark-variant alias keeps its raw form: it still carries a mode hint.
459 expect(canonicalThemePreference("t3-chat-dark")).toBe("t3-chat-dark");
460 // A stored mix that predates the rename resolves to the new ids.
461 expect(parseThemeHalves(JSON.stringify({ light: "t3-ocean", dark: "t3-grove" }))).toEqual({
462 light: OCEAN_THEME.id,
463 dark: GROVE_THEME.id,
464 });
465 });
466
467 it("recognizes only preferences the runtime can render", () => {
468 for (const preference of ["light", "dark", "system", T3_CHAT_THEME.id, GROVE_THEME.id]) {
469 expect(isKnownThemePreference(preference)).toBe(true);
470 }
471 expect(isKnownThemePreference(`${GROVE_THEME.id}:dark`)).toBe(false);
472 expect(isKnownThemePreference("missing-theme")).toBe(false);
473 });
474
475 it("keeps stored themes with unknown roles and drops invalid entries", () => {
476 vi.stubGlobal("window", {
477 localStorage: {
478 getItem: (key: string) =>
479 key === CUSTOM_THEMES_STORAGE_KEY
480 ? JSON.stringify([
481 {
482 id: "aurora",
483 label: "Aurora",
484 appearance: "light",
485 colors: { canvas: "#f8fbff", futureRole: "#123456", accent: "not-a-color" },
486 variants: { light: { canvas: "#101827" } },
487 },
488 { id: "light", label: "Reserved", appearance: "light", colors: {} },
489 { id: "aurora", label: "Duplicate", appearance: "dark", colors: {} },
490 ])
491 : null,
492 },
493 });
494 invalidateCustomThemes();
495
496 const themes = getCustomThemes();
497 expect(themes).toHaveLength(1);
498 expect(themes[0]).toMatchObject({
499 id: "aurora",
500 colors: { canvas: "#f8fbff", accent: getDefaultThemeColors("light").accent },
501 });
502 // The variant shadowing the base appearance is dropped so the theme
503 // round-trips through parseThemeFile on export.
504 expect(getThemeModes(themes[0]!)).toEqual(["light"]);
505
506 vi.unstubAllGlobals();
507 invalidateCustomThemes();
508 });
509});
apps/web/src/themePalette.ts created+1767
...@@ -0,0 +1,1767 @@
1import * as Schema from "effect/Schema";
2
3export const T3_CHAT_THEME_ID = "t3-chat" as const;
4export const T3_CHAT_THEME_LABEL = "T3 Chat";
5export const GROVE_THEME_ID = "grove" as const;
6export const GROVE_THEME_LABEL = "Grove";
7export const OCEAN_THEME_ID = "ocean" as const;
8export const OCEAN_THEME_LABEL = "Ocean";
9export const EMBER_THEME_ID = "ember" as const;
10export const EMBER_THEME_LABEL = "Ember";
11export const IRIS_THEME_ID = "iris" as const;
12export const IRIS_THEME_LABEL = "Iris";
13export const THEME_FILE_VERSION = 1 as const;
14export const CUSTOM_THEMES_STORAGE_KEY = "t3code:themes:v1";
15export const THEME_FOLLOW_SYSTEM_STORAGE_KEY = "t3code:theme-follow-system";
16export const THEME_APPEARANCE_MODE_STORAGE_KEY = "t3code:theme-appearance-mode";
17export const THEME_HALVES_STORAGE_KEY = "t3code:theme-halves:v1";
18
19const LEGACY_T3_CHAT_DARK_THEME_ID = "t3-chat-dark";
20
21export const ThemePreference = Schema.String;
22export type ThemePreference = typeof ThemePreference.Type;
23
24export const THEME_COLOR_ROLES = [
25 "canvas",
26 "chrome",
27 "toolbar",
28 "toolbarForeground",
29 "toolbarBorder",
30 "toolbarControl",
31 "toolbarControlForeground",
32 "toolbarControlHover",
33 "surface",
34 "surfaceRaised",
35 "surfaceOverlay",
36 "text",
37 "textMuted",
38 "border",
39 "input",
40 "focus",
41 "accent",
42 "accentForeground",
43 "secondary",
44 "secondaryForeground",
45 "muted",
46 "mutedForeground",
47 "placeholder",
48 "secondaryLabel",
49 "iconMuted",
50 "error",
51 "errorForeground",
52 "errorSurface",
53 "warning",
54 "warningForeground",
55 "warningSurface",
56 "update",
57 "updateForeground",
58 "updateSurface",
59 "accentSurface",
60 "accentSurfaceForeground",
61 "messageSurface",
62 "messageForeground",
63 "messageAction",
64 "messageActionForeground",
65 "messageActionHover",
66 "codeBackground",
67 "codeForeground",
68 "sidebar",
69 "sidebarForeground",
70 "sidebarMutedForeground",
71 "sidebarControlSurface",
72 "sidebarRowHover",
73 "sidebarRowActive",
74 "sidebarRowSelected",
75 "sidebarBorder",
76 "terminalBackground",
77 "terminalForeground",
78 "terminalCursor",
79 "terminalSelection",
80 "terminalScrollbar",
81 "terminalScrollbarHover",
82] as const;
83
84export type ThemeColorRole = (typeof THEME_COLOR_ROLES)[number];
85const THEME_COLOR_ROLE_SET: ReadonlySet<string> = new Set(THEME_COLOR_ROLES);
86export type ThemeAppearance = "light" | "dark";
87
88export type ThemeColors = Readonly<Record<ThemeColorRole, string>>;
89export type ThemeColorOverrides = Readonly<Partial<Record<ThemeColorRole, string>>>;
90export type ThemeVariants = Readonly<Partial<Record<ThemeAppearance, ThemeColors>>>;
91export type ThemeVariantOverrides = Readonly<Partial<Record<ThemeAppearance, ThemeColorOverrides>>>;
92export type ThemePreferenceMode = ThemeAppearance | "system";
93export type ThemeDefinition = Readonly<{
94 id: string;
95 label: string;
96 appearance: ThemeAppearance;
97 colors: ThemeColors;
98 variants?: ThemeVariants;
99 /** True when the palette was generated by the guided editor from its
100 * canvas and accent; such themes reopen in guided mode. */
101 managed?: boolean;
102}>;
103export type ThemeFile = Readonly<{
104 version: typeof THEME_FILE_VERSION;
105 id: string;
106 name: string;
107 appearance: ThemeAppearance;
108 colors: ThemeColorOverrides;
109 variants?: ThemeVariantOverrides;
110 managed?: boolean;
111}>;
112
113const RESERVED_THEME_IDS = new Set([
114 "system",
115 "light",
116 "dark",
117 T3_CHAT_THEME_ID,
118 GROVE_THEME_ID,
119 OCEAN_THEME_ID,
120 EMBER_THEME_ID,
121 IRIS_THEME_ID,
122 LEGACY_T3_CHAT_DARK_THEME_ID,
123 "t3-grove",
124 "t3-ocean",
125 "t3-ember",
126 "t3-iris",
127]);
128
129const customThemeListeners = new Set<() => void>();
130let customThemesSnapshot: ReadonlyArray<ThemeDefinition> | null = null;
131
132function isRecord(value: unknown): value is Record<string, unknown> {
133 return typeof value === "object" && value !== null && !Array.isArray(value);
134}
135
136function isThemeAppearance(value: unknown): value is ThemeAppearance {
137 return value === "light" || value === "dark";
138}
139
140export function isThemeColor(value: unknown): value is string {
141 return (
142 typeof value === "string" &&
143 /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value)
144 );
145}
146
147function isThemeId(value: unknown): value is string {
148 return typeof value === "string" && /^[a-z0-9](?:[a-z0-9-]{0,47})$/.test(value);
149}
150
151function isThemeLabel(value: unknown): value is string {
152 return typeof value === "string" && value.trim().length > 0 && value.trim().length <= 48;
153}
154
155function parseStoredThemeColors(value: unknown, appearance: ThemeAppearance): ThemeColors | null {
156 if (!isRecord(value)) return null;
157
158 const colors: Partial<Record<ThemeColorRole, string>> = {
159 ...getDefaultThemeColors(appearance),
160 };
161 // Tolerate unknown roles and malformed values so themes saved by other
162 // builds (for example one that adds a new role) keep their remaining colors.
163 for (const [role, color] of Object.entries(value)) {
164 if (THEME_COLOR_ROLE_SET.has(role) && isThemeColor(color)) {
165 colors[role as ThemeColorRole] = color;
166 }
167 }
168 return colors as ThemeColors;
169}
170
171function parseStoredThemeVariants(
172 value: unknown,
173 baseAppearance: ThemeAppearance,
174): ThemeVariants | null | undefined {
175 if (value === undefined) return undefined;
176 if (!isRecord(value)) return null;
177
178 const variants: Partial<Record<ThemeAppearance, ThemeColors>> = {};
179 for (const [appearance, colors] of Object.entries(value)) {
180 if (!isThemeAppearance(appearance)) return null;
181 // A variant matching the base appearance would be shadowed by the base
182 // colors; drop it so the theme round-trips through parseThemeFile.
183 if (appearance === baseAppearance) continue;
184 const parsedColors = parseStoredThemeColors(colors, appearance);
185 if (!parsedColors) return null;
186 variants[appearance] = parsedColors;
187 }
188 return Object.keys(variants).length > 0 ? variants : undefined;
189}
190
191function parseStoredTheme(value: unknown): ThemeDefinition | null {
192 if (!isRecord(value)) return null;
193 if (!isThemeId(value.id) || RESERVED_THEME_IDS.has(value.id)) return null;
194 if (!isThemeLabel(value.label) || !isThemeAppearance(value.appearance)) return null;
195 const colors = parseStoredThemeColors(value.colors, value.appearance);
196 if (!colors) return null;
197 const variants = parseStoredThemeVariants(value.variants, value.appearance);
198 if (value.variants !== undefined && variants === null) return null;
199
200 return {
201 id: value.id,
202 label: value.label.trim(),
203 appearance: value.appearance,
204 colors,
205 ...(variants ? { variants } : {}),
206 ...(value.managed === true ? { managed: true } : {}),
207 };
208}
209
210function readCustomThemesFromStorage(): ReadonlyArray<ThemeDefinition> {
211 if (typeof window === "undefined") return [];
212
213 try {
214 const raw = window.localStorage.getItem(CUSTOM_THEMES_STORAGE_KEY);
215 if (!raw) return [];
216 const parsed: unknown = JSON.parse(raw);
217 if (!Array.isArray(parsed)) return [];
218
219 const themes: ThemeDefinition[] = [];
220 for (const value of parsed) {
221 const theme = parseStoredTheme(value);
222 if (theme && !themes.some((existing) => existing.id === theme.id)) {
223 themes.push(theme);
224 }
225 }
226 return themes;
227 } catch {
228 return [];
229 }
230}
231
232function notifyCustomThemeListeners() {
233 for (const listener of customThemeListeners) listener();
234}
235
236export function invalidateCustomThemes() {
237 customThemesSnapshot = null;
238 notifyCustomThemeListeners();
239}
240
241export function getCustomThemes(): ReadonlyArray<ThemeDefinition> {
242 if (customThemesSnapshot === null) {
243 customThemesSnapshot = readCustomThemesFromStorage();
244 }
245 return customThemesSnapshot;
246}
247
248export function subscribeToCustomThemes(listener: () => void): () => void {
249 customThemeListeners.add(listener);
250 if (typeof window === "undefined") {
251 return () => customThemeListeners.delete(listener);
252 }
253 const handleStorage = (event: StorageEvent) => {
254 if (event.key === CUSTOM_THEMES_STORAGE_KEY || event.key === null) {
255 invalidateCustomThemes();
256 }
257 };
258 window.addEventListener("storage", handleStorage);
259
260 return () => {
261 customThemeListeners.delete(listener);
262 window.removeEventListener("storage", handleStorage);
263 };
264}
265
266// Earlier builds shipped every maintainer theme under a t3- prefix; only the
267// genuinely T3-branded palette keeps it. Stored preferences and mixes with the
268// old ids stay readable through this alias table.
269const LEGACY_THEME_ID_ALIASES: Readonly<Record<string, string>> = {
270 [LEGACY_T3_CHAT_DARK_THEME_ID]: T3_CHAT_THEME_ID,
271 "t3-grove": GROVE_THEME_ID,
272 "t3-ocean": OCEAN_THEME_ID,
273 "t3-ember": EMBER_THEME_ID,
274 "t3-iris": IRIS_THEME_ID,
275};
276
277function normalizeThemeId(themeId: string): string {
278 return LEGACY_THEME_ID_ALIASES[themeId] ?? themeId;
279}
280
281/**
282 * Map a stored preference onto the id the runtime applies, so selection state
283 * matches the theme cards. The legacy dark-variant id stays as-is because it
284 * still carries the appearance hint getThemePreferenceMode reads.
285 */
286export function canonicalThemePreference(theme: string): string {
287 return theme === LEGACY_T3_CHAT_DARK_THEME_ID ? theme : normalizeThemeId(theme);
288}
289
290function themeIdFromPreference(theme: ThemePreference): string {
291 return normalizeThemeId(theme);
292}
293
294// Older builds stored the dark T3 Chat palette as a separate theme. Keep
295// those preferences readable while mapping them to the dark variant.
296function legacyThemeMode(theme: ThemePreference): ThemeAppearance | null {
297 return theme === LEGACY_T3_CHAT_DARK_THEME_ID ? "dark" : null;
298}
299
300/**
301 * Maintainer palettes use product color roles rather than Tailwind or component
302 * names so the same definitions can feed other clients and native surfaces.
303 */
304// Measured from the live t3.chat default theme. Translucent chat surfaces are
305// flattened over --chat-background so this opaque palette reproduces the
306// pixels users see after T3 Chat's blur and noise layers are composited.
307const T3_CHAT_LIGHT_COLORS: ThemeColors = {
308 canvas: "#fdf7fd",
309 // T3 Code's workspace header belongs to the chat panel, so keep it seamless
310 // with the light chat canvas rather than mapping it to T3 Chat's outer shell.
311 chrome: "#fdf7fd",
312 toolbar: "#fdf7fd",
313 toolbarForeground: "#501854",
314 toolbarBorder: "#efbdeb",
315 // T3 Chat's light chrome controls sit on its pale gradient-noise surface,
316 // not the substantially darker solid accent token.
317 toolbarControl: "#f3e6f5",
318 toolbarControlForeground: "#501854",
319 toolbarControlHover: "#eccfe3",
320 surface: "#faf3fb",
321 surfaceRaised: "#fdfafd",
322 surfaceOverlay: "#ffffff",
323 text: "#501854",
324 textMuted: "#ac1668",
325 border: "#eee1ed",
326 input: "#e7c1dc",
327 focus: "#db2777",
328 accent: "#e33f86",
329 accentForeground: "#ffffff",
330 secondary: "#f1c4e6",
331 secondaryForeground: "#77347c",
332 muted: "#eaa7cb",
333 mutedForeground: "#ac1668",
334 placeholder: "#ad83b0",
335 secondaryLabel: "#ac1668",
336 iconMuted: "#ac1668",
337 error: "#f7086c",
338 errorForeground: "#9d174d",
339 errorSurface: "#fde4f1",
340 warning: "#f59e0b",
341 warningForeground: "#b45309",
342 warningSurface: "#fcf0ea",
343 update: "#e33f86",
344 updateForeground: "#ac1668",
345 updateSurface: "#fadfef",
346 accentSurface: "#f3e6f5",
347 accentSurfaceForeground: "#454554",
348 messageSurface: "#f7def2",
349 messageForeground: "#492c61",
350 messageAction: "#e33f86",
351 messageActionForeground: "#ffffff",
352 messageActionHover: "#d56698",
353 // T3 Chat uses a light lavender code surface in light mode. Keeping the
354 // dark plum pair here also leaked the dark palette into T3 Code's diffs.
355 codeBackground: "#f5ecf9",
356 codeForeground: "#673c8b",
357 // The live sidebar is transparent over T3 Chat's outer shell. Use that
358 // rendered shell color rather than its unused, darker sidebar token.
359 sidebar: "#f2e1f4",
360 sidebarForeground: "#454554",
361 sidebarMutedForeground: "#ac1668",
362 sidebarControlSurface: "#f8f8f7",
363 sidebarRowHover: "#f8f8f7",
364 sidebarRowActive: "#f8f8f7",
365 sidebarRowSelected: "#f8f8f7",
366 sidebarBorder: "#eceae9",
367 terminalBackground: "#fdf7fd",
368 terminalForeground: "#501854",
369 terminalCursor: "#db2777",
370 terminalSelection: "#f1c4e6",
371 terminalScrollbar: "#e7c1dc",
372 terminalScrollbarHover: "#eaa7cb",
373};
374
375const T3_CHAT_DARK_COLORS: ThemeColors = {
376 canvas: "#1f1a24",
377 // T3 Code's workspace header belongs to the chat panel, so keep it seamless
378 // with the canvas rather than mapping it to T3 Chat's outer shell.
379 chrome: "#1f1a24",
380 toolbar: "#1f1a24",
381 toolbarForeground: "#f9f8fb",
382 toolbarBorder: "#27242c",
383 toolbarControl: "#362d3d",
384 toolbarControlForeground: "#d4c7e1",
385 toolbarControlHover: "#463753",
386 // Cards and panels stay in T3 Chat's plum surface family. Near-black here
387 // made the right-panel surface picker look unrelated to the chat canvas.
388 surface: "#29232d",
389 // Pre-composited for the composer's 80% glass layer; this resolves to the
390 // measured #29232d input fill over the canvas.
391 surfaceRaised: "#2c2631",
392 surfaceOverlay: "#100a0e",
393 text: "#f9f8fb",
394 textMuted: "#e7d0dd",
395 border: "#27242c",
396 input: "#302029",
397 focus: "#db2777",
398 accent: "#a3004c",
399 accentForeground: "#fbd0e8",
400 secondary: "#362d3d",
401 secondaryForeground: "#d4c7e1",
402 muted: "#423a45",
403 mutedForeground: "#e7d0dd",
404 placeholder: "#8f8699",
405 secondaryLabel: "#e7d0dd",
406 iconMuted: "#d4c7e1",
407 error: "#9d174d",
408 errorForeground: "#fbd0e8",
409 errorSurface: "#331a2b",
410 warning: "#f59e0b",
411 warningForeground: "#fbbf24",
412 warningSurface: "#412f20",
413 update: "#a3004c",
414 updateForeground: "#fbd0e8",
415 updateSurface: "#37152b",
416 accentSurface: "#463753",
417 accentSurfaceForeground: "#f8f1f5",
418 messageSurface: "#2b2431",
419 messageForeground: "#f2ebfa",
420 messageAction: "#a3004c",
421 messageActionForeground: "#fbd0e8",
422 messageActionHover: "#a2004c",
423 // Diffs and file previews are full workspace surfaces in T3 Code. Keep them
424 // continuous with the themed canvas instead of dropping to near-black.
425 codeBackground: "#1f1a24",
426 codeForeground: "#d8c3ef",
427 // The live sidebar starts from #131314, then gains its hue from a pink
428 // gradient/noise stack. This pre-grain base lands on the same #1a131a
429 // visible shell color after our surface-grain layer is composited.
430 sidebar: "#171018",
431 sidebarForeground: "#f4f4f5",
432 sidebarMutedForeground: "#e7d0dd",
433 sidebarControlSurface: "#261922",
434 sidebarRowHover: "#261922",
435 sidebarRowActive: "#261922",
436 sidebarRowSelected: "#261922",
437 // T3 Chat draws the chat panel edge in this muted pink. The resize rail uses
438 // the same role on hover, so it stays pink instead of falling back to black.
439 sidebarBorder: "#322028",
440 terminalBackground: "#1f1a24",
441 terminalForeground: "#f9f8fb",
442 terminalCursor: "#db2777",
443 terminalSelection: "#362d3d",
444 terminalScrollbar: "#302029",
445 terminalScrollbarHover: "#423a45",
446};
447
448/**
449 * The palette T3 Code wears with no theme installed, captured from the app's
450 * stock tokens (index.css) so a draft seeded from the default look paints the
451 * pixels the user is already seeing. Alpha-bearing tokens are flattened over
452 * their real backdrops (canvas, or the sidebar for its rows) because theme
453 * colors must be opaque hex.
454 */
455const T3_CODE_LIGHT_THEME_COLORS: ThemeColors = {
456 canvas: "#fcfcfc",
457 chrome: "#fcfcfc",
458 toolbar: "#fcfcfc",
459 toolbarForeground: "#27272a",
460 toolbarBorder: "#e4e4e7",
461 toolbarControl: "#ffffff",
462 toolbarControlForeground: "#27272a",
463 toolbarControlHover: "#f4f4f5",
464 surface: "#ffffff",
465 surfaceRaised: "#fcfcfc",
466 surfaceOverlay: "#ffffff",
467 text: "#27272a",
468 textMuted: "#71717b",
469 border: "#e4e4e7",
470 input: "#d4d4d8",
471 focus: "#1b4ed8",
472 accent: "#1b4ed8",
473 accentForeground: "#ffffff",
474 secondary: "#fafafa",
475 secondaryForeground: "#27272a",
476 muted: "#fafafa",
477 mutedForeground: "#71717b",
478 placeholder: "#71717b",
479 secondaryLabel: "#71717b",
480 iconMuted: "#71717b",
481 error: "#fb2c36",
482 errorForeground: "#c10007",
483 errorSurface: "#fcebec",
484 warning: "#fe9a00",
485 warningForeground: "#bb4d00",
486 warningSurface: "#fcf4e8",
487 update: "#1b4ed8",
488 updateForeground: "#1b4ed8",
489 updateSurface: "#e0e6f7",
490 accentSurface: "#f4f4f5",
491 accentSurfaceForeground: "#18181b",
492 messageSurface: "#f4f4f5",
493 messageForeground: "#27272a",
494 messageAction: "#1b4ed8",
495 messageActionForeground: "#ffffff",
496 messageActionHover: "#3160db",
497 codeBackground: "#ffffff",
498 codeForeground: "#27272a",
499 sidebar: "#fafafa",
500 sidebarForeground: "#27272a",
501 sidebarMutedForeground: "#71717b",
502 sidebarControlSurface: "#f4f4f5",
503 sidebarRowHover: "#fcfcfc",
504 sidebarRowActive: "#ffffff",
505 sidebarRowSelected: "#ffffff",
506 sidebarBorder: "#e4e4e7",
507 terminalBackground: "#fcfcfc",
508 terminalForeground: "#27272a",
509 terminalCursor: "#26384e",
510 terminalSelection: "#d0d6dd",
511 terminalScrollbar: "#d6d6d6",
512 terminalScrollbarHover: "#bdbdbd",
513};
514
515const T3_CODE_DARK_THEME_COLORS: ThemeColors = {
516 canvas: "#0a0a0a",
517 chrome: "#0a0a0a",
518 toolbar: "#0a0a0a",
519 toolbarForeground: "#f5f5f5",
520 toolbarBorder: "#191919",
521 toolbarControl: "#191919",
522 toolbarControlForeground: "#f5f5f5",
523 toolbarControlHover: "#141414",
524 surface: "#111111",
525 surfaceRaised: "#141414",
526 surfaceOverlay: "#191919",
527 text: "#f5f5f5",
528 textMuted: "#818181",
529 border: "#191919",
530 input: "#1e1e1e",
531 focus: "#366ffb",
532 accent: "#366ffb",
533 accentForeground: "#ffffff",
534 secondary: "#141414",
535 secondaryForeground: "#f5f5f5",
536 muted: "#141414",
537 mutedForeground: "#818181",
538 placeholder: "#818181",
539 secondaryLabel: "#818181",
540 iconMuted: "#818181",
541 error: "#fb414a",
542 errorForeground: "#ff6467",
543 errorSurface: "#301214",
544 warning: "#fe9a00",
545 warningForeground: "#ffb900",
546 warningSurface: "#312108",
547 update: "#366ffb",
548 updateForeground: "#366ffb",
549 updateSurface: "#121c35",
550 accentSurface: "#141414",
551 accentSurfaceForeground: "#f5f5f5",
552 messageSurface: "#141414",
553 messageForeground: "#f5f5f5",
554 messageAction: "#366ffb",
555 messageActionForeground: "#ffffff",
556 messageActionHover: "#3265e3",
557 codeBackground: "#111111",
558 codeForeground: "#f5f5f5",
559 sidebar: "#000000",
560 sidebarForeground: "#f1f3f7",
561 sidebarMutedForeground: "#a3a3a3",
562 sidebarControlSurface: "#0a0a0a",
563 sidebarRowHover: "#131313",
564 sidebarRowActive: "#1a1b1b",
565 sidebarRowSelected: "#111111",
566 sidebarBorder: "#141414",
567 terminalBackground: "#0a0a0a",
568 terminalForeground: "#f5f5f5",
569 terminalCursor: "#b4cbff",
570 terminalSelection: "#343a47",
571 terminalScrollbar: "#222222",
572 terminalScrollbarHover: "#363636",
573};
574
575/**
576 * The standard T3 Code look as a theme palette, for seeding a new theme when
577 * no theme is installed. Distinct from {@link getDefaultThemeColors}, which
578 * carries the flagship T3 Chat palette used to fill roles omitted by theme
579 * files.
580 */
581export function getStandardThemeColors(appearance: ThemeAppearance): ThemeColors {
582 return appearance === "dark" ? T3_CODE_DARK_THEME_COLORS : T3_CODE_LIGHT_THEME_COLORS;
583}
584
585type ThemeRgbColor = {
586 r: number;
587 g: number;
588 b: number;
589};
590
591type ThemeHslColor = {
592 h: number;
593 s: number;
594 l: number;
595};
596
597const THEME_LIGHT_FOREGROUND: ThemeRgbColor = { r: 255, g: 250, b: 255 };
598const THEME_DARK_FOREGROUND: ThemeRgbColor = { r: 36, g: 21, b: 35 };
599const THEME_WHITE_FOREGROUND: ThemeRgbColor = { r: 255, g: 255, b: 255 };
600const THEME_BLACK_FOREGROUND: ThemeRgbColor = { r: 0, g: 0, b: 0 };
601
602function parseThemeRgbColor(value: string, fallback: ThemeRgbColor): ThemeRgbColor {
603 const match = value.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
604 if (!match) return fallback;
605
606 const raw = match[1];
607 if (!raw) return fallback;
608 const hex =
609 raw.length <= 4
610 ? raw
611 .slice(0, 3)
612 .split("")
613 .map((part) => part.repeat(2))
614 .join("")
615 : raw.slice(0, 6);
616 if (hex.length !== 6) return fallback;
617
618 return {
619 r: Number.parseInt(hex.slice(0, 2), 16),
620 g: Number.parseInt(hex.slice(2, 4), 16),
621 b: Number.parseInt(hex.slice(4, 6), 16),
622 };
623}
624
625function themeRgbToHexColor(color: ThemeRgbColor): string {
626 return `#${[color.r, color.g, color.b]
627 .map((channel) =>
628 Math.round(Math.min(255, Math.max(0, channel)))
629 .toString(16)
630 .padStart(2, "0"),
631 )
632 .join("")}`;
633}
634
635function themeRgbToHsl(color: ThemeRgbColor): ThemeHslColor {
636 const red = color.r / 255;
637 const green = color.g / 255;
638 const blue = color.b / 255;
639 const max = Math.max(red, green, blue);
640 const min = Math.min(red, green, blue);
641 const delta = max - min;
642 const lightness = (max + min) / 2;
643
644 if (delta === 0) return { h: 0, s: 0, l: lightness };
645
646 const saturation = delta / (1 - Math.abs(2 * lightness - 1));
647 let hue = 0;
648 if (max === red) hue = ((green - blue) / delta) % 6;
649 else if (max === green) hue = (blue - red) / delta + 2;
650 else hue = (red - green) / delta + 4;
651
652 return { h: (hue * 60 + 360) % 360, s: saturation, l: lightness };
653}
654
655function themeHslToRgb(color: ThemeHslColor): ThemeRgbColor {
656 const hue = ((color.h % 360) + 360) % 360;
657 const chroma = (1 - Math.abs(2 * color.l - 1)) * color.s;
658 const hueSector = hue / 60;
659 const secondary = chroma * (1 - Math.abs((hueSector % 2) - 1));
660 const match = color.l - chroma / 2;
661 const [red, green, blue] =
662 hueSector < 1
663 ? [chroma, secondary, 0]
664 : hueSector < 2
665 ? [secondary, chroma, 0]
666 : hueSector < 3
667 ? [0, chroma, secondary]
668 : hueSector < 4
669 ? [0, secondary, chroma]
670 : hueSector < 5
671 ? [secondary, 0, chroma]
672 : [chroma, 0, secondary];
673
674 return { r: (red + match) * 255, g: (green + match) * 255, b: (blue + match) * 255 };
675}
676
677function mixThemeRgbColors(
678 base: ThemeRgbColor,
679 overlay: ThemeRgbColor,
680 amount: number,
681): ThemeRgbColor {
682 return {
683 r: base.r + (overlay.r - base.r) * amount,
684 g: base.g + (overlay.g - base.g) * amount,
685 b: base.b + (overlay.b - base.b) * amount,
686 };
687}
688
689function themeRelativeLuminance(color: ThemeRgbColor): number {
690 const linearize = (channel: number) => {
691 const normalized = channel / 255;
692 return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
693 };
694
695 return 0.2126 * linearize(color.r) + 0.7152 * linearize(color.g) + 0.0722 * linearize(color.b);
696}
697
698// ---------------------------------------------------------------------------
699// Vivid palette engine: perceptual (OKLCH) derivation for user-created themes.
700// Built-in themes keep the legacy derivation so their shipped palettes and the
701// boot splash copies stay byte-identical.
702
703type ThemeOklch = { L: number; C: number; h: number };
704
705function srgbChannelToLinear(channel: number): number {
706 const c = channel / 255;
707 return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
708}
709
710function linearChannelToSrgb(channel: number): number {
711 const c = channel <= 0.0031308 ? channel * 12.92 : 1.055 * channel ** (1 / 2.4) - 0.055;
712 return Math.round(Math.min(1, Math.max(0, c)) * 255);
713}
714
715function themeRgbToOklch(color: ThemeRgbColor): ThemeOklch {
716 const r = srgbChannelToLinear(color.r);
717 const g = srgbChannelToLinear(color.g);
718 const b = srgbChannelToLinear(color.b);
719 const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
720 const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
721 const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
722 const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
723 const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
724 const bb = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
725 return { L, C: Math.hypot(a, bb), h: (Math.atan2(bb, a) * 180) / Math.PI };
726}
727
728function oklchToRgbUnclamped({ L, C, h }: ThemeOklch): { r: number; g: number; b: number } {
729 const hr = (h * Math.PI) / 180;
730 const a = C * Math.cos(hr);
731 const bb = C * Math.sin(hr);
732 const l = (L + 0.3963377774 * a + 0.2158037573 * bb) ** 3;
733 const m = (L - 0.1055613458 * a - 0.0638541728 * bb) ** 3;
734 const s = (L - 0.0894841775 * a - 1.291485548 * bb) ** 3;
735 return {
736 r: 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
737 g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
738 b: -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
739 };
740}
741
742/** Convert to sRGB, walking chroma toward grey until the color is in gamut. */
743function themeOklchToRgb(color: ThemeOklch): ThemeRgbColor {
744 let { C } = color;
745 for (let step = 0; step < 12; step += 1) {
746 const linear = oklchToRgbUnclamped({ ...color, C });
747 const inGamut = [linear.r, linear.g, linear.b].every(
748 (channel) => channel >= -0.0001 && channel <= 1.0001,
749 );
750 if (inGamut) {
751 return {
752 r: linearChannelToSrgb(linear.r),
753 g: linearChannelToSrgb(linear.g),
754 b: linearChannelToSrgb(linear.b),
755 };
756 }
757 C *= 0.82;
758 }
759 const linear = oklchToRgbUnclamped({ ...color, C: 0 });
760 return {
761 r: linearChannelToSrgb(linear.r),
762 g: linearChannelToSrgb(linear.g),
763 b: linearChannelToSrgb(linear.b),
764 };
765}
766
767/** Binary-search the lightness that reaches the contrast target against a background. */
768function solveOklchLightness(
769 base: ThemeOklch,
770 against: ThemeRgbColor,
771 minContrast: number,
772 direction: "lighter" | "darker",
773): ThemeOklch {
774 let low = direction === "lighter" ? base.L : 0;
775 let high = direction === "lighter" ? 1 : base.L;
776 let candidate = { ...base };
777 if (themeContrastRatio(themeOklchToRgb(candidate), against) >= minContrast) return candidate;
778 for (let step = 0; step < 18; step += 1) {
779 const mid = (low + high) / 2;
780 candidate = { ...base, L: mid };
781 const contrast = themeContrastRatio(themeOklchToRgb(candidate), against);
782 if (contrast >= minContrast) {
783 if (direction === "lighter") high = mid;
784 else low = mid;
785 } else {
786 if (direction === "lighter") low = mid;
787 else high = mid;
788 }
789 }
790 return { ...base, L: direction === "lighter" ? high : low };
791}
792
793/**
794 * The status colors T3 Code shows without a theme, read from the app's own
795 * tokens (red-500 / amber-500 families). Generated palettes fall back to
796 * these instead of the flagship theme's, so an imported or created theme
797 * never inherits a brand tint on destructive buttons and warnings.
798 */
799const STANDARD_STATUS_COLORS = {
800 light: {
801 error: "#fb2c36",
802 errorForeground: "#c10007",
803 warning: "#fe9a00",
804 warningForeground: "#bb4d00",
805 },
806 dark: {
807 error: "#fb414a",
808 errorForeground: "#ff6467",
809 warning: "#fe9a00",
810 warningForeground: "#ffb900",
811 },
812} as const;
813
814/**
815 * Status surfaces are the standard color laid over the theme's own canvas
816 * (the unthemed app uses 8% in light and 16% in dark), so alerts still sit on
817 * the palette while the signal color stays standard.
818 */
819function standardStatusColors(canvas: ThemeRgbColor): {
820 error: string;
821 errorForeground: string;
822 errorSurface: string;
823 warning: string;
824 warningForeground: string;
825 warningSurface: string;
826} {
827 // Keyed off the canvas rather than the appearance slot: a dark canvas saved
828 // as a light theme still needs the dark pair, or the alert foreground lands
829 // on a dark surface unreadable.
830 const appearance: ThemeAppearance = themeRelativeLuminance(canvas) < 0.179 ? "dark" : "light";
831 const standard = STANDARD_STATUS_COLORS[appearance];
832 const surfaceMix = appearance === "dark" ? 0.16 : 0.08;
833 const surfaceOf = (value: string) =>
834 mixThemeRgbColors(canvas, parseThemeRgbColor(value, canvas), surfaceMix);
835 // The standard foregrounds are tuned against the unthemed canvas; on a
836 // tinted one they can fall just short, so lightness is nudged until the
837 // pair clears 4.5 while the hue stays standard.
838 const readableOn = (foreground: string, surface: ThemeRgbColor) =>
839 themeRgbToHexColor(
840 themeOklchToRgb(
841 solveOklchLightness(
842 themeRgbToOklch(parseThemeRgbColor(foreground, canvas)),
843 surface,
844 // A hair above 4.5: the solve happens in OKLCH and the result is
845 // quantized to 8-bit hex, which can shave the last hundredth off.
846 4.6,
847 appearance === "dark" ? "lighter" : "darker",
848 ),
849 ),
850 );
851 const errorSurface = surfaceOf(standard.error);
852 const warningSurface = surfaceOf(standard.warning);
853 return {
854 ...standard,
855 errorForeground: readableOn(standard.errorForeground, errorSurface),
856 errorSurface: themeRgbToHexColor(errorSurface),
857 warningForeground: readableOn(standard.warningForeground, warningSurface),
858 warningSurface: themeRgbToHexColor(warningSurface),
859 };
860}
861
862/**
863 * Derive a full palette from two exact seed colors, in OKLCH. Surfaces climb a
864 * perceptually even lightness ramp that carries the accent hue at low chroma,
865 * a companion action color is rotated off the accent, and every foreground is
866 * contrast-solved against its own surface.
867 */
868export function createVividThemeColors(
869 appearance: ThemeAppearance,
870 backgroundValue: string,
871 accentValue: string,
872): ThemeColors {
873 const defaults = getDefaultThemeColors(appearance);
874 const canvasRgb = parseThemeRgbColor(
875 backgroundValue,
876 appearance === "dark" ? { r: 24, g: 15, b: 27 } : { r: 250, g: 245, b: 250 },
877 );
878 const accentRgb = parseThemeRgbColor(accentValue, { r: 168, g: 67, b: 112 });
879 const canvas = themeRgbToOklch(canvasRgb);
880 const accent = themeRgbToOklch(accentRgb);
881 // The ramp and every contrast search follow the canvas the user actually
882 // picked, not the appearance slot, so a dark canvas saved as a light theme
883 // still gets light text and raised surfaces. 0.179 is the relative
884 // luminance where white and black text have equal contrast headroom.
885 const dark = themeRelativeLuminance(canvasRgb) < 0.179;
886 const hue = accent.C < 0.02 ? canvas.h : accent.h;
887 const tintC = Math.min(0.045, Math.max(0.008, accent.C * 0.22));
888 const step = dark ? 1 : -1;
889
890 const surfaceAt = (deltaL: number, chroma = tintC): ThemeOklch => ({
891 L: Math.min(0.98, Math.max(0.05, canvas.L + step * deltaL)),
892 C: chroma,
893 h: hue,
894 });
895 const hex = (color: ThemeOklch) => themeRgbToHexColor(themeOklchToRgb(color));
896
897 // Text carries a whisper of the accent hue instead of falling back to a
898 // fixed foreground, and is solved to WCAG AAA against the canvas.
899 const textBase: ThemeOklch = {
900 L: dark ? 0.95 : 0.2,
901 C: Math.min(0.035, accent.C * 0.25),
902 h: hue,
903 };
904 const text = solveOklchLightness(textBase, canvasRgb, 7, dark ? "lighter" : "darker");
905 const textRgb = themeOklchToRgb(text);
906 const textMutedRgb = standardMutedThemeText(canvasRgb, textRgb);
907
908 // The companion action rotates off the accent so a two-color theme still
909 // gets the dual-voice character of the hand-tuned palettes.
910 const action: ThemeOklch = {
911 L: Math.min(0.85, Math.max(0.35, accent.L + (dark ? 0.06 : -0.02))),
912 C: Math.max(accent.C * 0.9, 0.06),
913 h: (hue + 50) % 360,
914 };
915 const actionRgb = themeOklchToRgb(action);
916 const actionForeground = readableThemeForeground(actionRgb);
917 const accentForeground = readableThemeForeground(accentRgb);
918
919 const sidebar = surfaceAt(0.045, tintC * 1.4);
920 const sidebarRgb = themeOklchToRgb(sidebar);
921 const surface = surfaceAt(0.015);
922 const surfaceRaised = surfaceAt(0.05);
923 const surfaceOverlay = surfaceAt(0.075);
924 const border = surfaceAt(dark ? 0.16 : 0.12, Math.min(0.07, accent.C * 0.35));
925 const input = surfaceAt(dark ? 0.21 : 0.16, Math.min(0.08, accent.C * 0.4));
926 const secondary = surfaceAt(dark ? 0.1 : 0.06, Math.min(0.09, accent.C * 0.5));
927 const secondaryRgb = themeOklchToRgb(secondary);
928 const muted = surfaceAt(dark ? 0.06 : 0.04, Math.min(0.06, accent.C * 0.35));
929 const accentSurface = surfaceAt(dark ? 0.13 : 0.08, Math.min(0.11, accent.C * 0.55));
930 const accentSurfaceRgb = themeOklchToRgb(accentSurface);
931 const messageSurface = surfaceAt(dark ? 0.16 : 0.1, Math.min(0.13, accent.C * 0.6));
932 const messageSurfaceRgb = themeOklchToRgb(messageSurface);
933 const codeBackground = surfaceAt(0.035, tintC * 0.8);
934 const updateSurface = surfaceAt(dark ? 0.14 : 0.09, Math.min(0.12, accent.C * 0.55));
935
936 const foregroundOn = (surfaceRgb: ThemeRgbColor): string =>
937 themeRgbToHexColor(
938 themeOklchToRgb(solveOklchLightness(textBase, surfaceRgb, 4.6, dark ? "lighter" : "darker")),
939 );
940
941 const actionHover: ThemeOklch = { ...action, L: action.L + (dark ? 0.06 : -0.06) };
942
943 return {
944 ...defaults,
945 ...standardStatusColors(canvasRgb),
946 canvas: themeRgbToHexColor(canvasRgb),
947 // The top bar shares the canvas so the main panel reads as one surface.
948 chrome: themeRgbToHexColor(canvasRgb),
949 toolbar: themeRgbToHexColor(canvasRgb),
950 toolbarForeground: themeRgbToHexColor(textRgb),
951 toolbarBorder: hex(surfaceAt(dark ? 0.14 : 0.1, Math.min(0.08, accent.C * 0.4))),
952 toolbarControl: hex(surfaceAt(dark ? 0.09 : 0.05, tintC * 1.3)),
953 toolbarControlForeground: themeRgbToHexColor(textRgb),
954 toolbarControlHover: hex(surfaceAt(dark ? 0.14 : 0.09, tintC * 1.6)),
955 surface: hex(surface),
956 surfaceRaised: hex(surfaceRaised),
957 surfaceOverlay: hex(surfaceOverlay),
958 text: themeRgbToHexColor(textRgb),
959 textMuted: themeRgbToHexColor(textMutedRgb),
960 border: hex(border),
961 input: hex(input),
962 focus: themeRgbToHexColor(accentRgb),
963 accent: themeRgbToHexColor(accentRgb),
964 accentForeground: themeRgbToHexColor(accentForeground),
965 secondary: hex(secondary),
966 secondaryForeground: foregroundOn(secondaryRgb),
967 muted: hex(muted),
968 mutedForeground: themeRgbToHexColor(textMutedRgb),
969 placeholder: themeRgbToHexColor(textMutedRgb),
970 secondaryLabel: themeRgbToHexColor(textMutedRgb),
971 iconMuted: themeRgbToHexColor(textMutedRgb),
972 update: themeRgbToHexColor(accentRgb),
973 updateForeground: foregroundOn(themeOklchToRgb(updateSurface)),
974 updateSurface: hex(updateSurface),
975 accentSurface: hex(accentSurface),
976 accentSurfaceForeground: foregroundOn(accentSurfaceRgb),
977 messageSurface: hex(messageSurface),
978 messageForeground: foregroundOn(messageSurfaceRgb),
979 messageAction: themeRgbToHexColor(actionRgb),
980 messageActionForeground: themeRgbToHexColor(actionForeground),
981 messageActionHover: hex(actionHover),
982 codeBackground: hex(codeBackground),
983 codeForeground: themeRgbToHexColor(textRgb),
984 sidebar: hex(sidebar),
985 sidebarForeground: foregroundOn(sidebarRgb),
986 sidebarMutedForeground: themeRgbToHexColor(standardMutedThemeText(sidebarRgb, textRgb)),
987 sidebarControlSurface: hex(surfaceAt(dark ? 0.1 : 0.07, tintC * 1.5)),
988 sidebarRowHover: hex(surfaceAt(dark ? 0.08 : 0.06, Math.min(0.08, accent.C * 0.45))),
989 sidebarRowActive: hex(surfaceAt(dark ? 0.12 : 0.09, Math.min(0.1, accent.C * 0.55))),
990 sidebarRowSelected: hex(surfaceAt(dark ? 0.14 : 0.1, Math.min(0.11, accent.C * 0.6))),
991 sidebarBorder: hex(surfaceAt(dark ? 0.17 : 0.12, Math.min(0.08, accent.C * 0.4))),
992 terminalBackground: themeRgbToHexColor(canvasRgb),
993 terminalForeground: themeRgbToHexColor(textRgb),
994 terminalCursor: themeRgbToHexColor(accentRgb),
995 terminalSelection: hex(surfaceAt(dark ? 0.18 : 0.12, Math.min(0.12, accent.C * 0.55))),
996 terminalScrollbar: hex(surfaceAt(dark ? 0.22 : 0.16, tintC)),
997 terminalScrollbarHover: hex(surfaceAt(dark ? 0.3 : 0.22, tintC)),
998 };
999}
1000
1001function themeContrastRatio(first: ThemeRgbColor, second: ThemeRgbColor): number {
1002 const firstLuminance = themeRelativeLuminance(first);
1003 const secondLuminance = themeRelativeLuminance(second);
1004 const lighter = Math.max(firstLuminance, secondLuminance);
1005 const darker = Math.min(firstLuminance, secondLuminance);
1006 return (lighter + 0.05) / (darker + 0.05);
1007}
1008
1009function readableThemeForeground(background: ThemeRgbColor): ThemeRgbColor {
1010 const lightContrast = themeContrastRatio(background, THEME_LIGHT_FOREGROUND);
1011 const darkContrast = themeContrastRatio(background, THEME_DARK_FOREGROUND);
1012 if (Math.max(lightContrast, darkContrast) >= 4.5) {
1013 return lightContrast >= darkContrast ? THEME_LIGHT_FOREGROUND : THEME_DARK_FOREGROUND;
1014 }
1015
1016 return themeContrastRatio(background, THEME_WHITE_FOREGROUND) >=
1017 themeContrastRatio(background, THEME_BLACK_FOREGROUND)
1018 ? THEME_WHITE_FOREGROUND
1019 : THEME_BLACK_FOREGROUND;
1020}
1021
1022function readableThemeText(
1023 background: ThemeRgbColor,
1024 foreground: ThemeRgbColor,
1025 amount: number,
1026 minimumRatio: number,
1027): ThemeRgbColor {
1028 const softened = mixThemeRgbColors(foreground, background, amount);
1029 if (themeContrastRatio(softened, background) >= minimumRatio) return softened;
1030
1031 // Find the quietest point between the requested mix and the primary
1032 // foreground that still clears the contrast floor. Returning the primary
1033 // foreground here made secondary labels jump from slightly too dim to full
1034 // brightness, which is especially conspicuous in dark custom themes.
1035 let readable = foreground;
1036 let lowerAmount = 0;
1037 let upperAmount = amount;
1038 for (let index = 0; index < 12; index += 1) {
1039 const candidateAmount = (lowerAmount + upperAmount) / 2;
1040 const candidate = mixThemeRgbColors(foreground, background, candidateAmount);
1041 if (themeContrastRatio(candidate, background) >= minimumRatio) {
1042 readable = candidate;
1043 lowerAmount = candidateAmount;
1044 } else {
1045 upperAmount = candidateAmount;
1046 }
1047 }
1048 return readable;
1049}
1050
1051// Match the perceived strength of the stock palettes rather than choosing an
1052// arbitrary foreground mix. These are the measured contrast ratios of zinc-500
1053// on the standard light canvas and #818181 on the standard dark canvas.
1054const STANDARD_LIGHT_MUTED_CONTRAST = 4.705;
1055const STANDARD_DARK_MUTED_CONTRAST = 5.082;
1056
1057function standardMutedThemeText(
1058 background: ThemeRgbColor,
1059 foreground: ThemeRgbColor,
1060): ThemeRgbColor {
1061 const target =
1062 themeRelativeLuminance(background) < 0.179
1063 ? STANDARD_DARK_MUTED_CONTRAST
1064 : STANDARD_LIGHT_MUTED_CONTRAST;
1065 return readableThemeText(background, foreground, 1, target);
1066}
1067
1068function managedThemeBackground(value: string, appearance: ThemeAppearance): ThemeRgbColor {
1069 const selected = parseThemeRgbColor(
1070 value,
1071 appearance === "dark" ? { r: 24, g: 15, b: 27 } : { r: 250, g: 245, b: 250 },
1072 );
1073 const hsl = themeRgbToHsl(selected);
1074 return themeHslToRgb({
1075 h: hsl.h,
1076 // A background tint should support the selected mode, not turn the whole
1077 // app into a high-saturation surface.
1078 s: Math.min(hsl.s, appearance === "dark" ? 0.3 : 0.2),
1079 l:
1080 appearance === "dark"
1081 ? Math.min(0.13, Math.max(0.07, hsl.l))
1082 : Math.min(0.985, Math.max(0.94, hsl.l)),
1083 });
1084}
1085
1086function managedThemeAccent(
1087 value: string,
1088 appearance: ThemeAppearance,
1089 background: ThemeRgbColor,
1090): ThemeRgbColor {
1091 const selected = parseThemeRgbColor(value, { r: 168, g: 67, b: 112 });
1092 const hsl = themeRgbToHsl(selected);
1093 const preferredLightness =
1094 appearance === "dark"
1095 ? Math.min(0.72, Math.max(0.42, hsl.l))
1096 : Math.min(0.58, Math.max(0.35, hsl.l));
1097 const lightnessRange: readonly [number, number] =
1098 appearance === "dark" ? [0.42, 0.82] : [0.22, 0.58];
1099 const saturation = Math.min(hsl.s, 0.82);
1100 const candidates = Array.from({ length: 61 }, (_, index) => {
1101 const lightness =
1102 lightnessRange[0] + ((lightnessRange[1] - lightnessRange[0]) * index) / (61 - 1);
1103 const color = themeHslToRgb({ h: hsl.h, s: saturation, l: lightness });
1104 return { color, lightness, contrast: themeContrastRatio(color, background) };
1105 });
1106 // Leave a little room for rounding when the generated RGB values become a
1107 // six-digit hex token.
1108 const readableCandidates = candidates.filter((candidate) => candidate.contrast >= 4.7);
1109 const pool = readableCandidates.length > 0 ? readableCandidates : candidates;
1110
1111 return pool.reduce((best, candidate) => {
1112 const distance = Math.abs(candidate.lightness - preferredLightness);
1113 const bestDistance = Math.abs(best.lightness - preferredLightness);
1114 return distance < bestDistance ||
1115 (distance === bestDistance && candidate.contrast > best.contrast)
1116 ? candidate
1117 : best;
1118 }).color;
1119}
1120
1121/**
1122 * Creates the guided palette used by the basic theme editor. The two user
1123 * colors control the mood, while dependent roles are generated together so
1124 * text, surfaces, message actions, code, and terminal UI stay coherent.
1125 */
1126export function createManagedThemeColors(
1127 appearance: ThemeAppearance,
1128 backgroundValue: string,
1129 accentValue: string,
1130 options?: {
1131 /** Use the seeds exactly as given instead of nudging them into the
1132 * readability envelope. Derived foregrounds still adapt for contrast. */
1133 exactSeeds?: boolean;
1134 },
1135): ThemeColors {
1136 const defaults = getDefaultThemeColors(appearance);
1137 const canvas = options?.exactSeeds
1138 ? parseThemeRgbColor(
1139 backgroundValue,
1140 appearance === "dark" ? { r: 24, g: 15, b: 27 } : { r: 250, g: 245, b: 250 },
1141 )
1142 : managedThemeBackground(backgroundValue, appearance);
1143 const accent = options?.exactSeeds
1144 ? parseThemeRgbColor(accentValue, { r: 168, g: 67, b: 112 })
1145 : managedThemeAccent(accentValue, appearance, canvas);
1146 const text = readableThemeForeground(canvas);
1147 const textMuted = standardMutedThemeText(canvas, text);
1148 // The top bar is part of the main panel, not a separate chrome layer: it
1149 // shares the canvas, and its controls sit on the panel's own surfaces.
1150 const chrome = canvas;
1151 const sidebar = mixThemeRgbColors(canvas, accent, 0.08);
1152 const surfaceRaised = mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.12 : 0.035);
1153 const surfaceOverlay = mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.18 : 0.06);
1154 const secondary = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.2 : 0.08);
1155 const muted = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.13 : 0.06);
1156 const accentSurface = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.3 : 0.14);
1157 const messageSurface = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.36 : 0.18);
1158 const toolbarControl = mixThemeRgbColors(chrome, accent, appearance === "dark" ? 0.2 : 0.08);
1159 const toolbarBorder = mixThemeRgbColors(chrome, accent, appearance === "dark" ? 0.35 : 0.14);
1160 const accentForeground = readableThemeForeground(accent);
1161 // Code and terminal are large surfaces: they keep the canvas hue instead of
1162 // drifting toward the foreground grey. Code sits just above the canvas —
1163 // a whisper of the text tint — and the terminal sits on the canvas itself.
1164 const codeBackground = mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.06 : 0.025);
1165 const terminalBackground = canvas;
1166 const messageActionHover = mixThemeRgbColors(
1167 accent,
1168 accentForeground === THEME_LIGHT_FOREGROUND || accentForeground === THEME_WHITE_FOREGROUND
1169 ? THEME_BLACK_FOREGROUND
1170 : THEME_WHITE_FOREGROUND,
1171 0.12,
1172 );
1173
1174 // The update family follows the accent instead of inheriting the default
1175 // palette's brand color, so generated themes carry their own identity in
1176 // update pills and banners. Error and warning stay semantic defaults.
1177 const updateSurface = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.32 : 0.16);
1178 const updateForeground = mixThemeRgbColors(
1179 accent,
1180 appearance === "dark" ? THEME_WHITE_FOREGROUND : THEME_BLACK_FOREGROUND,
1181 0.35,
1182 );
1183
1184 return {
1185 ...defaults,
1186 ...standardStatusColors(canvas),
1187 update: themeRgbToHexColor(accent),
1188 updateForeground: themeRgbToHexColor(updateForeground),
1189 updateSurface: themeRgbToHexColor(updateSurface),
1190 canvas: themeRgbToHexColor(canvas),
1191 chrome: themeRgbToHexColor(chrome),
1192 toolbar: themeRgbToHexColor(chrome),
1193 toolbarForeground: themeRgbToHexColor(text),
1194 toolbarBorder: themeRgbToHexColor(toolbarBorder),
1195 toolbarControl: themeRgbToHexColor(toolbarControl),
1196 toolbarControlForeground: themeRgbToHexColor(text),
1197 toolbarControlHover: themeRgbToHexColor(accentSurface),
1198 surface: themeRgbToHexColor(canvas),
1199 surfaceRaised: themeRgbToHexColor(surfaceRaised),
1200 surfaceOverlay: themeRgbToHexColor(surfaceOverlay),
1201 text: themeRgbToHexColor(text),
1202 textMuted: themeRgbToHexColor(textMuted),
1203 // Borders blend through the accent before lightening so control chrome
1204 // carries the theme hue like the hand-tuned palettes (#5c345b, #e0d3e1)
1205 // instead of flattening to grey.
1206 border: themeRgbToHexColor(
1207 mixThemeRgbColors(
1208 mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.22 : 0.1),
1209 text,
1210 0.1,
1211 ),
1212 ),
1213 input: themeRgbToHexColor(
1214 mixThemeRgbColors(
1215 mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.3 : 0.14),
1216 text,
1217 appearance === "dark" ? 0.14 : 0.13,
1218 ),
1219 ),
1220 focus: themeRgbToHexColor(accent),
1221 accent: themeRgbToHexColor(accent),
1222 accentForeground: themeRgbToHexColor(accentForeground),
1223 secondary: themeRgbToHexColor(secondary),
1224 secondaryForeground: themeRgbToHexColor(readableThemeForeground(secondary)),
1225 muted: themeRgbToHexColor(muted),
1226 mutedForeground: themeRgbToHexColor(textMuted),
1227 placeholder: themeRgbToHexColor(textMuted),
1228 secondaryLabel: themeRgbToHexColor(textMuted),
1229 iconMuted: themeRgbToHexColor(textMuted),
1230 accentSurface: themeRgbToHexColor(accentSurface),
1231 accentSurfaceForeground: themeRgbToHexColor(readableThemeForeground(accentSurface)),
1232 messageSurface: themeRgbToHexColor(messageSurface),
1233 messageForeground: themeRgbToHexColor(readableThemeForeground(messageSurface)),
1234 messageAction: themeRgbToHexColor(accent),
1235 messageActionForeground: themeRgbToHexColor(accentForeground),
1236 messageActionHover: themeRgbToHexColor(messageActionHover),
1237 codeBackground: themeRgbToHexColor(codeBackground),
1238 codeForeground: themeRgbToHexColor(readableThemeForeground(codeBackground)),
1239 sidebar: themeRgbToHexColor(sidebar),
1240 sidebarForeground: themeRgbToHexColor(readableThemeForeground(sidebar)),
1241 sidebarMutedForeground: themeRgbToHexColor(standardMutedThemeText(sidebar, text)),
1242 sidebarControlSurface: themeRgbToHexColor(
1243 mixThemeRgbColors(sidebar, text, appearance === "dark" ? 0.16 : 0.08),
1244 ),
1245 sidebarRowHover: themeRgbToHexColor(mixThemeRgbColors(sidebar, accent, 0.12)),
1246 sidebarRowActive: themeRgbToHexColor(mixThemeRgbColors(sidebar, accent, 0.2)),
1247 sidebarRowSelected: themeRgbToHexColor(mixThemeRgbColors(sidebar, accent, 0.24)),
1248 sidebarBorder: themeRgbToHexColor(
1249 mixThemeRgbColors(sidebar, text, appearance === "dark" ? 0.35 : 0.12),
1250 ),
1251 terminalBackground: themeRgbToHexColor(terminalBackground),
1252 terminalForeground: themeRgbToHexColor(readableThemeForeground(terminalBackground)),
1253 terminalCursor: themeRgbToHexColor(accent),
1254 terminalSelection: themeRgbToHexColor(
1255 mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.35 : 0.18),
1256 ),
1257 terminalScrollbar: themeRgbToHexColor(
1258 mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.42 : 0.22),
1259 ),
1260 terminalScrollbarHover: themeRgbToHexColor(
1261 mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.55 : 0.32),
1262 ),
1263 };
1264}
1265
1266export const T3_CHAT_THEME: ThemeDefinition = {
1267 id: T3_CHAT_THEME_ID,
1268 label: T3_CHAT_THEME_LABEL,
1269 appearance: "light",
1270 colors: T3_CHAT_LIGHT_COLORS,
1271 variants: {
1272 dark: T3_CHAT_DARK_COLORS,
1273 },
1274};
1275
1276/** Theme-file defaults follow the flagship palette for the requested mode. */
1277export function getDefaultThemeColors(appearance: ThemeAppearance): ThemeColors {
1278 return appearance === "dark" ? T3_CHAT_DARK_COLORS : T3_CHAT_LIGHT_COLORS;
1279}
1280
1281/**
1282 * A companion action color in the T3 Chat mold. This gives send buttons,
1283 * status pills, and theme previews a second voice; foreground and hover follow
1284 * the same rules as the managed generator.
1285 */
1286function themeActionColors(
1287 action: string,
1288): Pick<ThemeColors, "messageAction" | "messageActionForeground" | "messageActionHover"> {
1289 const rgb = parseThemeRgbColor(action, THEME_DARK_FOREGROUND);
1290 const foreground = readableThemeForeground(rgb);
1291 const towardOpposite =
1292 foreground === THEME_LIGHT_FOREGROUND || foreground === THEME_WHITE_FOREGROUND
1293 ? THEME_BLACK_FOREGROUND
1294 : THEME_WHITE_FOREGROUND;
1295 return {
1296 messageAction: action,
1297 messageActionForeground: themeRgbToHexColor(foreground),
1298 messageActionHover: themeRgbToHexColor(mixThemeRgbColors(rgb, towardOpposite, 0.12)),
1299 };
1300}
1301
1302export const GROVE_THEME: ThemeDefinition = {
1303 id: GROVE_THEME_ID,
1304 label: GROVE_THEME_LABEL,
1305 appearance: "light",
1306 colors: {
1307 ...createManagedThemeColors("light", "#f2f8f4", "#19734a"),
1308 ...themeActionColors("#8f6410"),
1309 },
1310 variants: {
1311 dark: {
1312 ...createManagedThemeColors("dark", "#1d2b24", "#69d69a"),
1313 ...themeActionColors("#e3b34e"),
1314 },
1315 },
1316};
1317
1318export const OCEAN_THEME: ThemeDefinition = {
1319 id: OCEAN_THEME_ID,
1320 label: OCEAN_THEME_LABEL,
1321 appearance: "light",
1322 colors: {
1323 ...createManagedThemeColors("light", "#f2f7fb", "#2878b8"),
1324 ...themeActionColors("#0a6f75"),
1325 },
1326 variants: {
1327 dark: {
1328 ...createManagedThemeColors("dark", "#1b2938", "#70b9ee"),
1329 ...themeActionColors("#5bd0d6"),
1330 },
1331 },
1332};
1333
1334export const EMBER_THEME: ThemeDefinition = {
1335 id: EMBER_THEME_ID,
1336 label: EMBER_THEME_LABEL,
1337 appearance: "light",
1338 colors: {
1339 ...createManagedThemeColors("light", "#fff6ef", "#c4602f"),
1340 ...themeActionColors("#b23535"),
1341 },
1342 variants: {
1343 dark: {
1344 ...createManagedThemeColors("dark", "#30231e", "#f39a62"),
1345 ...themeActionColors("#f78a7a"),
1346 },
1347 },
1348};
1349
1350export const IRIS_THEME: ThemeDefinition = {
1351 id: IRIS_THEME_ID,
1352 label: IRIS_THEME_LABEL,
1353 appearance: "light",
1354 colors: {
1355 ...createManagedThemeColors("light", "#f7f4fc", "#7254b9"),
1356 ...themeActionColors("#a82c87"),
1357 },
1358 variants: {
1359 dark: {
1360 ...createManagedThemeColors("dark", "#29243b", "#ad92f5"),
1361 ...themeActionColors("#f099d8"),
1362 },
1363 },
1364};
1365
1366const BUILT_IN_THEME_DEFINITIONS: ReadonlyArray<ThemeDefinition> = [
1367 T3_CHAT_THEME,
1368 GROVE_THEME,
1369 OCEAN_THEME,
1370 EMBER_THEME,
1371 IRIS_THEME,
1372];
1373
1374export function getThemeDefinition(theme: ThemePreference): ThemeDefinition | null {
1375 const themeId = themeIdFromPreference(theme);
1376 return (
1377 BUILT_IN_THEME_DEFINITIONS.find((definition) => definition.id === themeId) ??
1378 getCustomThemes().find((definition) => definition.id === themeId) ??
1379 null
1380 );
1381}
1382
1383export function getThemeColorsForMode(
1384 theme: ThemeDefinition,
1385 mode: ThemeAppearance,
1386): ThemeColors | null {
1387 if (mode === theme.appearance) return theme.colors;
1388 return theme.variants?.[mode] ?? null;
1389}
1390
1391export function getThemeModes(theme: ThemeDefinition): ReadonlyArray<ThemeAppearance> {
1392 return (["light", "dark"] as const).filter((mode) => getThemeColorsForMode(theme, mode) !== null);
1393}
1394
1395export function getThemePreferenceMode(theme: ThemePreference): ThemeAppearance | null {
1396 if (theme === "system") return null;
1397 if (theme === "light" || theme === "dark") return theme;
1398 const legacyMode = legacyThemeMode(theme);
1399 if (legacyMode) return legacyMode;
1400 return getThemeDefinition(theme)?.appearance ?? null;
1401}
1402
1403export function themeIdFromName(name: string): string {
1404 const normalized = name
1405 .trim()
1406 .toLowerCase()
1407 .replace(/[^a-z0-9]+/g, "-")
1408 .replace(/^-+|-+$/g, "")
1409 .slice(0, 48);
1410 return normalized || "custom-theme";
1411}
1412
1413export class ThemeLibraryStorageError extends Schema.TaggedErrorClass<ThemeLibraryStorageError>()(
1414 "ThemeLibraryStorageError",
1415 { storageKey: Schema.String, cause: Schema.Defect() },
1416) {
1417 override get message(): string {
1418 return `Failed to write the theme library to ${this.storageKey}.`;
1419 }
1420}
1421
1422export const isThemeLibraryStorageError = Schema.is(ThemeLibraryStorageError);
1423
1424function saveCustomThemes(themes: ReadonlyArray<ThemeDefinition>): void {
1425 if (typeof window === "undefined") return;
1426 try {
1427 window.localStorage.setItem(CUSTOM_THEMES_STORAGE_KEY, JSON.stringify(themes));
1428 customThemesSnapshot = themes;
1429 } catch (cause) {
1430 throw new ThemeLibraryStorageError({ storageKey: CUSTOM_THEMES_STORAGE_KEY, cause });
1431 }
1432 notifyCustomThemeListeners();
1433}
1434
1435export function installCustomTheme(theme: ThemeDefinition): ThemeDefinition {
1436 if (RESERVED_THEME_IDS.has(theme.id)) {
1437 throw new Error(`The theme id "${theme.id}" is reserved.`);
1438 }
1439 if (
1440 [...BUILT_IN_THEME_DEFINITIONS, ...getCustomThemes()].some(
1441 (existing) => existing.id === theme.id,
1442 )
1443 ) {
1444 throw new Error(`A theme named "${theme.label}" is already installed.`);
1445 }
1446 saveCustomThemes([...getCustomThemes(), theme]);
1447 return theme;
1448}
1449
1450export function updateCustomTheme(theme: ThemeDefinition): ThemeDefinition {
1451 if (RESERVED_THEME_IDS.has(theme.id)) {
1452 throw new Error(`The theme id "${theme.id}" is reserved.`);
1453 }
1454
1455 const themes = getCustomThemes();
1456 const themeIndex = themes.findIndex((existing) => existing.id === theme.id);
1457 if (themeIndex === -1) {
1458 throw new Error(`The theme "${theme.label}" is not installed.`);
1459 }
1460
1461 const nextThemes = [...themes];
1462 nextThemes[themeIndex] = theme;
1463 saveCustomThemes(nextThemes);
1464 return theme;
1465}
1466
1467export function removeCustomTheme(themeId: string): void {
1468 const nextThemes = getCustomThemes().filter((theme) => theme.id !== themeId);
1469 if (nextThemes.length === getCustomThemes().length) return;
1470 saveCustomThemes(nextThemes);
1471}
1472
1473function parseThemeColorOverrides(value: unknown): ThemeColorOverrides {
1474 if (!isRecord(value)) throw new Error("Theme colors must be objects.");
1475
1476 const overrides: Partial<Record<ThemeColorRole, string>> = {};
1477 for (const [role, color] of Object.entries(value)) {
1478 if (!THEME_COLOR_ROLE_SET.has(role)) {
1479 throw new Error(`"${role}" is not a supported theme color role.`);
1480 }
1481 if (!isThemeColor(color)) {
1482 throw new Error(`The color for "${role}" must be a hex color such as #8b5cf6.`);
1483 }
1484 overrides[role as ThemeColorRole] = color;
1485 }
1486 if (Object.keys(overrides).length === 0) {
1487 throw new Error("Add at least one color role to the theme file.");
1488 }
1489 return overrides;
1490}
1491
1492export function parseThemeFile(value: unknown): ThemeDefinition {
1493 if (!isRecord(value)) {
1494 throw new Error("Theme files must contain a JSON object.");
1495 }
1496 if (value.version !== THEME_FILE_VERSION) {
1497 throw new Error(`This theme file uses an unsupported version. Expected ${THEME_FILE_VERSION}.`);
1498 }
1499
1500 const name = value.name;
1501 const appearance = value.appearance;
1502 const rawColors = value.colors;
1503 if (!isThemeLabel(name)) throw new Error("Theme files need a name (48 characters or fewer).");
1504 if (!isThemeAppearance(appearance)) {
1505 throw new Error('Theme files need an appearance of "light" or "dark".');
1506 }
1507 if (!isRecord(rawColors)) throw new Error("Theme files need a colors object.");
1508
1509 const id = value.id === undefined ? themeIdFromName(name) : value.id;
1510 if (!isThemeId(id)) {
1511 throw new Error("Theme ids may only contain lowercase letters, numbers, and hyphens.");
1512 }
1513 if (RESERVED_THEME_IDS.has(id)) {
1514 throw new Error(`The theme id "${id}" is reserved.`);
1515 }
1516
1517 const overrides = parseThemeColorOverrides(rawColors);
1518
1519 const fallback = getDefaultThemeColors(appearance);
1520 const variants: Partial<Record<ThemeAppearance, ThemeColors>> = {};
1521 if (value.variants !== undefined) {
1522 if (!isRecord(value.variants)) throw new Error("Theme variants must be an object.");
1523 for (const [variantAppearance, variantColors] of Object.entries(value.variants)) {
1524 if (!isThemeAppearance(variantAppearance)) {
1525 throw new Error('Theme variants may only be named "light" or "dark".');
1526 }
1527 if (variantAppearance === appearance) {
1528 throw new Error(`Theme variants must not repeat the base appearance "${appearance}".`);
1529 }
1530 const variantFallback = getDefaultThemeColors(variantAppearance);
1531 variants[variantAppearance] = {
1532 ...variantFallback,
1533 ...parseThemeColorOverrides(variantColors),
1534 };
1535 }
1536 }
1537
1538 return {
1539 id,
1540 label: name.trim(),
1541 appearance,
1542 colors: { ...fallback, ...overrides },
1543 ...(Object.keys(variants).length > 0 ? { variants } : {}),
1544 ...(value.managed === true ? { managed: true } : {}),
1545 };
1546}
1547
1548export function serializeThemeFile(theme: ThemeDefinition): string {
1549 const file: ThemeFile = {
1550 version: THEME_FILE_VERSION,
1551 id: theme.id,
1552 name: theme.label,
1553 appearance: theme.appearance,
1554 colors: theme.colors,
1555 ...(theme.variants ? { variants: theme.variants } : {}),
1556 ...(theme.managed ? { managed: true } : {}),
1557 };
1558 return `${JSON.stringify(file, null, 2)}\n`;
1559}
1560
1561const APP_THEME_VARIABLES: Readonly<Record<ThemeColorRole, string>> = {
1562 canvas: "--app-theme-canvas",
1563 chrome: "--app-theme-chrome",
1564 toolbar: "--app-theme-toolbar",
1565 toolbarForeground: "--app-theme-toolbar-foreground",
1566 toolbarBorder: "--app-theme-toolbar-border",
1567 toolbarControl: "--app-theme-toolbar-control",
1568 toolbarControlForeground: "--app-theme-toolbar-control-foreground",
1569 toolbarControlHover: "--app-theme-toolbar-control-hover",
1570 surface: "--app-theme-surface",
1571 surfaceRaised: "--app-theme-surface-raised",
1572 surfaceOverlay: "--app-theme-surface-overlay",
1573 text: "--app-theme-text",
1574 textMuted: "--app-theme-text-muted",
1575 border: "--app-theme-border",
1576 input: "--app-theme-input",
1577 focus: "--app-theme-focus",
1578 accent: "--app-theme-accent",
1579 accentForeground: "--app-theme-accent-foreground",
1580 secondary: "--app-theme-secondary",
1581 secondaryForeground: "--app-theme-secondary-foreground",
1582 muted: "--app-theme-muted",
1583 mutedForeground: "--app-theme-muted-foreground",
1584 placeholder: "--app-theme-placeholder",
1585 secondaryLabel: "--app-theme-secondary-label",
1586 iconMuted: "--app-theme-icon-muted",
1587 error: "--app-theme-error",
1588 errorForeground: "--app-theme-error-foreground",
1589 errorSurface: "--app-theme-error-surface",
1590 warning: "--app-theme-warning",
1591 warningForeground: "--app-theme-warning-foreground",
1592 warningSurface: "--app-theme-warning-surface",
1593 update: "--app-theme-update",
1594 updateForeground: "--app-theme-update-foreground",
1595 updateSurface: "--app-theme-update-surface",
1596 accentSurface: "--app-theme-accent-surface",
1597 accentSurfaceForeground: "--app-theme-accent-surface-foreground",
1598 messageSurface: "--app-theme-message-surface",
1599 messageForeground: "--app-theme-message-foreground",
1600 messageAction: "--app-theme-message-action",
1601 messageActionForeground: "--app-theme-message-action-foreground",
1602 messageActionHover: "--app-theme-message-action-hover",
1603 codeBackground: "--app-theme-code-background",
1604 codeForeground: "--app-theme-code-foreground",
1605 sidebar: "--app-theme-sidebar",
1606 sidebarForeground: "--app-theme-sidebar-foreground",
1607 sidebarMutedForeground: "--app-theme-sidebar-muted-foreground",
1608 sidebarControlSurface: "--app-theme-sidebar-control-surface",
1609 sidebarRowHover: "--app-theme-sidebar-row-hover",
1610 sidebarRowActive: "--app-theme-sidebar-row-active",
1611 sidebarRowSelected: "--app-theme-sidebar-row-selected",
1612 sidebarBorder: "--app-theme-sidebar-border",
1613 terminalBackground: "--app-theme-terminal-background",
1614 terminalForeground: "--app-theme-terminal-foreground",
1615 terminalCursor: "--app-theme-terminal-cursor",
1616 terminalSelection: "--app-theme-terminal-selection-background",
1617 terminalScrollbar: "--app-theme-terminal-scrollbar",
1618 terminalScrollbarHover: "--app-theme-terminal-scrollbar-hover",
1619};
1620
1621export function getThemeColorVariable(role: ThemeColorRole): string {
1622 return APP_THEME_VARIABLES[role];
1623}
1624
1625/** Marks the document as wearing an unsaved draft rather than a stored theme. */
1626export const THEME_PREVIEW_ID = "__preview";
1627
1628/**
1629 * Paint a draft palette onto the live app without installing it, so the editor
1630 * can be judged against the real interface instead of a miniature. Callers
1631 * restore the stored theme (refreshTheme) when the draft goes away.
1632 */
1633export function applyThemeColorPreview(colors: ThemeColors, appearance: ThemeAppearance): void {
1634 if (typeof document === "undefined") return;
1635 const root = document.documentElement;
1636 if (!root?.style) return;
1637
1638 root.dataset.themeId = THEME_PREVIEW_ID;
1639 root.classList.toggle("dark", appearance === "dark");
1640 for (const [role, value] of Object.entries(colors) as Array<[ThemeColorRole, string]>) {
1641 // A half-typed hex keeps the last good value instead of blanking the role.
1642 if (isThemeColor(value)) root.style.setProperty(APP_THEME_VARIABLES[role], value);
1643 }
1644}
1645
1646export function applyThemePalette(theme: ThemePreference, appearance?: ThemeAppearance): void {
1647 if (typeof document === "undefined") return;
1648
1649 const root = document.documentElement;
1650 if (!root?.style) return;
1651
1652 const palette = getThemeDefinition(theme);
1653
1654 if (palette) {
1655 root.dataset.themeId = palette.id;
1656 const mode = appearance ?? legacyThemeMode(theme) ?? palette.appearance;
1657 const colors = getThemeColorsForMode(palette, mode) ?? palette.colors;
1658 for (const [role, value] of Object.entries(colors) as Array<[ThemeColorRole, string]>) {
1659 root.style.setProperty(APP_THEME_VARIABLES[role], value);
1660 }
1661 return;
1662 }
1663
1664 delete root.dataset.themeId;
1665 for (const variable of Object.values(APP_THEME_VARIABLES)) {
1666 root.style.removeProperty(variable);
1667 }
1668}
1669
1670export function resolveThemeAppearance(
1671 theme: ThemePreference,
1672 systemDark: boolean,
1673 followSystem?: boolean,
1674 appearanceMode?: ThemePreferenceMode,
1675 halves?: ThemeHalves | null,
1676): "light" | "dark" {
1677 const systemAppearance = systemDark ? "dark" : "light";
1678 const mode = appearanceMode ?? ((followSystem ?? theme === "system") ? "system" : null);
1679 if (mode === "system") {
1680 // A configured half guarantees the appearance is renderable even when the
1681 // base theme lacks that mode.
1682 if (halves?.[systemAppearance]) return systemAppearance;
1683 const definition = getThemeDefinition(theme);
1684 return definition && getThemeColorsForMode(definition, systemAppearance) === null
1685 ? definition.appearance
1686 : systemAppearance;
1687 }
1688 if (mode === "light" || mode === "dark") {
1689 if (halves?.[mode]) return mode;
1690 const definition = getThemeDefinition(theme);
1691 return definition && getThemeColorsForMode(definition, mode) === null
1692 ? definition.appearance
1693 : mode;
1694 }
1695 return getThemePreferenceMode(theme) ?? "light";
1696}
1697
1698export function resolveDesktopTheme(
1699 theme: ThemePreference,
1700 followSystem?: boolean,
1701 appearanceMode?: ThemePreferenceMode,
1702 halves?: ThemeHalves | null,
1703): "light" | "dark" | "system" {
1704 const mode = appearanceMode ?? ((followSystem ?? theme === "system") ? "system" : null);
1705 if (mode === "system") {
1706 const definition = getThemeDefinition(theme);
1707 // A configured half fills in an appearance the base theme cannot render.
1708 const hasLightMode =
1709 halves?.light !== undefined ||
1710 (definition !== null && getThemeColorsForMode(definition, "light") !== null);
1711 const hasDarkMode =
1712 halves?.dark !== undefined ||
1713 (definition !== null && getThemeColorsForMode(definition, "dark") !== null);
1714 return definition && (!hasLightMode || !hasDarkMode) ? definition.appearance : "system";
1715 }
1716 if (mode === "light" || mode === "dark") {
1717 if (halves?.[mode]) return mode;
1718 const definition = getThemeDefinition(theme);
1719 return definition && getThemeColorsForMode(definition, mode) === null
1720 ? definition.appearance
1721 : mode;
1722 }
1723 return getThemePreferenceMode(theme) ?? "system";
1724}
1725
1726export function isKnownThemePreference(theme: string): boolean {
1727 if (theme === "light" || theme === "dark" || theme === "system") return true;
1728 return getThemeDefinition(theme) !== null;
1729}
1730
1731/**
1732 * An automatic-mode mix: a different theme per resolved appearance. Halves
1733 * only name real themes that can render their half; anything else is dropped
1734 * so a stale mix degrades to the base preference.
1735 */
1736export type ThemeHalves = Readonly<{ light?: string; dark?: string }>;
1737
1738export function parseThemeHalves(raw: string | null): ThemeHalves | null {
1739 if (!raw) return null;
1740 try {
1741 const value: unknown = JSON.parse(raw);
1742 if (!isRecord(value)) return null;
1743 const halves: { light?: string; dark?: string } = {};
1744 for (const appearance of ["light", "dark"] as const) {
1745 const themeId = value[appearance];
1746 if (typeof themeId !== "string") continue;
1747 const definition = getThemeDefinition(themeId);
1748 if (definition && getThemeColorsForMode(definition, appearance) !== null) {
1749 // Store the definition's id so legacy aliases resolve to the same
1750 // value the runtime applies to the document.
1751 halves[appearance] = definition.id;
1752 }
1753 }
1754 return halves.light !== undefined || halves.dark !== undefined ? halves : null;
1755 } catch {
1756 return null;
1757 }
1758}
1759
1760/** The theme that should render the given appearance under a mix, if any. */
1761export function resolveThemeHalf(
1762 theme: ThemePreference,
1763 halves: ThemeHalves | null,
1764 appearance: ThemeAppearance,
1765): ThemePreference {
1766 return halves?.[appearance] ?? theme;
1767}
apps/web/src/vscodeThemeImport.test.ts created+282
...@@ -0,0 +1,282 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { getThemeColorsForMode, THEME_FILE_VERSION } from "./themePalette";
4import {
5 isVsCodeThemeFile,
6 pairVsCodeThemes,
7 parseVsCodeThemeFile,
8 resolveThemeLabelCollisions,
9} from "./vscodeThemeImport";
10
11function contrastRatio(first: string, second: string): number {
12 const toChannels = (value: string) => {
13 const hex = value.slice(1);
14 return [0, 1, 2].map(
15 (channel) => Number.parseInt(hex.slice(channel * 2, channel * 2 + 2), 16) / 255,
16 );
17 };
18 const luminance = (value: string) =>
19 toChannels(value)
20 .map((channel) => (channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4))
21 .reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index]!, 0);
22 const lighter = Math.max(luminance(first), luminance(second));
23 const darker = Math.min(luminance(first), luminance(second));
24 return (lighter + 0.05) / (darker + 0.05);
25}
26
27// Shaped like a real workbench theme: dotted keys, alpha overlays, and a lot
28// of roles simply left out.
29const VSCODE_DARK = {
30 name: "pierre-dark-soft",
31 type: "dark",
32 colors: {
33 "editor.background": "#171717",
34 "editor.foreground": "#d4d4d4",
35 foreground: "#d4d4d4",
36 "sideBar.background": "#101010",
37 "sideBar.foreground": "#8a8a8a",
38 "sideBar.border": "#1d1d1d",
39 focusBorder: "#69b1ff",
40 "button.background": "#69b1ff",
41 "button.foreground": "#171717",
42 "input.border": "#2c2c2c",
43 "input.placeholderForeground": "#525252",
44 "terminal.background": "#101010",
45 "terminal.foreground": "#8a8a8a",
46 "list.hoverBackground": "#1f3e5e59",
47 "list.activeSelectionBackground": "#1f3e5e99",
48 },
49 tokenColors: [],
50};
51
52describe("VS Code theme import", () => {
53 it("recognises workbench themes and rejects our own files", () => {
54 expect(isVsCodeThemeFile(VSCODE_DARK)).toBe(true);
55 expect(isVsCodeThemeFile({ type: "dark", tokenColors: [] })).toBe(true);
56 expect(
57 isVsCodeThemeFile({
58 version: THEME_FILE_VERSION,
59 name: "Aurora",
60 appearance: "light",
61 colors: { canvas: "#ffffff" },
62 }),
63 ).toBe(false);
64 expect(isVsCodeThemeFile("nope")).toBe(false);
65 });
66
67 it("carries the editor surfaces and accent across", () => {
68 const theme = parseVsCodeThemeFile(VSCODE_DARK);
69 // The slug name is read as words; a displayName would win verbatim.
70 expect(theme.label).toBe("Pierre Dark Soft");
71 expect(theme.appearance).toBe("dark");
72 expect(theme.colors.canvas).toBe("#171717");
73 expect(theme.colors.text).toBe("#d4d4d4");
74 expect(theme.colors.accent).toBe("#69b1ff");
75 expect(theme.colors.sidebar).toBe("#101010");
76 expect(theme.colors.terminalBackground).toBe("#101010");
77 });
78
79 it("flattens alpha overlays onto the surface they sit on", () => {
80 const theme = parseVsCodeThemeFile(VSCODE_DARK);
81 // #1f3e5e59 over the #101010 sidebar, not left semi-transparent.
82 expect(theme.colors.sidebarRowHover).toMatch(/^#[0-9a-f]{6}$/);
83 expect(theme.colors.sidebarRowHover).not.toBe("#1f3e5e59");
84 expect(theme.colors.sidebarRowSelected).not.toBe(theme.colors.sidebar);
85 });
86
87 it("fills every role the file omits with a readable derived value", () => {
88 const theme = parseVsCodeThemeFile(VSCODE_DARK);
89 const colors = getThemeColorsForMode(theme, "dark")!;
90 for (const value of Object.values(colors)) {
91 expect(value).toMatch(/^#[0-9a-f]{3,8}$/i);
92 }
93 expect(contrastRatio(colors.text, colors.canvas)).toBeGreaterThanOrEqual(4.5);
94 expect(contrastRatio(colors.sidebarForeground, colors.sidebar)).toBeGreaterThanOrEqual(4.5);
95 expect(
96 contrastRatio(colors.messageActionForeground, colors.messageAction),
97 ).toBeGreaterThanOrEqual(4.5);
98 });
99
100 it("falls back to the editor background when the type is missing or odd", () => {
101 const untyped = parseVsCodeThemeFile({
102 name: "Untyped",
103 colors: { "editor.background": "#fdfdfd", "editor.foreground": "#202020" },
104 });
105 expect(untyped.appearance).toBe("light");
106 const hc = parseVsCodeThemeFile({
107 name: "High contrast",
108 type: "hc-light",
109 colors: { "editor.background": "#ffffff" },
110 });
111 expect(hc.appearance).toBe("light");
112 });
113
114 it("keeps an unreadable foreground out of the palette", () => {
115 const theme = parseVsCodeThemeFile({
116 name: "Unreadable",
117 type: "dark",
118 colors: { "editor.background": "#101010", "editor.foreground": "#111111" },
119 });
120 expect(theme.colors.text).not.toBe("#111111");
121 expect(contrastRatio(theme.colors.text, theme.colors.canvas)).toBeGreaterThanOrEqual(4.5);
122 });
123
124 it("stays readable when the file replaces a surface but not its foreground", () => {
125 // A dark theme with a light sidebar: the derived sidebar foreground was
126 // solved for a dark surface and would vanish on this one.
127 const theme = parseVsCodeThemeFile({
128 name: "Split",
129 type: "dark",
130 colors: {
131 "editor.background": "#101010",
132 "editor.foreground": "#f5f5f5",
133 "sideBar.background": "#fafafa",
134 "terminal.background": "#fbfbfb",
135 },
136 });
137 expect(theme.colors.sidebar).toBe("#fafafa");
138 expect(
139 contrastRatio(theme.colors.sidebarForeground, theme.colors.sidebar),
140 ).toBeGreaterThanOrEqual(4.5);
141 expect(
142 contrastRatio(theme.colors.terminalForeground, theme.colors.terminalBackground),
143 ).toBeGreaterThanOrEqual(4.5);
144 });
145
146 it("reads wide-gamut color() notation", () => {
147 // Themes authored for P3 displays (the shipped Pierre "vibrant" pair) use
148 // this instead of hex.
149 const theme = parseVsCodeThemeFile({
150 name: "Vibrant",
151 type: "dark",
152 colors: {
153 "editor.background": "color(display-p3 0.039216 0.039216 0.039216)",
154 "editor.foreground": "color(display-p3 0.980392 0.980392 0.980392)",
155 focusBorder: "color(display-p3 0.308664 0.645271 1.000000)",
156 "editor.selectionBackground": "color(display-p3 0.308664 0.645271 1.000000 / 0.300000)",
157 },
158 });
159 expect(theme.colors.canvas).toMatch(/^#0[89ab]/);
160 expect(theme.colors.text).toMatch(/^#f[a-f0-9]/);
161 // The P3 blue lands in sRGB blue, not black or a clipped grey.
162 const accent = theme.colors.accent;
163 const [red, green, blue] = [1, 3, 5].map((index) =>
164 Number.parseInt(accent.slice(index, index + 2), 16),
165 ) as [number, number, number];
166 expect(blue).toBeGreaterThan(200);
167 expect(blue).toBeGreaterThan(red);
168 expect(green).toBeGreaterThan(red);
169 });
170
171 it("pairs light and dark files from one family into dual-mode themes", () => {
172 const make = (name: string, type: "light" | "dark") =>
173 parseVsCodeThemeFile({
174 name,
175 type,
176 colors: {
177 "editor.background": type === "dark" ? "#101014" : "#fdfdfd",
178 "editor.foreground": type === "dark" ? "#e6e6e6" : "#1f1f1f",
179 focusBorder: "#69b1ff",
180 },
181 });
182 const themes = pairVsCodeThemes([
183 make("github-dark", "dark"),
184 make("github-light", "light"),
185 make("github-dark-colorblind", "dark"),
186 make("github-light-colorblind", "light"),
187 make("github-dark-dimmed", "dark"),
188 ]);
189
190 const labels = themes.map((theme) => theme.label);
191 expect(labels).toEqual(["Github", "Github Colorblind", "Github Dark Dimmed"]);
192 const github = themes[0]!;
193 expect(github.appearance).toBe("light");
194 expect(getThemeColorsForMode(github, "dark")).not.toBeNull();
195 expect(getThemeColorsForMode(github, "dark")!.canvas).toBe("#101014");
196 expect(github.colors.canvas).toBe("#fdfdfd");
197 // The unpaired dimmed variant stays a single dark theme.
198 expect(getThemeColorsForMode(themes[2]!, "light")).toBeNull();
199 });
200
201 it("does not guess when a family is ambiguous", () => {
202 const make = (name: string, type: "light" | "dark") =>
203 parseVsCodeThemeFile({
204 name,
205 type,
206 colors: { "editor.background": type === "dark" ? "#101014" : "#fdfdfd" },
207 });
208 const themes = pairVsCodeThemes([
209 make("solar-dark", "dark"),
210 make("solar-dark-soft", "dark"),
211 make("solar-light", "light"),
212 ]);
213 // "solar-dark-soft" groups under its own key with no light partner, so
214 // it keeps its full name; the remaining pair merges.
215 expect(themes.map((theme) => theme.label).sort()).toEqual(["Solar", "Solar Dark Soft"]);
216 });
217
218 it("keeps derived surfaces neutral instead of washing them with the accent", () => {
219 // A gray theme with a blue focusBorder: roles the file omits (code
220 // surface, plain surfaces) must stay near the canvas, not turn blue.
221 const theme = parseVsCodeThemeFile(VSCODE_DARK);
222 const spread = (value: string) => {
223 const channels = [1, 3, 5].map((index) => Number.parseInt(value.slice(index, index + 2), 16));
224 return Math.max(...channels) - Math.min(...channels);
225 };
226 expect(spread(theme.colors.codeBackground)).toBeLessThanOrEqual(8);
227 expect(spread(theme.colors.surface)).toBeLessThanOrEqual(8);
228 expect(spread(theme.colors.text)).toBeLessThanOrEqual(12);
229 // The accent itself keeps the file's color.
230 expect(theme.colors.accent).toBe("#69b1ff");
231 });
232
233 it("tells same-named variants apart by their file names", () => {
234 // Dracula ships dracula.json and dracula-soft.json that both say
235 // "Dracula" inside.
236 const dracula = (bg: string) =>
237 parseVsCodeThemeFile({
238 name: "Dracula",
239 type: "dark",
240 colors: { "editor.background": bg, "editor.foreground": "#f8f8f2" },
241 });
242 const themes = resolveThemeLabelCollisions([
243 { theme: dracula("#282a36"), sourceName: "dracula.json" },
244 { theme: dracula("#22232e"), sourceName: "dracula-soft.json" },
245 ]);
246 expect(themes.map((theme) => theme.label)).toEqual(["Dracula", "Dracula Soft"]);
247 expect(themes.map((theme) => theme.id)).toEqual(["dracula", "dracula-soft"]);
248 // Without file names the second falls back to numbering.
249 const numbered = resolveThemeLabelCollisions([
250 { theme: dracula("#282a36") },
251 { theme: dracula("#22232e") },
252 ]);
253 expect(numbered.map((theme) => theme.label)).toEqual(["Dracula", "Dracula 2"]);
254 });
255
256 it("falls back to the name when the displayName humanizes to nothing", () => {
257 const theme = parseVsCodeThemeFile({
258 displayName: "---",
259 name: "night-owl",
260 type: "dark",
261 colors: { "editor.background": "#011627" },
262 });
263 expect(theme.label).toBe("Night Owl");
264 });
265
266 it("keeps a pair whose stripped name is reserved as two single themes", () => {
267 const make = (name: string, type: "light" | "dark") =>
268 parseVsCodeThemeFile({
269 name,
270 type,
271 colors: { "editor.background": type === "dark" ? "#101014" : "#fdfdfd" },
272 });
273 const themes = pairVsCodeThemes([make("grove-light", "light"), make("grove-dark", "dark")]);
274 expect(themes.map((theme) => theme.label).sort()).toEqual(["Grove Dark", "Grove Light"]);
275 });
276
277 it("explains a file with no editor background", () => {
278 expect(() => parseVsCodeThemeFile({ name: "Empty", type: "dark", colors: {} })).toThrow(
279 /editor\.background/,
280 );
281 });
282});
apps/web/src/vscodeThemeImport.ts created+449
...@@ -0,0 +1,449 @@
1import {
2 createVividThemeColors,
3 getThemeModes,
4 parseThemeFile,
5 THEME_FILE_VERSION,
6 type ThemeAppearance,
7 type ThemeColorRole,
8 type ThemeDefinition,
9} from "./themePalette";
10
11/**
12 * Best-effort import of a VS Code color theme (`*-color-theme.json`).
13 *
14 * VS Code themes describe editor chrome, not an app palette: they carry a few
15 * hundred workbench keys, leave most of them unset, and freely use 8-digit
16 * hex with alpha for overlays. So the conversion derives a complete, contrast
17 * -solved palette from the theme's editor background and accent, then layers
18 * the workbench colors it did specify on top. Anything the file omits keeps
19 * the derived value instead of falling back to an unrelated palette.
20 */
21
22type VsCodeRgba = { r: number; g: number; b: number; a: number };
23type VsCodeRgb = { r: number; g: number; b: number };
24
25function isRecord(value: unknown): value is Record<string, unknown> {
26 return typeof value === "object" && value !== null && !Array.isArray(value);
27}
28
29/** sRGB transfer function, and its inverse, shared by the wide-gamut path. */
30function decodeGamma(value: number): number {
31 return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
32}
33
34function encodeGamma(value: number): number {
35 const clamped = Math.max(0, Math.min(1, value));
36 return clamped <= 0.0031308 ? clamped * 12.92 : 1.055 * clamped ** (1 / 2.4) - 0.055;
37}
38
39/**
40 * `color(display-p3 r g b / a)` shows up in themes authored for wide-gamut
41 * displays. Out-of-gamut colors clip to the sRGB edge, which is what a browser
42 * on an sRGB screen shows anyway.
43 */
44function parseColorFunction(value: string): VsCodeRgba | null {
45 const match = /^color\(\s*(display-p3|srgb)\s+([^)]+)\)$/i.exec(value);
46 if (!match) return null;
47 const [space, body] = [match[1]!.toLowerCase(), match[2]!];
48 const [channelPart, alphaPart] = body.split("/");
49 const channels = channelPart!
50 .trim()
51 .split(/\s+/)
52 .map((part) => (part.endsWith("%") ? Number.parseFloat(part) / 100 : Number.parseFloat(part)));
53 if (channels.length !== 3 || channels.some((channel) => !Number.isFinite(channel))) return null;
54 const alphaRaw = alphaPart?.trim();
55 const alpha =
56 alphaRaw === undefined
57 ? 1
58 : alphaRaw.endsWith("%")
59 ? Number.parseFloat(alphaRaw) / 100
60 : Number.parseFloat(alphaRaw);
61 if (!Number.isFinite(alpha)) return null;
62
63 const [red, green, blue] = channels as [number, number, number];
64 if (space === "srgb") {
65 return { r: red * 255, g: green * 255, b: blue * 255, a: Math.max(0, Math.min(1, alpha)) };
66 }
67 const [linearRed, linearGreen, linearBlue] = [red, green, blue].map(decodeGamma) as [
68 number,
69 number,
70 number,
71 ];
72 // Display P3 linear -> sRGB linear.
73 const srgb = [
74 1.2249401762805 * linearRed - 0.2249401762805 * linearGreen,
75 -0.042056961239 * linearRed + 1.042056961239 * linearGreen,
76 -0.0196375547643 * linearRed - 0.0786360655012 * linearGreen + 1.0982736202656 * linearBlue,
77 ].map((channel) => encodeGamma(channel) * 255) as [number, number, number];
78 return { r: srgb[0], g: srgb[1], b: srgb[2], a: Math.max(0, Math.min(1, alpha)) };
79}
80
81/** VS Code accepts #RGB, #RGBA, #RRGGBB, and #RRGGBBAA; some themes also use
82 * CSS color() notation for wide-gamut palettes. */
83function parseVsCodeColor(value: unknown): VsCodeRgba | null {
84 if (typeof value !== "string") return null;
85 const trimmed = value.trim();
86 if (trimmed.startsWith("color(")) return parseColorFunction(trimmed);
87 const hex = trimmed.replace(/^#/, "");
88 if (!/^(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(hex)) return null;
89 const expand = (part: string) =>
90 part.length === 1 ? Number.parseInt(part + part, 16) : Number.parseInt(part, 16);
91 if (hex.length <= 4) {
92 return {
93 r: expand(hex[0]!),
94 g: expand(hex[1]!),
95 b: expand(hex[2]!),
96 a: hex.length === 4 ? expand(hex[3]!) / 255 : 1,
97 };
98 }
99 return {
100 r: Number.parseInt(hex.slice(0, 2), 16),
101 g: Number.parseInt(hex.slice(2, 4), 16),
102 b: Number.parseInt(hex.slice(4, 6), 16),
103 a: hex.length === 8 ? Number.parseInt(hex.slice(6, 8), 16) / 255 : 1,
104 };
105}
106
107function toHex(color: VsCodeRgb): string {
108 const channel = (value: number) =>
109 Math.max(0, Math.min(255, Math.round(value)))
110 .toString(16)
111 .padStart(2, "0");
112 return `#${channel(color.r)}${channel(color.g)}${channel(color.b)}`;
113}
114
115/** Overlays are semi-transparent in VS Code; our roles are opaque, so they are
116 * composited onto whatever surface they sit on. */
117function flattenOver(color: VsCodeRgba, base: VsCodeRgb): string {
118 if (color.a >= 1) return toHex(color);
119 return toHex({
120 r: color.r * color.a + base.r * (1 - color.a),
121 g: color.g * color.a + base.g * (1 - color.a),
122 b: color.b * color.a + base.b * (1 - color.a),
123 });
124}
125
126function relativeLuminance(color: VsCodeRgb): number {
127 const channel = (value: number) => {
128 const ratio = value / 255;
129 return ratio <= 0.03928 ? ratio / 12.92 : ((ratio + 0.055) / 1.055) ** 2.4;
130 };
131 return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b);
132}
133
134function contrastRatio(first: VsCodeRgb, second: VsCodeRgb): number {
135 const a = relativeLuminance(first);
136 const b = relativeLuminance(second);
137 return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
138}
139
140function hexToRgb(value: string): VsCodeRgb {
141 return parseVsCodeColor(value) ?? { r: 0, g: 0, b: 0, a: 1 };
142}
143
144/**
145 * A VS Code theme is recognised by its workbench colors: the keys are dotted
146 * paths (`editor.background`), which our own files never use.
147 */
148export function isVsCodeThemeFile(value: unknown): boolean {
149 if (!isRecord(value)) return false;
150 if (value.version === THEME_FILE_VERSION) return false;
151 const hasWorkbenchColors =
152 isRecord(value.colors) && Object.keys(value.colors).some((key) => key.includes("."));
153 return hasWorkbenchColors || Array.isArray(value.tokenColors);
154}
155
156function resolveAppearance(value: Record<string, unknown>, canvas: VsCodeRgb): ThemeAppearance {
157 const type = typeof value.type === "string" ? value.type.toLowerCase() : null;
158 if (type === "light" || type === "hc-light") return "light";
159 if (type === "dark" || type === "hc-black") return "dark";
160 // Unlabelled themes (and the odd custom `type`) follow the editor surface.
161 return relativeLuminance(canvas) < 0.179 ? "dark" : "light";
162}
163
164/** Extension `name` fields are often package slugs; read them as words. */
165export function humanizeThemeName(raw: string): string {
166 const trimmed = raw.trim();
167 if (/\s/.test(trimmed) || !/[-_.]/.test(trimmed)) return trimmed;
168 return trimmed
169 .split(/[-_.]+/)
170 .filter(Boolean)
171 .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
172 .join(" ");
173}
174
175function resolveName(value: Record<string, unknown>): string {
176 // Judge candidates by their humanized form: a displayName of "---"
177 // humanizes to nothing and must fall through to the name.
178 for (const candidate of [value.displayName, value.name]) {
179 if (typeof candidate !== "string") continue;
180 const humanized = humanizeThemeName(candidate);
181 if (humanized.length > 0) return humanized.slice(0, 48);
182 }
183 return "VS Code theme";
184}
185
186export function parseVsCodeThemeFile(value: unknown): ThemeDefinition {
187 if (!isRecord(value)) throw new Error("Theme files must contain a JSON object.");
188 const colors = isRecord(value.colors) ? value.colors : {};
189
190 /** First key that carries a usable color, in priority order. */
191 const pick = (...keys: ReadonlyArray<string>): VsCodeRgba | null => {
192 for (const key of keys) {
193 const parsed = parseVsCodeColor(colors[key]);
194 if (parsed) return parsed;
195 }
196 return null;
197 };
198 const solidOver = (base: VsCodeRgb, ...keys: ReadonlyArray<string>): string | null => {
199 const parsed = pick(...keys);
200 return parsed ? flattenOver(parsed, base) : null;
201 };
202
203 const canvasColor = pick("editor.background", "editorPane.background");
204 if (!canvasColor) {
205 throw new Error(
206 'That VS Code theme has no "editor.background" color, so there is nothing to build a palette from.',
207 );
208 }
209 const canvas = { r: canvasColor.r, g: canvasColor.g, b: canvasColor.b };
210 const appearance = resolveAppearance(value, canvas);
211
212 const accentColor = pick(
213 "focusBorder",
214 "button.background",
215 "textLink.foreground",
216 "activityBarBadge.background",
217 "progressBar.background",
218 "badge.background",
219 );
220 const canvasHex = toHex(canvas);
221 const accentHex = accentColor ? flattenOver(accentColor, canvas) : null;
222
223 // The derived palette is the floor: every role starts contrast-solved, then
224 // the theme's own workbench colors replace what it actually specified. The
225 // floor derives from a muted accent -- the vivid engine carries the accent
226 // hue into every surface, which washes an imported neutral palette (a gray
227 // theme with a blue focusBorder would get blue code and text surfaces).
228 const mutedAccentHex = accentColor
229 ? flattenOver({ r: accentColor.r, g: accentColor.g, b: accentColor.b, a: 0.2 }, canvas)
230 : null;
231 const derived = createVividThemeColors(appearance, canvasHex, mutedAccentHex ?? canvasHex);
232 const sidebarHex =
233 solidOver(canvas, "sideBar.background", "activityBar.background") ?? derived.sidebar;
234 const sidebar = hexToRgb(sidebarHex);
235 const terminalHex =
236 solidOver(canvas, "terminal.background", "panel.background") ?? derived.terminalBackground;
237 const terminal = hexToRgb(terminalHex);
238
239 /** Foregrounds only win when they stay readable on the surface they land on;
240 * a theme tuned for its own chrome can be unreadable on ours. */
241 const readableOn = (
242 surface: string,
243 fallback: string,
244 ...keys: ReadonlyArray<string>
245 ): string => {
246 const surfaceRgb = hexToRgb(surface);
247 const isReadable = (candidate: string) => contrastRatio(hexToRgb(candidate), surfaceRgb) >= 4.5;
248 const specified = solidOver(surfaceRgb, ...keys);
249 if (specified && isReadable(specified)) return specified;
250 // The derived fallback was solved against the derived surface. When the
251 // file replaced that surface (a light sideBar in a dark theme, say), the
252 // fallback has to clear the bar there too, or the text falls back to the
253 // readable end of the greyscale for the surface actually in play.
254 if (isReadable(fallback)) return fallback;
255 return relativeLuminance(surfaceRgb) < 0.179 ? "#ffffff" : "#000000";
256 };
257
258 const overrides: Partial<Record<ThemeColorRole, string>> = {
259 canvas: canvasHex,
260 text: readableOn(canvasHex, derived.text, "editor.foreground", "foreground"),
261 textMuted: readableOn(
262 canvasHex,
263 derived.textMuted,
264 "descriptionForeground",
265 "disabledForeground",
266 ),
267 surface: solidOver(canvas, "editorWidget.background") ?? derived.surface,
268 surfaceRaised:
269 solidOver(canvas, "editorWidget.background", "dropdown.background") ?? derived.surfaceRaised,
270 surfaceOverlay:
271 solidOver(canvas, "menu.background", "quickInput.background", "dropdown.background") ??
272 derived.surfaceOverlay,
273 border:
274 solidOver(canvas, "panel.border", "editorGroup.border", "contrastBorder") ?? derived.border,
275 input: solidOver(canvas, "input.border", "dropdown.border") ?? derived.input,
276 placeholder: readableOn(canvasHex, derived.placeholder, "input.placeholderForeground"),
277 error: readableOn(canvasHex, derived.error, "editorError.foreground", "errorForeground"),
278 warning: readableOn(canvasHex, derived.warning, "editorWarning.foreground"),
279 accentSurface:
280 solidOver(canvas, "list.activeSelectionBackground", "list.hoverBackground") ??
281 derived.accentSurface,
282 codeBackground: solidOver(canvas, "textCodeBlock.background") ?? derived.codeBackground,
283 sidebar: sidebarHex,
284 sidebarForeground: readableOn(sidebarHex, derived.sidebarForeground, "sideBar.foreground"),
285 sidebarBorder: solidOver(sidebar, "sideBar.border") ?? derived.sidebarBorder,
286 sidebarRowHover: solidOver(sidebar, "list.hoverBackground") ?? derived.sidebarRowHover,
287 sidebarRowActive:
288 solidOver(sidebar, "list.inactiveSelectionBackground", "list.hoverBackground") ??
289 derived.sidebarRowActive,
290 sidebarRowSelected:
291 solidOver(sidebar, "list.activeSelectionBackground") ?? derived.sidebarRowSelected,
292 terminalBackground: terminalHex,
293 terminalForeground: readableOn(terminalHex, derived.terminalForeground, "terminal.foreground"),
294 terminalCursor:
295 solidOver(terminal, "terminalCursor.foreground", "editorCursor.foreground") ??
296 derived.terminalCursor,
297 terminalSelection:
298 solidOver(terminal, "terminal.selectionBackground", "editor.selectionBackground") ??
299 derived.terminalSelection,
300 terminalScrollbar:
301 solidOver(terminal, "scrollbarSlider.background") ?? derived.terminalScrollbar,
302 };
303 if (accentHex) {
304 overrides.accent = accentHex;
305 overrides.focus = accentHex;
306 // The button pair is the closest thing VS Code has to our action color.
307 const actionHex = solidOver(canvas, "button.background") ?? accentHex;
308 overrides.messageAction = actionHex;
309 overrides.messageActionForeground = readableOn(
310 actionHex,
311 derived.messageActionForeground,
312 "button.foreground",
313 );
314 overrides.accentForeground = readableOn(
315 accentHex,
316 derived.accentForeground,
317 "button.foreground",
318 );
319 }
320
321 // Reuse the theme-file parser so ids, names, and color values go through the
322 // same validation as a hand-written file.
323 return parseThemeFile({
324 version: THEME_FILE_VERSION,
325 name: resolveName(value),
326 appearance,
327 colors: { ...derived, ...overrides },
328 });
329}
330
331/**
332 * Extensions ship a family of themes (GitHub: dark, light, dark-colorblind,
333 * light-colorblind, dark-dimmed, ...). When several are imported together,
334 * a light and a dark file whose names differ only by the appearance word
335 * become one dual-mode theme; everything else stays its own theme.
336 */
337export function pairVsCodeThemes(
338 themes: ReadonlyArray<ThemeDefinition>,
339): ReadonlyArray<ThemeDefinition> {
340 const stripAppearance = (label: string) =>
341 label
342 .replace(/\b(?:light|dark)\b/gi, " ")
343 .replace(/\s+/g, " ")
344 .trim();
345
346 type Group = { light: ThemeDefinition[]; dark: ThemeDefinition[]; order: number };
347 const groups = new Map<string, Group>();
348 const passthrough: Array<{ theme: ThemeDefinition; order: number }> = [];
349 themes.forEach((theme, order) => {
350 // Only single-appearance themes with an appearance word in the name can
351 // pair; anything else is already what the user asked for.
352 const key = stripAppearance(theme.label);
353 if (getThemeModes(theme).length !== 1 || key === theme.label || key.length === 0) {
354 passthrough.push({ theme, order });
355 return;
356 }
357 const group = groups.get(key) ?? { light: [], dark: [], order };
358 group[theme.appearance].push(theme);
359 groups.set(key, group);
360 });
361
362 const paired: Array<{ theme: ThemeDefinition; order: number }> = [];
363 for (const [key, group] of groups) {
364 // Ambiguity (two darks for one light) is not guessed at, and a pair
365 // whose stripped name collides with a built-in id ("Grove Light" +
366 // "Grove Dark" -> the reserved "grove") stays two single themes rather
367 // than failing the whole batch.
368 if (group.light.length === 1 && group.dark.length === 1) {
369 try {
370 paired.push({
371 order: group.order,
372 theme: parseThemeFile({
373 version: THEME_FILE_VERSION,
374 name: key,
375 appearance: "light",
376 colors: group.light[0]!.colors,
377 variants: { dark: group.dark[0]!.colors },
378 }),
379 });
380 continue;
381 } catch {
382 // Fall through to the individual themes below.
383 }
384 }
385 for (const theme of [...group.light, ...group.dark]) {
386 paired.push({ theme, order: group.order });
387 }
388 }
389
390 return [...passthrough, ...paired].sort((a, b) => a.order - b.order).map((entry) => entry.theme);
391}
392
393/**
394 * Some extensions reuse one display name across every file: Dracula ships
395 * dracula.json and dracula-soft.json that both say "Dracula". When a batch
396 * carries the same id twice, the file names are the only thing that tells
397 * the variants apart, so colliding entries are relabelled from their file
398 * name (and numbered only when even that collides).
399 */
400export function resolveThemeLabelCollisions(
401 entries: ReadonlyArray<{ theme: ThemeDefinition; sourceName?: string | undefined }>,
402): ReadonlyArray<ThemeDefinition> {
403 // Null when the new name is unusable (reserved id, invalid characters).
404 const rename = (theme: ThemeDefinition, name: string): ThemeDefinition | null => {
405 try {
406 return parseThemeFile({
407 version: THEME_FILE_VERSION,
408 name: name.slice(0, 48),
409 appearance: theme.appearance,
410 colors: theme.colors,
411 ...(theme.variants ? { variants: theme.variants } : {}),
412 ...(theme.managed ? { managed: true } : {}),
413 });
414 } catch {
415 return null;
416 }
417 };
418
419 const counts = new Map<string, number>();
420 for (const entry of entries) {
421 counts.set(entry.theme.id, (counts.get(entry.theme.id) ?? 0) + 1);
422 }
423 const relabelled = entries.map(({ theme, sourceName }) => {
424 if ((counts.get(theme.id) ?? 0) < 2) return theme;
425 const stem = sourceName?.replace(/\.[^.]+$/, "");
426 const fromFile = stem ? humanizeThemeName(stem) : null;
427 const renamed =
428 fromFile && fromFile.toLowerCase() !== theme.label.toLowerCase()
429 ? rename(theme, fromFile)
430 : null;
431 return renamed ?? theme;
432 });
433
434 const seen = new Set<string>();
435 return relabelled.map((theme) => {
436 if (!seen.has(theme.id)) {
437 seen.add(theme.id);
438 return theme;
439 }
440 for (let suffix = 2; suffix < 100; suffix += 1) {
441 const candidate = rename(theme, `${theme.label} ${suffix}`);
442 if (candidate && !seen.has(candidate.id)) {
443 seen.add(candidate.id);
444 return candidate;
445 }
446 }
447 return theme;
448 });
449}
docs/user/keybindings.md+6
...@@ -40,6 +40,12 @@ Commands are IDs like `terminal.toggle`, `commandPalette.toggle`, `preview.refre...@@ -40,6 +40,12 @@ Commands are IDs like `terminal.toggle`, `commandPalette.toggle`, `preview.refre
40`filePicker.toggle` opens file search for the active project and defaults to `mod+p`.40`filePicker.toggle` opens file search for the active project and defaults to `mod+p`.
41`projectSearch.toggle` searches inside the active project's files and defaults to `mod+shift+f`.41`projectSearch.toggle` searches inside the active project's files and defaults to `mod+shift+f`.
42Repeating either shortcut closes that search, and switching shortcuts replaces the open search.42Repeating either shortcut closes that search, and switching shortcuts replaces the open search.
43`themeEditor.toggle` opens or closes the floating theme editor and defaults to
44`mod+alt+shift+t`. Select a color label to spotlight the elements that use it; select the label
45again to clear the spotlight. The swatch and hex field keep that color selected while you edit it.
46Use **Inspect** to pick an element in the app and reveal its color token. Inspect disarms after one
47successful pick; its hover glow and badge preview the element and token that click will select.
48**Cancel** or `Escape` exits Inspect and clears its selection and spotlight.
4349
44The command palette searches active thread titles, projects, branches, user messages, and final50The command palette searches active thread titles, projects, branches, user messages, and final
45agent responses across connected environments. Message matches show one labeled excerpt while51agent responses across connected environments. Message matches show one labeled excerpt while
packages/contracts/src/ipc.ts+23
...@@ -440,6 +440,23 @@ export const PickFolderOptionsSchema = Schema.Struct({...@@ -440,6 +440,23 @@ export const PickFolderOptionsSchema = Schema.Struct({
440 targetEnvironmentId: Schema.optionalKey(Schema.String),440 targetEnvironmentId: Schema.optionalKey(Schema.String),
441});441});
442442
443/**
444 * A file returned by the desktop theme-file picker. Oversized files carry an
445 * empty text so the renderer can reject them by size without the main
446 * process ever holding their contents.
447 */
448export interface PickedThemeFile {
449 name: string;
450 size: number;
451 text: string;
452}
453
454export const PickedThemeFileSchema = Schema.Struct({
455 name: Schema.String,
456 size: Schema.Number,
457 text: Schema.String,
458});
459
443export interface DesktopWslDistro {460export interface DesktopWslDistro {
444 name: string;461 name: string;
445 isDefault: boolean;462 isDefault: boolean;
...@@ -1019,6 +1036,12 @@ export interface DesktopBridge {...@@ -1019,6 +1036,12 @@ export interface DesktopBridge {
1019 setWslDistro: (distro: string | null) => Promise<DesktopWslState>;1036 setWslDistro: (distro: string | null) => Promise<DesktopWslState>;
1020 setWslOnly: (enabled: boolean) => Promise<DesktopWslState>;1037 setWslOnly: (enabled: boolean) => Promise<DesktopWslState>;
1021 pickFolder: (options?: PickFolderOptions) => Promise<string | null>;1038 pickFolder: (options?: PickFolderOptions) => Promise<string | null>;
1039 /**
1040 * Multi-select JSON file picker that opens in the VS Code extensions
1041 * directory when one exists. Optional: older desktop builds lack it, and
1042 * web callers fall back to a plain file input.
1043 */
1044 pickThemeFiles?: () => Promise<readonly PickedThemeFile[] | null>;
1022 confirm: (message: string) => Promise<boolean>;1045 confirm: (message: string) => Promise<boolean>;
1023 setTheme: (theme: DesktopTheme) => Promise<void>;1046 setTheme: (theme: DesktopTheme) => Promise<void>;
1024 showContextMenu: <T extends string>(1047 showContextMenu: <T extends string>(
packages/contracts/src/keybindings.test.ts+6
...@@ -72,6 +72,12 @@ it.effect("parses keybinding rules", () =>...@@ -72,6 +72,12 @@ it.effect("parses keybinding rules", () =>
72 });72 });
73 assert.strictEqual(parsedProjectSearch.command, "projectSearch.toggle");73 assert.strictEqual(parsedProjectSearch.command, "projectSearch.toggle");
7474
75 const parsedThemeEditor = yield* decode(KeybindingRule, {
76 key: "mod+alt+shift+t",
77 command: "themeEditor.toggle",
78 });
79 assert.strictEqual(parsedThemeEditor.command, "themeEditor.toggle");
80
75 const parsedLocal = yield* decode(KeybindingRule, {81 const parsedLocal = yield* decode(KeybindingRule, {
76 key: "mod+shift+n",82 key: "mod+shift+n",
77 command: "chat.newLocal",83 command: "chat.newLocal",
packages/contracts/src/keybindings.ts+1
...@@ -65,6 +65,7 @@ const STATIC_KEYBINDING_COMMANDS = [...@@ -65,6 +65,7 @@ const STATIC_KEYBINDING_COMMANDS = [
65 "commandPalette.toggle",65 "commandPalette.toggle",
66 "filePicker.toggle",66 "filePicker.toggle",
67 "projectSearch.toggle",67 "projectSearch.toggle",
68 "themeEditor.toggle",
68 "composer.stash",69 "composer.stash",
69 "chat.new",70 "chat.new",
70 "chat.newLocal",71 "chat.newLocal",
packages/shared/src/keybindings.ts+1
...@@ -37,6 +37,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [...@@ -37,6 +37,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
37 { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" },37 { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" },
38 { key: "mod+p", command: "filePicker.toggle", when: "!terminalFocus" },38 { key: "mod+p", command: "filePicker.toggle", when: "!terminalFocus" },
39 { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" },39 { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" },
40 { key: "mod+alt+shift+t", command: "themeEditor.toggle" },
40 { key: "mod+s", command: "composer.stash", when: "!terminalFocus" },41 { key: "mod+s", command: "composer.stash", when: "!terminalFocus" },
41 { key: "mod+n", command: "chat.new", when: "!terminalFocus" },42 { key: "mod+n", command: "chat.new", when: "!terminalFocus" },
42 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },43 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },