| author | |
| committer | |
| log | 85b1734d4d4914e743044f0ea8cb38a796de7da5 |
| tree | fc415a0d4cecf8960e02fbbf10e1051f1b55013d |
| parent | 3ffe84f9688e22afea531988807a1c7faeccdc37 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 23 | } |
| 24 | 24 | } |
| 25 | 25 | |
| 26 | export 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 | ||
| 26 | 41 | export class ElectronDialogConfirmError extends Schema.TaggedErrorClass<ElectronDialogConfirmError>()( |
| 27 | 42 | "ElectronDialogConfirmError", |
| 28 | 43 | { |
| ... | ... | @@ -69,6 +84,7 @@ export class ElectronDialogShowErrorBoxError extends Schema.TaggedErrorClass<Ele |
| 69 | 84 | |
| 70 | 85 | export const ElectronDialogError = Schema.Union([ |
| 71 | 86 | ElectronDialogPickFolderError, |
| 87 | ElectronDialogPickFilesError, | |
| 72 | 88 | ElectronDialogConfirmError, |
| 73 | 89 | ElectronDialogShowMessageBoxError, |
| 74 | 90 | ElectronDialogShowErrorBoxError, |
| ... | ... | @@ -81,6 +97,12 @@ export interface ElectronDialogPickFolderInput { |
| 81 | 97 | readonly defaultPath: Option.Option<string>; |
| 82 | 98 | } |
| 83 | 99 | |
| 100 | export interface ElectronDialogPickFilesInput { | |
| 101 | readonly owner: Option.Option<Electron.BrowserWindow>; | |
| 102 | readonly defaultPath: Option.Option<string>; | |
| 103 | readonly filters: readonly Electron.FileFilter[]; | |
| 104 | } | |
| 105 | ||
| 84 | 106 | export interface ElectronDialogConfirmInput { |
| 85 | 107 | readonly owner: Option.Option<Electron.BrowserWindow>; |
| 86 | 108 | readonly message: string; |
| ... | ... | @@ -92,6 +114,9 @@ export class ElectronDialog extends Context.Service< |
| 92 | 114 | readonly pickFolder: ( |
| 93 | 115 | input: ElectronDialogPickFolderInput, |
| 94 | 116 | ) => Effect.Effect<Option.Option<string>, ElectronDialogPickFolderError>; |
| 117 | readonly pickFiles: ( | |
| 118 | input: ElectronDialogPickFilesInput, | |
| 119 | ) => Effect.Effect<readonly string[], ElectronDialogPickFilesError>; | |
| 95 | 120 | readonly confirm: ( |
| 96 | 121 | input: ElectronDialogConfirmInput, |
| 97 | 122 | ) => Effect.Effect<boolean, ElectronDialogConfirmError>; |
| ... | ... | @@ -137,6 +162,32 @@ export const make = ElectronDialog.of({ |
| 137 | 162 | } |
| 138 | 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 | 191 | confirm: Effect.fn("desktop.electron.dialog.confirm")(function* (input) { |
| 141 | 192 | const normalizedMessage = input.message.trim(); |
| 142 | 193 | if (normalizedMessage.length === 0) { |
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2| ... | ... | @@ -38,6 +38,7 @@ import { |
| 38 | 38 | getWindowFullscreenState, |
| 39 | 39 | openExternal, |
| 40 | 40 | pickFolder, |
| 41 | pickThemeFiles, | |
| 41 | 42 | setTheme, |
| 42 | 43 | showContextMenu, |
| 43 | 44 | } from "./methods/window.ts"; |
| ... | ... | @@ -79,6 +80,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" |
| 79 | 80 | yield* ipc.handle(setWslOnly); |
| 80 | 81 | |
| 81 | 82 | yield* ipc.handle(pickFolder); |
| 83 | yield* ipc.handle(pickThemeFiles); | |
| 82 | 84 | yield* ipc.handle(confirm); |
| 83 | 85 | yield* ipc.handle(setTheme); |
| 84 | 86 | yield* ipc.handle(showContextMenu); |
apps/desktop/src/ipc/channels.ts+1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; |
| 2 | export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; | |
| 2 | 3 | export const CONFIRM_CHANNEL = "desktop:confirm"; |
| 3 | 4 | export const SET_THEME_CHANNEL = "desktop:set-theme"; |
| 4 | 5 | export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
apps/desktop/src/ipc/methods/window.ts+51| ... | ... | @@ -3,10 +3,15 @@ import { |
| 3 | 3 | DesktopAppBrandingSchema, |
| 4 | 4 | DesktopEnvironmentBootstrapSchema, |
| 5 | 5 | DesktopThemeSchema, |
| 6 | PickedThemeFileSchema, | |
| 6 | 7 | PickFolderOptionsSchema, |
| 7 | 8 | PRIMARY_LOCAL_ENVIRONMENT_ID, |
| 8 | 9 | type DesktopEnvironmentBootstrap, |
| 10 | type PickedThemeFile, | |
| 9 | 11 | } from "@t3tools/contracts"; |
| 12 | import * as NodeOS from "node:os"; | |
| 13 | import * as FileSystem from "effect/FileSystem"; | |
| 14 | import * as Path from "effect/Path"; | |
| 10 | 15 | import * as Effect from "effect/Effect"; |
| 11 | 16 | import * as Option from "effect/Option"; |
| 12 | 17 | import * as Schema from "effect/Schema"; |
| ... | ... | @@ -268,3 +273,49 @@ export const openExternal = DesktopIpc.makeIpcMethod({ |
| 268 | 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. */ | |
| 279 | const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024; | |
| 280 | ||
| 281 | export 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 | 97 | setWslDistro: (distro) => ipcRenderer.invoke(IpcChannels.SET_WSL_DISTRO_CHANNEL, distro), |
| 98 | 98 | setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled), |
| 99 | 99 | pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), |
| 100 | pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined), | |
| 100 | 101 | confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message), |
| 101 | 102 | setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), |
| 102 | 103 | showContextMenu: (items, position) => |
apps/desktop/src/window/DesktopApplicationMenu.test.ts+1| ... | ... | @@ -52,6 +52,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { |
| 52 | 52 | |
| 53 | 53 | const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { |
| 54 | 54 | pickFolder: () => Effect.succeed(Option.none()), |
| 55 | pickFiles: () => Effect.succeed([]), | |
| 55 | 56 | confirm: () => Effect.succeed(false), |
| 56 | 57 | showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }), |
| 57 | 58 | showErrorBox: () => Effect.void, |
apps/server/src/keybindings.test.ts+1| ... | ... | @@ -198,6 +198,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { |
| 198 | 198 | assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); |
| 199 | 199 | assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); |
| 200 | 200 | assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); |
| 201 | assert.equal(defaultsByCommand.get("themeEditor.toggle"), "mod+alt+shift+t"); | |
| 201 | 202 | assert.equal(defaultsByCommand.get("filePicker.toggle"), "mod+p"); |
| 202 | 203 | assert.equal(defaultsByCommand.get("projectSearch.toggle"), "mod+shift+f"); |
| 203 | 204 | assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); |
apps/web/index.html+371-13| ... | ... | @@ -6,8 +6,6 @@ |
| 6 | 6 | name="viewport" |
| 7 | 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 | 9 | <meta name="theme-color" content="#0a0a0a" /> |
| 12 | 10 | <link rel="icon" href="/favicon.ico" sizes="48x48" /> |
| 13 | 11 | <link rel="apple-touch-icon" href="/apple-touch-icon.png" /> |
| ... | ... | @@ -15,23 +13,373 @@ |
| 15 | 13 | (() => { |
| 16 | 14 | const LIGHT_BACKGROUND = "#ffffff"; |
| 17 | 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 | 201 | try { |
| 20 | 202 | const storedTheme = window.localStorage.getItem("t3code:theme"); |
| 21 | const theme = | |
| 22 | storedTheme === "light" || storedTheme === "dark" || storedTheme === "system" | |
| 23 | ? storedTheme | |
| 24 | : "system"; | |
| 25 | 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 | 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 | 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 | 369 | } catch { |
| 32 | document.documentElement.classList.add("dark"); | |
| 33 | document.documentElement.style.backgroundColor = DARK_BACKGROUND; | |
| 34 | themeColorMeta?.setAttribute("content", DARK_BACKGROUND); | |
| 370 | // Mirror the runtime's storage-failure fallback: follow the OS. | |
| 371 | let prefersDark = true; | |
| 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 | 385 | </script> |
| ... | ... | @@ -56,14 +404,24 @@ |
| 56 | 404 | } |
| 57 | 405 | |
| 58 | 406 | #boot-shell { |
| 407 | position: relative; | |
| 408 | overflow: hidden; | |
| 59 | 409 | display: flex; |
| 60 | 410 | min-height: 100%; |
| 61 | 411 | align-items: center; |
| 62 | 412 | justify-content: center; |
| 63 | 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 | } |
| 65 | 421 | |
| 66 | 422 | #boot-shell-card { |
| 423 | position: relative; | |
| 424 | z-index: 1; | |
| 67 | 425 | display: flex; |
| 68 | 426 | align-items: center; |
| 69 | 427 | justify-content: center; |
apps/web/src/components/ChatView.tsx+4-4| ... | ... | @@ -5842,10 +5842,10 @@ function ChatViewContent(props: ChatViewProps) { |
| 5842 | 5842 | const panelLayoutControls = ( |
| 5843 | 5843 | <div |
| 5844 | 5844 | className={cn( |
| 5845 | "workspace-titlebar-controls z-50 gap-1 [-webkit-app-region:no-drag]", | |
| 5846 | rightPanelOpen && !shouldUseRightPanelSheet | |
| 5847 | ? "right-2 wco:right-[var(--workspace-controls-right)]" | |
| 5848 | : "mr-px", | |
| 5845 | // One inset in both states: the controls move between containers when | |
| 5846 | // the right panel opens, and a different right offset made them jump | |
| 5847 | // sideways on every toggle. | |
| 5848 | "workspace-titlebar-controls z-50 mr-px gap-1 [-webkit-app-region:no-drag]", | |
| 5849 | 5849 | )} |
| 5850 | 5850 | > |
| 5851 | 5851 | {rightPanelOpen && !shouldUseRightPanelSheet ? ( |
apps/web/src/components/CommandPalette.logic.ts+1-1| ... | ... | @@ -12,7 +12,7 @@ import { formatRelativeTimeLabel } from "../timestampFormat"; |
| 12 | 12 | import { type Project, type SidebarThreadSummary, type Thread } from "../types"; |
| 13 | 13 | |
| 14 | 14 | export const RECENT_THREAD_LIMIT = 12; |
| 15 | export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; | |
| 15 | export const ITEM_ICON_CLASS = "size-4 text-icon-muted"; | |
| 16 | 16 | export const ADDON_ICON_CLASS = "size-4"; |
| 17 | 17 | |
| 18 | 18 | /** |
apps/web/src/components/CommandPalette.tsx+32-1| ... | ... | @@ -35,6 +35,7 @@ import { |
| 35 | 35 | FolderPlusIcon, |
| 36 | 36 | LinkIcon, |
| 37 | 37 | MessageSquareIcon, |
| 38 | PaletteIcon, | |
| 38 | 39 | SettingsIcon, |
| 39 | 40 | SquarePenIcon, |
| 40 | 41 | TextSearchIcon, |
| ... | ... | @@ -57,6 +58,7 @@ import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; |
| 57 | 58 | import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; |
| 58 | 59 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 59 | 60 | import { useClientSettings } from "../hooks/useSettings"; |
| 61 | import { useTheme } from "../hooks/useTheme"; | |
| 60 | 62 | import { readLocalApi } from "../localApi"; |
| 61 | 63 | import { desktopLocalBackendId } from "../connection/desktopLocal"; |
| 62 | 64 | import { filesystemEnvironment } from "../state/filesystem"; |
| ... | ... | @@ -121,6 +123,7 @@ import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons" |
| 121 | 123 | import { ProjectFavicon } from "./ProjectFavicon"; |
| 122 | 124 | import { ProjectFilePicker } from "./files/ProjectFilePicker"; |
| 123 | 125 | import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; |
| 126 | import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; | |
| 124 | 127 | import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; |
| 125 | 128 | import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; |
| 126 | 129 | import { resolveDefaultProviderModelSelection } from "../providerInstances"; |
| ... | ... | @@ -386,6 +389,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { |
| 386 | 389 | const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); |
| 387 | 390 | const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); |
| 388 | 391 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 392 | const { theme, themeHalves, resolvedTheme } = useTheme(); | |
| 389 | 393 | const composerHandleRef = useRef<ChatComposerHandle | null>(null); |
| 390 | 394 | const routeTarget = useParams({ |
| 391 | 395 | strict: false, |
| ... | ... | @@ -428,6 +432,16 @@ export function CommandPalette({ children }: { children: ReactNode }) { |
| 428 | 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 | 445 | const mode = overlayModeForCommand(command); |
| 432 | 446 | if (mode === null) { |
| 433 | 447 | return; |
| ... | ... | @@ -438,7 +452,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { |
| 438 | 452 | }; |
| 439 | 453 | window.addEventListener("keydown", onKeyDown); |
| 440 | 454 | return () => window.removeEventListener("keydown", onKeyDown); |
| 441 | }, [keybindings, previewOpen, terminalOpen, toggleMode]); | |
| 455 | }, [keybindings, previewOpen, resolvedTheme, terminalOpen, theme, themeHalves, toggleMode]); | |
| 442 | 456 | |
| 443 | 457 | useEffect( |
| 444 | 458 | () => |
| ... | ... | @@ -567,6 +581,7 @@ function OpenCommandPaletteDialog(props: { |
| 567 | 581 | const projectOrder = useUiStateStore((store) => store.projectOrder); |
| 568 | 582 | const threads = useThreadShells(); |
| 569 | 583 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 584 | const { theme, themeHalves, resolvedTheme } = useTheme(); | |
| 570 | 585 | const providers = useAtomValue(primaryServerProvidersAtom); |
| 571 | 586 | const [viewStack, setViewStack] = useState<CommandPaletteView[]>([]); |
| 572 | 587 | const currentView = viewStack.at(-1) ?? null; |
| ... | ... | @@ -1463,6 +1478,22 @@ function OpenCommandPaletteDialog(props: { |
| 1463 | 1478 | }); |
| 1464 | 1479 | } |
| 1465 | 1480 | |
| 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 | 1497 | actionItems.push({ |
| 1467 | 1498 | kind: "action", |
| 1468 | 1499 | value: "action:settings", |
apps/web/src/components/ComposerPromptEditor.tsx+1-1| ... | ... | @@ -1765,7 +1765,7 @@ function ComposerPromptEditorInner({ |
| 1765 | 1765 | } |
| 1766 | 1766 | placeholder={ |
| 1767 | 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 | 1769 | {placeholder} |
| 1770 | 1770 | </div> |
| 1771 | 1771 | ) |
apps/web/src/components/DiffPanel.tsx+23-69| ... | ... | @@ -36,6 +36,7 @@ import { |
| 36 | 36 | getRenderablePatch, |
| 37 | 37 | resolveDiffThemeName, |
| 38 | 38 | resolveFileDiffPath, |
| 39 | DIFF_SURFACE_THEME_UNSAFE_CSS, | |
| 39 | 40 | } from "../lib/diffRendering"; |
| 40 | 41 | import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse"; |
| 41 | 42 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| ... | ... | @@ -86,54 +87,7 @@ interface CollapsedDiffFilesState { |
| 86 | 87 | |
| 87 | 88 | const EMPTY_COLLAPSED_DIFF_FILE_KEYS: ReadonlySet<string> = new Set(); |
| 88 | 89 | |
| 89 | const DIFF_PANEL_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 | ||
| 90 | const DIFF_PANEL_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS} | |
| 137 | 91 | :is( |
| 138 | 92 | [data-line], |
| 139 | 93 | [data-line-annotation], |
| ... | ... | @@ -144,13 +98,13 @@ const DIFF_PANEL_UNSAFE_CSS = ` |
| 144 | 98 | --diffs-line-bg: light-dark( |
| 145 | 99 | color-mix( |
| 146 | 100 | in lab, |
| 147 | var(--background) 88%, | |
| 148 | color-mix(in srgb, var(--background) 50%, var(--diffs-modified-base)) | |
| 101 | var(--code-background) 88%, | |
| 102 | color-mix(in srgb, var(--code-background) 50%, var(--diffs-modified-base)) | |
| 149 | 103 | ), |
| 150 | 104 | color-mix( |
| 151 | 105 | in lab, |
| 152 | var(--background) 80%, | |
| 153 | color-mix(in srgb, var(--background) 70%, var(--diffs-modified-base)) | |
| 106 | var(--code-background) 80%, | |
| 107 | color-mix(in srgb, var(--code-background) 70%, var(--diffs-modified-base)) | |
| 154 | 108 | ) |
| 155 | 109 | ) !important; |
| 156 | 110 | } |
| ... | ... | @@ -159,13 +113,13 @@ const DIFF_PANEL_UNSAFE_CSS = ` |
| 159 | 113 | --diffs-line-bg: light-dark( |
| 160 | 114 | color-mix( |
| 161 | 115 | in lab, |
| 162 | var(--background) 91%, | |
| 163 | color-mix(in srgb, var(--background) 35%, var(--diffs-modified-base)) | |
| 116 | var(--code-background) 91%, | |
| 117 | color-mix(in srgb, var(--code-background) 35%, var(--diffs-modified-base)) | |
| 164 | 118 | ), |
| 165 | 119 | color-mix( |
| 166 | 120 | in lab, |
| 167 | var(--background) 85%, | |
| 168 | color-mix(in srgb, var(--background) 60%, var(--diffs-modified-base)) | |
| 121 | var(--code-background) 85%, | |
| 122 | color-mix(in srgb, var(--code-background) 60%, var(--diffs-modified-base)) | |
| 169 | 123 | ) |
| 170 | 124 | ) !important; |
| 171 | 125 | } |
| ... | ... | @@ -192,16 +146,16 @@ const DIFF_PANEL_UNSAFE_CSS = ` |
| 192 | 146 | } |
| 193 | 147 | |
| 194 | 148 | [data-file-info] { |
| 195 | background-color: var(--background) !important; | |
| 149 | background-color: var(--code-background) !important; | |
| 196 | 150 | border-block-color: transparent !important; |
| 197 | color: var(--foreground) !important; | |
| 151 | color: var(--code-foreground) !important; | |
| 198 | 152 | } |
| 199 | 153 | |
| 200 | 154 | [data-diffs-header] { |
| 201 | 155 | position: sticky !important; |
| 202 | 156 | top: 0; |
| 203 | 157 | z-index: 4; |
| 204 | background-color: var(--background) !important; | |
| 158 | background-color: var(--code-background) !important; | |
| 205 | 159 | border-bottom-color: transparent !important; |
| 206 | 160 | align-items: center !important; |
| 207 | 161 | font-family: var(--font-sans) !important; |
| ... | ... | @@ -213,13 +167,13 @@ const DIFF_PANEL_UNSAFE_CSS = ` |
| 213 | 167 | } |
| 214 | 168 | |
| 215 | 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 | } |
| 218 | 172 | |
| 219 | 173 | :is([data-separator="line-info"], [data-separator="line-info-basic"]) { |
| 220 | 174 | height: 24px !important; |
| 221 | 175 | margin-block: 0 !important; |
| 222 | background-color: var(--background) !important; | |
| 176 | background-color: var(--code-background) !important; | |
| 223 | 177 | } |
| 224 | 178 | |
| 225 | 179 | :is([data-separator="line-info"], [data-separator="line-info-basic"]) |
| ... | ... | @@ -233,7 +187,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` |
| 233 | 187 | gap: 8px; |
| 234 | 188 | padding-inline: 0 !important; |
| 235 | 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 | 191 | font-family: var(--font-sans) !important; |
| 238 | 192 | font-size: 11px !important; |
| 239 | 193 | text-decoration: none !important; |
| ... | ... | @@ -257,7 +211,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` |
| 257 | 211 | height: 1px; |
| 258 | 212 | flex: 1 1 auto; |
| 259 | 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 | } |
| 262 | 216 | |
| 263 | 217 | :is([data-separator="line-info"], [data-separator="line-info-basic"])[data-expand-index] |
| ... | ... | @@ -286,7 +240,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` |
| 286 | 240 | [data-expand-button] |
| 287 | 241 | ):hover |
| 288 | 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 | } |
| 291 | 245 | |
| 292 | 246 | :is([data-separator="line-info"], [data-separator="line-info-basic"]):has( |
| ... | ... | @@ -297,7 +251,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` |
| 297 | 251 | [data-expand-button] |
| 298 | 252 | ):hover |
| 299 | 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 | } |
| 302 | 256 | |
| 303 | 257 | [data-diffs-header] [data-header-content] { |
| ... | ... | @@ -337,7 +291,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` |
| 337 | 291 | } |
| 338 | 292 | |
| 339 | 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 | 295 | text-decoration-color: currentColor; |
| 342 | 296 | } |
| 343 | 297 | `; |
| ... | ... | @@ -796,11 +750,11 @@ export default function DiffPanel({ |
| 796 | 750 | <div className="flex min-w-0 flex-1 items-center gap-3 [-webkit-app-region:no-drag]"> |
| 797 | 751 | <DropdownMenu> |
| 798 | 752 | <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 | 754 | aria-label={`Diff scope: ${selectedScopeLabel}`} |
| 801 | 755 | > |
| 802 | 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 | 758 | </DropdownMenuTrigger> |
| 805 | 759 | <DropdownMenuContent align="start" className="w-60"> |
| 806 | 760 | <DropdownMenuItem |
| ... | ... | @@ -1120,7 +1074,7 @@ export default function DiffPanel({ |
| 1120 | 1074 | )} |
| 1121 | 1075 | {selectedPatchError && !renderablePatch && ( |
| 1122 | 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 | 1078 | </div> |
| 1125 | 1079 | )} |
| 1126 | 1080 | {!renderablePatch ? ( |
apps/web/src/components/ProjectFavicon.tsx+1-1| ... | ... | @@ -47,7 +47,7 @@ function ProjectFaviconFallback({ |
| 47 | 47 | readonly className?: string | undefined; |
| 48 | 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 | } |
| 52 | 52 | |
| 53 | 53 | function ProjectFaviconImage({ |
apps/web/src/components/ProjectScriptsControl.tsx+6| ... | ... | @@ -343,6 +343,9 @@ export default function ProjectScriptsControl({ |
| 343 | 343 | variant="outline" |
| 344 | 344 | className="w-7 px-0 sm:w-6 @3xl/header-actions:w-auto! @3xl/header-actions:px-[calc(--spacing(2)-1px)]" |
| 345 | 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 | 349 | onClick={() => onRunScript(primaryScript)} |
| 347 | 350 | /> |
| 348 | 351 | } |
| ... | ... | @@ -447,6 +450,9 @@ export default function ProjectScriptsControl({ |
| 447 | 450 | variant="outline" |
| 448 | 451 | className="w-7 px-0 sm:w-6 @3xl/header-actions:w-auto! @3xl/header-actions:px-[calc(--spacing(2)-1px)]" |
| 449 | 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 | 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 | 225 | separate: "Keep separate", |
| 226 | 226 | }; |
| 227 | 227 | const 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"; | |
| 229 | 229 | |
| 230 | 230 | function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) { |
| 231 | 231 | useEnvironmentThread(threadRef.environmentId, threadRef.threadId); |
| ... | ... | @@ -857,9 +857,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr |
| 857 | 857 | ) : ( |
| 858 | 858 | <span |
| 859 | 859 | className={`text-[10px] tabular-nums ${ |
| 860 | isHighlighted | |
| 861 | ? "text-foreground/72 dark:text-foreground/82" | |
| 862 | : "text-muted-foreground/40" | |
| 860 | isHighlighted ? "text-foreground" : "text-secondary-label" | |
| 863 | 861 | }`} |
| 864 | 862 | > |
| 865 | 863 | {formatRelativeTimeLabel( |
| ... | ... | @@ -2245,7 +2243,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2245 | 2243 | }`} |
| 2246 | 2244 | /> |
| 2247 | 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 | 2247 | </TooltipTrigger> |
| 2250 | 2248 | <TooltipPopup side="top">{projectStatus.label}</TooltipPopup> |
| 2251 | 2249 | </Tooltip> |
| ... | ... | @@ -2262,7 +2260,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2262 | 2260 | {project.displayName} |
| 2263 | 2261 | </span> |
| 2264 | 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 | 2264 | {project.groupedProjectCount} projects |
| 2267 | 2265 | </span> |
| 2268 | 2266 | ) : null} |
| ... | ... | @@ -2281,7 +2279,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2281 | 2279 | ? "Local sandbox project" |
| 2282 | 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 | 2598 | <Tooltip> |
| 2601 | 2599 | <TooltipTrigger |
| 2602 | 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 | 2604 | <ArrowUpDownIcon className="size-3.5" /> |
| ... | ... | @@ -2824,7 +2822,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( |
| 2824 | 2822 | <SidebarContent |
| 2825 | 2823 | className="gap-0" |
| 2826 | 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 | 2828 | <SidebarMenu> |
| 2829 | 2829 | <SidebarMenuItem> |
| 2830 | 2830 | <CommandDialogTrigger |
| ... | ... | @@ -2891,7 +2891,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( |
| 2891 | 2891 | type="button" |
| 2892 | 2892 | aria-label="Add project" |
| 2893 | 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 | 2895 | onClick={openAddProject} |
| 2896 | 2896 | /> |
| 2897 | 2897 | } |
| ... | ... | @@ -2977,9 +2977,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( |
| 2977 | 2977 | )} |
| 2978 | 2978 | |
| 2979 | 2979 | {projectsLength === 0 && ( |
| 2980 | <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60"> | |
| 2981 | No projects yet | |
| 2982 | </div> | |
| 2980 | <div className="px-2 pt-4 text-center text-secondary-label text-xs">No projects yet</div> | |
| 2983 | 2981 | )} |
| 2984 | 2982 | </SidebarGroup> |
| 2985 | 2983 | </SidebarContent> |
apps/web/src/components/SidebarStageBackdrop.test.tsx+1-2| ... | ... | @@ -5,7 +5,6 @@ import { |
| 5 | 5 | resolveEnvironmentIdentificationPillLabel, |
| 6 | 6 | resolveSidebarStageBackdropVariant, |
| 7 | 7 | StageBackdropArt, |
| 8 | StageBackdropButtonArt, | |
| 9 | 8 | } from "./SidebarStageBackdrop"; |
| 10 | 9 | |
| 11 | 10 | describe("SidebarStageBackdrop", () => { |
| ... | ... | @@ -29,7 +28,7 @@ describe("SidebarStageBackdrop", () => { |
| 29 | 28 | const markup = renderToStaticMarkup( |
| 30 | 29 | <> |
| 31 | 30 | <StageBackdropArt variant={variant} /> |
| 32 | <StageBackdropButtonArt variant={variant} /> | |
| 31 | <StageBackdropArt variant={variant} /> | |
| 33 | 32 | </>, |
| 34 | 33 | ); |
| 35 | 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 | 62 | return variant === "nightly" ? <NightlySkyArt /> : <DevBlueprintArt />; |
| 63 | 63 | } |
| 64 | 64 | |
| 65 | export function StageBackdropButtonArt({ variant }: { variant: SidebarStageBackdropVariant }) { | |
| 66 | return variant === "nightly" ? <NightlySkyArt compact /> : <DevBlueprintArt compact />; | |
| 67 | } | |
| 68 | ||
| 69 | 65 | const NIGHTLY_STARS: ReadonlyArray<{ |
| 70 | 66 | cx: number; |
| 71 | 67 | cy: number; |
| ... | ... | @@ -97,7 +93,7 @@ const NIGHTLY_SPARKLES: ReadonlyArray<{ x: number; y: number }> = [ |
| 97 | 93 | { x: 246, y: 26 }, |
| 98 | 94 | ]; |
| 99 | 95 | |
| 100 | function NightlySkyArt({ compact = false }: { compact?: boolean }) { | |
| 96 | function NightlySkyArt() { | |
| 101 | 97 | const idPrefix = useId().replaceAll(":", ""); |
| 102 | 98 | const skyId = `${idPrefix}-stage-night-sky`; |
| 103 | 99 | const glowId = `${idPrefix}-stage-night-glow`; |
| ... | ... | @@ -111,7 +107,7 @@ function NightlySkyArt({ compact = false }: { compact?: boolean }) { |
| 111 | 107 | className="h-full w-full" |
| 112 | 108 | fill="none" |
| 113 | 109 | preserveAspectRatio="xMinYMin slice" |
| 114 | viewBox={compact ? "96 0 8192 96" : STAGE_BACKDROP_VIEW_BOX} | |
| 110 | viewBox={STAGE_BACKDROP_VIEW_BOX} | |
| 115 | 111 | xmlns="http://www.w3.org/2000/svg" |
| 116 | 112 | > |
| 117 | 113 | <defs> |
| ... | ... | @@ -195,7 +191,7 @@ function NightlySkyArt({ compact = false }: { compact?: boolean }) { |
| 195 | 191 | ); |
| 196 | 192 | } |
| 197 | 193 | |
| 198 | function DevBlueprintArt({ compact = false }: { compact?: boolean }) { | |
| 194 | function DevBlueprintArt() { | |
| 199 | 195 | const idPrefix = useId().replaceAll(":", ""); |
| 200 | 196 | const paperId = `${idPrefix}-stage-bp-paper`; |
| 201 | 197 | const glowId = `${idPrefix}-stage-bp-glow`; |
| ... | ... | @@ -212,7 +208,7 @@ function DevBlueprintArt({ compact = false }: { compact?: boolean }) { |
| 212 | 208 | className="stage-blueprint h-full w-full" |
| 213 | 209 | fill="none" |
| 214 | 210 | preserveAspectRatio="xMinYMin slice" |
| 215 | viewBox={compact ? "64 0 8192 96" : STAGE_BACKDROP_VIEW_BOX} | |
| 211 | viewBox={STAGE_BACKDROP_VIEW_BOX} | |
| 216 | 212 | xmlns="http://www.w3.org/2000/svg" |
| 217 | 213 | > |
| 218 | 214 | <defs> |
apps/web/src/components/SidebarV2.tsx+13-11| ... | ... | @@ -768,7 +768,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 768 | 768 | isUnread || isWoke |
| 769 | 769 | ? "text-foreground" |
| 770 | 770 | : shouldRecede |
| 771 | ? "text-muted-foreground/80" | |
| 771 | ? "text-secondary-label" | |
| 772 | 772 | : status === "failed" |
| 773 | 773 | ? "text-foreground/95" |
| 774 | 774 | : "text-foreground/90", |
| ... | ... | @@ -779,7 +779,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 779 | 779 | ? "text-foreground" |
| 780 | 780 | : isUnread |
| 781 | 781 | ? "text-muted-foreground" |
| 782 | : "text-muted-foreground/70", | |
| 782 | : "text-secondary-label/70", | |
| 783 | 783 | ), |
| 784 | 784 | isRegeneratingTitle && "opacity-[0.55]", |
| 785 | 785 | )} |
| ... | ... | @@ -799,8 +799,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 799 | 799 | "shrink-0 text-xs tabular-nums hover:underline", |
| 800 | 800 | variant === "slim" && variantAction === "unsettle" |
| 801 | 801 | ? props.isActive |
| 802 | ? "text-muted-foreground/70" | |
| 803 | : cn("text-muted-foreground/35 transition-colors", settledPrHoverClass) | |
| 802 | ? "text-secondary-label" | |
| 803 | : cn("text-secondary-label transition-colors", settledPrHoverClass) | |
| 804 | 804 | : prStatus.colorClass, |
| 805 | 805 | )} |
| 806 | 806 | aria-label={prStatus.tooltip} |
| ... | ... | @@ -871,7 +871,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 871 | 871 | <span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end"> |
| 872 | 872 | <span |
| 873 | 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 | 875 | !isWoke && "group-hover/v2-row:opacity-0", |
| 876 | 876 | )} |
| 877 | 877 | > |
| ... | ... | @@ -983,7 +983,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 983 | 983 | {props.projectTitle ? ( |
| 984 | 984 | <span |
| 985 | 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 | 987 | shouldRecede ? "font-normal" : "font-medium", |
| 988 | 988 | )} |
| 989 | 989 | > |
| ... | ... | @@ -1012,7 +1012,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 1012 | 1012 | isWokeStatus |
| 1013 | 1013 | ? "pointer-events-auto" |
| 1014 | 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 | 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 | 1101 | </span> |
| 1102 | 1102 | ) : null} |
| 1103 | 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 | 1105 | {/* While working, the current plan step outranks the branch: |
| 1106 | 1106 | it's the one line that says what the thread is doing. */} |
| 1107 | 1107 | {status === "working" && thread.planProgress ? ( |
| 1108 | 1108 | <span className="min-w-0 flex-1 truncate whitespace-nowrap"> |
| 1109 | 1109 | {thread.planProgress.step} |
| 1110 | 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 | 1113 | {thread.planProgress.completedSteps}/{thread.planProgress.totalSteps} |
| 1114 | 1114 | </span> |
| ... | ... | @@ -2834,7 +2834,9 @@ export default function SidebarV2() { |
| 2834 | 2834 | <SidebarContent |
| 2835 | 2835 | className="gap-0" |
| 2836 | 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 | 2840 | <div className="flex items-center gap-1"> |
| 2839 | 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 | 2842 | <SearchIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" /> |
| ... | ... | @@ -2969,7 +2971,7 @@ export default function SidebarV2() { |
| 2969 | 2971 | type="button" |
| 2970 | 2972 | aria-label={`Project actions for ${project.displayName}`} |
| 2971 | 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 | 2975 | onPointerDown={(event) => event.stopPropagation()} |
| 2974 | 2976 | onClick={(event) => { |
| 2975 | 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 | 135 | return value ?? fallback; |
| 136 | 136 | } |
| 137 | 137 | |
| 138 | function readThemeColor(styles: CSSStyleDeclaration, variable: string, fallback: string): string { | |
| 139 | return normalizeComputedColor(styles.getPropertyValue(variable), fallback); | |
| 140 | } | |
| 141 | ||
| 138 | 142 | /** The surface treats an omitted family or size as "use the built-in default". */ |
| 139 | 143 | function terminalFontOptions(family: string, size: number): { family?: string; size: number } { |
| 140 | 144 | const trimmed = family.trim(); |
| ... | ... | @@ -151,6 +155,7 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty |
| 151 | 155 | document.body; |
| 152 | 156 | const drawerStyles = getComputedStyle(drawerSurface); |
| 153 | 157 | const bodyStyles = getComputedStyle(document.body); |
| 158 | const themeStyles = getComputedStyle(document.documentElement); | |
| 154 | 159 | const background = normalizeComputedColor( |
| 155 | 160 | drawerStyles.backgroundColor, |
| 156 | 161 | normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground), |
| ... | ... | @@ -159,20 +164,32 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty |
| 159 | 164 | drawerStyles.color, |
| 160 | 165 | normalizeComputedColor(bodyStyles.color, fallbackForeground), |
| 161 | 166 | ); |
| 162 | ||
| 167 | 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 | 179 | return { |
| 164 | 180 | background: parseTerminalColor( |
| 165 | background, | |
| 181 | terminalBackground, | |
| 166 | 182 | isDark ? { r: 14, g: 18, b: 24 } : { r: 255, g: 255, b: 255 }, |
| 167 | 183 | ), |
| 168 | 184 | foreground: parseTerminalColor( |
| 169 | foreground, | |
| 185 | terminalForeground, | |
| 170 | 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 }, | |
| 173 | // Matches the xterm selection overlays this renderer replaced; the text | |
| 174 | // color underneath is left unchanged for contrast in both themes. | |
| 175 | selectionBackground: isDark ? "rgba(180, 203, 255, 0.25)" : "rgba(37, 63, 99, 0.2)", | |
| 188 | cursor: parseTerminalColor( | |
| 189 | terminalCursor, | |
| 190 | isDark ? { r: 180, g: 203, b: 255 } : { r: 38, g: 56, b: 78 }, | |
| 191 | ), | |
| 192 | selectionBackground: terminalSelection, | |
| 176 | 193 | }; |
| 177 | 194 | } |
| 178 | 195 |
apps/web/src/components/chat/ChangedFilesTree.tsx+1| ... | ... | @@ -62,6 +62,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { |
| 62 | 62 | } |
| 63 | 63 | > |
| 64 | 64 | <div |
| 65 | data-changed-files-header="" | |
| 65 | 66 | className={cn( |
| 66 | 67 | "flex items-center justify-between gap-2 rounded-xl px-1", |
| 67 | 68 | expanded && |
apps/web/src/components/chat/ChatComposer.tsx+8-10| ... | ... | @@ -318,8 +318,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop |
| 318 | 318 | className={cn( |
| 319 | 319 | "shrink-0 whitespace-nowrap", |
| 320 | 320 | props.interactionMode === "plan" |
| 321 | ? "bg-blue-500/10 text-blue-400 hover:bg-blue-500/15 hover:text-blue-300" | |
| 322 | : "text-muted-foreground/70 hover:text-foreground/80", | |
| 321 | ? "bg-accent text-accent-foreground hover:bg-accent/80" | |
| 322 | : "text-secondary-label hover:text-foreground", | |
| 323 | 323 | )} |
| 324 | 324 | type="button" |
| 325 | 325 | onClick={props.onToggleInteractionMode} |
| ... | ... | @@ -420,7 +420,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( |
| 420 | 420 | /> |
| 421 | 421 | ) : null} |
| 422 | 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 | 424 | ) : null} |
| 425 | 425 | <ComposerPrimaryActions |
| 426 | 426 | compact={props.compact} |
| ... | ... | @@ -2767,9 +2767,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2767 | 2767 | type="button" |
| 2768 | 2768 | className={cn( |
| 2769 | 2769 | "min-w-0 flex-1 truncate bg-transparent py-1.5 text-left text-sm", |
| 2770 | activePendingProgress?.customAnswer | |
| 2771 | ? "text-foreground" | |
| 2772 | : "text-muted-foreground/60", | |
| 2770 | activePendingProgress?.customAnswer ? "text-foreground" : "text-placeholder", | |
| 2773 | 2771 | !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2", |
| 2774 | 2772 | )} |
| 2775 | 2773 | onPointerDown={(event) => event.preventDefault()} |
| ... | ... | @@ -2814,7 +2812,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2814 | 2812 | "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none", |
| 2815 | 2813 | (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim()) |
| 2816 | 2814 | ? "text-foreground" |
| 2817 | : "text-muted-foreground/35", | |
| 2815 | : "text-placeholder", | |
| 2818 | 2816 | )} |
| 2819 | 2817 | onPointerDown={(event) => event.preventDefault()} |
| 2820 | 2818 | onClick={expandMobileComposer} |
| ... | ... | @@ -2828,7 +2826,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2828 | 2826 | </button> |
| 2829 | 2827 | <button |
| 2830 | 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 | 2830 | disabled={collapsedComposerPrimaryActionDisabled} |
| 2833 | 2831 | aria-label={collapsedComposerPrimaryActionLabel} |
| 2834 | 2832 | onPointerDown={(event) => event.preventDefault()} |
| ... | ... | @@ -2978,7 +2976,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2978 | 2976 | /> |
| 2979 | 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 | 2980 | {image.name} |
| 2983 | 2981 | </div> |
| 2984 | 2982 | )} |
| ... | ... | @@ -3116,7 +3114,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3116 | 3114 | variant="ghost" |
| 3117 | 3115 | disabled |
| 3118 | 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 | 3119 | <CircleAlertIcon className="size-4" /> |
| 3122 | 3120 | No provider available |
apps/web/src/components/chat/ChatHeader.tsx+1-1| ... | ... | @@ -114,7 +114,7 @@ export const ChatHeader = memo(function ChatHeader({ |
| 114 | 114 | </TooltipTrigger> |
| 115 | 115 | <TooltipPopup side="top">New thread in {activeProjectName}</TooltipPopup> |
| 116 | 116 | </Tooltip> |
| 117 | <span aria-hidden className="text-muted-foreground/40"> | |
| 117 | <span aria-hidden className="text-icon-muted"> | |
| 118 | 118 | / |
| 119 | 119 | </span> |
| 120 | 120 | </span> |
apps/web/src/components/chat/ComposerCommandMenu.tsx+9-9| ... | ... | @@ -150,7 +150,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { |
| 150 | 150 | {groupIndex > 0 ? <CommandSeparator className="my-0.5" /> : null} |
| 151 | 151 | <CommandGroup> |
| 152 | 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 | 154 | {group.label} |
| 155 | 155 | </CommandGroupLabel> |
| 156 | 156 | ) : null} |
| ... | ... | @@ -172,10 +172,10 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { |
| 172 | 172 | <div className="px-5 py-3.5"> |
| 173 | 173 | {props.triggerKind === "skill" ? ( |
| 174 | 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 | 176 | Skills |
| 177 | 177 | </CommandGroupLabel> |
| 178 | <p className="text-muted-foreground/70 text-xs"> | |
| 178 | <p className="text-secondary-label text-xs"> | |
| 179 | 179 | {props.isLoading |
| 180 | 180 | ? "Searching workspace skills..." |
| 181 | 181 | : (props.emptyStateText ?? |
| ... | ... | @@ -183,7 +183,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { |
| 183 | 183 | </p> |
| 184 | 184 | </CommandGroup> |
| 185 | 185 | ) : ( |
| 186 | <p className="text-muted-foreground/70 text-xs"> | |
| 186 | <p className="text-secondary-label text-xs"> | |
| 187 | 187 | {props.isLoading |
| 188 | 188 | ? "Searching workspace files..." |
| 189 | 189 | : (props.emptyStateText ?? |
| ... | ... | @@ -235,26 +235,26 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { |
| 235 | 235 | /> |
| 236 | 236 | ) : null} |
| 237 | 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 | 239 | ) : null} |
| 240 | 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 | 242 | <SkillGlyph className="size-3.5" /> |
| 243 | 243 | </span> |
| 244 | 244 | ) : null} |
| 245 | 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 | 247 | <SkillGlyph className="size-3.5" /> |
| 248 | 248 | </span> |
| 249 | 249 | ) : null} |
| 250 | 250 | <span className="flex min-w-0 flex-1 items-center gap-2"> |
| 251 | 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 | 253 | {props.item.description} |
| 254 | 254 | </span> |
| 255 | 255 | </span> |
| 256 | 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 | 258 | ) : null} |
| 259 | 259 | </CommandItem> |
| 260 | 260 | ); |
apps/web/src/components/chat/ComposerControl.tsx+2-2| ... | ... | @@ -6,7 +6,7 @@ import { Button } from "../ui/button"; |
| 6 | 6 | import { SelectTrigger } from "../ui/select"; |
| 7 | 7 | |
| 8 | 8 | const 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"; | |
| 10 | 10 | |
| 11 | 11 | export function ComposerControl({ |
| 12 | 12 | className, |
| ... | ... | @@ -46,7 +46,7 @@ export function ComposerControlChevron() { |
| 46 | 46 | return ( |
| 47 | 47 | <ChevronDownIcon |
| 48 | 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 | 50 | data-composer-control-chevron |
| 51 | 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 | 155 | return ( |
| 156 | 156 | <div className="px-4 py-3 sm:px-5"> |
| 157 | 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 | 159 | {activeQuestion.header} |
| 160 | 160 | </span> |
| 161 | 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 | 163 | {questionIndex + 1}/{prompt.questions.length} |
| 164 | 164 | </span> |
| 165 | 165 | ) : null} |
| 166 | 166 | </div> |
| 167 | 167 | <p className="text-sm text-foreground/90">{activeQuestion.question}</p> |
| 168 | 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 | 170 | ) : null} |
| 171 | 171 | <div className="mt-3 space-y-1.5"> |
| 172 | 172 | {activeQuestion.options.map((option, index) => { |
| ... | ... | @@ -190,7 +190,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 190 | 190 | <div className="min-w-0 flex-1 flex flex-col gap-0.5"> |
| 191 | 191 | <span className="text-sm font-medium">{option.label}</span> |
| 192 | 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 | 194 | ) : null} |
| 195 | 195 | </div> |
| 196 | 196 | {isSelected ? ( |
| ... | ... | @@ -199,7 +199,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 199 | 199 | <kbd |
| 200 | 200 | className={cn( |
| 201 | 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 | 205 | {shortcutKey} |
apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx+5-5| ... | ... | @@ -63,13 +63,13 @@ export function ComposerPreviewAnnotationCards({ |
| 63 | 63 | /> |
| 64 | 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 | 67 | <MousePointerClick className="size-3.5" /> |
| 68 | 68 | </span> |
| 69 | 69 | )} |
| 70 | 70 | <div className="min-w-0 px-2.5 py-2 pr-8"> |
| 71 | 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 | 73 | {annotation.comment.trim()} |
| 74 | 74 | </p> |
| 75 | 75 | ) : null} |
| ... | ... | @@ -84,13 +84,13 @@ export function ComposerPreviewAnnotationCards({ |
| 84 | 84 | {elementLabels.slice(0, 2).map(({ id, label }) => ( |
| 85 | 85 | <span |
| 86 | 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 | 89 | {label} |
| 90 | 90 | </span> |
| 91 | 91 | ))} |
| 92 | 92 | {elementLabels.length > 2 ? ( |
| 93 | <span className="text-[10px] text-muted-foreground"> | |
| 93 | <span className="text-secondary-label text-[10px]"> | |
| 94 | 94 | +{elementLabels.length - 2} |
| 95 | 95 | </span> |
| 96 | 96 | ) : null} |
| ... | ... | @@ -131,7 +131,7 @@ export function ComposerPreviewAnnotationCards({ |
| 131 | 131 | <button |
| 132 | 132 | type="button" |
| 133 | 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 | 135 | onClick={() => onRemove(annotation.id)} |
| 136 | 136 | > |
| 137 | 137 | <X className="size-3" /> |
apps/web/src/components/chat/ComposerPrimaryActions.tsx+12-19| ... | ... | @@ -1,8 +1,6 @@ |
| 1 | 1 | import { memo, type PointerEventHandler } from "react"; |
| 2 | 2 | import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react"; |
| 3 | import { useEnvironmentIdentificationMode } from "~/hooks/useSettings"; | |
| 4 | 3 | import { cn } from "~/lib/utils"; |
| 5 | import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../SidebarStageBackdrop"; | |
| 6 | 4 | import { Button } from "../ui/button"; |
| 7 | 5 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; |
| 8 | 6 | import { Spinner } from "../ui/spinner"; |
| ... | ... | @@ -75,11 +73,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 75 | 73 | const pointerFocusProps = preserveComposerFocusOnPointerDown |
| 76 | 74 | ? { onPointerDown: preventPointerFocus } |
| 77 | 75 | : undefined; |
| 78 | const environmentIdentificationMode = useEnvironmentIdentificationMode(); | |
| 79 | 76 | const isSendDisabled = sendDisabledReason !== null; |
| 80 | const stageBackdropVariant = useSidebarStageBackdropVariant( | |
| 81 | environmentIdentificationMode === "artwork", | |
| 82 | ); | |
| 83 | 77 | |
| 84 | 78 | if (pendingAction) { |
| 85 | 79 | return ( |
| ... | ... | @@ -113,7 +107,10 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 113 | 107 | <Button |
| 114 | 108 | type="submit" |
| 115 | 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 | 114 | {...pointerFocusProps} |
| 118 | 115 | disabled={ |
| 119 | 116 | isEnvironmentUnavailable || |
| ... | ... | @@ -154,7 +151,10 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 154 | 151 | <Button |
| 155 | 152 | type="submit" |
| 156 | 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 | 158 | {...pointerFocusProps} |
| 159 | 159 | disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable} |
| 160 | 160 | > |
| ... | ... | @@ -168,7 +168,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 168 | 168 | <Button |
| 169 | 169 | type="submit" |
| 170 | 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 | 172 | {...pointerFocusProps} |
| 173 | 173 | disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable} |
| 174 | 174 | > |
| ... | ... | @@ -180,7 +180,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 180 | 180 | <Button |
| 181 | 181 | size="sm" |
| 182 | 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 | 184 | aria-label="Implementation actions" |
| 185 | 185 | {...pointerFocusProps} |
| 186 | 186 | disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable} |
| ... | ... | @@ -206,10 +206,8 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 206 | 206 | <button |
| 207 | 207 | type="submit" |
| 208 | 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", | |
| 210 | stageBackdropVariant | |
| 211 | ? "bg-transparent enabled:shadow-black/24 enabled:hover:brightness-110" | |
| 212 | : "bg-primary/90 enabled:shadow-primary/24 hover:bg-primary", | |
| 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 | "bg-message-action enabled:shadow-message-action/24 hover:bg-message-action-hover", | |
| 213 | 211 | )} |
| 214 | 212 | {...pointerFocusProps} |
| 215 | 213 | disabled={ |
| ... | ... | @@ -233,11 +231,6 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 233 | 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 | 234 | {isConnecting || isSendBusy ? ( |
| 242 | 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 | 94 | <div className="dropdown-glass relative w-full overflow-hidden rounded-[20px]"> |
| 95 | 95 | <CommandList className="max-h-72"> |
| 96 | 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 | 98 | <BookmarkIcon className="size-3" aria-hidden="true" /> |
| 99 | 99 | Stashed prompts |
| 100 | 100 | </CommandGroupLabel> |
| 101 | 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 | 103 | Nothing stashed yet. Press ⌘S with a prompt in the composer to stash it. |
| 104 | 104 | </p> |
| 105 | 105 | ) : ( |
| ... | ... | @@ -134,23 +134,23 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { |
| 134 | 134 | ))} |
| 135 | 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 | 139 | <span className="min-w-0 flex-1 truncate text-sm"> |
| 140 | 140 | {stashEntrySnippet(entry)} |
| 141 | 141 | </span> |
| 142 | 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 | 144 | saving {entry.pendingImageCount} image |
| 145 | 145 | {entry.pendingImageCount === 1 ? "" : "s"}… |
| 146 | 146 | </span> |
| 147 | 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 | 149 | {missingImageCount(entry)} image |
| 150 | 150 | {missingImageCount(entry) === 1 ? "" : "s"} dropped |
| 151 | 151 | </span> |
| 152 | 152 | ) : null} |
| 153 | <span className="shrink-0 text-muted-foreground/60 text-xs"> | |
| 153 | <span className="shrink-0 text-secondary-label text-xs"> | |
| 154 | 154 | {formatRelativeTimeLabel(entry.createdAt)} |
| 155 | 155 | </span> |
| 156 | 156 | <Button |
apps/web/src/components/chat/ContextWindowMeter.tsx+6-6| ... | ... | @@ -26,7 +26,7 @@ export function ContextWindowMeter(props: { |
| 26 | 26 | const showTotalProcessed = totalProcessedTokens !== null && totalProcessedTokens > 0; |
| 27 | 27 | const isOverloaded = normalizedPercentage > 90; |
| 28 | 28 | const usageColor = isOverloaded |
| 29 | ? "var(--color-red-500)" | |
| 29 | ? "var(--color-error)" | |
| 30 | 30 | : "color-mix(in oklab, var(--color-muted-foreground) 72%, transparent)"; |
| 31 | 31 | |
| 32 | 32 | return ( |
| ... | ... | @@ -91,7 +91,7 @@ export function ContextWindowMeter(props: { |
| 91 | 91 | <div className="flex items-center justify-between gap-3"> |
| 92 | 92 | <div className="font-medium text-muted-foreground text-xs">Context Window</div> |
| 93 | 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 | 95 | <span>{usedPercentage}</span> |
| 96 | 96 | <span className="mx-1">·</span> |
| 97 | 97 | <span> |
| ... | ... | @@ -100,7 +100,7 @@ export function ContextWindowMeter(props: { |
| 100 | 100 | </span> |
| 101 | 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 | 104 | {formatContextWindowTokens(usage.usedTokens)} |
| 105 | 105 | </div> |
| 106 | 106 | )} |
| ... | ... | @@ -122,14 +122,14 @@ export function ContextWindowMeter(props: { |
| 122 | 122 | ) : null} |
| 123 | 123 | {showTotalProcessed ? ( |
| 124 | 124 | <div className="flex items-center justify-between gap-3 text-[11px] leading-4"> |
| 125 | <span className="text-muted-foreground/60">Total processed</span> | |
| 126 | <span className="font-medium tabular-nums text-muted-foreground/80"> | |
| 125 | <span className="text-secondary-label">Total processed</span> | |
| 126 | <span className="font-medium tabular-nums text-secondary-label"> | |
| 127 | 127 | {formatContextWindowTokens(totalProcessedTokens)} |
| 128 | 128 | </span> |
| 129 | 129 | </div> |
| 130 | 130 | ) : null} |
| 131 | 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 | 133 | {providerDisplayName ?? "It"} automatically compacts its context when needed. |
| 134 | 134 | </div> |
| 135 | 135 | ) : null} |
apps/web/src/components/chat/MessagesTimeline.test.tsx+1-1| ... | ... | @@ -461,7 +461,7 @@ describe("MessagesTimeline", () => { |
| 461 | 461 | |
| 462 | 462 | expect(markup).not.toContain("Show full message"); |
| 463 | 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 | }); |
| 466 | 466 | |
| 467 | 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 | 534 | } |
| 535 | 535 | return ( |
| 536 | 536 | <div className="flex h-full items-center justify-center"> |
| 537 | <p className="text-sm text-muted-foreground/30"> | |
| 538 | Send a message to start the conversation. | |
| 539 | </p> | |
| 537 | <p className="text-placeholder text-sm">Send a message to start the conversation.</p> | |
| 540 | 538 | </div> |
| 541 | 539 | ); |
| 542 | 540 | } |
| ... | ... | @@ -965,7 +963,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" |
| 965 | 963 | |
| 966 | 964 | return ( |
| 967 | 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 | 967 | {regularImages.length > 0 && ( |
| 970 | 968 | <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2"> |
| 971 | 969 | {regularImages.map((image: NonNullable<TimelineMessage["attachments"]>[number]) => ( |
| ... | ... | @@ -991,7 +989,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" |
| 991 | 989 | /> |
| 992 | 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 | 993 | {image.name} |
| 996 | 994 | </div> |
| 997 | 995 | )} |
| ... | ... | @@ -1270,7 +1268,7 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin |
| 1270 | 1268 | const { workingStepLabel } = use(TimelineRowActivityCtx); |
| 1271 | 1269 | return ( |
| 1272 | 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 | 1272 | <span className="inline-flex items-center gap-[3px]"> |
| 1275 | 1273 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse" /> |
| 1276 | 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 | 1347 | return ( |
| 1350 | 1348 | <section className="-mx-1 space-y-0.5 px-1 py-0.5" aria-label={groupLabel}> |
| 1351 | 1349 | {!onlyToolEntries && ( |
| 1352 | <p className="px-0.5 pb-0.5 font-medium text-[11px] text-muted-foreground/65"> | |
| 1353 | {groupLabel} | |
| 1354 | </p> | |
| 1350 | <p className="px-0.5 pb-0.5 font-medium text-secondary-label text-[11px]">{groupLabel}</p> | |
| 1355 | 1351 | )} |
| 1356 | 1352 | <div className="space-y-px"> |
| 1357 | 1353 | {nonEmptyEntries.map((workEntry) => ( |
| ... | ... | @@ -1391,7 +1387,7 @@ function WorkGroupToggleTimelineRow({ |
| 1391 | 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 | 1391 | <ChevronDownIcon |
| 1396 | 1392 | className={cn( |
| 1397 | 1393 | "size-3.5 shrink-0 opacity-70 transition-transform duration-200", |
| ... | ... | @@ -1400,11 +1396,11 @@ function WorkGroupToggleTimelineRow({ |
| 1400 | 1396 | /> |
| 1401 | 1397 | </span> |
| 1402 | 1398 | {row.expanded ? ( |
| 1403 | <span className="font-medium text-foreground/82"> | |
| 1399 | <span className="font-medium text-foreground"> | |
| 1404 | 1400 | Show fewer {row.onlyToolEntries ? "tool calls" : "log entries"} |
| 1405 | 1401 | </span> |
| 1406 | 1402 | ) : ( |
| 1407 | <span className="font-medium text-foreground/82"> | |
| 1403 | <span className="font-medium text-foreground"> | |
| 1408 | 1404 | +{row.hiddenCount} previous {labelNoun} |
| 1409 | 1405 | </span> |
| 1410 | 1406 | )} |
| ... | ... | @@ -1509,7 +1505,7 @@ const UserMessageElementContextChip = memo(function UserMessageElementContextChi |
| 1509 | 1505 | <Tooltip> |
| 1510 | 1506 | <TooltipTrigger |
| 1511 | 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 | 1509 | <MousePointerClickIcon className="size-3 shrink-0" /> |
| 1514 | 1510 | <span className="truncate">{props.context.header}</span> |
| 1515 | 1511 | </span> |
| ... | ... | @@ -1549,13 +1545,13 @@ function UserMessagePreviewAnnotationCard(props: { |
| 1549 | 1545 | ) : null} |
| 1550 | 1546 | <div className="min-w-0 px-2.5 py-2"> |
| 1551 | 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 | 1549 | {props.annotation.comment} |
| 1554 | 1550 | </div> |
| 1555 | 1551 | ) : null} |
| 1556 | 1552 | <div |
| 1557 | 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 | 1555 | props.annotation.comment && "mt-1", |
| 1560 | 1556 | )} |
| 1561 | 1557 | > |
| ... | ... | @@ -1644,7 +1640,7 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop |
| 1644 | 1640 | aria-expanded={expanded} |
| 1645 | 1641 | data-scroll-anchor-ignore |
| 1646 | 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 | 1645 | {expanded ? "Show less" : "Show full message"} |
| 1650 | 1646 | </Button> |
| ... | ... | @@ -1683,7 +1679,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 1683 | 1679 | cwd={props.markdownCwd} |
| 1684 | 1680 | threadRef={ctx.threadRef ?? undefined} |
| 1685 | 1681 | skills={props.skills} |
| 1686 | className="text-foreground" | |
| 1682 | className="text-message-foreground" | |
| 1687 | 1683 | lineBreaks |
| 1688 | 1684 | /> |
| 1689 | 1685 | ) : null} |
| ... | ... | @@ -1695,7 +1691,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 1695 | 1691 | const reviewCommentSegments = parseReviewCommentMessageSegments(props.text); |
| 1696 | 1692 | if (reviewCommentSegments.some((segment) => segment.kind === "review-comment")) { |
| 1697 | 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 | 1695 | {reviewCommentSegments.map((segment) => |
| 1700 | 1696 | segment.kind === "text" ? ( |
| 1701 | 1697 | segment.text.trim().length > 0 ? ( |
| ... | ... | @@ -1705,7 +1701,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 1705 | 1701 | cwd={props.markdownCwd} |
| 1706 | 1702 | threadRef={ctx.threadRef ?? undefined} |
| 1707 | 1703 | skills={props.skills} |
| 1708 | className="text-foreground" | |
| 1704 | className="text-message-foreground" | |
| 1709 | 1705 | lineBreaks |
| 1710 | 1706 | /> |
| 1711 | 1707 | </div> |
| ... | ... | @@ -1764,7 +1760,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 1764 | 1760 | } |
| 1765 | 1761 | |
| 1766 | 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 | 1764 | {inlineNodes} |
| 1769 | 1765 | </div> |
| 1770 | 1766 | ); |
| ... | ... | @@ -1793,7 +1789,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 1793 | 1789 | cwd={props.markdownCwd} |
| 1794 | 1790 | threadRef={ctx.threadRef ?? undefined} |
| 1795 | 1791 | skills={props.skills} |
| 1796 | className="text-foreground" | |
| 1792 | className="text-message-foreground" | |
| 1797 | 1793 | lineBreaks |
| 1798 | 1794 | />, |
| 1799 | 1795 | ); |
| ... | ... | @@ -1802,7 +1798,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 1802 | 1798 | } |
| 1803 | 1799 | |
| 1804 | 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 | 1802 | {inlineNodes} |
| 1807 | 1803 | </div> |
| 1808 | 1804 | ); |
| ... | ... | @@ -1818,7 +1814,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 1818 | 1814 | cwd={props.markdownCwd} |
| 1819 | 1815 | threadRef={ctx.threadRef ?? undefined} |
| 1820 | 1816 | skills={props.skills} |
| 1821 | className="text-foreground" | |
| 1817 | className="text-message-foreground" | |
| 1822 | 1818 | lineBreaks |
| 1823 | 1819 | /> |
| 1824 | 1820 | ); |
| ... | ... | @@ -1835,10 +1831,10 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte |
| 1835 | 1831 | return ( |
| 1836 | 1832 | <div className="space-y-2 rounded-lg border border-border/70 bg-background/70 p-3"> |
| 1837 | 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 | 1835 | {formatWorkspaceRelativePath(comment.filePath, ctx.workspaceRoot)} |
| 1840 | 1836 | </div> |
| 1841 | <div className="text-[11px] text-muted-foreground"> | |
| 1837 | <div className="text-secondary-label text-[11px]"> | |
| 1842 | 1838 | {comment.sectionTitle} · {comment.rangeLabel} |
| 1843 | 1839 | </div> |
| 1844 | 1840 | </div> |
| ... | ... | @@ -1853,7 +1849,7 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte |
| 1853 | 1849 | cwd={ctx.markdownCwd} |
| 1854 | 1850 | threadRef={ctx.threadRef ?? undefined} |
| 1855 | 1851 | skills={ctx.skills} |
| 1856 | className="text-foreground" | |
| 1852 | className="text-message-foreground" | |
| 1857 | 1853 | /> |
| 1858 | 1854 | )} |
| 1859 | 1855 | {renderablePatch?.kind === "files" && |
| ... | ... | @@ -1978,24 +1974,24 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { |
| 1978 | 1974 | if (tone === "error") { |
| 1979 | 1975 | return { |
| 1980 | 1976 | iconName: "circle-alert", |
| 1981 | className: "text-foreground/92", | |
| 1977 | className: "text-foreground", | |
| 1982 | 1978 | }; |
| 1983 | 1979 | } |
| 1984 | 1980 | if (tone === "thinking") { |
| 1985 | 1981 | return { |
| 1986 | 1982 | iconName: "bot", |
| 1987 | className: "text-foreground/92", | |
| 1983 | className: "text-foreground", | |
| 1988 | 1984 | }; |
| 1989 | 1985 | } |
| 1990 | 1986 | if (tone === "info") { |
| 1991 | 1987 | return { |
| 1992 | 1988 | iconName: "check", |
| 1993 | className: "text-muted-foreground", | |
| 1989 | className: "text-icon-muted", | |
| 1994 | 1990 | }; |
| 1995 | 1991 | } |
| 1996 | 1992 | return { |
| 1997 | 1993 | iconName: "zap", |
| 1998 | className: "text-foreground/92", | |
| 1994 | className: "text-foreground", | |
| 1999 | 1995 | }; |
| 2000 | 1996 | } |
| 2001 | 1997 | |
| ... | ... | @@ -2244,14 +2240,14 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2244 | 2240 | : showDestructiveRowStyle |
| 2245 | 2241 | ? "text-destructive" |
| 2246 | 2242 | : workEntry.tone === "tool" || showFailedIndicator |
| 2247 | ? "text-muted-foreground/65" | |
| 2243 | ? "text-icon-muted" | |
| 2248 | 2244 | : iconConfig.className, |
| 2249 | 2245 | ); |
| 2250 | 2246 | const headingClass = showWarningIndicator |
| 2251 | 2247 | ? "font-medium text-warning" |
| 2252 | 2248 | : showDestructiveRowStyle |
| 2253 | 2249 | ? "font-medium text-destructive" |
| 2254 | : "font-medium text-foreground/82"; | |
| 2250 | : "font-medium text-foreground"; | |
| 2255 | 2251 | const turnSettled = !activity.activeTurnInProgress; |
| 2256 | 2252 | const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry); |
| 2257 | 2253 | const showSuccessIndicator = |
| ... | ... | @@ -2293,11 +2289,11 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2293 | 2289 | <p className="flex min-w-0 w-full items-baseline gap-1.5 text-[12px] leading-5"> |
| 2294 | 2290 | <span className={cn("min-w-0 shrink truncate", headingClass)}>{heading}</span> |
| 2295 | 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 | 2294 | </p> |
| 2299 | 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 | 2297 | <span |
| 2302 | 2298 | className="flex size-4 shrink-0 items-center justify-center" |
| 2303 | 2299 | aria-hidden={!canExpand} |
| ... | ... | @@ -2362,7 +2358,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2362 | 2358 | onClick={stopRowToggle} |
| 2363 | 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 | 2362 | {expandedBody} |
| 2367 | 2363 | </pre> |
| 2368 | 2364 | </div> |
apps/web/src/components/chat/ModelListRow.tsx+1-1| ... | ... | @@ -69,7 +69,7 @@ export const ModelListRow = memo(function ModelListRow(props: { |
| 69 | 69 | </div> |
| 70 | 70 | {props.showNewBadge ? ( |
| 71 | 71 | <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 | 73 | aria-label="New model" |
| 74 | 74 | > |
| 75 | 75 | New |
apps/web/src/components/chat/ModelPickerSidebar.tsx+1-1| ... | ... | @@ -29,7 +29,7 @@ const SELECTED_INDICATOR_CLASS = |
| 29 | 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"; |
| 30 | 30 | const BADGE_BASE_CLASS = |
| 31 | 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 "; |
| 32 | const NEW_BADGE_CLASS = `${BADGE_BASE_CLASS} text-amber-600 dark:text-amber-300 `; | |
| 32 | const NEW_BADGE_CLASS = `${BADGE_BASE_CLASS} text-update `; | |
| 33 | 33 | |
| 34 | 34 | /** Opens toward the rail so the list stays readable (not over the model names). */ |
| 35 | 35 | const 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 | 73 | |
| 74 | 74 | if (!icon) { |
| 75 | 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 | } |
| 81 | 81 |
apps/web/src/components/files/FilePreviewPanel.tsx+12-2| ... | ... | @@ -23,7 +23,7 @@ import { OpenInPicker } from "~/components/chat/OpenInPicker"; |
| 23 | 23 | import { useClientSettings } from "~/hooks/useSettings"; |
| 24 | 24 | import { useTheme } from "~/hooks/useTheme"; |
| 25 | 25 | import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; |
| 26 | import { resolveDiffThemeName } from "~/lib/diffRendering"; | |
| 26 | import { DIFF_SURFACE_THEME_UNSAFE_CSS, resolveDiffThemeName } from "~/lib/diffRendering"; | |
| 27 | 27 | import { cn } from "~/lib/utils"; |
| 28 | 28 | import { isPreviewSupportedInRuntime } from "~/previewStateStore"; |
| 29 | 29 | import { resolvePathLinkTarget } from "~/terminal-links"; |
| ... | ... | @@ -84,6 +84,16 @@ const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown"; |
| 84 | 84 | const FILE_SAVE_DEBOUNCE_MS = 500; |
| 85 | 85 | const FILE_LINK_REVEAL_ATTRIBUTE = "data-file-link-reveal"; |
| 86 | 86 | const 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 | 97 | [${FILE_LINK_REVEAL_ATTRIBUTE}][data-line] { |
| 88 | 98 | background-color: light-dark( |
| 89 | 99 | color-mix( |
| ... | ... | @@ -959,7 +969,7 @@ export default function FilePreviewPanel({ |
| 959 | 969 | </div> |
| 960 | 970 | ) : null} |
| 961 | 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 | 973 | Preview limited to the first 1 MB of a {file.data.byteLength.toLocaleString()} byte file. |
| 964 | 974 | </div> |
| 965 | 975 | ) : null} |
apps/web/src/components/settings/KeybindingsSettings.logic.test.ts+1| ... | ... | @@ -122,6 +122,7 @@ describe("KeybindingsSettings.logic", () => { |
| 122 | 122 | |
| 123 | 123 | it("formats static and project script command labels", () => { |
| 124 | 124 | expect(commandLabel("commandPalette.toggle")).toBe("Command Palette: Toggle"); |
| 125 | expect(commandLabel("themeEditor.toggle")).toBe("Theme Editor: Toggle"); | |
| 125 | 126 | expect(commandLabel("script.setup-db.run")).toBe("Run Script: Setup Db"); |
| 126 | 127 | }); |
| 127 | 128 |
apps/web/src/components/settings/ProviderInstanceCard.tsx+1-1| ... | ... | @@ -619,7 +619,7 @@ export function ProviderInstanceCard({ |
| 619 | 619 | "size-5 rounded-sm p-0", |
| 620 | 620 | versionAdvisory.emphasis === "strong" |
| 621 | 621 | ? "text-warning hover:text-warning" |
| 622 | : "text-primary hover:text-primary", | |
| 622 | : "text-update hover:text-update", | |
| 623 | 623 | )} |
| 624 | 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 | 85 | return promise; |
| 86 | 86 | } |
| 87 | 87 | |
| 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. | |
| 93 | const 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 | ||
| 88 | 108 | function StaticDiffHtml({ html }: { html: string }) { |
| 89 | 109 | const hostRef = useRef<HTMLDivElement | null>(null); |
| 90 | 110 | useEffect(() => { |
| ... | ... | @@ -92,6 +112,9 @@ function StaticDiffHtml({ html }: { html: string }) { |
| 92 | 112 | if (host === null) return; |
| 93 | 113 | const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" }); |
| 94 | 114 | shadow.innerHTML = html; |
| 115 | const bridge = document.createElement("style"); | |
| 116 | bridge.textContent = DIFF_PREVIEW_THEME_BRIDGE; | |
| 117 | shadow.append(bridge); | |
| 95 | 118 | }, [html]); |
| 96 | 119 | return <div ref={hostRef} />; |
| 97 | 120 | } |
| ... | ... | @@ -158,7 +181,7 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu |
| 158 | 181 | const mountRef = useRef<HTMLDivElement>(null); |
| 159 | 182 | const surfaceRef = useRef<GhosttyTerminalSurface | null>(null); |
| 160 | 183 | const fontRef = useRef({ family, size }); |
| 161 | const { resolvedTheme } = useTheme(); | |
| 184 | const { theme, resolvedTheme } = useTheme(); | |
| 162 | 185 | |
| 163 | 186 | useEffect(() => { |
| 164 | 187 | const current = fontRef.current; |
| ... | ... | @@ -167,12 +190,14 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu |
| 167 | 190 | void surfaceRef.current?.setFont(previewTerminalFont(family, size)); |
| 168 | 191 | }, [family, size]); |
| 169 | 192 | |
| 193 | // Re-read the terminal tokens on any theme change — switching between two | |
| 194 | // palettes can leave resolvedTheme (light/dark) untouched. | |
| 170 | 195 | useEffect(() => { |
| 171 | 196 | const mount = mountRef.current; |
| 172 | 197 | const surface = surfaceRef.current; |
| 173 | 198 | if (!mount || !surface) return; |
| 174 | 199 | surface.setTheme(terminalThemeFromApp(mount)); |
| 175 | }, [resolvedTheme]); | |
| 200 | }, [theme, resolvedTheme]); | |
| 176 | 201 | |
| 177 | 202 | useEffect(() => { |
| 178 | 203 | const mount = mountRef.current; |
apps/web/src/components/settings/SettingsPanels.tsx+109-52| ... | ... | @@ -52,7 +52,13 @@ import { |
| 52 | 52 | } from "../SidebarStageBackdrop"; |
| 53 | 53 | import { isElectron } from "../../env"; |
| 54 | 54 | import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; |
| 55 | import { useTheme } from "../../hooks/useTheme"; | |
| 55 | import { useCustomThemes } from "../../hooks/useCustomThemes"; | |
| 56 | import { | |
| 57 | readAppearanceModePreference, | |
| 58 | readThemeHalves, | |
| 59 | readThemePreference, | |
| 60 | useTheme, | |
| 61 | } from "../../hooks/useTheme"; | |
| 56 | 62 | import { useLocalStorage } from "../../hooks/useLocalStorage"; |
| 57 | 63 | import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; |
| 58 | 64 | import { useThreadActions } from "../../hooks/useThreadActions"; |
| ... | ... | @@ -106,6 +112,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ |
| 106 | 112 | import { Switch } from "../ui/switch"; |
| 107 | 113 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 108 | 114 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 115 | import { ThemeLibrary } from "./ThemeSettings"; | |
| 109 | 116 | import { |
| 110 | 117 | backgroundActivityOverrideSettings, |
| 111 | 118 | backgroundActivitySharedPolicySettings, |
| ... | ... | @@ -131,21 +138,6 @@ import { |
| 131 | 138 | import { searchableSetting } from "./settingsSearch"; |
| 132 | 139 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 133 | 140 | |
| 134 | const 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 | ||
| 149 | 141 | const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, string> = { |
| 150 | 142 | artwork: "Artwork", |
| 151 | 143 | pill: "Version pill", |
| ... | ... | @@ -432,7 +424,15 @@ function AboutVersionSection() { |
| 432 | 424 | } |
| 433 | 425 | |
| 434 | 426 | export 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 | 436 | const settings = usePrimarySettings(); |
| 437 | 437 | const updateSettings = useUpdatePrimarySettings(); |
| 438 | 438 | |
| ... | ... | @@ -445,6 +445,8 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 445 | 445 | const changedSettingLabels = useMemo( |
| 446 | 446 | () => [ |
| 447 | 447 | ...(theme !== "system" ? ["Theme"] : []), |
| 448 | ...(!followSystem ? ["Follow system"] : []), | |
| 449 | ...(themeHalves !== null ? ["Theme mix"] : []), | |
| 448 | 450 | ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []), |
| 449 | 451 | ...(settings.environmentIdentificationMode !== |
| 450 | 452 | DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode |
| ... | ... | @@ -525,6 +527,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 525 | 527 | settings.sidebarThreadPreviewCount, |
| 526 | 528 | settings.timestampFormat, |
| 527 | 529 | settings.wordWrap, |
| 530 | followSystem, | |
| 528 | 531 | theme, |
| 529 | 532 | ], |
| 530 | 533 | ); |
| ... | ... | @@ -539,7 +542,57 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 539 | 542 | ); |
| 540 | 543 | if (!confirmed) return; |
| 541 | 544 | |
| 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 | 596 | updateSettings({ |
| 544 | 597 | timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, |
| 545 | 598 | wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, |
| ... | ... | @@ -566,7 +619,17 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 566 | 619 | fontFamilyTerminal: DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal, |
| 567 | 620 | }); |
| 568 | 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 | ]); | |
| 570 | 633 | |
| 571 | 634 | return { |
| 572 | 635 | changedSettingLabels, |
| ... | ... | @@ -841,7 +904,18 @@ function BackgroundActivityAdvancedDialog({ |
| 841 | 904 | } |
| 842 | 905 | |
| 843 | 906 | export 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 | 919 | const settings = usePrimarySettings(); |
| 846 | 920 | const updateSettings = useUpdatePrimarySettings(); |
| 847 | 921 | const environmentStageLabel = useEnvironmentStageLabel(); |
| ... | ... | @@ -857,38 +931,21 @@ export function AppearanceSettingsPanel() { |
| 857 | 931 | return ( |
| 858 | 932 | <SettingsPageContainer> |
| 859 | 933 | <SettingsSection id="appearance" title="Appearance"> |
| 860 | <SettingsRow | |
| 861 | {...searchableSetting("theme")} | |
| 862 | description="Choose how T3 Code looks across the app." | |
| 863 | resetAction={ | |
| 864 | theme !== "system" ? ( | |
| 865 | <SettingResetButton label="theme" onClick={() => setTheme("system")} /> | |
| 866 | ) : null | |
| 867 | } | |
| 868 | control={ | |
| 869 | <Select | |
| 870 | value={theme} | |
| 871 | onValueChange={(value) => { | |
| 872 | if (value === "system" || value === "light" || value === "dark") { | |
| 873 | setTheme(value); | |
| 874 | } | |
| 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 | /> | |
| 934 | <div id={searchableSetting("theme").id}> | |
| 935 | <ThemeLibrary | |
| 936 | appearanceMode={appearanceMode} | |
| 937 | customThemes={customThemes} | |
| 938 | initialAppearance={resolvedTheme} | |
| 939 | refreshTheme={refreshTheme} | |
| 940 | isImportOpen={isImportThemeOpen} | |
| 941 | setAppearanceMode={setAppearanceMode} | |
| 942 | setTheme={setTheme} | |
| 943 | setThemeHalf={setThemeHalf} | |
| 944 | theme={theme} | |
| 945 | themeHalves={themeHalves} | |
| 946 | onImportOpenChange={setIsImportThemeOpen} | |
| 947 | /> | |
| 948 | </div> | |
| 892 | 949 | |
| 893 | 950 | <SettingsRow |
| 894 | 951 | {...searchableSetting("setting-glass-opacity")} |
apps/web/src/components/settings/ThemeColorPicker.tsx created+550| ... | ... | @@ -0,0 +1,550 @@ |
| 1 | import type { KeyboardEvent, PointerEvent } from "react"; | |
| 2 | import { memo, useCallback, useEffect, useRef, useState } from "react"; | |
| 3 | import { isThemeColor, type ThemeColorRole } from "../../themePalette"; | |
| 4 | import { cn } from "../../lib/utils"; | |
| 5 | import { Input } from "../ui/input"; | |
| 6 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | |
| 7 | export 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 | ||
| 29 | type ThemeColorHsv = { | |
| 30 | h: number; | |
| 31 | s: number; | |
| 32 | v: number; | |
| 33 | }; | |
| 34 | ||
| 35 | function 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 | */ | |
| 44 | function 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 | ||
| 54 | function 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 | ||
| 75 | function 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 | ||
| 105 | function 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 | ||
| 132 | function 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 | ||
| 137 | function 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 | ||
| 156 | function themeRgbValue(hex: string) { | |
| 157 | return themeHexToRgb(hex).join(", "); | |
| 158 | } | |
| 159 | ||
| 160 | function 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 | ||
| 445 | function 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 | ||
| 488 | export 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 @@ |
| 1 | import { useCallback } from "react"; | |
| 2 | ||
| 3 | import { useTheme } from "../../hooks/useTheme"; | |
| 4 | import { getThemeDefinition, type ThemeAppearance, type ThemeDefinition } from "../../themePalette"; | |
| 5 | import { stackedThreadToast, toastManager } from "../ui/toast"; | |
| 6 | import { ThemeEditorPanel } from "./ThemeEditorPanel"; | |
| 7 | import { 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 | */ | |
| 14 | export 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 @@ |
| 1 | import { ChevronDownIcon, ChevronUpIcon, MousePointer2Icon, PlusIcon, XIcon } from "lucide-react"; | |
| 2 | import { | |
| 3 | useCallback, | |
| 4 | useEffect, | |
| 5 | useRef, | |
| 6 | useState, | |
| 7 | type PointerEvent as ReactPointerEvent, | |
| 8 | } from "react"; | |
| 9 | import { | |
| 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"; | |
| 28 | import { cn } from "../../lib/utils"; | |
| 29 | import { Button } from "../ui/button"; | |
| 30 | import { Input } from "../ui/input"; | |
| 31 | import { Switch } from "../ui/switch"; | |
| 32 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 33 | import { getThemeRoleLabel, ThemeColorField } from "./ThemeColorPicker"; | |
| 34 | import { | |
| 35 | clearThemeInspectorHover, | |
| 36 | clearThemeInspectorHighlights, | |
| 37 | highlightThemeRoleUsage, | |
| 38 | inspectThemeRoleAtElement, | |
| 39 | inspectThemeRoleFromUtilitiesAtElement, | |
| 40 | refreshThemeInspectorSpotlight, | |
| 41 | showThemeInspectorHover, | |
| 42 | type ThemeElementInspection, | |
| 43 | } from "./themeInspector"; | |
| 44 | ||
| 45 | const 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 | ||
| 60 | const THEME_EDITOR_SIMPLE_ROLES: ReadonlyArray<ThemeColorRole> = ["canvas", "accent"]; | |
| 61 | ||
| 62 | const 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 | ||
| 74 | const THEME_EDITOR_ADVANCED_ROLES = THEME_COLOR_ROLES.filter( | |
| 75 | (role) => !THEME_EDITOR_PRIMARY_ROLES.includes(role) && !THEME_EDITOR_STATUS_ROLES.includes(role), | |
| 76 | ); | |
| 77 | ||
| 78 | const 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 | ||
| 100 | type ThemeEditorColors = Record<ThemeColorRole, string>; | |
| 101 | type 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. | |
| 106 | function getThemeEditorDefaults(appearance: ThemeAppearance): ThemeEditorColors { | |
| 107 | return { ...getStandardThemeColors(appearance) }; | |
| 108 | } | |
| 109 | ||
| 110 | function getThemeEditorColorsByAppearance(): ThemeEditorColorsByAppearance { | |
| 111 | return { | |
| 112 | light: getThemeEditorDefaults("light"), | |
| 113 | dark: getThemeEditorDefaults("dark"), | |
| 114 | }; | |
| 115 | } | |
| 116 | ||
| 117 | function isThemeEditorColor(value: string): boolean { | |
| 118 | return isThemeColor(value.trim()); | |
| 119 | } | |
| 120 | ||
| 121 | function 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 | ||
| 135 | export 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { describeOversizedThemeFile, MAX_THEME_FILE_BYTES } from "./ThemeImportDialog"; | |
| 4 | ||
| 5 | describe("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 @@ |
| 1 | import { PlusIcon, UploadIcon } from "lucide-react"; | |
| 2 | import type { ChangeEvent, DragEvent, UIEvent } from "react"; | |
| 3 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 4 | import { cn } from "../../lib/utils"; | |
| 5 | import { | |
| 6 | getCustomThemes, | |
| 7 | installCustomTheme, | |
| 8 | parseThemeFile, | |
| 9 | removeCustomTheme, | |
| 10 | THEME_FILE_VERSION, | |
| 11 | updateCustomTheme, | |
| 12 | type ThemeDefinition, | |
| 13 | } from "../../themePalette"; | |
| 14 | import { | |
| 15 | humanizeThemeName, | |
| 16 | isVsCodeThemeFile, | |
| 17 | pairVsCodeThemes, | |
| 18 | parseVsCodeThemeFile, | |
| 19 | resolveThemeLabelCollisions, | |
| 20 | } from "../../vscodeThemeImport"; | |
| 21 | import { Alert } from "../ui/alert"; | |
| 22 | import { Button } from "../ui/button"; | |
| 23 | import { | |
| 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 | */ | |
| 38 | export 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. */ | |
| 42 | const MAX_HIGHLIGHTED_JSON_LENGTH = 20_000; | |
| 43 | ||
| 44 | function 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. */ | |
| 51 | export 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 | ||
| 56 | function 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 | ||
| 70 | function 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 | ||
| 96 | function 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. */ | |
| 150 | type ImportableThemeFile = { name: string; size: number; text: () => Promise<string> }; | |
| 151 | ||
| 152 | export 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 @@ |
| 1 | import { MoonIcon, SunIcon } from "lucide-react"; | |
| 2 | import type { CSSProperties } from "react"; | |
| 3 | import { cn } from "../../lib/utils"; | |
| 4 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 5 | import { | |
| 6 | getThemeColorsForMode, | |
| 7 | getThemeModes, | |
| 8 | type ThemeAppearance, | |
| 9 | type ThemeDefinition, | |
| 10 | } from "../../themePalette"; | |
| 11 | ||
| 12 | const THEME_PREVIEW_ROLES = [ | |
| 13 | "sidebar", | |
| 14 | "canvas", | |
| 15 | "surface", | |
| 16 | "accentSurface", | |
| 17 | "accent", | |
| 18 | "messageSurface", | |
| 19 | "messageAction", | |
| 20 | ] as const; | |
| 21 | type ThemePreviewRole = (typeof THEME_PREVIEW_ROLES)[number]; | |
| 22 | type ThemeCardPreview = { | |
| 23 | mode: ThemeAppearance; | |
| 24 | colors: Readonly<Record<ThemePreviewRole, string>>; | |
| 25 | }; | |
| 26 | export type ThemeCardDefinition = { | |
| 27 | id: string; | |
| 28 | label: string; | |
| 29 | previews: ReadonlyArray<ThemeCardPreview>; | |
| 30 | }; | |
| 31 | export type ThemeMode = ThemeAppearance | "system"; | |
| 32 | export type ThemeCardPreviewColors = ThemeCardPreview["colors"]; | |
| 33 | ||
| 34 | const 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 | ||
| 58 | export 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 | ||
| 69 | export function previewColorsOf( | |
| 70 | card: ThemeCardDefinition, | |
| 71 | mode: ThemeAppearance, | |
| 72 | ): ThemeCardPreviewColors | null { | |
| 73 | return card.previews.find((preview) => preview.mode === mode)?.colors ?? null; | |
| 74 | } | |
| 75 | ||
| 76 | export 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. | |
| 101 | function 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. | |
| 128 | function 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 | ||
| 134 | export 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 | */ | |
| 160 | export 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 @@ |
| 1 | import { | |
| 2 | CopyIcon, | |
| 3 | DownloadIcon, | |
| 4 | PenLineIcon, | |
| 5 | PlusIcon, | |
| 6 | Trash2Icon, | |
| 7 | UploadIcon, | |
| 8 | } from "lucide-react"; | |
| 9 | import { useCallback, useEffect, useRef, useState } from "react"; | |
| 10 | import { cn } from "../../lib/utils"; | |
| 11 | import { | |
| 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"; | |
| 25 | import { | |
| 26 | AlertDialog, | |
| 27 | AlertDialogClose, | |
| 28 | AlertDialogDescription, | |
| 29 | AlertDialogFooter, | |
| 30 | AlertDialogHeader, | |
| 31 | AlertDialogPopup, | |
| 32 | AlertDialogTitle, | |
| 33 | } from "../ui/alert-dialog"; | |
| 34 | import { Button } from "../ui/button"; | |
| 35 | import { stackedThreadToast, toastManager } from "../ui/toast"; | |
| 36 | import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; | |
| 37 | import { ThemeImportDialog } from "./ThemeImportDialog"; | |
| 38 | import { useThemeEditorStore } from "./themeEditorStore"; | |
| 39 | import { | |
| 40 | STANDARD_THEME_CARDS, | |
| 41 | getThemeCardDefinition, | |
| 42 | previewColorsOf, | |
| 43 | ThemePreviewCircles, | |
| 44 | type ThemeCardDefinition, | |
| 45 | type ThemeMode, | |
| 46 | } from "./ThemePreviewCircles"; | |
| 47 | import { ThemeWireframe } from "./ThemeWireframe"; | |
| 48 | ||
| 49 | const MAINTAINER_THEMES: ReadonlyArray<ThemeDefinition> = [ | |
| 50 | T3_CHAT_THEME, | |
| 51 | GROVE_THEME, | |
| 52 | OCEAN_THEME, | |
| 53 | EMBER_THEME, | |
| 54 | IRIS_THEME, | |
| 55 | ]; | |
| 56 | ||
| 57 | function 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 | ||
| 68 | function 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 | ||
| 227 | export 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 @@ |
| 1 | import { cn } from "../../lib/utils"; | |
| 2 | import 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. | |
| 7 | export 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 | ||
| 115 | export 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 | 37 | * here once instead of separately in the panel and the index. |
| 38 | 38 | */ |
| 39 | 39 | export 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 | 48 | id: "theme", |
| 42 | title: "Theme", | |
| 49 | title: "Themes", | |
| 43 | 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 | 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 @@ |
| 1 | import { afterEach, describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { OCEAN_THEME_ID, T3_CHAT_THEME_ID } from "../../themePalette"; | |
| 4 | import { toggleThemeEditorForTheme, useThemeEditorStore } from "./themeEditorStore"; | |
| 5 | ||
| 6 | afterEach(() => { | |
| 7 | useThemeEditorStore.getState().closeThemeEditor(); | |
| 8 | }); | |
| 9 | ||
| 10 | describe("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 @@ |
| 1 | import { create } from "zustand"; | |
| 2 | ||
| 3 | import { | |
| 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 | */ | |
| 16 | export 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 | ||
| 26 | export 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 | ||
| 34 | type ThemeEditorStore = { | |
| 35 | session: ThemeEditorSession | null; | |
| 36 | openThemeEditor: (session: ThemeEditorSessionInput) => void; | |
| 37 | closeThemeEditor: () => void; | |
| 38 | }; | |
| 39 | ||
| 40 | let nextSessionId = 0; | |
| 41 | ||
| 42 | export const useThemeEditorStore = create<ThemeEditorStore>((set) => ({ | |
| 43 | session: null, | |
| 44 | openThemeEditor: (session) => set({ session: { ...session, id: ++nextSessionId } }), | |
| 45 | closeThemeEditor: () => set({ session: null }), | |
| 46 | })); | |
| 47 | ||
| 48 | export 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 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | changedThemePaintKinds, | |
| 5 | themeRoleFromUtilityClass, | |
| 6 | type ThemePaintSnapshot, | |
| 7 | } from "./themeInspector"; | |
| 8 | ||
| 9 | const 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 | ||
| 15 | describe("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 | ||
| 31 | describe("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 @@ |
| 1 | import { THEME_COLOR_ROLES, getThemeColorVariable, type ThemeColorRole } from "../../themePalette"; | |
| 2 | ||
| 3 | export type ThemePaintKind = "background" | "border" | "foreground"; | |
| 4 | ||
| 5 | export type ThemePaintSnapshot = Readonly<Record<ThemePaintKind, string>>; | |
| 6 | export type ThemeElementInspection = Readonly<{ | |
| 7 | element: Element; | |
| 8 | role: ThemeColorRole; | |
| 9 | }>; | |
| 10 | ||
| 11 | const THEME_PAINT_KIND_ORDER: ReadonlyArray<ThemePaintKind> = [ | |
| 12 | "background", | |
| 13 | "border", | |
| 14 | "foreground", | |
| 15 | ]; | |
| 16 | ||
| 17 | export const THEME_INSPECTOR_MATCH_ATTRIBUTE = "data-theme-inspector-match"; | |
| 18 | ||
| 19 | const THEME_TOKEN_PROBE_ATTRIBUTE = "data-theme-token-probe"; | |
| 20 | const THEME_TOKEN_PROBE_COLOR = "#01fea7"; | |
| 21 | const THEME_TOKEN_ALTERNATE_PROBE_COLOR = "#fe01a7"; | |
| 22 | const THEME_SPOTLIGHT_ID = "theme-inspector-spotlight"; | |
| 23 | const THEME_SPOTLIGHT_MASK_ID = "theme-inspector-spotlight-mask"; | |
| 24 | const THEME_SPOTLIGHT_GLOW_ID = "theme-inspector-spotlight-glow"; | |
| 25 | const THEME_HOVER_ID = "theme-inspector-hover"; | |
| 26 | const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; | |
| 27 | ||
| 28 | const 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 | ||
| 76 | const THEME_UTILITY_PREFIXES: Readonly<Record<ThemePaintKind, ReadonlyArray<string>>> = { | |
| 77 | background: ["bg-"], | |
| 78 | border: ["border-", "outline-", "ring-"], | |
| 79 | foreground: ["text-", "caret-", "fill-", "stroke-"], | |
| 80 | }; | |
| 81 | ||
| 82 | function clearThemeInspectorAttribute(attribute: string): void { | |
| 83 | document | |
| 84 | .querySelectorAll(`[${attribute}]`) | |
| 85 | .forEach((element) => element.removeAttribute(attribute)); | |
| 86 | } | |
| 87 | ||
| 88 | export function clearThemeInspectorHighlights(): void { | |
| 89 | clearThemeInspectorAttribute(THEME_INSPECTOR_MATCH_ATTRIBUTE); | |
| 90 | document.getElementById(THEME_SPOTLIGHT_ID)?.remove(); | |
| 91 | } | |
| 92 | ||
| 93 | export function clearThemeInspectorHover(): void { | |
| 94 | document.getElementById(THEME_HOVER_ID)?.remove(); | |
| 95 | } | |
| 96 | ||
| 97 | function svgElement<Name extends keyof SVGElementTagNameMap>( | |
| 98 | name: Name, | |
| 99 | ): SVGElementTagNameMap[Name] { | |
| 100 | return document.createElementNS(SVG_NAMESPACE, name); | |
| 101 | } | |
| 102 | ||
| 103 | function 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 | ||
| 138 | export 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 | ||
| 166 | function 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 | ||
| 246 | export function refreshThemeInspectorSpotlight(): void { | |
| 247 | renderThemeInspectorSpotlight([ | |
| 248 | ...document.querySelectorAll(`[${THEME_INSPECTOR_MATCH_ATTRIBUTE}]`), | |
| 249 | ]); | |
| 250 | } | |
| 251 | ||
| 252 | function 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 | ||
| 259 | function 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 | ||
| 297 | export 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 | ||
| 304 | function 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 | ||
| 318 | function 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 | ||
| 339 | function applyThemeTokenProbes(roles: ReadonlyArray<ThemeColorRole>): () => void { | |
| 340 | const restores = roles.map(applyThemeTokenProbe); | |
| 341 | return () => { | |
| 342 | for (const restore of restores.toReversed()) restore(); | |
| 343 | }; | |
| 344 | } | |
| 345 | ||
| 346 | function 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 | ||
| 354 | export 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 | ||
| 369 | function 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 | ||
| 379 | function 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 | ||
| 389 | export 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 | */ | |
| 405 | export 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. */ | |
| 453 | export 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 | 13 | |
| 14 | 14 | const PROVIDER_UPDATE_PILL_STYLES = { |
| 15 | 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 | 17 | success: |
| 18 | 18 | "bg-success/12 text-success group-has-[button.provider-update-main:hover]/provider-update:bg-success/18", |
| 19 | 19 | warning: |
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+3-3| ... | ... | @@ -159,11 +159,11 @@ export function SidebarUpdatePill() { |
| 159 | 159 | )} |
| 160 | 160 | {visible && ( |
| 161 | 161 | <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 | 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 | 167 | <Tooltip> |
| 168 | 168 | <TooltipTrigger |
| 169 | 169 | render={ |
| ... | ... | @@ -224,7 +224,7 @@ export function SidebarUpdatePill() { |
| 224 | 224 | <button |
| 225 | 225 | type="button" |
| 226 | 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 | 228 | onClick={() => setDismissed(true)} |
| 229 | 229 | > |
| 230 | 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 | 12 | variant: { |
| 13 | 13 | default: "bg-transparent dark:bg-input/32 [&_svg]:text-muted-foreground", |
| 14 | 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 | 16 | info: "border-info/32 bg-info/4 [&_svg]:text-info", |
| 17 | 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 | 112 | <AutocompleteInput |
| 113 | 113 | autoFocus |
| 114 | 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 | 116 | className, |
| 117 | 117 | )} |
| 118 | 118 | placeholder={placeholder} |
| 119 | 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 | 121 | {...props} |
| 122 | 122 | /> |
| 123 | 123 | </div> |
| ... | ... | @@ -203,7 +203,7 @@ function CommandShortcut({ className, ...props }: React.ComponentProps<"kbd">) { |
| 203 | 203 | return ( |
| 204 | 204 | <kbd |
| 205 | 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 | 207 | className, |
| 208 | 208 | )} |
| 209 | 209 | data-slot="command-shortcut" |
apps/web/src/components/ui/input.tsx+1-1| ... | ... | @@ -19,7 +19,7 @@ function Input({ |
| 19 | 19 | ...props |
| 20 | 20 | }: InputProps) { |
| 21 | 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 | 23 | size === "sm" && "h-7.5 px-[calc(--spacing(2.5)-1px)] leading-7.5 sm:h-6.5 sm:leading-6.5", |
| 24 | 24 | size === "lg" && "h-9.5 leading-9.5 sm:h-8.5 sm:leading-8.5", |
| 25 | 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 | 203 | return ( |
| 204 | 204 | <kbd |
| 205 | 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 | 207 | className, |
| 208 | 208 | )} |
| 209 | 209 | data-slot="menu-shortcut" |
apps/web/src/components/ui/select.tsx+4-6| ... | ... | @@ -21,9 +21,9 @@ const selectTriggerVariants = cva( |
| 21 | 21 | variants: { |
| 22 | 22 | variant: { |
| 23 | 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 | 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 | 28 | size: { |
| 29 | 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 | 50 | const defaultProps = { |
| 51 | 51 | children: ( |
| 52 | 52 | <> |
| 53 | <span className="flex-1 truncate in-data-placeholder:text-muted-foreground/72"> | |
| 54 | {children} | |
| 55 | </span> | |
| 53 | <span className="flex-1 truncate in-data-placeholder:text-placeholder">{children}</span> | |
| 56 | 54 | {variant === "ghost" ? ( |
| 57 | 55 | <ChevronDownIcon className="-me-1 size-3 opacity-50" /> |
| 58 | 56 | ) : ( |
| ... | ... | @@ -98,7 +96,7 @@ function SelectTrigger({ |
| 98 | 96 | function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) { |
| 99 | 97 | return ( |
| 100 | 98 | <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 | 100 | data-slot="select-value" |
| 103 | 101 | {...props} |
| 104 | 102 | /> |
apps/web/src/hooks/useCustomThemes.ts created+9| ... | ... | @@ -0,0 +1,9 @@ |
| 1 | import { useSyncExternalStore } from "react"; | |
| 2 | ||
| 3 | import { getCustomThemes, subscribeToCustomThemes } from "../themePalette"; | |
| 4 | ||
| 5 | const EMPTY_CUSTOM_THEMES: readonly [] = []; | |
| 6 | ||
| 7 | export 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 | 70 | } |
| 71 | 71 | }); |
| 72 | 72 | |
| 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 | 85 | it("falls back during initial theme application and logs only safe attributes", async () => { |
| 74 | 86 | const cause = new Error("private browsing storage failure"); |
| 75 | 87 | const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); |
| ... | ... | @@ -104,9 +116,10 @@ describe("theme failure handling", () => { |
| 104 | 116 | |
| 105 | 117 | it("retries a failed storage read only after a relevant storage event", async () => { |
| 106 | 118 | const cause = new Error("persistent storage failure"); |
| 107 | const getItem = vi.fn(() => { | |
| 119 | const themeGetItem = vi.fn((): string | null => { | |
| 108 | 120 | throw cause; |
| 109 | 121 | }); |
| 122 | const getItem = vi.fn((key: string) => (key === "t3code:theme" ? themeGetItem() : null)); | |
| 110 | 123 | const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); |
| 111 | 124 | let readSnapshot: (() => unknown) | undefined; |
| 112 | 125 | let subscribeToTheme: ((listener: () => void) => () => void) | undefined; |
| ... | ... | @@ -141,14 +154,14 @@ describe("theme failure handling", () => { |
| 141 | 154 | readSnapshot?.(); |
| 142 | 155 | readSnapshot?.(); |
| 143 | 156 | |
| 144 | expect(getItem).toHaveBeenCalledTimes(1); | |
| 157 | expect(themeGetItem).toHaveBeenCalledTimes(1); | |
| 145 | 158 | expect(errorLog).toHaveBeenCalledTimes(1); |
| 146 | 159 | |
| 147 | 160 | const unsubscribe = subscribeToTheme?.(() => undefined); |
| 148 | 161 | storageHandler?.({ key: "t3code:theme" } as StorageEvent); |
| 149 | 162 | readSnapshot?.(); |
| 150 | 163 | |
| 151 | expect(getItem).toHaveBeenCalledTimes(2); | |
| 164 | expect(themeGetItem).toHaveBeenCalledTimes(2); | |
| 152 | 165 | expect(errorLog).toHaveBeenCalledTimes(2); |
| 153 | 166 | unsubscribe?.(); |
| 154 | 167 | }); |
apps/web/src/hooks/useTheme.ts+369-60| ... | ... | @@ -2,12 +2,33 @@ import type { DesktopBridge } from "@t3tools/contracts"; |
| 2 | 2 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; |
| 3 | 3 | import * as Schema from "effect/Schema"; |
| 4 | 4 | import { useCallback, useEffect, useSyncExternalStore } from "react"; |
| 5 | ||
| 6 | const ThemePreference = Schema.Literals(["light", "dark", "system"]); | |
| 7 | type Theme = typeof ThemePreference.Type; | |
| 5 | import { | |
| 6 | applyThemePalette, | |
| 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 | ||
| 25 | type Theme = ThemePreference; | |
| 8 | 26 | type ThemeSnapshot = { |
| 9 | 27 | theme: Theme; |
| 10 | 28 | systemDark: boolean; |
| 29 | followSystem: boolean; | |
| 30 | appearanceMode: ThemePreferenceMode; | |
| 31 | themeHalves: ThemeHalves | null; | |
| 11 | 32 | }; |
| 12 | 33 | |
| 13 | 34 | type DesktopThemeBridge = Pick<DesktopBridge, "setTheme">; |
| ... | ... | @@ -17,7 +38,29 @@ const MEDIA_QUERY = "(prefers-color-scheme: dark)"; |
| 17 | 38 | const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { |
| 18 | 39 | theme: "system", |
| 19 | 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). */ | |
| 48 | export function readThemeHalves(): ThemeHalves | null { | |
| 49 | return readStoredThemeHalves(); | |
| 50 | } | |
| 51 | ||
| 52 | function 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 | ||
| 61 | function themeHalvesSignature(halves: ThemeHalves | null): string { | |
| 62 | return `${halves?.light ?? ""}|${halves?.dark ?? ""}`; | |
| 63 | } | |
| 21 | 64 | const THEME_COLOR_META_NAME = "theme-color"; |
| 22 | 65 | const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`; |
| 23 | 66 | |
| ... | ... | @@ -53,11 +96,13 @@ export const isDesktopThemeSyncError = Schema.is(DesktopThemeSyncError); |
| 53 | 96 | |
| 54 | 97 | let listeners: Array<() => void> = []; |
| 55 | 98 | let lastSnapshot: ThemeSnapshot | null = null; |
| 56 | let lastDesktopTheme: Theme | null = null; | |
| 99 | let snapshotStale = true; | |
| 100 | let lastDesktopTheme: "light" | "dark" | "system" | null = null; | |
| 57 | 101 | let lastAppliedTheme: ThemeSnapshot | null = null; |
| 58 | 102 | let themeStorageReadFailure: ThemeStorageError | null = null; |
| 59 | 103 | |
| 60 | 104 | function emitChange() { |
| 105 | snapshotStale = true; | |
| 61 | 106 | for (const listener of listeners) listener(); |
| 62 | 107 | } |
| 63 | 108 | |
| ... | ... | @@ -69,6 +114,53 @@ function getSystemDark() { |
| 69 | 114 | ); |
| 70 | 115 | } |
| 71 | 116 | |
| 117 | function 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 | ||
| 131 | function isThemePreferenceMode(value: string | null): value is ThemePreferenceMode { | |
| 132 | return value === "light" || value === "dark" || value === "system"; | |
| 133 | } | |
| 134 | ||
| 135 | export 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 | ||
| 149 | function 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 | ||
| 72 | 164 | export function readThemePreference(): Theme { |
| 73 | 165 | if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT.theme; |
| 74 | 166 | let raw: string | null; |
| ... | ... | @@ -81,7 +173,9 @@ export function readThemePreference(): Theme { |
| 81 | 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 | 179 | return DEFAULT_THEME_SNAPSHOT.theme; |
| 86 | 180 | } |
| 87 | 181 | |
| ... | ... | @@ -161,34 +255,66 @@ function resolveBrowserChromeSurface(): HTMLElement { |
| 161 | 255 | |
| 162 | 256 | export function syncBrowserChromeTheme() { |
| 163 | 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 | 262 | const surfaceColor = normalizeThemeColor( |
| 165 | 263 | getComputedStyle(resolveBrowserChromeSurface()).backgroundColor, |
| 166 | 264 | ); |
| 167 | 265 | const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor); |
| 168 | const backgroundColor = surfaceColor ?? fallbackColor; | |
| 266 | const backgroundColor = themeChromeColor ?? surfaceColor ?? fallbackColor; | |
| 169 | 267 | if (!backgroundColor) return; |
| 170 | 268 | |
| 171 | 269 | document.documentElement.style.backgroundColor = backgroundColor; |
| 172 | 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 | } |
| 175 | 284 | |
| 176 | 285 | function applyTheme(theme: Theme, suppressTransitions = false) { |
| 177 | 286 | if (typeof document === "undefined" || typeof window === "undefined") return; |
| 178 | const systemDark = theme === "system" ? getSystemDark() : false; | |
| 179 | if (lastAppliedTheme?.theme === theme && lastAppliedTheme.systemDark === systemDark) { | |
| 180 | syncDesktopTheme(theme); | |
| 287 | const appearanceMode = readAppearanceModePreference(theme); | |
| 288 | const followSystem = appearanceMode === "system"; | |
| 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 | 299 | return; |
| 182 | 300 | } |
| 183 | 301 | |
| 184 | 302 | if (suppressTransitions) { |
| 185 | 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 | 314 | document.documentElement.classList.toggle("dark", isDark); |
| 189 | lastAppliedTheme = { theme, systemDark }; | |
| 315 | lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves }; | |
| 190 | 316 | syncBrowserChromeTheme(); |
| 191 | syncDesktopTheme(theme); | |
| 317 | syncDesktopTheme(theme, followSystem, appearanceMode); | |
| 192 | 318 | if (suppressTransitions) { |
| 193 | 319 | // Force a reflow so the no-transitions class takes effect before removal |
| 194 | 320 | // oxlint-disable-next-line no-unused-expressions |
| ... | ... | @@ -202,34 +328,45 @@ function applyTheme(theme: Theme, suppressTransitions = false) { |
| 202 | 328 | export async function syncDesktopThemePreference( |
| 203 | 329 | bridge: DesktopThemeBridge, |
| 204 | 330 | theme: Theme, |
| 331 | followSystem?: boolean, | |
| 332 | appearanceMode?: ThemePreferenceMode, | |
| 333 | halves: ThemeHalves | null = readStoredThemeHalves(), | |
| 205 | 334 | ): Promise<void> { |
| 206 | 335 | try { |
| 207 | await bridge.setTheme(theme); | |
| 336 | await bridge.setTheme(resolveDesktopTheme(theme, followSystem, appearanceMode, halves)); | |
| 208 | 337 | } catch (cause) { |
| 209 | 338 | throw new DesktopThemeSyncError({ theme, cause }); |
| 210 | 339 | } |
| 211 | 340 | } |
| 212 | 341 | |
| 213 | export function syncDesktopTheme(theme: Theme) { | |
| 342 | export function syncDesktopTheme( | |
| 343 | theme: Theme, | |
| 344 | followSystem?: boolean, | |
| 345 | appearanceMode?: ThemePreferenceMode, | |
| 346 | ) { | |
| 214 | 347 | if (typeof window === "undefined") return; |
| 215 | 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 | 352 | return; |
| 218 | 353 | } |
| 219 | 354 | |
| 220 | lastDesktopTheme = theme; | |
| 221 | void syncDesktopThemePreference(bridge, theme).catch((cause: unknown) => { | |
| 222 | const error = isDesktopThemeSyncError(cause) | |
| 223 | ? cause | |
| 224 | : new DesktopThemeSyncError({ theme, cause }); | |
| 225 | console.error(error.message, { | |
| 226 | theme: error.theme, | |
| 227 | ...safeErrorLogAttributes(error), | |
| 228 | }); | |
| 229 | if (lastDesktopTheme === theme) { | |
| 230 | lastDesktopTheme = null; | |
| 231 | } | |
| 232 | }); | |
| 355 | lastDesktopTheme = desktopTheme; | |
| 356 | void syncDesktopThemePreference(bridge, theme, followSystem, appearanceMode, halves).catch( | |
| 357 | (cause: unknown) => { | |
| 358 | const error = isDesktopThemeSyncError(cause) | |
| 359 | ? cause | |
| 360 | : new DesktopThemeSyncError({ theme, cause }); | |
| 361 | console.error(error.message, { | |
| 362 | theme: error.theme, | |
| 363 | ...safeErrorLogAttributes(error), | |
| 364 | }); | |
| 365 | if (lastDesktopTheme === desktopTheme) { | |
| 366 | lastDesktopTheme = null; | |
| 367 | } | |
| 368 | }, | |
| 369 | ); | |
| 233 | 370 | } |
| 234 | 371 | |
| 235 | 372 | // Apply immediately on module load to prevent flash |
| ... | ... | @@ -239,14 +376,28 @@ if (typeof document !== "undefined" && typeof window !== "undefined") { |
| 239 | 376 | |
| 240 | 377 | function getSnapshot(): ThemeSnapshot { |
| 241 | 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 | 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(); | |
| 244 | 388 | |
| 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 | 397 | return lastSnapshot; |
| 247 | 398 | } |
| 248 | 399 | |
| 249 | lastSnapshot = { theme, systemDark }; | |
| 400 | lastSnapshot = { theme, systemDark, followSystem, appearanceMode, themeHalves }; | |
| 250 | 401 | return lastSnapshot; |
| 251 | 402 | } |
| 252 | 403 | |
| ... | ... | @@ -254,32 +405,56 @@ function getServerSnapshot() { |
| 254 | 405 | return DEFAULT_THEME_SNAPSHOT; |
| 255 | 406 | } |
| 256 | 407 | |
| 408 | function handleSystemAppearanceChange() { | |
| 409 | const storedTheme = getStored(); | |
| 410 | if (readAppearanceModePreference(storedTheme) === "system") applyTheme(storedTheme, true); | |
| 411 | emitChange(); | |
| 412 | } | |
| 413 | ||
| 414 | function 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 | ||
| 434 | let removeWindowListeners: (() => void) | null = null; | |
| 435 | ||
| 257 | 436 | function subscribe(listener: () => void): () => void { |
| 258 | 437 | if (typeof window === "undefined") return () => {}; |
| 259 | 438 | listeners.push(listener); |
| 260 | 439 | |
| 261 | // Listen for system preference changes | |
| 262 | const mq = typeof window.matchMedia === "function" ? window.matchMedia(MEDIA_QUERY) : null; | |
| 263 | const handleChange = () => { | |
| 264 | if (getStored() === "system") applyTheme("system", true); | |
| 265 | emitChange(); | |
| 266 | }; | |
| 267 | mq?.addEventListener("change", handleChange); | |
| 268 | ||
| 269 | // Listen for storage changes from other tabs | |
| 270 | const handleStorage = (e: StorageEvent) => { | |
| 271 | if (e.key === STORAGE_KEY) { | |
| 272 | themeStorageReadFailure = null; | |
| 273 | applyTheme(getStored(), true); | |
| 274 | emitChange(); | |
| 275 | } | |
| 276 | }; | |
| 277 | window.addEventListener("storage", handleStorage); | |
| 440 | // The system-preference and cross-tab listeners are shared by all | |
| 441 | // subscribers; each event applies the theme once and notifies everyone. | |
| 442 | if (!removeWindowListeners) { | |
| 443 | const mq = typeof window.matchMedia === "function" ? window.matchMedia(MEDIA_QUERY) : null; | |
| 444 | mq?.addEventListener("change", handleSystemAppearanceChange); | |
| 445 | window.addEventListener("storage", handleStorageChange); | |
| 446 | removeWindowListeners = () => { | |
| 447 | mq?.removeEventListener("change", handleSystemAppearanceChange); | |
| 448 | window.removeEventListener("storage", handleStorageChange); | |
| 449 | }; | |
| 450 | } | |
| 278 | 451 | |
| 279 | 452 | return () => { |
| 280 | 453 | listeners = listeners.filter((l) => l !== listener); |
| 281 | mq?.removeEventListener("change", handleChange); | |
| 282 | window.removeEventListener("storage", handleStorage); | |
| 454 | if (listeners.length === 0) { | |
| 455 | removeWindowListeners?.(); | |
| 456 | removeWindowListeners = null; | |
| 457 | } | |
| 283 | 458 | }; |
| 284 | 459 | } |
| 285 | 460 | |
| ... | ... | @@ -287,13 +462,34 @@ export function useTheme() { |
| 287 | 462 | const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); |
| 288 | 463 | const theme = snapshot.theme; |
| 289 | 464 | |
| 290 | const resolvedTheme: "light" | "dark" = | |
| 291 | theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme; | |
| 465 | const resolvedTheme: "light" | "dark" = resolveThemeAppearance( | |
| 466 | theme, | |
| 467 | snapshot.systemDark, | |
| 468 | snapshot.followSystem, | |
| 469 | snapshot.appearanceMode, | |
| 470 | snapshot.themeHalves, | |
| 471 | ); | |
| 292 | 472 | |
| 293 | const setTheme = useCallback((next: Theme) => { | |
| 294 | if (typeof window === "undefined") return; | |
| 473 | const setTheme = useCallback((next: Theme): boolean => { | |
| 474 | if (typeof window === "undefined") return false; | |
| 295 | 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 | 493 | } catch (cause) { |
| 298 | 494 | const error = isThemeStorageError(cause) |
| 299 | 495 | ? cause |
| ... | ... | @@ -309,16 +505,129 @@ export function useTheme() { |
| 309 | 505 | theme: next, |
| 310 | 506 | ...safeErrorLogAttributes(error), |
| 311 | 507 | }); |
| 312 | return; | |
| 508 | return false; | |
| 313 | 509 | } |
| 314 | 510 | applyTheme(next, true); |
| 315 | 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 | }, []); |
| 317 | 614 | |
| 318 | 615 | // Keep DOM in sync on mount/change |
| 319 | 616 | useEffect(() => { |
| 320 | 617 | applyTheme(theme); |
| 321 | }, [theme]); | |
| 322 | ||
| 323 | return { theme, setTheme, resolvedTheme } as const; | |
| 618 | }, [snapshot.appearanceMode, theme]); | |
| 619 | ||
| 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 | 148 | --animate-status-ping: status-ping 2s infinite; |
| 149 | 149 | --color-warning-foreground: var(--warning-foreground); |
| 150 | 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 | 158 | --color-success-foreground: var(--success-foreground); |
| 152 | 159 | --color-success: var(--success); |
| 153 | 160 | --color-info-foreground: var(--info-foreground); |
| ... | ... | @@ -161,6 +168,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 161 | 168 | --color-accent: var(--accent); |
| 162 | 169 | --color-muted-foreground: var(--muted-foreground); |
| 163 | 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 | 174 | --color-secondary-foreground: var(--secondary-foreground); |
| 165 | 175 | --color-secondary: var(--secondary); |
| 166 | 176 | --color-primary-foreground: var(--primary-foreground); |
| ... | ... | @@ -172,6 +182,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 172 | 182 | --color-foreground: var(--foreground); |
| 173 | 183 | --color-background: var(--background); |
| 174 | 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 | 190 | --color-sidebar: var(--sidebar); |
| 176 | 191 | --color-sidebar-foreground: var(--sidebar-foreground); |
| 177 | 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 | 848 | @utility surface-grain { |
| 834 | 849 | background-image: var(--surface-grain); |
| 835 | 850 | background-repeat: repeat; |
| 836 | background-size: 256px 256px; | |
| 851 | background-size: var(--surface-grain-size); | |
| 837 | 852 | } |
| 838 | 853 | |
| 839 | 854 | /* Suppress all transitions during theme changes */ |
| ... | ... | @@ -850,6 +865,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 850 | 865 | --radius: 0.625rem; |
| 851 | 866 | --background: var(--color-zinc-25); |
| 852 | 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 | 874 | --surface-raised: color-mix(in srgb, var(--card) 20%, transparent); |
| 854 | 875 | --foreground: var(--color-zinc-800); |
| 855 | 876 | --card: var(--color-white); |
| ... | ... | @@ -862,19 +883,34 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 862 | 883 | --secondary-foreground: var(--color-zinc-800); |
| 863 | 884 | --muted: var(--color-zinc-50); |
| 864 | 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 | 894 | --accent: var(--color-zinc-100); |
| 866 | 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 | 900 | --border: var(--color-zinc-200); |
| 869 | 901 | --input: var(--color-zinc-300); |
| 870 | 902 | --ring: oklch(0.488 0.217 264); |
| 871 | --destructive-foreground: var(--color-red-700); | |
| 903 | --destructive-foreground: var(--error-foreground); | |
| 872 | 904 | --info: var(--color-blue-500); |
| 873 | 905 | --info-foreground: var(--color-blue-700); |
| 874 | 906 | --success: var(--color-emerald-500); |
| 875 | 907 | --success-foreground: var(--color-emerald-700); |
| 876 | 908 | --warning: var(--color-amber-500); |
| 877 | 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 | 914 | /* Keep every sidebar primitive on the same light surface hierarchy, including |
| 879 | 915 | portaled mobile sheets and settings navigation outside the app sidebar. */ |
| 880 | 916 | --sidebar: var(--color-zinc-50); |
| ... | ... | @@ -886,6 +922,14 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 886 | 922 | --sidebar-row-selected: var(--color-white); |
| 887 | 923 | --sidebar-border: var(--border); |
| 888 | 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%); | |
| 889 | 933 | |
| 890 | 934 | @variant dark { |
| 891 | 935 | color-scheme: dark; |
| ... | ... | @@ -905,19 +949,34 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 905 | 949 | --secondary-foreground: var(--color-neutral-100); |
| 906 | 950 | --muted: --alpha(var(--color-white) / 4%); |
| 907 | 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 | 960 | --accent: --alpha(var(--color-white) / 4%); |
| 909 | 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 | 966 | --border: --alpha(var(--color-white) / 6%); |
| 912 | 967 | --input: --alpha(var(--color-white) / 8%); |
| 913 | 968 | --ring: oklch(0.588 0.217 264); |
| 914 | --destructive-foreground: var(--color-red-400); | |
| 969 | --destructive-foreground: var(--error-foreground); | |
| 915 | 970 | --info: var(--color-blue-500); |
| 916 | 971 | --info-foreground: var(--color-blue-400); |
| 917 | 972 | --success: var(--color-emerald-500); |
| 918 | 973 | --success-foreground: var(--color-emerald-400); |
| 919 | 974 | --warning: var(--color-amber-500); |
| 920 | 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 | 980 | --sidebar: var(--card); |
| 922 | 981 | --sidebar-foreground: var(--foreground); |
| 923 | 982 | --sidebar-muted-foreground: var(--muted-foreground); |
| ... | ... | @@ -927,6 +986,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 927 | 986 | --sidebar-row-selected: var(--muted); |
| 928 | 987 | --sidebar-border: var(--border); |
| 929 | 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 | } |
| 932 | 997 | |
| ... | ... | @@ -984,6 +1049,335 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 984 | 1049 | --sidebar-stage-fade: var(--card); |
| 985 | 1050 | } |
| 986 | 1051 | |
| 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. */ | |
| 1057 | html[data-theme-id], | |
| 1058 | html.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. */ | |
| 1129 | html[data-theme-id] .chat-composer-glass-shell { | |
| 1130 | --chat-composer-glass-surface: var(--app-theme-surface-raised); | |
| 1131 | } | |
| 1132 | ||
| 1133 | html[data-theme-id]:not(.dark) .chat-composer-glass-shell { | |
| 1134 | --chat-composer-outline: var(--app-theme-toolbar-border); | |
| 1135 | } | |
| 1136 | ||
| 1137 | html.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 | ||
| 1142 | html.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 | ||
| 1149 | html[data-theme-id]:not(.dark) { | |
| 1150 | color-scheme: light; | |
| 1151 | } | |
| 1152 | ||
| 1153 | html.dark[data-theme-id] { | |
| 1154 | color-scheme: dark; | |
| 1155 | } | |
| 1156 | ||
| 1157 | html[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. */ | |
| 1164 | html[data-theme-token-probe], | |
| 1165 | html[data-theme-token-probe] *, | |
| 1166 | html[data-theme-token-probe] *::before, | |
| 1167 | html[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. */ | |
| 1238 | html[data-theme-id] [data-chat-header] { | |
| 1239 | background-color: var(--toolbar-background); | |
| 1240 | color: var(--toolbar-foreground); | |
| 1241 | } | |
| 1242 | ||
| 1243 | html[data-theme-id] [data-chat-header] [data-slot="button"], | |
| 1244 | html[data-theme-id] [data-chat-header] [data-slot="menu-trigger"], | |
| 1245 | html[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. */ | |
| 1257 | html[data-theme-id] [data-panel-layout-controls] [data-slot="toggle"], | |
| 1258 | html[data-theme-id] [data-panel-layout-controls] [data-slot="tooltip-trigger"], | |
| 1259 | html[data-theme-id] .workspace-titlebar-controls [data-slot="toggle"], | |
| 1260 | html[data-theme-id] .workspace-titlebar-controls [data-slot="tooltip-trigger"] { | |
| 1261 | --control-icon-color: var(--toolbar-foreground); | |
| 1262 | color: var(--toolbar-foreground); | |
| 1263 | } | |
| 1264 | ||
| 1265 | html[data-theme-id] [data-chat-header] [data-slot="button"]:hover, | |
| 1266 | html[data-theme-id] [data-chat-header] [data-slot="button"][data-pressed], | |
| 1267 | html[data-theme-id] [data-chat-header] [data-slot="menu-trigger"]:hover, | |
| 1268 | html[data-theme-id] [data-chat-header] [data-slot="menu-trigger"][data-pressed], | |
| 1269 | html[data-theme-id] [data-chat-header] [data-toolbar-control]:hover, | |
| 1270 | html[data-theme-id] [data-chat-header] [data-toolbar-control][data-pressed] { | |
| 1271 | background-color: var(--toolbar-control-hover); | |
| 1272 | } | |
| 1273 | ||
| 1274 | html[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. */ | |
| 1281 | html[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 | ||
| 1287 | html[data-theme-id] .chat-markdown pre { | |
| 1288 | background-color: var(--code-background); | |
| 1289 | color: var(--code-foreground); | |
| 1290 | } | |
| 1291 | ||
| 1292 | html[data-theme-id] .chat-markdown .chat-markdown-codeblock pre { | |
| 1293 | background-color: transparent; | |
| 1294 | } | |
| 1295 | ||
| 1296 | html[data-theme-id] .chat-markdown .chat-markdown-codeblock-header, | |
| 1297 | html[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. */ | |
| 1304 | html[data-theme-id="t3-chat"]:not(.dark) .chat-markdown :not(pre) > code, | |
| 1305 | html[data-theme-id="t3-chat"]:not(.dark) [data-changed-files-state], | |
| 1306 | html[data-theme-id="t3-chat"]:not(.dark) [data-changed-files-header] { | |
| 1307 | background-color: var(--message-surface); | |
| 1308 | } | |
| 1309 | ||
| 1310 | html[data-theme-id="t3-chat"]:not(.dark) .chat-markdown :not(pre) > code, | |
| 1311 | html[data-theme-id="t3-chat"]:not(.dark) [data-changed-files-state] { | |
| 1312 | border-color: transparent; | |
| 1313 | } | |
| 1314 | ||
| 1315 | html[data-theme-id="t3-chat"]:not(.dark) .chat-markdown :not(pre) > code { | |
| 1316 | color: var(--message-foreground); | |
| 1317 | } | |
| 1318 | ||
| 1319 | html[data-theme-id] .chat-markdown .chat-markdown-chrome-action:hover, | |
| 1320 | html[data-theme-id] .chat-markdown .chat-markdown-chrome-action[aria-pressed="true"] { | |
| 1321 | color: var(--code-foreground); | |
| 1322 | } | |
| 1323 | ||
| 1324 | html[data-theme-id] [data-sidebar-version="v1"], | |
| 1325 | html[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. */ | |
| 1351 | html[data-theme-id] [data-app-sidebar] { | |
| 1352 | border-color: color-mix(in srgb, var(--sidebar-foreground) 10%, transparent); | |
| 1353 | } | |
| 1354 | ||
| 1355 | html.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. */ | |
| 1361 | html.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 | ||
| 987 | 1381 | body { |
| 988 | 1382 | /* Reference the theme token (not a literal stack) so the Settings -> |
| 989 | 1383 | Appearance runtime override of --font-sans reaches all interface text. */ |
| ... | ... | @@ -1028,12 +1422,21 @@ body { |
| 1028 | 1422 | grain's subtle brightening and stand out against the body. */ |
| 1029 | 1423 | :root { |
| 1030 | 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. */ | |
| 1431 | html[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 | } |
| 1032 | 1435 | |
| 1033 | 1436 | body { |
| 1034 | 1437 | background-image: var(--surface-grain); |
| 1035 | 1438 | background-repeat: repeat; |
| 1036 | background-size: 256px 256px; | |
| 1439 | background-size: var(--surface-grain-size); | |
| 1037 | 1440 | } |
| 1038 | 1441 | |
| 1039 | 1442 | pre, |
| ... | ... | @@ -1584,15 +1987,34 @@ label:has(> select#reasoning-effort) select { |
| 1584 | 1987 | |
| 1585 | 1988 | /* Diffs theme bridge (match diff surfaces to app palette) */ |
| 1586 | 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 | } |
| 1589 | 1999 | |
| 1590 | 2000 | .diff-render-file, |
| 1591 | .diff-render-surface > diffs-container { | |
| 1592 | border: 1px solid var(--border); | |
| 1593 | border-radius: 0.5rem; | |
| 2001 | .diff-render-surface diffs-container { | |
| 2002 | border: 0; | |
| 2003 | border-radius: 0; | |
| 1594 | 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 | } |
| 1597 | 2019 | |
| 1598 | 2020 | @keyframes ultrathink-rainbow { |
apps/web/src/keybindings.test.ts+23| ... | ... | @@ -128,6 +128,10 @@ const DEFAULT_BINDINGS = compile([ |
| 128 | 128 | command: "projectSearch.toggle", |
| 129 | 129 | whenAst: whenNot(whenIdentifier("terminalFocus")), |
| 130 | 130 | }, |
| 131 | { | |
| 132 | shortcut: modShortcut("t", { altKey: true, shiftKey: true }), | |
| 133 | command: "themeEditor.toggle", | |
| 134 | }, | |
| 131 | 135 | { |
| 132 | 136 | shortcut: modShortcut("m", { shiftKey: true }), |
| 133 | 137 | command: "modelPicker.toggle", |
| ... | ... | @@ -566,6 +570,25 @@ describe("chat/editor shortcuts", () => { |
| 566 | 570 | ); |
| 567 | 571 | }); |
| 568 | 572 | |
| 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 | 592 | it("matches diff.toggle shortcut outside terminal focus", () => { |
| 570 | 593 | assert.isTrue( |
| 571 | 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 | 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 | */ | |
| 182 | export 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 | 19 | import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; |
| 20 | 20 | import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; |
| 21 | 21 | import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; |
| 22 | import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; | |
| 22 | 23 | import { Button } from "../components/ui/button"; |
| 23 | 24 | import { |
| 24 | 25 | AnchoredToastProvider, |
| ... | ... | @@ -139,6 +140,9 @@ function RootRouteView() { |
| 139 | 140 | {primaryEnvironmentAuthenticated ? <EventRouter /> : null} |
| 140 | 141 | {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null} |
| 141 | 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 | 146 | </AnchoredToastProvider> |
| 143 | 147 | </ToastProvider> |
| 144 | 148 | ); |
apps/web/src/themeBoot.test.ts created+455| ... | ... | @@ -0,0 +1,455 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | import indexHtml from "../index.html?raw"; | |
| 4 | import { | |
| 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 | ||
| 20 | const 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. | |
| 23 | const DEFAULT_DARK_CHROME = getDefaultThemeColors("dark").chrome; | |
| 24 | ||
| 25 | const 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 | ||
| 31 | type 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 | ||
| 40 | function 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. */ | |
| 99 | function 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 | ||
| 131 | const 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 | }; | |
| 138 | const CHARCOAL_DARK_ONLY = { | |
| 139 | id: "charcoal", | |
| 140 | label: "Charcoal", | |
| 141 | appearance: "dark", | |
| 142 | colors: { canvas: "#1c1210", text: "#ffe8d9", accent: "#ff7a45" }, | |
| 143 | }; | |
| 144 | ||
| 145 | describe("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 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 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 | ||
| 32 | function 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 | ||
| 48 | describe("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 | ||
| 410 | describe("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 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | ||
| 3 | export const T3_CHAT_THEME_ID = "t3-chat" as const; | |
| 4 | export const T3_CHAT_THEME_LABEL = "T3 Chat"; | |
| 5 | export const GROVE_THEME_ID = "grove" as const; | |
| 6 | export const GROVE_THEME_LABEL = "Grove"; | |
| 7 | export const OCEAN_THEME_ID = "ocean" as const; | |
| 8 | export const OCEAN_THEME_LABEL = "Ocean"; | |
| 9 | export const EMBER_THEME_ID = "ember" as const; | |
| 10 | export const EMBER_THEME_LABEL = "Ember"; | |
| 11 | export const IRIS_THEME_ID = "iris" as const; | |
| 12 | export const IRIS_THEME_LABEL = "Iris"; | |
| 13 | export const THEME_FILE_VERSION = 1 as const; | |
| 14 | export const CUSTOM_THEMES_STORAGE_KEY = "t3code:themes:v1"; | |
| 15 | export const THEME_FOLLOW_SYSTEM_STORAGE_KEY = "t3code:theme-follow-system"; | |
| 16 | export const THEME_APPEARANCE_MODE_STORAGE_KEY = "t3code:theme-appearance-mode"; | |
| 17 | export const THEME_HALVES_STORAGE_KEY = "t3code:theme-halves:v1"; | |
| 18 | ||
| 19 | const LEGACY_T3_CHAT_DARK_THEME_ID = "t3-chat-dark"; | |
| 20 | ||
| 21 | export const ThemePreference = Schema.String; | |
| 22 | export type ThemePreference = typeof ThemePreference.Type; | |
| 23 | ||
| 24 | export 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 | ||
| 84 | export type ThemeColorRole = (typeof THEME_COLOR_ROLES)[number]; | |
| 85 | const THEME_COLOR_ROLE_SET: ReadonlySet<string> = new Set(THEME_COLOR_ROLES); | |
| 86 | export type ThemeAppearance = "light" | "dark"; | |
| 87 | ||
| 88 | export type ThemeColors = Readonly<Record<ThemeColorRole, string>>; | |
| 89 | export type ThemeColorOverrides = Readonly<Partial<Record<ThemeColorRole, string>>>; | |
| 90 | export type ThemeVariants = Readonly<Partial<Record<ThemeAppearance, ThemeColors>>>; | |
| 91 | export type ThemeVariantOverrides = Readonly<Partial<Record<ThemeAppearance, ThemeColorOverrides>>>; | |
| 92 | export type ThemePreferenceMode = ThemeAppearance | "system"; | |
| 93 | export 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 | }>; | |
| 103 | export 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 | ||
| 113 | const 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 | ||
| 129 | const customThemeListeners = new Set<() => void>(); | |
| 130 | let customThemesSnapshot: ReadonlyArray<ThemeDefinition> | null = null; | |
| 131 | ||
| 132 | function isRecord(value: unknown): value is Record<string, unknown> { | |
| 133 | return typeof value === "object" && value !== null && !Array.isArray(value); | |
| 134 | } | |
| 135 | ||
| 136 | function isThemeAppearance(value: unknown): value is ThemeAppearance { | |
| 137 | return value === "light" || value === "dark"; | |
| 138 | } | |
| 139 | ||
| 140 | export 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 | ||
| 147 | function isThemeId(value: unknown): value is string { | |
| 148 | return typeof value === "string" && /^[a-z0-9](?:[a-z0-9-]{0,47})$/.test(value); | |
| 149 | } | |
| 150 | ||
| 151 | function isThemeLabel(value: unknown): value is string { | |
| 152 | return typeof value === "string" && value.trim().length > 0 && value.trim().length <= 48; | |
| 153 | } | |
| 154 | ||
| 155 | function 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 | ||
| 171 | function 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 | ||
| 191 | function 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 | ||
| 210 | function 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 | ||
| 232 | function notifyCustomThemeListeners() { | |
| 233 | for (const listener of customThemeListeners) listener(); | |
| 234 | } | |
| 235 | ||
| 236 | export function invalidateCustomThemes() { | |
| 237 | customThemesSnapshot = null; | |
| 238 | notifyCustomThemeListeners(); | |
| 239 | } | |
| 240 | ||
| 241 | export function getCustomThemes(): ReadonlyArray<ThemeDefinition> { | |
| 242 | if (customThemesSnapshot === null) { | |
| 243 | customThemesSnapshot = readCustomThemesFromStorage(); | |
| 244 | } | |
| 245 | return customThemesSnapshot; | |
| 246 | } | |
| 247 | ||
| 248 | export 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. | |
| 269 | const 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 | ||
| 277 | function 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 | */ | |
| 286 | export function canonicalThemePreference(theme: string): string { | |
| 287 | return theme === LEGACY_T3_CHAT_DARK_THEME_ID ? theme : normalizeThemeId(theme); | |
| 288 | } | |
| 289 | ||
| 290 | function 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. | |
| 296 | function 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. | |
| 307 | const 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 | ||
| 375 | const 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 | */ | |
| 455 | const 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 | ||
| 515 | const 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 | */ | |
| 581 | export function getStandardThemeColors(appearance: ThemeAppearance): ThemeColors { | |
| 582 | return appearance === "dark" ? T3_CODE_DARK_THEME_COLORS : T3_CODE_LIGHT_THEME_COLORS; | |
| 583 | } | |
| 584 | ||
| 585 | type ThemeRgbColor = { | |
| 586 | r: number; | |
| 587 | g: number; | |
| 588 | b: number; | |
| 589 | }; | |
| 590 | ||
| 591 | type ThemeHslColor = { | |
| 592 | h: number; | |
| 593 | s: number; | |
| 594 | l: number; | |
| 595 | }; | |
| 596 | ||
| 597 | const THEME_LIGHT_FOREGROUND: ThemeRgbColor = { r: 255, g: 250, b: 255 }; | |
| 598 | const THEME_DARK_FOREGROUND: ThemeRgbColor = { r: 36, g: 21, b: 35 }; | |
| 599 | const THEME_WHITE_FOREGROUND: ThemeRgbColor = { r: 255, g: 255, b: 255 }; | |
| 600 | const THEME_BLACK_FOREGROUND: ThemeRgbColor = { r: 0, g: 0, b: 0 }; | |
| 601 | ||
| 602 | function 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 | ||
| 625 | function 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 | ||
| 635 | function 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 | ||
| 655 | function 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 | ||
| 677 | function 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 | ||
| 689 | function 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 | ||
| 703 | type ThemeOklch = { L: number; C: number; h: number }; | |
| 704 | ||
| 705 | function 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 | ||
| 710 | function 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 | ||
| 715 | function 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 | ||
| 728 | function 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. */ | |
| 743 | function 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. */ | |
| 768 | function 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 | */ | |
| 799 | const 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 | */ | |
| 819 | function 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 | */ | |
| 868 | export 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 | ||
| 1001 | function 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 | ||
| 1009 | function 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 | ||
| 1022 | function 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. | |
| 1054 | const STANDARD_LIGHT_MUTED_CONTRAST = 4.705; | |
| 1055 | const STANDARD_DARK_MUTED_CONTRAST = 5.082; | |
| 1056 | ||
| 1057 | function 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 | ||
| 1068 | function 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 | ||
| 1086 | function 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 | */ | |
| 1126 | export 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 | ||
| 1266 | export 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. */ | |
| 1277 | export 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 | */ | |
| 1286 | function 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 | ||
| 1302 | export 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 | ||
| 1318 | export 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 | ||
| 1334 | export 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 | ||
| 1350 | export 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 | ||
| 1366 | const BUILT_IN_THEME_DEFINITIONS: ReadonlyArray<ThemeDefinition> = [ | |
| 1367 | T3_CHAT_THEME, | |
| 1368 | GROVE_THEME, | |
| 1369 | OCEAN_THEME, | |
| 1370 | EMBER_THEME, | |
| 1371 | IRIS_THEME, | |
| 1372 | ]; | |
| 1373 | ||
| 1374 | export 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 | ||
| 1383 | export 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 | ||
| 1391 | export function getThemeModes(theme: ThemeDefinition): ReadonlyArray<ThemeAppearance> { | |
| 1392 | return (["light", "dark"] as const).filter((mode) => getThemeColorsForMode(theme, mode) !== null); | |
| 1393 | } | |
| 1394 | ||
| 1395 | export 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 | ||
| 1403 | export 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 | ||
| 1413 | export 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 | ||
| 1422 | export const isThemeLibraryStorageError = Schema.is(ThemeLibraryStorageError); | |
| 1423 | ||
| 1424 | function 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 | ||
| 1435 | export 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 | ||
| 1450 | export 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 | ||
| 1467 | export 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 | ||
| 1473 | function 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 | ||
| 1492 | export 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 | ||
| 1548 | export 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 | ||
| 1561 | const 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 | ||
| 1621 | export 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. */ | |
| 1626 | export 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 | */ | |
| 1633 | export 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 | ||
| 1646 | export 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 | ||
| 1670 | export 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 | ||
| 1698 | export 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 | ||
| 1726 | export 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 | */ | |
| 1736 | export type ThemeHalves = Readonly<{ light?: string; dark?: string }>; | |
| 1737 | ||
| 1738 | export 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. */ | |
| 1761 | export 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { getThemeColorsForMode, THEME_FILE_VERSION } from "./themePalette"; | |
| 4 | import { | |
| 5 | isVsCodeThemeFile, | |
| 6 | pairVsCodeThemes, | |
| 7 | parseVsCodeThemeFile, | |
| 8 | resolveThemeLabelCollisions, | |
| 9 | } from "./vscodeThemeImport"; | |
| 10 | ||
| 11 | function 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. | |
| 29 | const 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 | ||
| 52 | describe("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 @@ |
| 1 | import { | |
| 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 | ||
| 22 | type VsCodeRgba = { r: number; g: number; b: number; a: number }; | |
| 23 | type VsCodeRgb = { r: number; g: number; b: number }; | |
| 24 | ||
| 25 | function 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. */ | |
| 30 | function decodeGamma(value: number): number { | |
| 31 | return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; | |
| 32 | } | |
| 33 | ||
| 34 | function 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 | */ | |
| 44 | function 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. */ | |
| 83 | function 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 | ||
| 107 | function 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. */ | |
| 117 | function 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 | ||
| 126 | function 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 | ||
| 134 | function 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 | ||
| 140 | function 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 | */ | |
| 148 | export 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 | ||
| 156 | function 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. */ | |
| 165 | export 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 | ||
| 175 | function 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 | ||
| 186 | export 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 | */ | |
| 337 | export 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 | */ | |
| 400 | export 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 | 40 | `filePicker.toggle` opens file search for the active project and defaults to `mod+p`. |
| 41 | 41 | `projectSearch.toggle` searches inside the active project's files and defaults to `mod+shift+f`. |
| 42 | 42 | Repeating 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 | |
| 45 | again to clear the spotlight. The swatch and hex field keep that color selected while you edit it. | |
| 46 | Use **Inspect** to pick an element in the app and reveal its color token. Inspect disarms after one | |
| 47 | successful 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. | |
| 43 | 49 | |
| 44 | 50 | The command palette searches active thread titles, projects, branches, user messages, and final |
| 45 | 51 | agent 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 | 440 | targetEnvironmentId: Schema.optionalKey(Schema.String), |
| 441 | 441 | }); |
| 442 | 442 | |
| 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 | */ | |
| 448 | export interface PickedThemeFile { | |
| 449 | name: string; | |
| 450 | size: number; | |
| 451 | text: string; | |
| 452 | } | |
| 453 | ||
| 454 | export const PickedThemeFileSchema = Schema.Struct({ | |
| 455 | name: Schema.String, | |
| 456 | size: Schema.Number, | |
| 457 | text: Schema.String, | |
| 458 | }); | |
| 459 | ||
| 443 | 460 | export interface DesktopWslDistro { |
| 444 | 461 | name: string; |
| 445 | 462 | isDefault: boolean; |
| ... | ... | @@ -1019,6 +1036,12 @@ export interface DesktopBridge { |
| 1019 | 1036 | setWslDistro: (distro: string | null) => Promise<DesktopWslState>; |
| 1020 | 1037 | setWslOnly: (enabled: boolean) => Promise<DesktopWslState>; |
| 1021 | 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 | 1045 | confirm: (message: string) => Promise<boolean>; |
| 1023 | 1046 | setTheme: (theme: DesktopTheme) => Promise<void>; |
| 1024 | 1047 | showContextMenu: <T extends string>( |
packages/contracts/src/keybindings.test.ts+6| ... | ... | @@ -72,6 +72,12 @@ it.effect("parses keybinding rules", () => |
| 72 | 72 | }); |
| 73 | 73 | assert.strictEqual(parsedProjectSearch.command, "projectSearch.toggle"); |
| 74 | 74 | |
| 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 | 81 | const parsedLocal = yield* decode(KeybindingRule, { |
| 76 | 82 | key: "mod+shift+n", |
| 77 | 83 | command: "chat.newLocal", |
packages/contracts/src/keybindings.ts+1| ... | ... | @@ -65,6 +65,7 @@ const STATIC_KEYBINDING_COMMANDS = [ |
| 65 | 65 | "commandPalette.toggle", |
| 66 | 66 | "filePicker.toggle", |
| 67 | 67 | "projectSearch.toggle", |
| 68 | "themeEditor.toggle", | |
| 68 | 69 | "composer.stash", |
| 69 | 70 | "chat.new", |
| 70 | 71 | "chat.newLocal", |
packages/shared/src/keybindings.ts+1| ... | ... | @@ -37,6 +37,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ |
| 37 | 37 | { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, |
| 38 | 38 | { key: "mod+p", command: "filePicker.toggle", when: "!terminalFocus" }, |
| 39 | 39 | { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" }, |
| 40 | { key: "mod+alt+shift+t", command: "themeEditor.toggle" }, | |
| 40 | 41 | { key: "mod+s", command: "composer.stash", when: "!terminalFocus" }, |
| 41 | 42 | { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, |
| 42 | 43 | { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, |