| 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,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 | export class ElectronDialogConfirmError extends Schema.TaggedErrorClass<ElectronDialogConfirmError>()( | 41 | export class ElectronDialogConfirmError extends Schema.TaggedErrorClass<ElectronDialogConfirmError>()( |
| 27 | "ElectronDialogConfirmError", | 42 | "ElectronDialogConfirmError", |
| 28 | { | 43 | { |
| ... | @@ -69,6 +84,7 @@ export class ElectronDialogShowErrorBoxError extends Schema.TaggedErrorClass<Ele | ... | @@ -69,6 +84,7 @@ export class ElectronDialogShowErrorBoxError extends Schema.TaggedErrorClass<Ele |
| 69 | 84 | ||
| 70 | export const ElectronDialogError = Schema.Union([ | 85 | export const ElectronDialogError = Schema.Union([ |
| 71 | ElectronDialogPickFolderError, | 86 | ElectronDialogPickFolderError, |
| 87 | ElectronDialogPickFilesError, | ||
| 72 | ElectronDialogConfirmError, | 88 | ElectronDialogConfirmError, |
| 73 | ElectronDialogShowMessageBoxError, | 89 | ElectronDialogShowMessageBoxError, |
| 74 | ElectronDialogShowErrorBoxError, | 90 | ElectronDialogShowErrorBoxError, |
| ... | @@ -81,6 +97,12 @@ export interface ElectronDialogPickFolderInput { | ... | @@ -81,6 +97,12 @@ export interface ElectronDialogPickFolderInput { |
| 81 | readonly defaultPath: Option.Option<string>; | 97 | readonly defaultPath: Option.Option<string>; |
| 82 | } | 98 | } |
| 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 | export interface ElectronDialogConfirmInput { | 106 | export interface ElectronDialogConfirmInput { |
| 85 | readonly owner: Option.Option<Electron.BrowserWindow>; | 107 | readonly owner: Option.Option<Electron.BrowserWindow>; |
| 86 | readonly message: string; | 108 | readonly message: string; |
| ... | @@ -92,6 +114,9 @@ export class ElectronDialog extends Context.Service< | ... | @@ -92,6 +114,9 @@ export class ElectronDialog extends Context.Service< |
| 92 | readonly pickFolder: ( | 114 | readonly pickFolder: ( |
| 93 | input: ElectronDialogPickFolderInput, | 115 | input: ElectronDialogPickFolderInput, |
| 94 | ) => Effect.Effect<Option.Option<string>, ElectronDialogPickFolderError>; | 116 | ) => Effect.Effect<Option.Option<string>, ElectronDialogPickFolderError>; |
| 117 | readonly pickFiles: ( | ||
| 118 | input: ElectronDialogPickFilesInput, | ||
| 119 | ) => Effect.Effect<readonly string[], ElectronDialogPickFilesError>; | ||
| 95 | readonly confirm: ( | 120 | readonly confirm: ( |
| 96 | input: ElectronDialogConfirmInput, | 121 | input: ElectronDialogConfirmInput, |
| 97 | ) => Effect.Effect<boolean, ElectronDialogConfirmError>; | 122 | ) => Effect.Effect<boolean, ElectronDialogConfirmError>; |
| ... | @@ -137,6 +162,32 @@ export const make = ElectronDialog.of({ | ... | @@ -137,6 +162,32 @@ export const make = ElectronDialog.of({ |
| 137 | } | 162 | } |
| 138 | return Option.fromNullishOr(result.filePaths[0]); | 163 | return Option.fromNullishOr(result.filePaths[0]); |
| 139 | }), | 164 | }), |
| 165 | pickFiles: Effect.fn("desktop.electron.dialog.pickFiles")(function* (input) { | ||
| 166 | const ownerWindowId = Option.match(input.owner, { | ||
| 167 | onNone: () => null, | ||
| 168 | onSome: (owner) => owner.id, | ||
| 169 | }); | ||
| 170 | const defaultPath = Option.getOrNull(input.defaultPath); | ||
| 171 | const openDialogOptions: Electron.OpenDialogOptions = { | ||
| 172 | properties: ["openFile", "multiSelections"], | ||
| 173 | filters: [...input.filters], | ||
| 174 | ...(defaultPath === null ? {} : { defaultPath }), | ||
| 175 | }; | ||
| 176 | const result = yield* Effect.tryPromise({ | ||
| 177 | try: () => | ||
| 178 | Option.match(input.owner, { | ||
| 179 | onNone: () => Electron.dialog.showOpenDialog(openDialogOptions), | ||
| 180 | onSome: (owner) => Electron.dialog.showOpenDialog(owner, openDialogOptions), | ||
| 181 | }), | ||
| 182 | catch: (cause) => | ||
| 183 | new ElectronDialogPickFilesError({ | ||
| 184 | ownerWindowId, | ||
| 185 | defaultPath, | ||
| 186 | cause, | ||
| 187 | }), | ||
| 188 | }); | ||
| 189 | return result.canceled ? [] : result.filePaths; | ||
| 190 | }), | ||
| 140 | confirm: Effect.fn("desktop.electron.dialog.confirm")(function* (input) { | 191 | confirm: Effect.fn("desktop.electron.dialog.confirm")(function* (input) { |
| 141 | const normalizedMessage = input.message.trim(); | 192 | const normalizedMessage = input.message.trim(); |
| 142 | if (normalizedMessage.length === 0) { | 193 | if (normalizedMessage.length === 0) { |
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2| ... | @@ -38,6 +38,7 @@ import { | ... | @@ -38,6 +38,7 @@ import { |
| 38 | getWindowFullscreenState, | 38 | getWindowFullscreenState, |
| 39 | openExternal, | 39 | openExternal, |
| 40 | pickFolder, | 40 | pickFolder, |
| 41 | pickThemeFiles, | ||
| 41 | setTheme, | 42 | setTheme, |
| 42 | showContextMenu, | 43 | showContextMenu, |
| 43 | } from "./methods/window.ts"; | 44 | } from "./methods/window.ts"; |
| ... | @@ -79,6 +80,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" | ... | @@ -79,6 +80,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" |
| 79 | yield* ipc.handle(setWslOnly); | 80 | yield* ipc.handle(setWslOnly); |
| 80 | 81 | ||
| 81 | yield* ipc.handle(pickFolder); | 82 | yield* ipc.handle(pickFolder); |
| 83 | yield* ipc.handle(pickThemeFiles); | ||
| 82 | yield* ipc.handle(confirm); | 84 | yield* ipc.handle(confirm); |
| 83 | yield* ipc.handle(setTheme); | 85 | yield* ipc.handle(setTheme); |
| 84 | yield* ipc.handle(showContextMenu); | 86 | yield* ipc.handle(showContextMenu); |
apps/desktop/src/ipc/channels.ts+1| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; | 1 | export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; |
| 2 | export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; | ||
| 2 | export const CONFIRM_CHANNEL = "desktop:confirm"; | 3 | export const CONFIRM_CHANNEL = "desktop:confirm"; |
| 3 | export const SET_THEME_CHANNEL = "desktop:set-theme"; | 4 | export const SET_THEME_CHANNEL = "desktop:set-theme"; |
| 4 | export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; | 5 | export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
apps/desktop/src/ipc/methods/window.ts+51| ... | @@ -3,10 +3,15 @@ import { | ... | @@ -3,10 +3,15 @@ import { |
| 3 | DesktopAppBrandingSchema, | 3 | DesktopAppBrandingSchema, |
| 4 | DesktopEnvironmentBootstrapSchema, | 4 | DesktopEnvironmentBootstrapSchema, |
| 5 | DesktopThemeSchema, | 5 | DesktopThemeSchema, |
| 6 | PickedThemeFileSchema, | ||
| 6 | PickFolderOptionsSchema, | 7 | PickFolderOptionsSchema, |
| 7 | PRIMARY_LOCAL_ENVIRONMENT_ID, | 8 | PRIMARY_LOCAL_ENVIRONMENT_ID, |
| 8 | type DesktopEnvironmentBootstrap, | 9 | type DesktopEnvironmentBootstrap, |
| 10 | type PickedThemeFile, | ||
| 9 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 12 | import * as NodeOS from "node:os"; | ||
| 13 | import * as FileSystem from "effect/FileSystem"; | ||
| 14 | import * as Path from "effect/Path"; | ||
| 10 | import * as Effect from "effect/Effect"; | 15 | import * as Effect from "effect/Effect"; |
| 11 | import * as Option from "effect/Option"; | 16 | import * as Option from "effect/Option"; |
| 12 | import * as Schema from "effect/Schema"; | 17 | import * as Schema from "effect/Schema"; |
| ... | @@ -268,3 +273,49 @@ export const openExternal = DesktopIpc.makeIpcMethod({ | ... | @@ -268,3 +273,49 @@ export const openExternal = DesktopIpc.makeIpcMethod({ |
| 268 | return yield* shell.openExternal(url); | 273 | return yield* shell.openExternal(url); |
| 269 | }), | 274 | }), |
| 270 | }); | 275 | }); |
| 276 | |||
| 277 | /** Theme files are a few KB; anything larger returns empty text and lets the | ||
| 278 | * renderer reject it by size without the contents ever crossing the bridge. */ | ||
| 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,6 +97,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 97 | setWslDistro: (distro) => ipcRenderer.invoke(IpcChannels.SET_WSL_DISTRO_CHANNEL, distro), | 97 | setWslDistro: (distro) => ipcRenderer.invoke(IpcChannels.SET_WSL_DISTRO_CHANNEL, distro), |
| 98 | setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled), | 98 | setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled), |
| 99 | pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), | 99 | pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), |
| 100 | pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined), | ||
| 100 | confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message), | 101 | confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message), |
| 101 | setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), | 102 | setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), |
| 102 | showContextMenu: (items, position) => | 103 | showContextMenu: (items, position) => |
apps/desktop/src/window/DesktopApplicationMenu.test.ts+1| ... | @@ -52,6 +52,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { | ... | @@ -52,6 +52,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { |
| 52 | 52 | ||
| 53 | const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { | 53 | const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { |
| 54 | pickFolder: () => Effect.succeed(Option.none()), | 54 | pickFolder: () => Effect.succeed(Option.none()), |
| 55 | pickFiles: () => Effect.succeed([]), | ||
| 55 | confirm: () => Effect.succeed(false), | 56 | confirm: () => Effect.succeed(false), |
| 56 | showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }), | 57 | showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }), |
| 57 | showErrorBox: () => Effect.void, | 58 | showErrorBox: () => Effect.void, |
apps/server/src/keybindings.test.ts+1| ... | @@ -198,6 +198,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { | ... | @@ -198,6 +198,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { |
| 198 | assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); | 198 | assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); |
| 199 | assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); | 199 | assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); |
| 200 | assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); | 200 | assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); |
| 201 | assert.equal(defaultsByCommand.get("themeEditor.toggle"), "mod+alt+shift+t"); | ||
| 201 | assert.equal(defaultsByCommand.get("filePicker.toggle"), "mod+p"); | 202 | assert.equal(defaultsByCommand.get("filePicker.toggle"), "mod+p"); |
| 202 | assert.equal(defaultsByCommand.get("projectSearch.toggle"), "mod+shift+f"); | 203 | assert.equal(defaultsByCommand.get("projectSearch.toggle"), "mod+shift+f"); |
| 203 | assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); | 204 | assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); |
apps/web/index.html+371-13| ... | @@ -6,8 +6,6 @@ | ... | @@ -6,8 +6,6 @@ |
| 6 | name="viewport" | 6 | name="viewport" |
| 7 | content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" | 7 | content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" |
| 8 | /> | 8 | /> |
| 9 | <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" /> | ||
| 10 | <meta name="theme-color" content="#0a0a0a" media="(prefers-color-scheme: dark)" /> | ||
| 11 | <meta name="theme-color" content="#0a0a0a" /> | 9 | <meta name="theme-color" content="#0a0a0a" /> |
| 12 | <link rel="icon" href="/favicon.ico" sizes="48x48" /> | 10 | <link rel="icon" href="/favicon.ico" sizes="48x48" /> |
| 13 | <link rel="apple-touch-icon" href="/apple-touch-icon.png" /> | 11 | <link rel="apple-touch-icon" href="/apple-touch-icon.png" /> |
| ... | @@ -15,23 +13,373 @@ | ... | @@ -15,23 +13,373 @@ |
| 15 | (() => { | 13 | (() => { |
| 16 | const LIGHT_BACKGROUND = "#ffffff"; | 14 | const LIGHT_BACKGROUND = "#ffffff"; |
| 17 | const DARK_BACKGROUND = "#0a0a0a"; | 15 | const DARK_BACKGROUND = "#0a0a0a"; |
| 18 | const themeColorMeta = document.querySelector('meta[name="theme-color"]'); | 16 | const CUSTOM_THEMES_STORAGE_KEY = "t3code:themes:v1"; |
| 17 | const THEME_FOLLOW_SYSTEM_STORAGE_KEY = "t3code:theme-follow-system"; | ||
| 18 | const THEME_APPEARANCE_MODE_STORAGE_KEY = "t3code:theme-appearance-mode"; | ||
| 19 | const THEME_HALVES_STORAGE_KEY = "t3code:theme-halves:v1"; | ||
| 20 | const SPLASH_COLORS = { | ||
| 21 | light: { | ||
| 22 | background: "#ffffff", | ||
| 23 | foreground: "#262626", | ||
| 24 | accent: "#4f46e5", | ||
| 25 | }, | ||
| 26 | dark: { | ||
| 27 | background: "#0a0a0a", | ||
| 28 | foreground: "#f5f5f5", | ||
| 29 | accent: "#818cf8", | ||
| 30 | }, | ||
| 31 | }; | ||
| 32 | // These are the runtime defaults used to complete a custom palette | ||
| 33 | // when a stored theme omits a role. Keep them separate from the | ||
| 34 | // unselected splash so a partial custom theme does not flash generic | ||
| 35 | // app colors before React mounts. | ||
| 36 | const DEFAULT_THEME_PALETTES = { | ||
| 37 | light: { | ||
| 38 | background: "#fdf7fd", | ||
| 39 | foreground: "#501854", | ||
| 40 | accent: "#e33f86", | ||
| 41 | chrome: "#fdf7fd", | ||
| 42 | }, | ||
| 43 | dark: { | ||
| 44 | background: "#1f1a24", | ||
| 45 | foreground: "#f9f8fb", | ||
| 46 | accent: "#a3004c", | ||
| 47 | chrome: "#1f1a24", | ||
| 48 | }, | ||
| 49 | }; | ||
| 50 | // Keep this small boot-time copy in sync with the built-in palettes so | ||
| 51 | // the selected theme is visible before the app has mounted. | ||
| 52 | const BUILT_IN_THEME_PALETTES = { | ||
| 53 | "t3-chat": { | ||
| 54 | light: { | ||
| 55 | background: "#fdf7fd", | ||
| 56 | foreground: "#501854", | ||
| 57 | accent: "#e33f86", | ||
| 58 | chrome: "#fdf7fd", | ||
| 59 | }, | ||
| 60 | dark: { | ||
| 61 | background: "#1f1a24", | ||
| 62 | foreground: "#f9f8fb", | ||
| 63 | accent: "#a3004c", | ||
| 64 | chrome: "#1f1a24", | ||
| 65 | }, | ||
| 66 | }, | ||
| 67 | grove: { | ||
| 68 | light: { | ||
| 69 | background: "#f3f7f4", | ||
| 70 | foreground: "#241523", | ||
| 71 | accent: "#1b7d50", | ||
| 72 | chrome: "#f3f7f4", | ||
| 73 | }, | ||
| 74 | dark: { | ||
| 75 | background: "#1b2821", | ||
| 76 | foreground: "#fffaff", | ||
| 77 | accent: "#69d69a", | ||
| 78 | chrome: "#1b2821", | ||
| 79 | }, | ||
| 80 | }, | ||
| 81 | ocean: { | ||
| 82 | light: { | ||
| 83 | background: "#f5f7f8", | ||
| 84 | foreground: "#241523", | ||
| 85 | accent: "#2672af", | ||
| 86 | chrome: "#f5f7f8", | ||
| 87 | }, | ||
| 88 | dark: { | ||
| 89 | background: "#17212b", | ||
| 90 | foreground: "#fffaff", | ||
| 91 | accent: "#70b9ee", | ||
| 92 | chrome: "#17212b", | ||
| 93 | }, | ||
| 94 | }, | ||
| 95 | ember: { | ||
| 96 | light: { | ||
| 97 | background: "#f9f7f5", | ||
| 98 | foreground: "#241523", | ||
| 99 | accent: "#ae552a", | ||
| 100 | chrome: "#f9f7f5", | ||
| 101 | }, | ||
| 102 | dark: { | ||
| 103 | background: "#291e1a", | ||
| 104 | foreground: "#fffaff", | ||
| 105 | accent: "#f09a64", | ||
| 106 | chrome: "#291e1a", | ||
| 107 | }, | ||
| 108 | }, | ||
| 109 | iris: { | ||
| 110 | light: { | ||
| 111 | background: "#f8f7f9", | ||
| 112 | foreground: "#241523", | ||
| 113 | accent: "#7253b9", | ||
| 114 | chrome: "#f8f7f9", | ||
| 115 | }, | ||
| 116 | dark: { | ||
| 117 | background: "#1d1929", | ||
| 118 | foreground: "#fffaff", | ||
| 119 | accent: "#9d7df2", | ||
| 120 | chrome: "#1d1929", | ||
| 121 | }, | ||
| 122 | }, | ||
| 123 | }; | ||
| 124 | // Keep the built-in mode list in sync with the runtime so the selected | ||
| 125 | // T3 Chat palette is visible before React has mounted. | ||
| 126 | const BUILT_IN_THEME_MODES = Object.fromEntries( | ||
| 127 | Object.entries(BUILT_IN_THEME_PALETTES).map(([id, palettes]) => [ | ||
| 128 | id, | ||
| 129 | Object.keys(palettes), | ||
| 130 | ]), | ||
| 131 | ); | ||
| 132 | const RESERVED_THEME_IDS = [ | ||
| 133 | "system", | ||
| 134 | "light", | ||
| 135 | "dark", | ||
| 136 | "t3-chat", | ||
| 137 | "grove", | ||
| 138 | "ocean", | ||
| 139 | "ember", | ||
| 140 | "iris", | ||
| 141 | "t3-chat-dark", | ||
| 142 | "t3-grove", | ||
| 143 | "t3-ocean", | ||
| 144 | "t3-ember", | ||
| 145 | "t3-iris", | ||
| 146 | ]; | ||
| 147 | // Update every theme-color meta so any element another layer added | ||
| 148 | // carries the resolved color too. | ||
| 149 | const setThemeColor = (color) => { | ||
| 150 | for (const meta of document.querySelectorAll('meta[name="theme-color"]')) { | ||
| 151 | meta.setAttribute("content", color); | ||
| 152 | } | ||
| 153 | }; | ||
| 154 | |||
| 155 | const isHexColor = (value) => | ||
| 156 | typeof value === "string" && | ||
| 157 | /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value); | ||
| 158 | const isRecord = (value) => | ||
| 159 | typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 160 | const isThemeId = (value) => | ||
| 161 | typeof value === "string" && /^[a-z0-9](?:[a-z0-9-]{0,47})$/.test(value); | ||
| 162 | const isThemeLabel = (value) => | ||
| 163 | typeof value === "string" && value.trim().length > 0 && value.trim().length <= 48; | ||
| 164 | const isThemePreferenceMode = (value) => | ||
| 165 | value === "light" || value === "dark" || value === "system"; | ||
| 166 | const isThemeAppearance = (value) => value === "light" || value === "dark"; | ||
| 167 | const isStoredCustomTheme = (value) => { | ||
| 168 | if ( | ||
| 169 | !isRecord(value) || | ||
| 170 | !isThemeId(value.id) || | ||
| 171 | RESERVED_THEME_IDS.includes(value.id) || | ||
| 172 | !isThemeLabel(value.label) || | ||
| 173 | !isThemeAppearance(value.appearance) || | ||
| 174 | !isRecord(value.colors) | ||
| 175 | ) { | ||
| 176 | return false; | ||
| 177 | } | ||
| 178 | if (value.variants === undefined) return true; | ||
| 179 | if (!isRecord(value.variants)) return false; | ||
| 180 | return Object.entries(value.variants).every( | ||
| 181 | ([appearance, colors]) => | ||
| 182 | isThemeAppearance(appearance) && | ||
| 183 | (appearance === value.appearance || isRecord(colors)), | ||
| 184 | ); | ||
| 185 | }; | ||
| 186 | const findCustomTheme = (themeId) => { | ||
| 187 | if (RESERVED_THEME_IDS.includes(themeId)) return null; | ||
| 188 | try { | ||
| 189 | const parsed = JSON.parse( | ||
| 190 | window.localStorage.getItem(CUSTOM_THEMES_STORAGE_KEY) ?? "null", | ||
| 191 | ); | ||
| 192 | if (!Array.isArray(parsed)) return null; | ||
| 193 | return ( | ||
| 194 | parsed.find((value) => isStoredCustomTheme(value) && value.id === themeId) ?? null | ||
| 195 | ); | ||
| 196 | } catch { | ||
| 197 | return null; | ||
| 198 | } | ||
| 199 | }; | ||
| 200 | |||
| 19 | try { | 201 | try { |
| 20 | const storedTheme = window.localStorage.getItem("t3code:theme"); | 202 | const storedTheme = window.localStorage.getItem("t3code:theme"); |
| 21 | const theme = | ||
| 22 | storedTheme === "light" || storedTheme === "dark" || storedTheme === "system" | ||
| 23 | ? storedTheme | ||
| 24 | : "system"; | ||
| 25 | const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; | 203 | const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; |
| 26 | const isDark = theme === "dark" || (theme === "system" && prefersDark); | 204 | // Older builds stored the dark T3 Chat palette as its own theme id |
| 205 | // and every maintainer theme under a t3- prefix; keep those | ||
| 206 | // preferences readable. Mirrors the runtime's legacy alias table. | ||
| 207 | const LEGACY_THEME_IDS = { | ||
| 208 | "t3-chat-dark": "t3-chat", | ||
| 209 | "t3-grove": "grove", | ||
| 210 | "t3-ocean": "ocean", | ||
| 211 | "t3-ember": "ember", | ||
| 212 | "t3-iris": "iris", | ||
| 213 | }; | ||
| 214 | const isLegacyT3ChatDark = storedTheme === "t3-chat-dark"; | ||
| 215 | const themeId = | ||
| 216 | storedTheme === null ? "" : (LEGACY_THEME_IDS[storedTheme] ?? storedTheme); | ||
| 217 | const legacyMode = isLegacyT3ChatDark ? "dark" : null; | ||
| 218 | const builtInModes = BUILT_IN_THEME_MODES[themeId] ?? null; | ||
| 219 | const customTheme = builtInModes ? null : findCustomTheme(themeId); | ||
| 220 | const themeModes = | ||
| 221 | builtInModes ?? | ||
| 222 | (customTheme | ||
| 223 | ? ["light", "dark"].filter( | ||
| 224 | (mode) => mode === customTheme.appearance || customTheme.variants?.[mode], | ||
| 225 | ) | ||
| 226 | : null); | ||
| 227 | // Mirrors the runtime's isKnownThemePreference. | ||
| 228 | const isKnownTheme = | ||
| 229 | storedTheme === "light" || | ||
| 230 | storedTheme === "dark" || | ||
| 231 | storedTheme === "system" || | ||
| 232 | themeModes !== null; | ||
| 233 | const theme = isKnownTheme && storedTheme ? storedTheme : "system"; | ||
| 234 | const isThemed = isKnownTheme && themeModes !== null; | ||
| 235 | const baseAppearance = customTheme ? customTheme.appearance : "light"; | ||
| 236 | const systemMode = prefersDark ? "dark" : "light"; | ||
| 237 | const storedAppearanceMode = window.localStorage.getItem( | ||
| 238 | THEME_APPEARANCE_MODE_STORAGE_KEY, | ||
| 239 | ); | ||
| 240 | const storedFollowSystem = window.localStorage.getItem(THEME_FOLLOW_SYSTEM_STORAGE_KEY); | ||
| 241 | // Mirrors the runtime's getThemePreferenceMode fallback: a bare | ||
| 242 | // "light"/"dark" preference names its own mode. | ||
| 243 | const preferenceMode = | ||
| 244 | theme === "light" || theme === "dark" ? theme : (legacyMode ?? baseAppearance); | ||
| 245 | const appearanceMode = isThemePreferenceMode(storedAppearanceMode) | ||
| 246 | ? storedAppearanceMode | ||
| 247 | : storedFollowSystem === "true" | ||
| 248 | ? "system" | ||
| 249 | : storedFollowSystem === "false" | ||
| 250 | ? preferenceMode | ||
| 251 | : theme === "system" | ||
| 252 | ? "system" | ||
| 253 | : preferenceMode; | ||
| 254 | // A valid mix half for the wanted appearance takes over: it decides | ||
| 255 | // light vs dark even when the base theme cannot render that mode. | ||
| 256 | // Mirrors the runtime's halves-aware resolveThemeAppearance. | ||
| 257 | const wantedMode = appearanceMode === "system" ? systemMode : appearanceMode; | ||
| 258 | let halfThemeId = null; | ||
| 259 | let halfCustomTheme = null; | ||
| 260 | try { | ||
| 261 | const halvesRaw = JSON.parse( | ||
| 262 | window.localStorage.getItem(THEME_HALVES_STORAGE_KEY) ?? "null", | ||
| 263 | ); | ||
| 264 | const candidate = isRecord(halvesRaw) ? halvesRaw[wantedMode] : null; | ||
| 265 | if (typeof candidate === "string") { | ||
| 266 | const halfId = LEGACY_THEME_IDS[candidate] ?? candidate; | ||
| 267 | const halfBuiltIn = BUILT_IN_THEME_MODES[halfId] ?? null; | ||
| 268 | const halfCustom = halfBuiltIn ? null : findCustomTheme(halfId); | ||
| 269 | const halfModes = | ||
| 270 | halfBuiltIn ?? | ||
| 271 | (halfCustom | ||
| 272 | ? ["light", "dark"].filter( | ||
| 273 | (mode) => mode === halfCustom.appearance || halfCustom.variants?.[mode], | ||
| 274 | ) | ||
| 275 | : null); | ||
| 276 | if (halfModes && halfModes.includes(wantedMode)) { | ||
| 277 | halfThemeId = halfId; | ||
| 278 | halfCustomTheme = halfCustom; | ||
| 279 | } | ||
| 280 | } | ||
| 281 | } catch { | ||
| 282 | // A malformed mix degrades to the base preference. | ||
| 283 | } | ||
| 284 | const themeMode = !isThemed | ||
| 285 | ? null | ||
| 286 | : appearanceMode === "system" | ||
| 287 | ? themeModes.includes(systemMode) | ||
| 288 | ? systemMode | ||
| 289 | : baseAppearance | ||
| 290 | : themeModes.includes(appearanceMode) | ||
| 291 | ? appearanceMode | ||
| 292 | : baseAppearance; | ||
| 293 | const isDark = | ||
| 294 | halfThemeId !== null | ||
| 295 | ? wantedMode === "dark" | ||
| 296 | : isThemed | ||
| 297 | ? themeMode === "dark" | ||
| 298 | : appearanceMode === "system" | ||
| 299 | ? prefersDark | ||
| 300 | : appearanceMode === "dark"; | ||
| 301 | const effectiveIsThemed = halfThemeId !== null || isThemed; | ||
| 302 | const effectiveThemeId = halfThemeId ?? themeId; | ||
| 303 | const effectiveCustomTheme = halfThemeId !== null ? halfCustomTheme : customTheme; | ||
| 304 | const effectiveThemeMode = halfThemeId !== null ? wantedMode : themeMode; | ||
| 305 | const customColors = | ||
| 306 | effectiveIsThemed && effectiveCustomTheme | ||
| 307 | ? effectiveThemeMode === effectiveCustomTheme.appearance | ||
| 308 | ? effectiveCustomTheme.colors | ||
| 309 | : (effectiveCustomTheme.variants?.[effectiveThemeMode] ?? | ||
| 310 | effectiveCustomTheme.colors) | ||
| 311 | : null; | ||
| 312 | const customDefaults = | ||
| 313 | effectiveCustomTheme && effectiveThemeMode | ||
| 314 | ? DEFAULT_THEME_PALETTES[effectiveThemeMode] | ||
| 315 | : null; | ||
| 316 | // The runtime tolerates individual malformed colors, so fall back | ||
| 317 | // per role rather than dropping the theme. | ||
| 318 | const builtInSplash = | ||
| 319 | effectiveIsThemed && !effectiveCustomTheme && effectiveThemeMode | ||
| 320 | ? (BUILT_IN_THEME_PALETTES[effectiveThemeId]?.[effectiveThemeMode] ?? null) | ||
| 321 | : null; | ||
| 322 | const fallbackSplash = isDark ? SPLASH_COLORS.dark : SPLASH_COLORS.light; | ||
| 323 | const customSplash = customColors | ||
| 324 | ? { | ||
| 325 | background: isHexColor(customColors.canvas) | ||
| 326 | ? customColors.canvas | ||
| 327 | : (customDefaults?.background ?? fallbackSplash.background), | ||
| 328 | foreground: isHexColor(customColors.text) | ||
| 329 | ? customColors.text | ||
| 330 | : (customDefaults?.foreground ?? fallbackSplash.foreground), | ||
| 331 | accent: isHexColor(customColors.accent) | ||
| 332 | ? customColors.accent | ||
| 333 | : (customDefaults?.accent ?? fallbackSplash.accent), | ||
| 334 | } | ||
| 335 | : null; | ||
| 336 | if (effectiveIsThemed) { | ||
| 337 | document.documentElement.dataset.themeId = effectiveCustomTheme | ||
| 338 | ? effectiveCustomTheme.id | ||
| 339 | : effectiveThemeId; | ||
| 340 | } else { | ||
| 341 | delete document.documentElement.dataset.themeId; | ||
| 342 | } | ||
| 343 | // A resolved half is a selection even when the base theme is gone | ||
| 344 | // (deleting the base leaves the mix pointing at a real theme). | ||
| 345 | const hasSelection = halfThemeId !== null || (isKnownTheme && storedTheme !== null); | ||
| 346 | if (hasSelection) { | ||
| 347 | document.documentElement.dataset.themeSelected = "true"; | ||
| 348 | } else { | ||
| 349 | delete document.documentElement.dataset.themeSelected; | ||
| 350 | } | ||
| 27 | document.documentElement.classList.toggle("dark", isDark); | 351 | document.documentElement.classList.toggle("dark", isDark); |
| 28 | const chromeColor = isDark ? DARK_BACKGROUND : LIGHT_BACKGROUND; | 352 | const chromeColor = customColors |
| 353 | ? isHexColor(customColors.chrome) | ||
| 354 | ? customColors.chrome | ||
| 355 | : (customDefaults?.chrome ?? fallbackSplash.background) | ||
| 356 | : builtInSplash | ||
| 357 | ? builtInSplash.chrome | ||
| 358 | : isDark | ||
| 359 | ? DARK_BACKGROUND | ||
| 360 | : LIGHT_BACKGROUND; | ||
| 29 | document.documentElement.style.backgroundColor = chromeColor; | 361 | document.documentElement.style.backgroundColor = chromeColor; |
| 30 | themeColorMeta?.setAttribute("content", chromeColor); | 362 | if (hasSelection) { |
| 363 | const splashColors = customSplash ?? builtInSplash ?? fallbackSplash; | ||
| 364 | for (const [name, value] of Object.entries(splashColors)) { | ||
| 365 | document.documentElement.style.setProperty(`--boot-${name}`, value); | ||
| 366 | } | ||
| 367 | } | ||
| 368 | setThemeColor(chromeColor); | ||
| 31 | } catch { | 369 | } catch { |
| 32 | document.documentElement.classList.add("dark"); | 370 | // Mirror the runtime's storage-failure fallback: follow the OS. |
| 33 | document.documentElement.style.backgroundColor = DARK_BACKGROUND; | 371 | let prefersDark = true; |
| 34 | themeColorMeta?.setAttribute("content", DARK_BACKGROUND); | 372 | try { |
| 373 | prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; | ||
| 374 | } catch { | ||
| 375 | // Keep the dark default when even matchMedia is unavailable. | ||
| 376 | } | ||
| 377 | delete document.documentElement.dataset.themeId; | ||
| 378 | delete document.documentElement.dataset.themeSelected; | ||
| 379 | document.documentElement.classList.toggle("dark", prefersDark); | ||
| 380 | const fallbackColor = prefersDark ? DARK_BACKGROUND : LIGHT_BACKGROUND; | ||
| 381 | document.documentElement.style.backgroundColor = fallbackColor; | ||
| 382 | setThemeColor(fallbackColor); | ||
| 35 | } | 383 | } |
| 36 | })(); | 384 | })(); |
| 37 | </script> | 385 | </script> |
| ... | @@ -56,14 +404,24 @@ | ... | @@ -56,14 +404,24 @@ |
| 56 | } | 404 | } |
| 57 | 405 | ||
| 58 | #boot-shell { | 406 | #boot-shell { |
| 407 | position: relative; | ||
| 408 | overflow: hidden; | ||
| 59 | display: flex; | 409 | display: flex; |
| 60 | min-height: 100%; | 410 | min-height: 100%; |
| 61 | align-items: center; | 411 | align-items: center; |
| 62 | justify-content: center; | 412 | justify-content: center; |
| 63 | background: inherit; | 413 | background: inherit; |
| 414 | color: inherit; | ||
| 415 | } | ||
| 416 | |||
| 417 | html[data-theme-selected="true"] #boot-shell { | ||
| 418 | background: var(--boot-background); | ||
| 419 | color: var(--boot-foreground); | ||
| 64 | } | 420 | } |
| 65 | 421 | ||
| 66 | #boot-shell-card { | 422 | #boot-shell-card { |
| 423 | position: relative; | ||
| 424 | z-index: 1; | ||
| 67 | display: flex; | 425 | display: flex; |
| 68 | align-items: center; | 426 | align-items: center; |
| 69 | justify-content: center; | 427 | justify-content: center; |
apps/web/src/components/ChatView.tsx+4-4| ... | @@ -5842,10 +5842,10 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -5842,10 +5842,10 @@ function ChatViewContent(props: ChatViewProps) { |
| 5842 | const panelLayoutControls = ( | 5842 | const panelLayoutControls = ( |
| 5843 | <div | 5843 | <div |
| 5844 | className={cn( | 5844 | className={cn( |
| 5845 | "workspace-titlebar-controls z-50 gap-1 [-webkit-app-region:no-drag]", | 5845 | // One inset in both states: the controls move between containers when |
| 5846 | rightPanelOpen && !shouldUseRightPanelSheet | 5846 | // the right panel opens, and a different right offset made them jump |
| 5847 | ? "right-2 wco:right-[var(--workspace-controls-right)]" | 5847 | // sideways on every toggle. |
| 5848 | : "mr-px", | 5848 | "workspace-titlebar-controls z-50 mr-px gap-1 [-webkit-app-region:no-drag]", |
| 5849 | )} | 5849 | )} |
| 5850 | > | 5850 | > |
| 5851 | {rightPanelOpen && !shouldUseRightPanelSheet ? ( | 5851 | {rightPanelOpen && !shouldUseRightPanelSheet ? ( |
apps/web/src/components/CommandPalette.logic.ts+1-1| ... | @@ -12,7 +12,7 @@ import { formatRelativeTimeLabel } from "../timestampFormat"; | ... | @@ -12,7 +12,7 @@ import { formatRelativeTimeLabel } from "../timestampFormat"; |
| 12 | import { type Project, type SidebarThreadSummary, type Thread } from "../types"; | 12 | import { type Project, type SidebarThreadSummary, type Thread } from "../types"; |
| 13 | 13 | ||
| 14 | export const RECENT_THREAD_LIMIT = 12; | 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 | export const ADDON_ICON_CLASS = "size-4"; | 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,6 +35,7 @@ import { |
| 35 | FolderPlusIcon, | 35 | FolderPlusIcon, |
| 36 | LinkIcon, | 36 | LinkIcon, |
| 37 | MessageSquareIcon, | 37 | MessageSquareIcon, |
| 38 | PaletteIcon, | ||
| 38 | SettingsIcon, | 39 | SettingsIcon, |
| 39 | SquarePenIcon, | 40 | SquarePenIcon, |
| 40 | TextSearchIcon, | 41 | TextSearchIcon, |
| ... | @@ -57,6 +58,7 @@ import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; | ... | @@ -57,6 +58,7 @@ import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; |
| 57 | import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; | 58 | import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; |
| 58 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; | 59 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 59 | import { useClientSettings } from "../hooks/useSettings"; | 60 | import { useClientSettings } from "../hooks/useSettings"; |
| 61 | import { useTheme } from "../hooks/useTheme"; | ||
| 60 | import { readLocalApi } from "../localApi"; | 62 | import { readLocalApi } from "../localApi"; |
| 61 | import { desktopLocalBackendId } from "../connection/desktopLocal"; | 63 | import { desktopLocalBackendId } from "../connection/desktopLocal"; |
| 62 | import { filesystemEnvironment } from "../state/filesystem"; | 64 | import { filesystemEnvironment } from "../state/filesystem"; |
| ... | @@ -121,6 +123,7 @@ import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons" | ... | @@ -121,6 +123,7 @@ import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons" |
| 121 | import { ProjectFavicon } from "./ProjectFavicon"; | 123 | import { ProjectFavicon } from "./ProjectFavicon"; |
| 122 | import { ProjectFilePicker } from "./files/ProjectFilePicker"; | 124 | import { ProjectFilePicker } from "./files/ProjectFilePicker"; |
| 123 | import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; | 125 | import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; |
| 126 | import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; | ||
| 124 | import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; | 127 | import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; |
| 125 | import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; | 128 | import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; |
| 126 | import { resolveDefaultProviderModelSelection } from "../providerInstances"; | 129 | import { resolveDefaultProviderModelSelection } from "../providerInstances"; |
| ... | @@ -386,6 +389,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { | ... | @@ -386,6 +389,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { |
| 386 | const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); | 389 | const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); |
| 387 | const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); | 390 | const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); |
| 388 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); | 391 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 392 | const { theme, themeHalves, resolvedTheme } = useTheme(); | ||
| 389 | const composerHandleRef = useRef<ChatComposerHandle | null>(null); | 393 | const composerHandleRef = useRef<ChatComposerHandle | null>(null); |
| 390 | const routeTarget = useParams({ | 394 | const routeTarget = useParams({ |
| 391 | strict: false, | 395 | strict: false, |
| ... | @@ -428,6 +432,16 @@ export function CommandPalette({ children }: { children: ReactNode }) { | ... | @@ -428,6 +432,16 @@ export function CommandPalette({ children }: { children: ReactNode }) { |
| 428 | previewOpen, | 432 | previewOpen, |
| 429 | }, | 433 | }, |
| 430 | }); | 434 | }); |
| 435 | if (command === "themeEditor.toggle") { | ||
| 436 | event.preventDefault(); | ||
| 437 | event.stopPropagation(); | ||
| 438 | toggleThemeEditorForTheme({ | ||
| 439 | theme, | ||
| 440 | themeHalves, | ||
| 441 | initialAppearance: resolvedTheme, | ||
| 442 | }); | ||
| 443 | return; | ||
| 444 | } | ||
| 431 | const mode = overlayModeForCommand(command); | 445 | const mode = overlayModeForCommand(command); |
| 432 | if (mode === null) { | 446 | if (mode === null) { |
| 433 | return; | 447 | return; |
| ... | @@ -438,7 +452,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { | ... | @@ -438,7 +452,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { |
| 438 | }; | 452 | }; |
| 439 | window.addEventListener("keydown", onKeyDown); | 453 | window.addEventListener("keydown", onKeyDown); |
| 440 | return () => window.removeEventListener("keydown", onKeyDown); | 454 | return () => window.removeEventListener("keydown", onKeyDown); |
| 441 | }, [keybindings, previewOpen, terminalOpen, toggleMode]); | 455 | }, [keybindings, previewOpen, resolvedTheme, terminalOpen, theme, themeHalves, toggleMode]); |
| 442 | 456 | ||
| 443 | useEffect( | 457 | useEffect( |
| 444 | () => | 458 | () => |
| ... | @@ -567,6 +581,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -567,6 +581,7 @@ function OpenCommandPaletteDialog(props: { |
| 567 | const projectOrder = useUiStateStore((store) => store.projectOrder); | 581 | const projectOrder = useUiStateStore((store) => store.projectOrder); |
| 568 | const threads = useThreadShells(); | 582 | const threads = useThreadShells(); |
| 569 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); | 583 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 584 | const { theme, themeHalves, resolvedTheme } = useTheme(); | ||
| 570 | const providers = useAtomValue(primaryServerProvidersAtom); | 585 | const providers = useAtomValue(primaryServerProvidersAtom); |
| 571 | const [viewStack, setViewStack] = useState<CommandPaletteView[]>([]); | 586 | const [viewStack, setViewStack] = useState<CommandPaletteView[]>([]); |
| 572 | const currentView = viewStack.at(-1) ?? null; | 587 | const currentView = viewStack.at(-1) ?? null; |
| ... | @@ -1463,6 +1478,22 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1463,6 +1478,22 @@ function OpenCommandPaletteDialog(props: { |
| 1463 | }); | 1478 | }); |
| 1464 | } | 1479 | } |
| 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 | actionItems.push({ | 1497 | actionItems.push({ |
| 1467 | kind: "action", | 1498 | kind: "action", |
| 1468 | value: "action:settings", | 1499 | value: "action:settings", |
apps/web/src/components/ComposerPromptEditor.tsx+1-1| ... | @@ -1765,7 +1765,7 @@ function ComposerPromptEditorInner({ | ... | @@ -1765,7 +1765,7 @@ function ComposerPromptEditorInner({ |
| 1765 | } | 1765 | } |
| 1766 | placeholder={ | 1766 | placeholder={ |
| 1767 | terminalContexts.length > 0 ? null : ( | 1767 | terminalContexts.length > 0 ? null : ( |
| 1768 | <div className="pointer-events-none absolute inset-0 leading-relaxed text-muted-foreground/35"> | 1768 | <div className="pointer-events-none absolute inset-0 leading-relaxed text-placeholder"> |
| 1769 | {placeholder} | 1769 | {placeholder} |
| 1770 | </div> | 1770 | </div> |
| 1771 | ) | 1771 | ) |
apps/web/src/components/DiffPanel.tsx+23-69| ... | @@ -36,6 +36,7 @@ import { | ... | @@ -36,6 +36,7 @@ import { |
| 36 | getRenderablePatch, | 36 | getRenderablePatch, |
| 37 | resolveDiffThemeName, | 37 | resolveDiffThemeName, |
| 38 | resolveFileDiffPath, | 38 | resolveFileDiffPath, |
| 39 | DIFF_SURFACE_THEME_UNSAFE_CSS, | ||
| 39 | } from "../lib/diffRendering"; | 40 | } from "../lib/diffRendering"; |
| 40 | import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse"; | 41 | import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse"; |
| 41 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; | 42 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| ... | @@ -86,54 +87,7 @@ interface CollapsedDiffFilesState { | ... | @@ -86,54 +87,7 @@ interface CollapsedDiffFilesState { |
| 86 | 87 | ||
| 87 | const EMPTY_COLLAPSED_DIFF_FILE_KEYS: ReadonlySet<string> = new Set(); | 88 | const EMPTY_COLLAPSED_DIFF_FILE_KEYS: ReadonlySet<string> = new Set(); |
| 88 | 89 | ||
| 89 | const DIFF_PANEL_UNSAFE_CSS = ` | 90 | const DIFF_PANEL_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS} |
| 90 | [data-diffs-header], | ||
| 91 | [data-diff], | ||
| 92 | [data-file], | ||
| 93 | [data-error-wrapper], | ||
| 94 | [data-virtualizer-buffer] { | ||
| 95 | --diffs-header-font-family: var(--font-sans) !important; | ||
| 96 | --diffs-font-family: var(--font-mono) !important; | ||
| 97 | --diffs-bg: var(--background) !important; | ||
| 98 | --diffs-light-bg: var(--background) !important; | ||
| 99 | --diffs-dark-bg: var(--background) !important; | ||
| 100 | --diffs-token-light-bg: transparent; | ||
| 101 | --diffs-token-dark-bg: transparent; | ||
| 102 | |||
| 103 | --diffs-bg-context-override: color-mix(in srgb, var(--background) 97%, var(--foreground)); | ||
| 104 | --diffs-bg-hover-override: color-mix(in srgb, var(--background) 94%, var(--foreground)); | ||
| 105 | --diffs-bg-separator-override: color-mix(in srgb, var(--background) 95%, var(--foreground)); | ||
| 106 | --diffs-bg-buffer-override: color-mix(in srgb, var(--background) 90%, var(--foreground)); | ||
| 107 | |||
| 108 | --diffs-bg-addition-override: light-dark( | ||
| 109 | color-mix(in srgb, var(--background) 50%, var(--success)), | ||
| 110 | color-mix(in srgb, var(--background) 70%, var(--success)) | ||
| 111 | ); | ||
| 112 | --diffs-bg-addition-number-override: light-dark( | ||
| 113 | color-mix(in srgb, var(--background) 35%, var(--success)), | ||
| 114 | color-mix(in srgb, var(--background) 60%, var(--success)) | ||
| 115 | ); | ||
| 116 | --diffs-bg-addition-hover-override: color-mix(in srgb, var(--background) 85%, var(--success)); | ||
| 117 | --diffs-bg-addition-emphasis-override: color-mix(in srgb, var(--background) 80%, var(--success)); | ||
| 118 | |||
| 119 | --diffs-bg-deletion-override: light-dark( | ||
| 120 | color-mix(in srgb, var(--background) 50%, var(--destructive)), | ||
| 121 | color-mix(in srgb, var(--background) 70%, var(--destructive)) | ||
| 122 | ); | ||
| 123 | --diffs-bg-deletion-number-override: light-dark( | ||
| 124 | color-mix(in srgb, var(--background) 35%, var(--destructive)), | ||
| 125 | color-mix(in srgb, var(--background) 60%, var(--destructive)) | ||
| 126 | ); | ||
| 127 | --diffs-bg-deletion-hover-override: color-mix(in srgb, var(--background) 85%, var(--destructive)); | ||
| 128 | --diffs-bg-deletion-emphasis-override: color-mix( | ||
| 129 | in srgb, | ||
| 130 | var(--background) 80%, | ||
| 131 | var(--destructive) | ||
| 132 | ); | ||
| 133 | |||
| 134 | background-color: var(--diffs-bg) !important; | ||
| 135 | } | ||
| 136 | |||
| 137 | :is( | 91 | :is( |
| 138 | [data-line], | 92 | [data-line], |
| 139 | [data-line-annotation], | 93 | [data-line-annotation], |
| ... | @@ -144,13 +98,13 @@ const DIFF_PANEL_UNSAFE_CSS = ` | ... | @@ -144,13 +98,13 @@ const DIFF_PANEL_UNSAFE_CSS = ` |
| 144 | --diffs-line-bg: light-dark( | 98 | --diffs-line-bg: light-dark( |
| 145 | color-mix( | 99 | color-mix( |
| 146 | in lab, | 100 | in lab, |
| 147 | var(--background) 88%, | 101 | var(--code-background) 88%, |
| 148 | color-mix(in srgb, var(--background) 50%, var(--diffs-modified-base)) | 102 | color-mix(in srgb, var(--code-background) 50%, var(--diffs-modified-base)) |
| 149 | ), | 103 | ), |
| 150 | color-mix( | 104 | color-mix( |
| 151 | in lab, | 105 | in lab, |
| 152 | var(--background) 80%, | 106 | var(--code-background) 80%, |
| 153 | color-mix(in srgb, var(--background) 70%, var(--diffs-modified-base)) | 107 | color-mix(in srgb, var(--code-background) 70%, var(--diffs-modified-base)) |
| 154 | ) | 108 | ) |
| 155 | ) !important; | 109 | ) !important; |
| 156 | } | 110 | } |
| ... | @@ -159,13 +113,13 @@ const DIFF_PANEL_UNSAFE_CSS = ` | ... | @@ -159,13 +113,13 @@ const DIFF_PANEL_UNSAFE_CSS = ` |
| 159 | --diffs-line-bg: light-dark( | 113 | --diffs-line-bg: light-dark( |
| 160 | color-mix( | 114 | color-mix( |
| 161 | in lab, | 115 | in lab, |
| 162 | var(--background) 91%, | 116 | var(--code-background) 91%, |
| 163 | color-mix(in srgb, var(--background) 35%, var(--diffs-modified-base)) | 117 | color-mix(in srgb, var(--code-background) 35%, var(--diffs-modified-base)) |
| 164 | ), | 118 | ), |
| 165 | color-mix( | 119 | color-mix( |
| 166 | in lab, | 120 | in lab, |
| 167 | var(--background) 85%, | 121 | var(--code-background) 85%, |
| 168 | color-mix(in srgb, var(--background) 60%, var(--diffs-modified-base)) | 122 | color-mix(in srgb, var(--code-background) 60%, var(--diffs-modified-base)) |
| 169 | ) | 123 | ) |
| 170 | ) !important; | 124 | ) !important; |
| 171 | } | 125 | } |
| ... | @@ -192,16 +146,16 @@ const DIFF_PANEL_UNSAFE_CSS = ` | ... | @@ -192,16 +146,16 @@ const DIFF_PANEL_UNSAFE_CSS = ` |
| 192 | } | 146 | } |
| 193 | 147 | ||
| 194 | [data-file-info] { | 148 | [data-file-info] { |
| 195 | background-color: var(--background) !important; | 149 | background-color: var(--code-background) !important; |
| 196 | border-block-color: transparent !important; | 150 | border-block-color: transparent !important; |
| 197 | color: var(--foreground) !important; | 151 | color: var(--code-foreground) !important; |
| 198 | } | 152 | } |
| 199 | 153 | ||
| 200 | [data-diffs-header] { | 154 | [data-diffs-header] { |
| 201 | position: sticky !important; | 155 | position: sticky !important; |
| 202 | top: 0; | 156 | top: 0; |
| 203 | z-index: 4; | 157 | z-index: 4; |
| 204 | background-color: var(--background) !important; | 158 | background-color: var(--code-background) !important; |
| 205 | border-bottom-color: transparent !important; | 159 | border-bottom-color: transparent !important; |
| 206 | align-items: center !important; | 160 | align-items: center !important; |
| 207 | font-family: var(--font-sans) !important; | 161 | font-family: var(--font-sans) !important; |
| ... | @@ -213,13 +167,13 @@ const DIFF_PANEL_UNSAFE_CSS = ` | ... | @@ -213,13 +167,13 @@ const DIFF_PANEL_UNSAFE_CSS = ` |
| 213 | } | 167 | } |
| 214 | 168 | ||
| 215 | [data-diffs-header]:hover { | 169 | [data-diffs-header]:hover { |
| 216 | background-color: color-mix(in srgb, var(--background) 97%, var(--foreground)) !important; | 170 | background-color: color-mix(in srgb, var(--code-background) 97%, var(--code-foreground)) !important; |
| 217 | } | 171 | } |
| 218 | 172 | ||
| 219 | :is([data-separator="line-info"], [data-separator="line-info-basic"]) { | 173 | :is([data-separator="line-info"], [data-separator="line-info-basic"]) { |
| 220 | height: 24px !important; | 174 | height: 24px !important; |
| 221 | margin-block: 0 !important; | 175 | margin-block: 0 !important; |
| 222 | background-color: var(--background) !important; | 176 | background-color: var(--code-background) !important; |
| 223 | } | 177 | } |
| 224 | 178 | ||
| 225 | :is([data-separator="line-info"], [data-separator="line-info-basic"]) | 179 | :is([data-separator="line-info"], [data-separator="line-info-basic"]) |
| ... | @@ -233,7 +187,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` | ... | @@ -233,7 +187,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` |
| 233 | gap: 8px; | 187 | gap: 8px; |
| 234 | padding-inline: 0 !important; | 188 | padding-inline: 0 !important; |
| 235 | background-color: transparent !important; | 189 | background-color: transparent !important; |
| 236 | color: color-mix(in srgb, var(--foreground) 52%, var(--background)) !important; | 190 | color: color-mix(in srgb, var(--code-foreground) 52%, var(--code-background)) !important; |
| 237 | font-family: var(--font-sans) !important; | 191 | font-family: var(--font-sans) !important; |
| 238 | font-size: 11px !important; | 192 | font-size: 11px !important; |
| 239 | text-decoration: none !important; | 193 | text-decoration: none !important; |
| ... | @@ -257,7 +211,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` | ... | @@ -257,7 +211,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` |
| 257 | height: 1px; | 211 | height: 1px; |
| 258 | flex: 1 1 auto; | 212 | flex: 1 1 auto; |
| 259 | content: ""; | 213 | content: ""; |
| 260 | background-color: color-mix(in srgb, var(--background) 92%, var(--foreground)); | 214 | background-color: color-mix(in srgb, var(--code-background) 92%, var(--code-foreground)); |
| 261 | } | 215 | } |
| 262 | 216 | ||
| 263 | :is([data-separator="line-info"], [data-separator="line-info-basic"])[data-expand-index] | 217 | :is([data-separator="line-info"], [data-separator="line-info-basic"])[data-expand-index] |
| ... | @@ -286,7 +240,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` | ... | @@ -286,7 +240,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` |
| 286 | [data-expand-button] | 240 | [data-expand-button] |
| 287 | ):hover | 241 | ):hover |
| 288 | [data-separator-content] { | 242 | [data-separator-content] { |
| 289 | color: color-mix(in srgb, var(--foreground) 76%, var(--background)) !important; | 243 | color: color-mix(in srgb, var(--code-foreground) 76%, var(--code-background)) !important; |
| 290 | } | 244 | } |
| 291 | 245 | ||
| 292 | :is([data-separator="line-info"], [data-separator="line-info-basic"]):has( | 246 | :is([data-separator="line-info"], [data-separator="line-info-basic"]):has( |
| ... | @@ -297,7 +251,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` | ... | @@ -297,7 +251,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` |
| 297 | [data-expand-button] | 251 | [data-expand-button] |
| 298 | ):hover | 252 | ):hover |
| 299 | [data-unmodified-lines]::after { | 253 | [data-unmodified-lines]::after { |
| 300 | background-color: color-mix(in srgb, var(--background) 84%, var(--foreground)); | 254 | background-color: color-mix(in srgb, var(--code-background) 84%, var(--code-foreground)); |
| 301 | } | 255 | } |
| 302 | 256 | ||
| 303 | [data-diffs-header] [data-header-content] { | 257 | [data-diffs-header] [data-header-content] { |
| ... | @@ -337,7 +291,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` | ... | @@ -337,7 +291,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` |
| 337 | } | 291 | } |
| 338 | 292 | ||
| 339 | [data-title]:hover { | 293 | [data-title]:hover { |
| 340 | color: color-mix(in srgb, var(--foreground) 84%, var(--primary)) !important; | 294 | color: color-mix(in srgb, var(--code-foreground) 84%, var(--primary)) !important; |
| 341 | text-decoration-color: currentColor; | 295 | text-decoration-color: currentColor; |
| 342 | } | 296 | } |
| 343 | `; | 297 | `; |
| ... | @@ -796,11 +750,11 @@ export default function DiffPanel({ | ... | @@ -796,11 +750,11 @@ export default function DiffPanel({ |
| 796 | <div className="flex min-w-0 flex-1 items-center gap-3 [-webkit-app-region:no-drag]"> | 750 | <div className="flex min-w-0 flex-1 items-center gap-3 [-webkit-app-region:no-drag]"> |
| 797 | <DropdownMenu> | 751 | <DropdownMenu> |
| 798 | <DropdownMenuTrigger | 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 | aria-label={`Diff scope: ${selectedScopeLabel}`} | 754 | aria-label={`Diff scope: ${selectedScopeLabel}`} |
| 801 | > | 755 | > |
| 802 | <span className="truncate">{selectedScopeLabel}</span> | 756 | <span className="truncate">{selectedScopeLabel}</span> |
| 803 | <ChevronDownIcon className="size-3.5 shrink-0 text-muted-foreground" /> | 757 | <ChevronDownIcon className="size-3.5 shrink-0 opacity-70" /> |
| 804 | </DropdownMenuTrigger> | 758 | </DropdownMenuTrigger> |
| 805 | <DropdownMenuContent align="start" className="w-60"> | 759 | <DropdownMenuContent align="start" className="w-60"> |
| 806 | <DropdownMenuItem | 760 | <DropdownMenuItem |
| ... | @@ -1120,7 +1074,7 @@ export default function DiffPanel({ | ... | @@ -1120,7 +1074,7 @@ export default function DiffPanel({ |
| 1120 | )} | 1074 | )} |
| 1121 | {selectedPatchError && !renderablePatch && ( | 1075 | {selectedPatchError && !renderablePatch && ( |
| 1122 | <div className="px-3"> | 1076 | <div className="px-3"> |
| 1123 | <p className="mb-2 text-[11px] text-red-500/80">{selectedPatchError}</p> | 1077 | <p className="mb-2 text-[11px] text-error/80">{selectedPatchError}</p> |
| 1124 | </div> | 1078 | </div> |
| 1125 | )} | 1079 | )} |
| 1126 | {!renderablePatch ? ( | 1080 | {!renderablePatch ? ( |
apps/web/src/components/ProjectFavicon.tsx+1-1| ... | @@ -47,7 +47,7 @@ function ProjectFaviconFallback({ | ... | @@ -47,7 +47,7 @@ function ProjectFaviconFallback({ |
| 47 | readonly className?: string | undefined; | 47 | readonly className?: string | undefined; |
| 48 | readonly icon: ComponentType<{ className?: string }>; | 48 | readonly icon: ComponentType<{ className?: string }>; |
| 49 | }) { | 49 | }) { |
| 50 | return <Icon className={cn("size-3.5 shrink-0 text-muted-foreground/50", className)} />; | 50 | return <Icon className={cn("size-3.5 shrink-0 text-icon-muted", className)} />; |
| 51 | } | 51 | } |
| 52 | 52 | ||
| 53 | function ProjectFaviconImage({ | 53 | function ProjectFaviconImage({ |
apps/web/src/components/ProjectScriptsControl.tsx+6| ... | @@ -343,6 +343,9 @@ export default function ProjectScriptsControl({ | ... | @@ -343,6 +343,9 @@ export default function ProjectScriptsControl({ |
| 343 | variant="outline" | 343 | variant="outline" |
| 344 | className="w-7 px-0 sm:w-6 @3xl/header-actions:w-auto! @3xl/header-actions:px-[calc(--spacing(2)-1px)]" | 344 | className="w-7 px-0 sm:w-6 @3xl/header-actions:w-auto! @3xl/header-actions:px-[calc(--spacing(2)-1px)]" |
| 345 | aria-label={`Run ${primaryScript.name}`} | 345 | aria-label={`Run ${primaryScript.name}`} |
| 346 | // The tooltip wrapper replaces data-slot="button", so themed | ||
| 347 | // toolbar styling needs its own hook. | ||
| 348 | data-toolbar-control="" | ||
| 346 | onClick={() => onRunScript(primaryScript)} | 349 | onClick={() => onRunScript(primaryScript)} |
| 347 | /> | 350 | /> |
| 348 | } | 351 | } |
| ... | @@ -447,6 +450,9 @@ export default function ProjectScriptsControl({ | ... | @@ -447,6 +450,9 @@ export default function ProjectScriptsControl({ |
| 447 | variant="outline" | 450 | variant="outline" |
| 448 | className="w-7 px-0 sm:w-6 @3xl/header-actions:w-auto! @3xl/header-actions:px-[calc(--spacing(2)-1px)]" | 451 | className="w-7 px-0 sm:w-6 @3xl/header-actions:w-auto! @3xl/header-actions:px-[calc(--spacing(2)-1px)]" |
| 449 | aria-label="Add action" | 452 | aria-label="Add action" |
| 453 | // The tooltip wrapper replaces data-slot="button", so themed | ||
| 454 | // toolbar styling needs its own hook. | ||
| 455 | data-toolbar-control="" | ||
| 450 | onClick={openAddDialog} | 456 | onClick={openAddDialog} |
| 451 | /> | 457 | /> |
| 452 | } | 458 | } |
apps/web/src/components/Sidebar.tsx+11-13| ... | @@ -225,7 +225,7 @@ const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = | ... | @@ -225,7 +225,7 @@ const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = |
| 225 | separate: "Keep separate", | 225 | separate: "Keep separate", |
| 226 | }; | 226 | }; |
| 227 | const SIDEBAR_ICON_ACTION_BUTTON_CLASS = | 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 | function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) { | 230 | function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) { |
| 231 | useEnvironmentThread(threadRef.environmentId, threadRef.threadId); | 231 | useEnvironmentThread(threadRef.environmentId, threadRef.threadId); |
| ... | @@ -857,9 +857,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr | ... | @@ -857,9 +857,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr |
| 857 | ) : ( | 857 | ) : ( |
| 858 | <span | 858 | <span |
| 859 | className={`text-[10px] tabular-nums ${ | 859 | className={`text-[10px] tabular-nums ${ |
| 860 | isHighlighted | 860 | isHighlighted ? "text-foreground" : "text-secondary-label" |
| 861 | ? "text-foreground/72 dark:text-foreground/82" | ||
| 862 | : "text-muted-foreground/40" | ||
| 863 | }`} | 861 | }`} |
| 864 | > | 862 | > |
| 865 | {formatRelativeTimeLabel( | 863 | {formatRelativeTimeLabel( |
| ... | @@ -2245,7 +2243,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -2245,7 +2243,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2245 | }`} | 2243 | }`} |
| 2246 | /> | 2244 | /> |
| 2247 | </span> | 2245 | </span> |
| 2248 | <ChevronRightIcon className="absolute inset-0 m-auto size-3.5 text-muted-foreground/70 opacity-0 transition-opacity duration-150 group-hover/project-header:opacity-100" /> | 2246 | <ChevronRightIcon className="absolute inset-0 m-auto size-3.5 text-icon-muted opacity-0 transition-opacity duration-150 group-hover/project-header:opacity-100" /> |
| 2249 | </TooltipTrigger> | 2247 | </TooltipTrigger> |
| 2250 | <TooltipPopup side="top">{projectStatus.label}</TooltipPopup> | 2248 | <TooltipPopup side="top">{projectStatus.label}</TooltipPopup> |
| 2251 | </Tooltip> | 2249 | </Tooltip> |
| ... | @@ -2262,7 +2260,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -2262,7 +2260,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2262 | {project.displayName} | 2260 | {project.displayName} |
| 2263 | </span> | 2261 | </span> |
| 2264 | {project.groupedProjectCount > 1 ? ( | 2262 | {project.groupedProjectCount > 1 ? ( |
| 2265 | <span className="shrink-0 text-[10px] text-muted-foreground/60"> | 2263 | <span className="shrink-0 text-secondary-label text-[10px]"> |
| 2266 | {project.groupedProjectCount} projects | 2264 | {project.groupedProjectCount} projects |
| 2267 | </span> | 2265 | </span> |
| 2268 | ) : null} | 2266 | ) : null} |
| ... | @@ -2281,7 +2279,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -2281,7 +2279,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2281 | ? "Local sandbox project" | 2279 | ? "Local sandbox project" |
| 2282 | : "Remote project" | 2280 | : "Remote project" |
| 2283 | } | 2281 | } |
| 2284 | className="pointer-events-none absolute top-1 right-1.5 inline-flex size-5 items-center justify-center rounded-md text-muted-foreground/60 transition-opacity duration-150 max-sm:right-7 group-hover/project-header:opacity-0 group-focus-within/project-header:opacity-0 max-sm:group-hover/project-header:opacity-100 max-sm:group-focus-within/project-header:opacity-100" | 2282 | className="pointer-events-none absolute top-1 right-1.5 inline-flex size-5 items-center justify-center rounded-md text-icon-muted transition-opacity duration-150 max-sm:right-7 group-hover/project-header:opacity-0 group-focus-within/project-header:opacity-0 max-sm:group-hover/project-header:opacity-100 max-sm:group-focus-within/project-header:opacity-100" |
| 2285 | /> | 2283 | /> |
| 2286 | } | 2284 | } |
| 2287 | > | 2285 | > |
| ... | @@ -2600,7 +2598,7 @@ function ProjectSortMenu({ | ... | @@ -2600,7 +2598,7 @@ function ProjectSortMenu({ |
| 2600 | <Tooltip> | 2598 | <Tooltip> |
| 2601 | <TooltipTrigger | 2599 | <TooltipTrigger |
| 2602 | render={ | 2600 | render={ |
| 2603 | <MenuTrigger className="inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" /> | 2601 | <MenuTrigger className="inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-icon-muted transition-colors hover:bg-accent hover:text-foreground" /> |
| 2604 | } | 2602 | } |
| 2605 | > | 2603 | > |
| 2606 | <ArrowUpDownIcon className="size-3.5" /> | 2604 | <ArrowUpDownIcon className="size-3.5" /> |
| ... | @@ -2824,7 +2822,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( | ... | @@ -2824,7 +2822,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( |
| 2824 | <SidebarContent | 2822 | <SidebarContent |
| 2825 | className="gap-0" | 2823 | className="gap-0" |
| 2826 | fixedHeader={ | 2824 | fixedHeader={ |
| 2827 | <SidebarGroup className="px-2 pt-2 pb-1"> | 2825 | // Lifted above the stage backdrop, whose fade bleeds below the |
| 2826 | // header and would otherwise paint across the search row's outline. | ||
| 2827 | <SidebarGroup className="relative z-[1] px-2 pt-2 pb-1"> | ||
| 2828 | <SidebarMenu> | 2828 | <SidebarMenu> |
| 2829 | <SidebarMenuItem> | 2829 | <SidebarMenuItem> |
| 2830 | <CommandDialogTrigger | 2830 | <CommandDialogTrigger |
| ... | @@ -2891,7 +2891,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( | ... | @@ -2891,7 +2891,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( |
| 2891 | type="button" | 2891 | type="button" |
| 2892 | aria-label="Add project" | 2892 | aria-label="Add project" |
| 2893 | data-testid="sidebar-add-project-trigger" | 2893 | data-testid="sidebar-add-project-trigger" |
| 2894 | className="inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" | 2894 | className="inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-icon-muted transition-colors hover:bg-accent hover:text-foreground" |
| 2895 | onClick={openAddProject} | 2895 | onClick={openAddProject} |
| 2896 | /> | 2896 | /> |
| 2897 | } | 2897 | } |
| ... | @@ -2977,9 +2977,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( | ... | @@ -2977,9 +2977,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( |
| 2977 | )} | 2977 | )} |
| 2978 | 2978 | ||
| 2979 | {projectsLength === 0 && ( | 2979 | {projectsLength === 0 && ( |
| 2980 | <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60"> | 2980 | <div className="px-2 pt-4 text-center text-secondary-label text-xs">No projects yet</div> |
| 2981 | No projects yet | ||
| 2982 | </div> | ||
| 2983 | )} | 2981 | )} |
| 2984 | </SidebarGroup> | 2982 | </SidebarGroup> |
| 2985 | </SidebarContent> | 2983 | </SidebarContent> |
apps/web/src/components/SidebarStageBackdrop.test.tsx+1-2| ... | @@ -5,7 +5,6 @@ import { | ... | @@ -5,7 +5,6 @@ import { |
| 5 | resolveEnvironmentIdentificationPillLabel, | 5 | resolveEnvironmentIdentificationPillLabel, |
| 6 | resolveSidebarStageBackdropVariant, | 6 | resolveSidebarStageBackdropVariant, |
| 7 | StageBackdropArt, | 7 | StageBackdropArt, |
| 8 | StageBackdropButtonArt, | ||
| 9 | } from "./SidebarStageBackdrop"; | 8 | } from "./SidebarStageBackdrop"; |
| 10 | 9 | ||
| 11 | describe("SidebarStageBackdrop", () => { | 10 | describe("SidebarStageBackdrop", () => { |
| ... | @@ -29,7 +28,7 @@ describe("SidebarStageBackdrop", () => { | ... | @@ -29,7 +28,7 @@ describe("SidebarStageBackdrop", () => { |
| 29 | const markup = renderToStaticMarkup( | 28 | const markup = renderToStaticMarkup( |
| 30 | <> | 29 | <> |
| 31 | <StageBackdropArt variant={variant} /> | 30 | <StageBackdropArt variant={variant} /> |
| 32 | <StageBackdropButtonArt variant={variant} /> | 31 | <StageBackdropArt variant={variant} /> |
| 33 | </>, | 32 | </>, |
| 34 | ); | 33 | ); |
| 35 | const ids = Array.from(markup.matchAll(/\sid="([^"]+)"/g), (match) => match[1]); | 34 | const ids = Array.from(markup.matchAll(/\sid="([^"]+)"/g), (match) => match[1]); |
apps/web/src/components/SidebarStageBackdrop.tsx+4-8| ... | @@ -62,10 +62,6 @@ export function StageBackdropArt({ variant }: { variant: SidebarStageBackdropVar | ... | @@ -62,10 +62,6 @@ export function StageBackdropArt({ variant }: { variant: SidebarStageBackdropVar |
| 62 | return variant === "nightly" ? <NightlySkyArt /> : <DevBlueprintArt />; | 62 | return variant === "nightly" ? <NightlySkyArt /> : <DevBlueprintArt />; |
| 63 | } | 63 | } |
| 64 | 64 | ||
| 65 | export function StageBackdropButtonArt({ variant }: { variant: SidebarStageBackdropVariant }) { | ||
| 66 | return variant === "nightly" ? <NightlySkyArt compact /> : <DevBlueprintArt compact />; | ||
| 67 | } | ||
| 68 | |||
| 69 | const NIGHTLY_STARS: ReadonlyArray<{ | 65 | const NIGHTLY_STARS: ReadonlyArray<{ |
| 70 | cx: number; | 66 | cx: number; |
| 71 | cy: number; | 67 | cy: number; |
| ... | @@ -97,7 +93,7 @@ const NIGHTLY_SPARKLES: ReadonlyArray<{ x: number; y: number }> = [ | ... | @@ -97,7 +93,7 @@ const NIGHTLY_SPARKLES: ReadonlyArray<{ x: number; y: number }> = [ |
| 97 | { x: 246, y: 26 }, | 93 | { x: 246, y: 26 }, |
| 98 | ]; | 94 | ]; |
| 99 | 95 | ||
| 100 | function NightlySkyArt({ compact = false }: { compact?: boolean }) { | 96 | function NightlySkyArt() { |
| 101 | const idPrefix = useId().replaceAll(":", ""); | 97 | const idPrefix = useId().replaceAll(":", ""); |
| 102 | const skyId = `${idPrefix}-stage-night-sky`; | 98 | const skyId = `${idPrefix}-stage-night-sky`; |
| 103 | const glowId = `${idPrefix}-stage-night-glow`; | 99 | const glowId = `${idPrefix}-stage-night-glow`; |
| ... | @@ -111,7 +107,7 @@ function NightlySkyArt({ compact = false }: { compact?: boolean }) { | ... | @@ -111,7 +107,7 @@ function NightlySkyArt({ compact = false }: { compact?: boolean }) { |
| 111 | className="h-full w-full" | 107 | className="h-full w-full" |
| 112 | fill="none" | 108 | fill="none" |
| 113 | preserveAspectRatio="xMinYMin slice" | 109 | preserveAspectRatio="xMinYMin slice" |
| 114 | viewBox={compact ? "96 0 8192 96" : STAGE_BACKDROP_VIEW_BOX} | 110 | viewBox={STAGE_BACKDROP_VIEW_BOX} |
| 115 | xmlns="http://www.w3.org/2000/svg" | 111 | xmlns="http://www.w3.org/2000/svg" |
| 116 | > | 112 | > |
| 117 | <defs> | 113 | <defs> |
| ... | @@ -195,7 +191,7 @@ function NightlySkyArt({ compact = false }: { compact?: boolean }) { | ... | @@ -195,7 +191,7 @@ function NightlySkyArt({ compact = false }: { compact?: boolean }) { |
| 195 | ); | 191 | ); |
| 196 | } | 192 | } |
| 197 | 193 | ||
| 198 | function DevBlueprintArt({ compact = false }: { compact?: boolean }) { | 194 | function DevBlueprintArt() { |
| 199 | const idPrefix = useId().replaceAll(":", ""); | 195 | const idPrefix = useId().replaceAll(":", ""); |
| 200 | const paperId = `${idPrefix}-stage-bp-paper`; | 196 | const paperId = `${idPrefix}-stage-bp-paper`; |
| 201 | const glowId = `${idPrefix}-stage-bp-glow`; | 197 | const glowId = `${idPrefix}-stage-bp-glow`; |
| ... | @@ -212,7 +208,7 @@ function DevBlueprintArt({ compact = false }: { compact?: boolean }) { | ... | @@ -212,7 +208,7 @@ function DevBlueprintArt({ compact = false }: { compact?: boolean }) { |
| 212 | className="stage-blueprint h-full w-full" | 208 | className="stage-blueprint h-full w-full" |
| 213 | fill="none" | 209 | fill="none" |
| 214 | preserveAspectRatio="xMinYMin slice" | 210 | preserveAspectRatio="xMinYMin slice" |
| 215 | viewBox={compact ? "64 0 8192 96" : STAGE_BACKDROP_VIEW_BOX} | 211 | viewBox={STAGE_BACKDROP_VIEW_BOX} |
| 216 | xmlns="http://www.w3.org/2000/svg" | 212 | xmlns="http://www.w3.org/2000/svg" |
| 217 | > | 213 | > |
| 218 | <defs> | 214 | <defs> |
apps/web/src/components/SidebarV2.tsx+13-11| ... | @@ -768,7 +768,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -768,7 +768,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 768 | isUnread || isWoke | 768 | isUnread || isWoke |
| 769 | ? "text-foreground" | 769 | ? "text-foreground" |
| 770 | : shouldRecede | 770 | : shouldRecede |
| 771 | ? "text-muted-foreground/80" | 771 | ? "text-secondary-label" |
| 772 | : status === "failed" | 772 | : status === "failed" |
| 773 | ? "text-foreground/95" | 773 | ? "text-foreground/95" |
| 774 | : "text-foreground/90", | 774 | : "text-foreground/90", |
| ... | @@ -779,7 +779,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -779,7 +779,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 779 | ? "text-foreground" | 779 | ? "text-foreground" |
| 780 | : isUnread | 780 | : isUnread |
| 781 | ? "text-muted-foreground" | 781 | ? "text-muted-foreground" |
| 782 | : "text-muted-foreground/70", | 782 | : "text-secondary-label/70", |
| 783 | ), | 783 | ), |
| 784 | isRegeneratingTitle && "opacity-[0.55]", | 784 | isRegeneratingTitle && "opacity-[0.55]", |
| 785 | )} | 785 | )} |
| ... | @@ -799,8 +799,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -799,8 +799,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 799 | "shrink-0 text-xs tabular-nums hover:underline", | 799 | "shrink-0 text-xs tabular-nums hover:underline", |
| 800 | variant === "slim" && variantAction === "unsettle" | 800 | variant === "slim" && variantAction === "unsettle" |
| 801 | ? props.isActive | 801 | ? props.isActive |
| 802 | ? "text-muted-foreground/70" | 802 | ? "text-secondary-label" |
| 803 | : cn("text-muted-foreground/35 transition-colors", settledPrHoverClass) | 803 | : cn("text-secondary-label transition-colors", settledPrHoverClass) |
| 804 | : prStatus.colorClass, | 804 | : prStatus.colorClass, |
| 805 | )} | 805 | )} |
| 806 | aria-label={prStatus.tooltip} | 806 | aria-label={prStatus.tooltip} |
| ... | @@ -871,7 +871,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -871,7 +871,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 871 | <span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end"> | 871 | <span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end"> |
| 872 | <span | 872 | <span |
| 873 | className={cn( | 873 | className={cn( |
| 874 | "inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity", | 874 | "inline-flex justify-end tabular-nums text-secondary-label transition-opacity", |
| 875 | !isWoke && "group-hover/v2-row:opacity-0", | 875 | !isWoke && "group-hover/v2-row:opacity-0", |
| 876 | )} | 876 | )} |
| 877 | > | 877 | > |
| ... | @@ -983,7 +983,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -983,7 +983,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 983 | {props.projectTitle ? ( | 983 | {props.projectTitle ? ( |
| 984 | <span | 984 | <span |
| 985 | className={cn( | 985 | className={cn( |
| 986 | "min-w-0 flex-1 truncate text-xs text-muted-foreground/85", | 986 | "min-w-0 flex-1 truncate text-secondary-label text-xs", |
| 987 | shouldRecede ? "font-normal" : "font-medium", | 987 | shouldRecede ? "font-normal" : "font-medium", |
| 988 | )} | 988 | )} |
| 989 | > | 989 | > |
| ... | @@ -1012,7 +1012,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -1012,7 +1012,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 1012 | isWokeStatus | 1012 | isWokeStatus |
| 1013 | ? "pointer-events-auto" | 1013 | ? "pointer-events-auto" |
| 1014 | : "pointer-events-none group-has-[:focus-visible]/v2-status-slot:absolute group-has-[:focus-visible]/v2-status-slot:right-0 group-has-[:focus-visible]/v2-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0", | 1014 | : "pointer-events-none group-has-[:focus-visible]/v2-status-slot:absolute group-has-[:focus-visible]/v2-status-slot:right-0 group-has-[:focus-visible]/v2-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0", |
| 1015 | "self-center justify-self-end tabular-nums text-muted-foreground/65 transition-opacity", | 1015 | "self-center justify-self-end tabular-nums text-secondary-label transition-opacity", |
| 1016 | snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0", | 1016 | snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0", |
| 1017 | )} | 1017 | )} |
| 1018 | > | 1018 | > |
| ... | @@ -1101,14 +1101,14 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -1101,14 +1101,14 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 1101 | </span> | 1101 | </span> |
| 1102 | ) : null} | 1102 | ) : null} |
| 1103 | </div> | 1103 | </div> |
| 1104 | <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground/75"> | 1104 | <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-secondary-label text-xs"> |
| 1105 | {/* While working, the current plan step outranks the branch: | 1105 | {/* While working, the current plan step outranks the branch: |
| 1106 | it's the one line that says what the thread is doing. */} | 1106 | it's the one line that says what the thread is doing. */} |
| 1107 | {status === "working" && thread.planProgress ? ( | 1107 | {status === "working" && thread.planProgress ? ( |
| 1108 | <span className="min-w-0 flex-1 truncate whitespace-nowrap"> | 1108 | <span className="min-w-0 flex-1 truncate whitespace-nowrap"> |
| 1109 | {thread.planProgress.step} | 1109 | {thread.planProgress.step} |
| 1110 | {/* Completed count, matching the transcript chip's n/m. */} | 1110 | {/* Completed count, matching the transcript chip's n/m. */} |
| 1111 | <span className="text-muted-foreground/50 tabular-nums"> | 1111 | <span className="text-icon-muted tabular-nums"> |
| 1112 | {" "} | 1112 | {" "} |
| 1113 | {thread.planProgress.completedSteps}/{thread.planProgress.totalSteps} | 1113 | {thread.planProgress.completedSteps}/{thread.planProgress.totalSteps} |
| 1114 | </span> | 1114 | </span> |
| ... | @@ -2834,7 +2834,9 @@ export default function SidebarV2() { | ... | @@ -2834,7 +2834,9 @@ export default function SidebarV2() { |
| 2834 | <SidebarContent | 2834 | <SidebarContent |
| 2835 | className="gap-0" | 2835 | className="gap-0" |
| 2836 | fixedHeader={ | 2836 | fixedHeader={ |
| 2837 | <SidebarGroup className="gap-1 p-[var(--sidebar-content-inset)]"> | 2837 | // Lifted above the stage backdrop, whose fade bleeds below the |
| 2838 | // header and would otherwise paint across the search row's outline. | ||
| 2839 | <SidebarGroup className="relative z-[1] gap-1 p-[var(--sidebar-content-inset)]"> | ||
| 2838 | <div className="flex items-center gap-1"> | 2840 | <div className="flex items-center gap-1"> |
| 2839 | <div className="flex h-8 min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-sidebar-muted-foreground hover:bg-sidebar-row-hover hover:text-sidebar-foreground"> | 2841 | <div className="flex h-8 min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-sidebar-muted-foreground hover:bg-sidebar-row-hover hover:text-sidebar-foreground"> |
| 2840 | <SearchIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" /> | 2842 | <SearchIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" /> |
| ... | @@ -2969,7 +2971,7 @@ export default function SidebarV2() { | ... | @@ -2969,7 +2971,7 @@ export default function SidebarV2() { |
| 2969 | type="button" | 2971 | type="button" |
| 2970 | aria-label={`Project actions for ${project.displayName}`} | 2972 | aria-label={`Project actions for ${project.displayName}`} |
| 2971 | title={`Project actions for ${project.displayName}`} | 2973 | title={`Project actions for ${project.displayName}`} |
| 2972 | className="ml-auto inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground/55 outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring" | 2974 | className="ml-auto inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-icon-muted outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring" |
| 2973 | onPointerDown={(event) => event.stopPropagation()} | 2975 | onPointerDown={(event) => event.stopPropagation()} |
| 2974 | onClick={(event) => { | 2976 | onClick={(event) => { |
| 2975 | void handleProjectActions(event, project); | 2977 | void handleProjectActions(event, project); |
apps/web/src/components/ThreadTerminalDrawer.tsx+24-7| ... | @@ -135,6 +135,10 @@ function normalizeComputedColor(value: string | null | undefined, fallback: stri | ... | @@ -135,6 +135,10 @@ function normalizeComputedColor(value: string | null | undefined, fallback: stri |
| 135 | return value ?? fallback; | 135 | return value ?? fallback; |
| 136 | } | 136 | } |
| 137 | 137 | ||
| 138 | function readThemeColor(styles: CSSStyleDeclaration, variable: string, fallback: string): string { | ||
| 139 | return normalizeComputedColor(styles.getPropertyValue(variable), fallback); | ||
| 140 | } | ||
| 141 | |||
| 138 | /** The surface treats an omitted family or size as "use the built-in default". */ | 142 | /** The surface treats an omitted family or size as "use the built-in default". */ |
| 139 | function terminalFontOptions(family: string, size: number): { family?: string; size: number } { | 143 | function terminalFontOptions(family: string, size: number): { family?: string; size: number } { |
| 140 | const trimmed = family.trim(); | 144 | const trimmed = family.trim(); |
| ... | @@ -151,6 +155,7 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty | ... | @@ -151,6 +155,7 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty |
| 151 | document.body; | 155 | document.body; |
| 152 | const drawerStyles = getComputedStyle(drawerSurface); | 156 | const drawerStyles = getComputedStyle(drawerSurface); |
| 153 | const bodyStyles = getComputedStyle(document.body); | 157 | const bodyStyles = getComputedStyle(document.body); |
| 158 | const themeStyles = getComputedStyle(document.documentElement); | ||
| 154 | const background = normalizeComputedColor( | 159 | const background = normalizeComputedColor( |
| 155 | drawerStyles.backgroundColor, | 160 | drawerStyles.backgroundColor, |
| 156 | normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground), | 161 | normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground), |
| ... | @@ -159,20 +164,32 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty | ... | @@ -159,20 +164,32 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty |
| 159 | drawerStyles.color, | 164 | drawerStyles.color, |
| 160 | normalizeComputedColor(bodyStyles.color, fallbackForeground), | 165 | normalizeComputedColor(bodyStyles.color, fallbackForeground), |
| 161 | ); | 166 | ); |
| 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 | return { | 179 | return { |
| 164 | background: parseTerminalColor( | 180 | background: parseTerminalColor( |
| 165 | background, | 181 | terminalBackground, |
| 166 | isDark ? { r: 14, g: 18, b: 24 } : { r: 255, g: 255, b: 255 }, | 182 | isDark ? { r: 14, g: 18, b: 24 } : { r: 255, g: 255, b: 255 }, |
| 167 | ), | 183 | ), |
| 168 | foreground: parseTerminalColor( | 184 | foreground: parseTerminalColor( |
| 169 | foreground, | 185 | terminalForeground, |
| 170 | isDark ? { r: 237, g: 241, b: 247 } : { r: 28, g: 33, b: 41 }, | 186 | isDark ? { r: 237, g: 241, b: 247 } : { r: 28, g: 33, b: 41 }, |
| 171 | ), | 187 | ), |
| 172 | cursor: isDark ? { r: 180, g: 203, b: 255 } : { r: 38, g: 56, b: 78 }, | 188 | cursor: parseTerminalColor( |
| 173 | // Matches the xterm selection overlays this renderer replaced; the text | 189 | terminalCursor, |
| 174 | // color underneath is left unchanged for contrast in both themes. | 190 | isDark ? { r: 180, g: 203, b: 255 } : { r: 38, g: 56, b: 78 }, |
| 175 | selectionBackground: isDark ? "rgba(180, 203, 255, 0.25)" : "rgba(37, 63, 99, 0.2)", | 191 | ), |
| 192 | selectionBackground: terminalSelection, | ||
| 176 | }; | 193 | }; |
| 177 | } | 194 | } |
| 178 | 195 |
apps/web/src/components/chat/ChangedFilesTree.tsx+1| ... | @@ -62,6 +62,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { | ... | @@ -62,6 +62,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { |
| 62 | } | 62 | } |
| 63 | > | 63 | > |
| 64 | <div | 64 | <div |
| 65 | data-changed-files-header="" | ||
| 65 | className={cn( | 66 | className={cn( |
| 66 | "flex items-center justify-between gap-2 rounded-xl px-1", | 67 | "flex items-center justify-between gap-2 rounded-xl px-1", |
| 67 | expanded && | 68 | expanded && |
apps/web/src/components/chat/ChatComposer.tsx+8-10| ... | @@ -318,8 +318,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop | ... | @@ -318,8 +318,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop |
| 318 | className={cn( | 318 | className={cn( |
| 319 | "shrink-0 whitespace-nowrap", | 319 | "shrink-0 whitespace-nowrap", |
| 320 | props.interactionMode === "plan" | 320 | props.interactionMode === "plan" |
| 321 | ? "bg-blue-500/10 text-blue-400 hover:bg-blue-500/15 hover:text-blue-300" | 321 | ? "bg-accent text-accent-foreground hover:bg-accent/80" |
| 322 | : "text-muted-foreground/70 hover:text-foreground/80", | 322 | : "text-secondary-label hover:text-foreground", |
| 323 | )} | 323 | )} |
| 324 | type="button" | 324 | type="button" |
| 325 | onClick={props.onToggleInteractionMode} | 325 | onClick={props.onToggleInteractionMode} |
| ... | @@ -420,7 +420,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( | ... | @@ -420,7 +420,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( |
| 420 | /> | 420 | /> |
| 421 | ) : null} | 421 | ) : null} |
| 422 | {props.isPreparingWorktree ? ( | 422 | {props.isPreparingWorktree ? ( |
| 423 | <span className="text-muted-foreground/70 text-xs">Preparing worktree...</span> | 423 | <span className="text-secondary-label text-xs">Preparing worktree...</span> |
| 424 | ) : null} | 424 | ) : null} |
| 425 | <ComposerPrimaryActions | 425 | <ComposerPrimaryActions |
| 426 | compact={props.compact} | 426 | compact={props.compact} |
| ... | @@ -2767,9 +2767,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2767,9 +2767,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2767 | type="button" | 2767 | type="button" |
| 2768 | className={cn( | 2768 | className={cn( |
| 2769 | "min-w-0 flex-1 truncate bg-transparent py-1.5 text-left text-sm", | 2769 | "min-w-0 flex-1 truncate bg-transparent py-1.5 text-left text-sm", |
| 2770 | activePendingProgress?.customAnswer | 2770 | activePendingProgress?.customAnswer ? "text-foreground" : "text-placeholder", |
| 2771 | ? "text-foreground" | ||
| 2772 | : "text-muted-foreground/60", | ||
| 2773 | !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2", | 2771 | !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2", |
| 2774 | )} | 2772 | )} |
| 2775 | onPointerDown={(event) => event.preventDefault()} | 2773 | onPointerDown={(event) => event.preventDefault()} |
| ... | @@ -2814,7 +2812,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2814,7 +2812,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2814 | "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none", | 2812 | "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none", |
| 2815 | (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim()) | 2813 | (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim()) |
| 2816 | ? "text-foreground" | 2814 | ? "text-foreground" |
| 2817 | : "text-muted-foreground/35", | 2815 | : "text-placeholder", |
| 2818 | )} | 2816 | )} |
| 2819 | onPointerDown={(event) => event.preventDefault()} | 2817 | onPointerDown={(event) => event.preventDefault()} |
| 2820 | onClick={expandMobileComposer} | 2818 | onClick={expandMobileComposer} |
| ... | @@ -2828,7 +2826,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2828,7 +2826,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2828 | </button> | 2826 | </button> |
| 2829 | <button | 2827 | <button |
| 2830 | type="button" | 2828 | type="button" |
| 2831 | className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/90 text-primary-foreground disabled:opacity-30" | 2829 | className="flex size-8 shrink-0 items-center justify-center rounded-full bg-message-action text-message-action-foreground hover:bg-message-action-hover disabled:opacity-30" |
| 2832 | disabled={collapsedComposerPrimaryActionDisabled} | 2830 | disabled={collapsedComposerPrimaryActionDisabled} |
| 2833 | aria-label={collapsedComposerPrimaryActionLabel} | 2831 | aria-label={collapsedComposerPrimaryActionLabel} |
| 2834 | onPointerDown={(event) => event.preventDefault()} | 2832 | onPointerDown={(event) => event.preventDefault()} |
| ... | @@ -2978,7 +2976,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2978,7 +2976,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2978 | /> | 2976 | /> |
| 2979 | </button> | 2977 | </button> |
| 2980 | ) : ( | 2978 | ) : ( |
| 2981 | <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70"> | 2979 | <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-secondary-label"> |
| 2982 | {image.name} | 2980 | {image.name} |
| 2983 | </div> | 2981 | </div> |
| 2984 | )} | 2982 | )} |
| ... | @@ -3116,7 +3114,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3116,7 +3114,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3116 | variant="ghost" | 3114 | variant="ghost" |
| 3117 | disabled | 3115 | disabled |
| 3118 | data-chat-provider-unavailable="true" | 3116 | data-chat-provider-unavailable="true" |
| 3119 | className="shrink-0 gap-2 px-2 text-muted-foreground/70 sm:px-3" | 3117 | className="shrink-0 gap-2 px-2 text-secondary-label sm:px-3" |
| 3120 | > | 3118 | > |
| 3121 | <CircleAlertIcon className="size-4" /> | 3119 | <CircleAlertIcon className="size-4" /> |
| 3122 | No provider available | 3120 | No provider available |
apps/web/src/components/chat/ChatHeader.tsx+1-1| ... | @@ -114,7 +114,7 @@ export const ChatHeader = memo(function ChatHeader({ | ... | @@ -114,7 +114,7 @@ export const ChatHeader = memo(function ChatHeader({ |
| 114 | </TooltipTrigger> | 114 | </TooltipTrigger> |
| 115 | <TooltipPopup side="top">New thread in {activeProjectName}</TooltipPopup> | 115 | <TooltipPopup side="top">New thread in {activeProjectName}</TooltipPopup> |
| 116 | </Tooltip> | 116 | </Tooltip> |
| 117 | <span aria-hidden className="text-muted-foreground/40"> | 117 | <span aria-hidden className="text-icon-muted"> |
| 118 | / | 118 | / |
| 119 | </span> | 119 | </span> |
| 120 | </span> | 120 | </span> |
apps/web/src/components/chat/ComposerCommandMenu.tsx+9-9| ... | @@ -150,7 +150,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { | ... | @@ -150,7 +150,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { |
| 150 | {groupIndex > 0 ? <CommandSeparator className="my-0.5" /> : null} | 150 | {groupIndex > 0 ? <CommandSeparator className="my-0.5" /> : null} |
| 151 | <CommandGroup> | 151 | <CommandGroup> |
| 152 | {group.label ? ( | 152 | {group.label ? ( |
| 153 | <CommandGroupLabel className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/55"> | 153 | <CommandGroupLabel className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-secondary-label"> |
| 154 | {group.label} | 154 | {group.label} |
| 155 | </CommandGroupLabel> | 155 | </CommandGroupLabel> |
| 156 | ) : null} | 156 | ) : null} |
| ... | @@ -172,10 +172,10 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { | ... | @@ -172,10 +172,10 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { |
| 172 | <div className="px-5 py-3.5"> | 172 | <div className="px-5 py-3.5"> |
| 173 | {props.triggerKind === "skill" ? ( | 173 | {props.triggerKind === "skill" ? ( |
| 174 | <CommandGroup> | 174 | <CommandGroup> |
| 175 | <CommandGroupLabel className="px-0 pt-0 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/55"> | 175 | <CommandGroupLabel className="px-0 pt-0 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-secondary-label"> |
| 176 | Skills | 176 | Skills |
| 177 | </CommandGroupLabel> | 177 | </CommandGroupLabel> |
| 178 | <p className="text-muted-foreground/70 text-xs"> | 178 | <p className="text-secondary-label text-xs"> |
| 179 | {props.isLoading | 179 | {props.isLoading |
| 180 | ? "Searching workspace skills..." | 180 | ? "Searching workspace skills..." |
| 181 | : (props.emptyStateText ?? | 181 | : (props.emptyStateText ?? |
| ... | @@ -183,7 +183,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { | ... | @@ -183,7 +183,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { |
| 183 | </p> | 183 | </p> |
| 184 | </CommandGroup> | 184 | </CommandGroup> |
| 185 | ) : ( | 185 | ) : ( |
| 186 | <p className="text-muted-foreground/70 text-xs"> | 186 | <p className="text-secondary-label text-xs"> |
| 187 | {props.isLoading | 187 | {props.isLoading |
| 188 | ? "Searching workspace files..." | 188 | ? "Searching workspace files..." |
| 189 | : (props.emptyStateText ?? | 189 | : (props.emptyStateText ?? |
| ... | @@ -235,26 +235,26 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { | ... | @@ -235,26 +235,26 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { |
| 235 | /> | 235 | /> |
| 236 | ) : null} | 236 | ) : null} |
| 237 | {props.item.type === "slash-command" ? ( | 237 | {props.item.type === "slash-command" ? ( |
| 238 | <BotIcon className="size-4 shrink-0 text-muted-foreground/80" /> | 238 | <BotIcon className="size-4 shrink-0 text-icon-muted" /> |
| 239 | ) : null} | 239 | ) : null} |
| 240 | {props.item.type === "provider-slash-command" ? ( | 240 | {props.item.type === "provider-slash-command" ? ( |
| 241 | <span className="inline-flex size-4 shrink-0 items-center justify-center text-muted-foreground/80"> | 241 | <span className="inline-flex size-4 shrink-0 items-center justify-center text-icon-muted"> |
| 242 | <SkillGlyph className="size-3.5" /> | 242 | <SkillGlyph className="size-3.5" /> |
| 243 | </span> | 243 | </span> |
| 244 | ) : null} | 244 | ) : null} |
| 245 | {props.item.type === "skill" ? ( | 245 | {props.item.type === "skill" ? ( |
| 246 | <span className="inline-flex size-4 shrink-0 items-center justify-center text-muted-foreground/80"> | 246 | <span className="inline-flex size-4 shrink-0 items-center justify-center text-icon-muted"> |
| 247 | <SkillGlyph className="size-3.5" /> | 247 | <SkillGlyph className="size-3.5" /> |
| 248 | </span> | 248 | </span> |
| 249 | ) : null} | 249 | ) : null} |
| 250 | <span className="flex min-w-0 flex-1 items-center gap-2"> | 250 | <span className="flex min-w-0 flex-1 items-center gap-2"> |
| 251 | <span className="shrink-0">{props.item.label}</span> | 251 | <span className="shrink-0">{props.item.label}</span> |
| 252 | <span className="min-w-0 flex-1 truncate text-muted-foreground/70 text-xs"> | 252 | <span className="min-w-0 flex-1 truncate text-secondary-label text-xs"> |
| 253 | {props.item.description} | 253 | {props.item.description} |
| 254 | </span> | 254 | </span> |
| 255 | </span> | 255 | </span> |
| 256 | {skillSourceLabel ? ( | 256 | {skillSourceLabel ? ( |
| 257 | <span className="shrink-0 pl-2 text-muted-foreground/70 text-xs">{skillSourceLabel}</span> | 257 | <span className="shrink-0 pl-2 text-secondary-label text-xs">{skillSourceLabel}</span> |
| 258 | ) : null} | 258 | ) : null} |
| 259 | </CommandItem> | 259 | </CommandItem> |
| 260 | ); | 260 | ); |
apps/web/src/components/chat/ComposerControl.tsx+2-2| ... | @@ -6,7 +6,7 @@ import { Button } from "../ui/button"; | ... | @@ -6,7 +6,7 @@ import { Button } from "../ui/button"; |
| 6 | import { SelectTrigger } from "../ui/select"; | 6 | import { SelectTrigger } from "../ui/select"; |
| 7 | 7 | ||
| 8 | const composerControlClassName = | 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 | export function ComposerControl({ | 11 | export function ComposerControl({ |
| 12 | className, | 12 | className, |
| ... | @@ -46,7 +46,7 @@ export function ComposerControlChevron() { | ... | @@ -46,7 +46,7 @@ export function ComposerControlChevron() { |
| 46 | return ( | 46 | return ( |
| 47 | <ChevronDownIcon | 47 | <ChevronDownIcon |
| 48 | aria-hidden="true" | 48 | aria-hidden="true" |
| 49 | className="-mx-0.5 size-3.5 shrink-0 text-muted-foreground opacity-70" | 49 | className="-mx-0.5 size-3.5 shrink-0 text-icon-muted" |
| 50 | data-composer-control-chevron | 50 | data-composer-control-chevron |
| 51 | strokeWidth={2.25} | 51 | strokeWidth={2.25} |
| 52 | /> | 52 | /> |
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+5-5| ... | @@ -155,18 +155,18 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( | ... | @@ -155,18 +155,18 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 155 | return ( | 155 | return ( |
| 156 | <div className="px-4 py-3 sm:px-5"> | 156 | <div className="px-4 py-3 sm:px-5"> |
| 157 | <div className="mb-2 flex items-center gap-3"> | 157 | <div className="mb-2 flex items-center gap-3"> |
| 158 | <span className="text-[11px] font-semibold tracking-widest text-muted-foreground/55 uppercase"> | 158 | <span className="text-secondary-label text-[11px] font-semibold tracking-widest uppercase"> |
| 159 | {activeQuestion.header} | 159 | {activeQuestion.header} |
| 160 | </span> | 160 | </span> |
| 161 | {prompt.questions.length > 1 ? ( | 161 | {prompt.questions.length > 1 ? ( |
| 162 | <span className="flex h-5 items-center rounded-md bg-muted/60 px-1.5 text-[10px] font-medium tabular-nums text-muted-foreground/60"> | 162 | <span className="flex h-5 items-center rounded-md bg-muted/60 px-1.5 text-secondary-label text-[10px] font-medium tabular-nums"> |
| 163 | {questionIndex + 1}/{prompt.questions.length} | 163 | {questionIndex + 1}/{prompt.questions.length} |
| 164 | </span> | 164 | </span> |
| 165 | ) : null} | 165 | ) : null} |
| 166 | </div> | 166 | </div> |
| 167 | <p className="text-sm text-foreground/90">{activeQuestion.question}</p> | 167 | <p className="text-sm text-foreground/90">{activeQuestion.question}</p> |
| 168 | {activeQuestion.multiSelect ? ( | 168 | {activeQuestion.multiSelect ? ( |
| 169 | <p className="mt-1 text-xs text-muted-foreground/65">Select one or more options.</p> | 169 | <p className="mt-1 text-secondary-label text-xs">Select one or more options.</p> |
| 170 | ) : null} | 170 | ) : null} |
| 171 | <div className="mt-3 space-y-1.5"> | 171 | <div className="mt-3 space-y-1.5"> |
| 172 | {activeQuestion.options.map((option, index) => { | 172 | {activeQuestion.options.map((option, index) => { |
| ... | @@ -190,7 +190,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( | ... | @@ -190,7 +190,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 190 | <div className="min-w-0 flex-1 flex flex-col gap-0.5"> | 190 | <div className="min-w-0 flex-1 flex flex-col gap-0.5"> |
| 191 | <span className="text-sm font-medium">{option.label}</span> | 191 | <span className="text-sm font-medium">{option.label}</span> |
| 192 | {option.description && option.description !== option.label ? ( | 192 | {option.description && option.description !== option.label ? ( |
| 193 | <span className="text-xs text-muted-foreground">{option.description}</span> | 193 | <span className="text-secondary-label text-xs">{option.description}</span> |
| 194 | ) : null} | 194 | ) : null} |
| 195 | </div> | 195 | </div> |
| 196 | {isSelected ? ( | 196 | {isSelected ? ( |
| ... | @@ -199,7 +199,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( | ... | @@ -199,7 +199,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 199 | <kbd | 199 | <kbd |
| 200 | className={cn( | 200 | className={cn( |
| 201 | "flex size-5 shrink-0 items-center justify-center rounded border border-border/50 text-[11px] font-medium tabular-nums transition-colors duration-150", | 201 | "flex size-5 shrink-0 items-center justify-center rounded border border-border/50 text-[11px] font-medium tabular-nums transition-colors duration-150", |
| 202 | "bg-background/35 text-muted-foreground/70 group-hover:border-border/70 group-hover:text-muted-foreground", | 202 | "bg-background/35 text-secondary-label group-hover:border-border/70 group-hover:text-foreground", |
| 203 | )} | 203 | )} |
| 204 | > | 204 | > |
| 205 | {shortcutKey} | 205 | {shortcutKey} |
apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx+5-5| ... | @@ -63,13 +63,13 @@ export function ComposerPreviewAnnotationCards({ | ... | @@ -63,13 +63,13 @@ export function ComposerPreviewAnnotationCards({ |
| 63 | /> | 63 | /> |
| 64 | </button> | 64 | </button> |
| 65 | ) : ( | 65 | ) : ( |
| 66 | <span className="grid size-10 shrink-0 place-items-center border-r border-border/70 text-blue-500"> | 66 | <span className="grid size-10 shrink-0 place-items-center border-r border-border/70 text-message-action"> |
| 67 | <MousePointerClick className="size-3.5" /> | 67 | <MousePointerClick className="size-3.5" /> |
| 68 | </span> | 68 | </span> |
| 69 | )} | 69 | )} |
| 70 | <div className="min-w-0 px-2.5 py-2 pr-8"> | 70 | <div className="min-w-0 px-2.5 py-2 pr-8"> |
| 71 | {annotation.comment.trim() ? ( | 71 | {annotation.comment.trim() ? ( |
| 72 | <p className="max-w-80 truncate text-xs font-medium text-foreground/90"> | 72 | <p className="max-w-80 truncate text-foreground text-xs font-medium"> |
| 73 | {annotation.comment.trim()} | 73 | {annotation.comment.trim()} |
| 74 | </p> | 74 | </p> |
| 75 | ) : null} | 75 | ) : null} |
| ... | @@ -84,13 +84,13 @@ export function ComposerPreviewAnnotationCards({ | ... | @@ -84,13 +84,13 @@ export function ComposerPreviewAnnotationCards({ |
| 84 | {elementLabels.slice(0, 2).map(({ id, label }) => ( | 84 | {elementLabels.slice(0, 2).map(({ id, label }) => ( |
| 85 | <span | 85 | <span |
| 86 | key={id} | 86 | key={id} |
| 87 | className="max-w-40 truncate font-mono text-[10px] text-foreground/65" | 87 | className="max-w-40 truncate font-mono text-secondary-label text-[10px]" |
| 88 | > | 88 | > |
| 89 | {label} | 89 | {label} |
| 90 | </span> | 90 | </span> |
| 91 | ))} | 91 | ))} |
| 92 | {elementLabels.length > 2 ? ( | 92 | {elementLabels.length > 2 ? ( |
| 93 | <span className="text-[10px] text-muted-foreground"> | 93 | <span className="text-secondary-label text-[10px]"> |
| 94 | +{elementLabels.length - 2} | 94 | +{elementLabels.length - 2} |
| 95 | </span> | 95 | </span> |
| 96 | ) : null} | 96 | ) : null} |
| ... | @@ -131,7 +131,7 @@ export function ComposerPreviewAnnotationCards({ | ... | @@ -131,7 +131,7 @@ export function ComposerPreviewAnnotationCards({ |
| 131 | <button | 131 | <button |
| 132 | type="button" | 132 | type="button" |
| 133 | aria-label="Remove preview annotation" | 133 | aria-label="Remove preview annotation" |
| 134 | className="absolute right-1.5 top-1.5 grid size-5 place-items-center rounded text-muted-foreground/60 transition hover:bg-muted hover:text-foreground" | 134 | className="absolute right-1.5 top-1.5 grid size-5 place-items-center rounded text-icon-muted transition hover:bg-muted hover:text-foreground" |
| 135 | onClick={() => onRemove(annotation.id)} | 135 | onClick={() => onRemove(annotation.id)} |
| 136 | > | 136 | > |
| 137 | <X className="size-3" /> | 137 | <X className="size-3" /> |
apps/web/src/components/chat/ComposerPrimaryActions.tsx+12-19| ... | @@ -1,8 +1,6 @@ | ... | @@ -1,8 +1,6 @@ |
| 1 | import { memo, type PointerEventHandler } from "react"; | 1 | import { memo, type PointerEventHandler } from "react"; |
| 2 | import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react"; | 2 | import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react"; |
| 3 | import { useEnvironmentIdentificationMode } from "~/hooks/useSettings"; | ||
| 4 | import { cn } from "~/lib/utils"; | 3 | import { cn } from "~/lib/utils"; |
| 5 | import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../SidebarStageBackdrop"; | ||
| 6 | import { Button } from "../ui/button"; | 4 | import { Button } from "../ui/button"; |
| 7 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; | 5 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; |
| 8 | import { Spinner } from "../ui/spinner"; | 6 | import { Spinner } from "../ui/spinner"; |
| ... | @@ -75,11 +73,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ | ... | @@ -75,11 +73,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 75 | const pointerFocusProps = preserveComposerFocusOnPointerDown | 73 | const pointerFocusProps = preserveComposerFocusOnPointerDown |
| 76 | ? { onPointerDown: preventPointerFocus } | 74 | ? { onPointerDown: preventPointerFocus } |
| 77 | : undefined; | 75 | : undefined; |
| 78 | const environmentIdentificationMode = useEnvironmentIdentificationMode(); | ||
| 79 | const isSendDisabled = sendDisabledReason !== null; | 76 | const isSendDisabled = sendDisabledReason !== null; |
| 80 | const stageBackdropVariant = useSidebarStageBackdropVariant( | ||
| 81 | environmentIdentificationMode === "artwork", | ||
| 82 | ); | ||
| 83 | 77 | ||
| 84 | if (pendingAction) { | 78 | if (pendingAction) { |
| 85 | return ( | 79 | return ( |
| ... | @@ -113,7 +107,10 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ | ... | @@ -113,7 +107,10 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 113 | <Button | 107 | <Button |
| 114 | type="submit" | 108 | type="submit" |
| 115 | size="sm" | 109 | size="sm" |
| 116 | className={cn("rounded-full", compact ? "px-3" : "px-4")} | 110 | className={cn( |
| 111 | "rounded-full bg-message-action text-message-action-foreground hover:bg-message-action-hover", | ||
| 112 | compact ? "px-3" : "px-4", | ||
| 113 | )} | ||
| 117 | {...pointerFocusProps} | 114 | {...pointerFocusProps} |
| 118 | disabled={ | 115 | disabled={ |
| 119 | isEnvironmentUnavailable || | 116 | isEnvironmentUnavailable || |
| ... | @@ -154,7 +151,10 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ | ... | @@ -154,7 +151,10 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 154 | <Button | 151 | <Button |
| 155 | type="submit" | 152 | type="submit" |
| 156 | size="sm" | 153 | size="sm" |
| 157 | className={cn("rounded-full", compact ? "h-9 px-3 sm:h-8" : "h-9 px-4 sm:h-8")} | 154 | className={cn( |
| 155 | "rounded-full bg-message-action text-message-action-foreground hover:bg-message-action-hover", | ||
| 156 | compact ? "h-9 px-3 sm:h-8" : "h-9 px-4 sm:h-8", | ||
| 157 | )} | ||
| 158 | {...pointerFocusProps} | 158 | {...pointerFocusProps} |
| 159 | disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable} | 159 | disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable} |
| 160 | > | 160 | > |
| ... | @@ -168,7 +168,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ | ... | @@ -168,7 +168,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 168 | <Button | 168 | <Button |
| 169 | type="submit" | 169 | type="submit" |
| 170 | size="sm" | 170 | size="sm" |
| 171 | className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8" | 171 | className="h-9 rounded-l-full rounded-r-none bg-message-action px-4 text-message-action-foreground hover:bg-message-action-hover sm:h-8" |
| 172 | {...pointerFocusProps} | 172 | {...pointerFocusProps} |
| 173 | disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable} | 173 | disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable} |
| 174 | > | 174 | > |
| ... | @@ -180,7 +180,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ | ... | @@ -180,7 +180,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 180 | <Button | 180 | <Button |
| 181 | size="sm" | 181 | size="sm" |
| 182 | variant="default" | 182 | variant="default" |
| 183 | className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8" | 183 | className="h-9 rounded-l-none rounded-r-full border-l-message-action-foreground/20 bg-message-action px-2 text-message-action-foreground hover:bg-message-action-hover sm:h-8" |
| 184 | aria-label="Implementation actions" | 184 | aria-label="Implementation actions" |
| 185 | {...pointerFocusProps} | 185 | {...pointerFocusProps} |
| 186 | disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable} | 186 | disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable} |
| ... | @@ -206,10 +206,8 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ | ... | @@ -206,10 +206,8 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 206 | <button | 206 | <button |
| 207 | type="submit" | 207 | type="submit" |
| 208 | className={cn( | 208 | className={cn( |
| 209 | "relative isolate flex h-9 w-9 items-center justify-center overflow-hidden rounded-full text-primary-foreground shadow-xs transition-all duration-150 enabled:cursor-pointer enabled:inset-shadow-[0_1px_--theme(--color-white/16%)] hover:scale-105 active:inset-shadow-[0_1px_--theme(--color-black/8%)] active:shadow-none disabled:pointer-events-none disabled:opacity-30 disabled:shadow-none disabled:hover:scale-100 sm:h-8 sm:w-8", | 209 | "relative isolate flex h-9 w-9 items-center justify-center overflow-hidden rounded-full text-message-action-foreground shadow-xs transition-all duration-150 enabled:cursor-pointer enabled:inset-shadow-[0_1px_--theme(--color-white/16%)] hover:scale-105 active:inset-shadow-[0_1px_--theme(--color-black/8%)] active:shadow-none disabled:pointer-events-none disabled:opacity-30 disabled:shadow-none disabled:hover:scale-100 sm:h-8 sm:w-8", |
| 210 | stageBackdropVariant | 210 | "bg-message-action enabled:shadow-message-action/24 hover:bg-message-action-hover", |
| 211 | ? "bg-transparent enabled:shadow-black/24 enabled:hover:brightness-110" | ||
| 212 | : "bg-primary/90 enabled:shadow-primary/24 hover:bg-primary", | ||
| 213 | )} | 211 | )} |
| 214 | {...pointerFocusProps} | 212 | {...pointerFocusProps} |
| 215 | disabled={ | 213 | disabled={ |
| ... | @@ -233,11 +231,6 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ | ... | @@ -233,11 +231,6 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 233 | : "Send message" | 231 | : "Send message" |
| 234 | } | 232 | } |
| 235 | > | 233 | > |
| 236 | {stageBackdropVariant ? ( | ||
| 237 | <span className="absolute inset-0 -z-10" aria-hidden="true"> | ||
| 238 | <StageBackdropButtonArt variant={stageBackdropVariant} /> | ||
| 239 | </span> | ||
| 240 | ) : null} | ||
| 241 | {isConnecting || isSendBusy ? ( | 234 | {isConnecting || isSendBusy ? ( |
| 242 | <Spinner className="size-3.5" aria-hidden="true" /> | 235 | <Spinner className="size-3.5" aria-hidden="true" /> |
| 243 | ) : ( | 236 | ) : ( |
apps/web/src/components/chat/ComposerStashMenu.tsx+6-6| ... | @@ -94,12 +94,12 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { | ... | @@ -94,12 +94,12 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { |
| 94 | <div className="dropdown-glass relative w-full overflow-hidden rounded-[20px]"> | 94 | <div className="dropdown-glass relative w-full overflow-hidden rounded-[20px]"> |
| 95 | <CommandList className="max-h-72"> | 95 | <CommandList className="max-h-72"> |
| 96 | <CommandGroup> | 96 | <CommandGroup> |
| 97 | <CommandGroupLabel className="flex items-center gap-1.5 px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/55"> | 97 | <CommandGroupLabel className="flex items-center gap-1.5 px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-secondary-label"> |
| 98 | <BookmarkIcon className="size-3" aria-hidden="true" /> | 98 | <BookmarkIcon className="size-3" aria-hidden="true" /> |
| 99 | Stashed prompts | 99 | Stashed prompts |
| 100 | </CommandGroupLabel> | 100 | </CommandGroupLabel> |
| 101 | {entries.length === 0 ? ( | 101 | {entries.length === 0 ? ( |
| 102 | <p className="px-3 pb-3 pt-1 text-muted-foreground/70 text-xs"> | 102 | <p className="px-3 pb-3 pt-1 text-secondary-label text-xs"> |
| 103 | Nothing stashed yet. Press ⌘S with a prompt in the composer to stash it. | 103 | Nothing stashed yet. Press ⌘S with a prompt in the composer to stash it. |
| 104 | </p> | 104 | </p> |
| 105 | ) : ( | 105 | ) : ( |
| ... | @@ -134,23 +134,23 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { | ... | @@ -134,23 +134,23 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { |
| 134 | ))} | 134 | ))} |
| 135 | </span> | 135 | </span> |
| 136 | ) : ( | 136 | ) : ( |
| 137 | <BookmarkIcon className="size-4 shrink-0 text-muted-foreground/60" /> | 137 | <BookmarkIcon className="size-4 shrink-0 text-icon-muted" /> |
| 138 | )} | 138 | )} |
| 139 | <span className="min-w-0 flex-1 truncate text-sm"> | 139 | <span className="min-w-0 flex-1 truncate text-sm"> |
| 140 | {stashEntrySnippet(entry)} | 140 | {stashEntrySnippet(entry)} |
| 141 | </span> | 141 | </span> |
| 142 | {entry.pendingImageCount ? ( | 142 | {entry.pendingImageCount ? ( |
| 143 | <span className="shrink-0 text-[10px] text-muted-foreground/60"> | 143 | <span className="shrink-0 text-[10px] text-secondary-label"> |
| 144 | saving {entry.pendingImageCount} image | 144 | saving {entry.pendingImageCount} image |
| 145 | {entry.pendingImageCount === 1 ? "" : "s"}… | 145 | {entry.pendingImageCount === 1 ? "" : "s"}… |
| 146 | </span> | 146 | </span> |
| 147 | ) : missingImageCount(entry) > 0 ? ( | 147 | ) : missingImageCount(entry) > 0 ? ( |
| 148 | <span className="shrink-0 text-[10px] text-amber-600"> | 148 | <span className="shrink-0 text-[10px] text-warning-foreground"> |
| 149 | {missingImageCount(entry)} image | 149 | {missingImageCount(entry)} image |
| 150 | {missingImageCount(entry) === 1 ? "" : "s"} dropped | 150 | {missingImageCount(entry) === 1 ? "" : "s"} dropped |
| 151 | </span> | 151 | </span> |
| 152 | ) : null} | 152 | ) : null} |
| 153 | <span className="shrink-0 text-muted-foreground/60 text-xs"> | 153 | <span className="shrink-0 text-secondary-label text-xs"> |
| 154 | {formatRelativeTimeLabel(entry.createdAt)} | 154 | {formatRelativeTimeLabel(entry.createdAt)} |
| 155 | </span> | 155 | </span> |
| 156 | <Button | 156 | <Button |
apps/web/src/components/chat/ContextWindowMeter.tsx+6-6| ... | @@ -26,7 +26,7 @@ export function ContextWindowMeter(props: { | ... | @@ -26,7 +26,7 @@ export function ContextWindowMeter(props: { |
| 26 | const showTotalProcessed = totalProcessedTokens !== null && totalProcessedTokens > 0; | 26 | const showTotalProcessed = totalProcessedTokens !== null && totalProcessedTokens > 0; |
| 27 | const isOverloaded = normalizedPercentage > 90; | 27 | const isOverloaded = normalizedPercentage > 90; |
| 28 | const usageColor = isOverloaded | 28 | const usageColor = isOverloaded |
| 29 | ? "var(--color-red-500)" | 29 | ? "var(--color-error)" |
| 30 | : "color-mix(in oklab, var(--color-muted-foreground) 72%, transparent)"; | 30 | : "color-mix(in oklab, var(--color-muted-foreground) 72%, transparent)"; |
| 31 | 31 | ||
| 32 | return ( | 32 | return ( |
| ... | @@ -91,7 +91,7 @@ export function ContextWindowMeter(props: { | ... | @@ -91,7 +91,7 @@ export function ContextWindowMeter(props: { |
| 91 | <div className="flex items-center justify-between gap-3"> | 91 | <div className="flex items-center justify-between gap-3"> |
| 92 | <div className="font-medium text-muted-foreground text-xs">Context Window</div> | 92 | <div className="font-medium text-muted-foreground text-xs">Context Window</div> |
| 93 | {usage.maxTokens !== null && usedPercentage ? ( | 93 | {usage.maxTokens !== null && usedPercentage ? ( |
| 94 | <div className="text-[11px] tabular-nums text-muted-foreground/70"> | 94 | <div className="text-secondary-label text-[11px] tabular-nums"> |
| 95 | <span>{usedPercentage}</span> | 95 | <span>{usedPercentage}</span> |
| 96 | <span className="mx-1">·</span> | 96 | <span className="mx-1">·</span> |
| 97 | <span> | 97 | <span> |
| ... | @@ -100,7 +100,7 @@ export function ContextWindowMeter(props: { | ... | @@ -100,7 +100,7 @@ export function ContextWindowMeter(props: { |
| 100 | </span> | 100 | </span> |
| 101 | </div> | 101 | </div> |
| 102 | ) : ( | 102 | ) : ( |
| 103 | <div className="text-[11px] tabular-nums text-muted-foreground/70"> | 103 | <div className="text-secondary-label text-[11px] tabular-nums"> |
| 104 | {formatContextWindowTokens(usage.usedTokens)} | 104 | {formatContextWindowTokens(usage.usedTokens)} |
| 105 | </div> | 105 | </div> |
| 106 | )} | 106 | )} |
| ... | @@ -122,14 +122,14 @@ export function ContextWindowMeter(props: { | ... | @@ -122,14 +122,14 @@ export function ContextWindowMeter(props: { |
| 122 | ) : null} | 122 | ) : null} |
| 123 | {showTotalProcessed ? ( | 123 | {showTotalProcessed ? ( |
| 124 | <div className="flex items-center justify-between gap-3 text-[11px] leading-4"> | 124 | <div className="flex items-center justify-between gap-3 text-[11px] leading-4"> |
| 125 | <span className="text-muted-foreground/60">Total processed</span> | 125 | <span className="text-secondary-label">Total processed</span> |
| 126 | <span className="font-medium tabular-nums text-muted-foreground/80"> | 126 | <span className="font-medium tabular-nums text-secondary-label"> |
| 127 | {formatContextWindowTokens(totalProcessedTokens)} | 127 | {formatContextWindowTokens(totalProcessedTokens)} |
| 128 | </span> | 128 | </span> |
| 129 | </div> | 129 | </div> |
| 130 | ) : null} | 130 | ) : null} |
| 131 | {usage.compactsAutomatically ? ( | 131 | {usage.compactsAutomatically ? ( |
| 132 | <div className="mt-1 text-pretty text-[11px] font-medium text-muted-foreground/70"> | 132 | <div className="mt-1 text-pretty text-secondary-label text-[11px] font-medium"> |
| 133 | {providerDisplayName ?? "It"} automatically compacts its context when needed. | 133 | {providerDisplayName ?? "It"} automatically compacts its context when needed. |
| 134 | </div> | 134 | </div> |
| 135 | ) : null} | 135 | ) : null} |
apps/web/src/components/chat/MessagesTimeline.test.tsx+1-1| ... | @@ -461,7 +461,7 @@ describe("MessagesTimeline", () => { | ... | @@ -461,7 +461,7 @@ describe("MessagesTimeline", () => { |
| 461 | 461 | ||
| 462 | expect(markup).not.toContain("Show full message"); | 462 | expect(markup).not.toContain("Show full message"); |
| 463 | expect(markup).toContain('data-user-message-collapsible="false"'); | 463 | expect(markup).toContain('data-user-message-collapsible="false"'); |
| 464 | expect(markup).toContain("rounded-2xl bg-accent p-3"); | 464 | expect(markup).toContain("rounded-2xl bg-message p-3"); |
| 465 | }); | 465 | }); |
| 466 | 466 | ||
| 467 | it("renders inline terminal labels with the composer chip UI", () => { | 467 | it("renders inline terminal labels with the composer chip UI", () => { |
apps/web/src/components/chat/MessagesTimeline.tsx+31-35| ... | @@ -534,9 +534,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -534,9 +534,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 534 | } | 534 | } |
| 535 | return ( | 535 | return ( |
| 536 | <div className="flex h-full items-center justify-center"> | 536 | <div className="flex h-full items-center justify-center"> |
| 537 | <p className="text-sm text-muted-foreground/30"> | 537 | <p className="text-placeholder text-sm">Send a message to start the conversation.</p> |
| 538 | Send a message to start the conversation. | ||
| 539 | </p> | ||
| 540 | </div> | 538 | </div> |
| 541 | ); | 539 | ); |
| 542 | } | 540 | } |
| ... | @@ -965,7 +963,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" | ... | @@ -965,7 +963,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" |
| 965 | 963 | ||
| 966 | return ( | 964 | return ( |
| 967 | <div className="group flex flex-col items-end gap-1"> | 965 | <div className="group flex flex-col items-end gap-1"> |
| 968 | <div className="relative max-w-[80%] rounded-2xl bg-accent p-3"> | 966 | <div className="relative max-w-[80%] rounded-2xl bg-message p-3 text-message-foreground"> |
| 969 | {regularImages.length > 0 && ( | 967 | {regularImages.length > 0 && ( |
| 970 | <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2"> | 968 | <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2"> |
| 971 | {regularImages.map((image: NonNullable<TimelineMessage["attachments"]>[number]) => ( | 969 | {regularImages.map((image: NonNullable<TimelineMessage["attachments"]>[number]) => ( |
| ... | @@ -991,7 +989,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" | ... | @@ -991,7 +989,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" |
| 991 | /> | 989 | /> |
| 992 | </button> | 990 | </button> |
| 993 | ) : ( | 991 | ) : ( |
| 994 | <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70"> | 992 | <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-secondary-label text-[11px]"> |
| 995 | {image.name} | 993 | {image.name} |
| 996 | </div> | 994 | </div> |
| 997 | )} | 995 | )} |
| ... | @@ -1270,7 +1268,7 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin | ... | @@ -1270,7 +1268,7 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin |
| 1270 | const { workingStepLabel } = use(TimelineRowActivityCtx); | 1268 | const { workingStepLabel } = use(TimelineRowActivityCtx); |
| 1271 | return ( | 1269 | return ( |
| 1272 | <div className="py-0.5 pl-1.5"> | 1270 | <div className="py-0.5 pl-1.5"> |
| 1273 | <div className="flex min-w-0 items-center gap-2 pt-1 text-[11px] text-muted-foreground/70 tabular-nums"> | 1271 | <div className="flex min-w-0 items-center gap-2 pt-1 text-secondary-label text-[11px] tabular-nums"> |
| 1274 | <span className="inline-flex items-center gap-[3px]"> | 1272 | <span className="inline-flex items-center gap-[3px]"> |
| 1275 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse" /> | 1273 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse" /> |
| 1276 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:200ms]" /> | 1274 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:200ms]" /> |
| ... | @@ -1349,9 +1347,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ | ... | @@ -1349,9 +1347,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ |
| 1349 | return ( | 1347 | return ( |
| 1350 | <section className="-mx-1 space-y-0.5 px-1 py-0.5" aria-label={groupLabel}> | 1348 | <section className="-mx-1 space-y-0.5 px-1 py-0.5" aria-label={groupLabel}> |
| 1351 | {!onlyToolEntries && ( | 1349 | {!onlyToolEntries && ( |
| 1352 | <p className="px-0.5 pb-0.5 font-medium text-[11px] text-muted-foreground/65"> | 1350 | <p className="px-0.5 pb-0.5 font-medium text-secondary-label text-[11px]">{groupLabel}</p> |
| 1353 | {groupLabel} | ||
| 1354 | </p> | ||
| 1355 | )} | 1351 | )} |
| 1356 | <div className="space-y-px"> | 1352 | <div className="space-y-px"> |
| 1357 | {nonEmptyEntries.map((workEntry) => ( | 1353 | {nonEmptyEntries.map((workEntry) => ( |
| ... | @@ -1391,7 +1387,7 @@ function WorkGroupToggleTimelineRow({ | ... | @@ -1391,7 +1387,7 @@ function WorkGroupToggleTimelineRow({ |
| 1391 | ctx.onToggleWorkGroup(row.groupId, anchorElement); | 1387 | ctx.onToggleWorkGroup(row.groupId, anchorElement); |
| 1392 | }} | 1388 | }} |
| 1393 | > | 1389 | > |
| 1394 | <span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground/65"> | 1390 | <span className="flex size-5 shrink-0 items-center justify-center text-icon-muted"> |
| 1395 | <ChevronDownIcon | 1391 | <ChevronDownIcon |
| 1396 | className={cn( | 1392 | className={cn( |
| 1397 | "size-3.5 shrink-0 opacity-70 transition-transform duration-200", | 1393 | "size-3.5 shrink-0 opacity-70 transition-transform duration-200", |
| ... | @@ -1400,11 +1396,11 @@ function WorkGroupToggleTimelineRow({ | ... | @@ -1400,11 +1396,11 @@ function WorkGroupToggleTimelineRow({ |
| 1400 | /> | 1396 | /> |
| 1401 | </span> | 1397 | </span> |
| 1402 | {row.expanded ? ( | 1398 | {row.expanded ? ( |
| 1403 | <span className="font-medium text-foreground/82"> | 1399 | <span className="font-medium text-foreground"> |
| 1404 | Show fewer {row.onlyToolEntries ? "tool calls" : "log entries"} | 1400 | Show fewer {row.onlyToolEntries ? "tool calls" : "log entries"} |
| 1405 | </span> | 1401 | </span> |
| 1406 | ) : ( | 1402 | ) : ( |
| 1407 | <span className="font-medium text-foreground/82"> | 1403 | <span className="font-medium text-foreground"> |
| 1408 | +{row.hiddenCount} previous {labelNoun} | 1404 | +{row.hiddenCount} previous {labelNoun} |
| 1409 | </span> | 1405 | </span> |
| 1410 | )} | 1406 | )} |
| ... | @@ -1509,7 +1505,7 @@ const UserMessageElementContextChip = memo(function UserMessageElementContextChi | ... | @@ -1509,7 +1505,7 @@ const UserMessageElementContextChip = memo(function UserMessageElementContextChi |
| 1509 | <Tooltip> | 1505 | <Tooltip> |
| 1510 | <TooltipTrigger | 1506 | <TooltipTrigger |
| 1511 | render={ | 1507 | render={ |
| 1512 | <span className="inline-flex max-w-full items-center gap-1 rounded-md border border-border/70 bg-background/70 px-1.5 py-0.5 text-xs text-foreground/85"> | 1508 | <span className="inline-flex max-w-full items-center gap-1 rounded-md border border-border/70 bg-background/70 px-1.5 py-0.5 text-foreground/85 text-xs"> |
| 1513 | <MousePointerClickIcon className="size-3 shrink-0" /> | 1509 | <MousePointerClickIcon className="size-3 shrink-0" /> |
| 1514 | <span className="truncate">{props.context.header}</span> | 1510 | <span className="truncate">{props.context.header}</span> |
| 1515 | </span> | 1511 | </span> |
| ... | @@ -1549,13 +1545,13 @@ function UserMessagePreviewAnnotationCard(props: { | ... | @@ -1549,13 +1545,13 @@ function UserMessagePreviewAnnotationCard(props: { |
| 1549 | ) : null} | 1545 | ) : null} |
| 1550 | <div className="min-w-0 px-2.5 py-2"> | 1546 | <div className="min-w-0 px-2.5 py-2"> |
| 1551 | {props.annotation.comment ? ( | 1547 | {props.annotation.comment ? ( |
| 1552 | <div className="max-w-80 truncate text-xs font-medium text-foreground/90"> | 1548 | <div className="max-w-80 truncate text-foreground text-xs font-medium"> |
| 1553 | {props.annotation.comment} | 1549 | {props.annotation.comment} |
| 1554 | </div> | 1550 | </div> |
| 1555 | ) : null} | 1551 | ) : null} |
| 1556 | <div | 1552 | <div |
| 1557 | className={cn( | 1553 | className={cn( |
| 1558 | "flex items-center gap-2 text-[10px] text-muted-foreground", | 1554 | "flex items-center gap-2 text-secondary-label text-[10px]", |
| 1559 | props.annotation.comment && "mt-1", | 1555 | props.annotation.comment && "mt-1", |
| 1560 | )} | 1556 | )} |
| 1561 | > | 1557 | > |
| ... | @@ -1644,7 +1640,7 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop | ... | @@ -1644,7 +1640,7 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop |
| 1644 | aria-expanded={expanded} | 1640 | aria-expanded={expanded} |
| 1645 | data-scroll-anchor-ignore | 1641 | data-scroll-anchor-ignore |
| 1646 | onClick={() => setExpanded((value) => !value)} | 1642 | onClick={() => setExpanded((value) => !value)} |
| 1647 | className="-ml-1 h-6 rounded-md px-1.5 text-xs text-muted-foreground/72 hover:bg-muted/55 hover:text-foreground/85" | 1643 | className="-ml-1 h-6 rounded-md px-1.5 text-secondary-label text-xs hover:bg-muted/55 hover:text-message-foreground" |
| 1648 | > | 1644 | > |
| 1649 | {expanded ? "Show less" : "Show full message"} | 1645 | {expanded ? "Show less" : "Show full message"} |
| 1650 | </Button> | 1646 | </Button> |
| ... | @@ -1683,7 +1679,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { | ... | @@ -1683,7 +1679,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 1683 | cwd={props.markdownCwd} | 1679 | cwd={props.markdownCwd} |
| 1684 | threadRef={ctx.threadRef ?? undefined} | 1680 | threadRef={ctx.threadRef ?? undefined} |
| 1685 | skills={props.skills} | 1681 | skills={props.skills} |
| 1686 | className="text-foreground" | 1682 | className="text-message-foreground" |
| 1687 | lineBreaks | 1683 | lineBreaks |
| 1688 | /> | 1684 | /> |
| 1689 | ) : null} | 1685 | ) : null} |
| ... | @@ -1695,7 +1691,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { | ... | @@ -1695,7 +1691,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 1695 | const reviewCommentSegments = parseReviewCommentMessageSegments(props.text); | 1691 | const reviewCommentSegments = parseReviewCommentMessageSegments(props.text); |
| 1696 | if (reviewCommentSegments.some((segment) => segment.kind === "review-comment")) { | 1692 | if (reviewCommentSegments.some((segment) => segment.kind === "review-comment")) { |
| 1697 | return ( | 1693 | return ( |
| 1698 | <div className="space-y-3 text-sm leading-relaxed text-foreground"> | 1694 | <div className="space-y-3 text-message-foreground text-sm leading-relaxed"> |
| 1699 | {reviewCommentSegments.map((segment) => | 1695 | {reviewCommentSegments.map((segment) => |
| 1700 | segment.kind === "text" ? ( | 1696 | segment.kind === "text" ? ( |
| 1701 | segment.text.trim().length > 0 ? ( | 1697 | segment.text.trim().length > 0 ? ( |
| ... | @@ -1705,7 +1701,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { | ... | @@ -1705,7 +1701,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 1705 | cwd={props.markdownCwd} | 1701 | cwd={props.markdownCwd} |
| 1706 | threadRef={ctx.threadRef ?? undefined} | 1702 | threadRef={ctx.threadRef ?? undefined} |
| 1707 | skills={props.skills} | 1703 | skills={props.skills} |
| 1708 | className="text-foreground" | 1704 | className="text-message-foreground" |
| 1709 | lineBreaks | 1705 | lineBreaks |
| 1710 | /> | 1706 | /> |
| 1711 | </div> | 1707 | </div> |
| ... | @@ -1764,7 +1760,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { | ... | @@ -1764,7 +1760,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 1764 | } | 1760 | } |
| 1765 | 1761 | ||
| 1766 | return ( | 1762 | return ( |
| 1767 | <div className="whitespace-pre-wrap wrap-break-word text-sm leading-relaxed text-foreground"> | 1763 | <div className="whitespace-pre-wrap wrap-break-word text-message-foreground text-sm leading-relaxed"> |
| 1768 | {inlineNodes} | 1764 | {inlineNodes} |
| 1769 | </div> | 1765 | </div> |
| 1770 | ); | 1766 | ); |
| ... | @@ -1793,7 +1789,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { | ... | @@ -1793,7 +1789,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 1793 | cwd={props.markdownCwd} | 1789 | cwd={props.markdownCwd} |
| 1794 | threadRef={ctx.threadRef ?? undefined} | 1790 | threadRef={ctx.threadRef ?? undefined} |
| 1795 | skills={props.skills} | 1791 | skills={props.skills} |
| 1796 | className="text-foreground" | 1792 | className="text-message-foreground" |
| 1797 | lineBreaks | 1793 | lineBreaks |
| 1798 | />, | 1794 | />, |
| 1799 | ); | 1795 | ); |
| ... | @@ -1802,7 +1798,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { | ... | @@ -1802,7 +1798,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 1802 | } | 1798 | } |
| 1803 | 1799 | ||
| 1804 | return ( | 1800 | return ( |
| 1805 | <div className="whitespace-pre-wrap wrap-break-word text-sm leading-relaxed text-foreground"> | 1801 | <div className="whitespace-pre-wrap wrap-break-word text-message-foreground text-sm leading-relaxed"> |
| 1806 | {inlineNodes} | 1802 | {inlineNodes} |
| 1807 | </div> | 1803 | </div> |
| 1808 | ); | 1804 | ); |
| ... | @@ -1818,7 +1814,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { | ... | @@ -1818,7 +1814,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 1818 | cwd={props.markdownCwd} | 1814 | cwd={props.markdownCwd} |
| 1819 | threadRef={ctx.threadRef ?? undefined} | 1815 | threadRef={ctx.threadRef ?? undefined} |
| 1820 | skills={props.skills} | 1816 | skills={props.skills} |
| 1821 | className="text-foreground" | 1817 | className="text-message-foreground" |
| 1822 | lineBreaks | 1818 | lineBreaks |
| 1823 | /> | 1819 | /> |
| 1824 | ); | 1820 | ); |
| ... | @@ -1835,10 +1831,10 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte | ... | @@ -1835,10 +1831,10 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte |
| 1835 | return ( | 1831 | return ( |
| 1836 | <div className="space-y-2 rounded-lg border border-border/70 bg-background/70 p-3"> | 1832 | <div className="space-y-2 rounded-lg border border-border/70 bg-background/70 p-3"> |
| 1837 | <div className="space-y-1"> | 1833 | <div className="space-y-1"> |
| 1838 | <div className="text-xs font-medium text-foreground"> | 1834 | <div className="text-message-foreground text-xs font-medium"> |
| 1839 | {formatWorkspaceRelativePath(comment.filePath, ctx.workspaceRoot)} | 1835 | {formatWorkspaceRelativePath(comment.filePath, ctx.workspaceRoot)} |
| 1840 | </div> | 1836 | </div> |
| 1841 | <div className="text-[11px] text-muted-foreground"> | 1837 | <div className="text-secondary-label text-[11px]"> |
| 1842 | {comment.sectionTitle} · {comment.rangeLabel} | 1838 | {comment.sectionTitle} · {comment.rangeLabel} |
| 1843 | </div> | 1839 | </div> |
| 1844 | </div> | 1840 | </div> |
| ... | @@ -1853,7 +1849,7 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte | ... | @@ -1853,7 +1849,7 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte |
| 1853 | cwd={ctx.markdownCwd} | 1849 | cwd={ctx.markdownCwd} |
| 1854 | threadRef={ctx.threadRef ?? undefined} | 1850 | threadRef={ctx.threadRef ?? undefined} |
| 1855 | skills={ctx.skills} | 1851 | skills={ctx.skills} |
| 1856 | className="text-foreground" | 1852 | className="text-message-foreground" |
| 1857 | /> | 1853 | /> |
| 1858 | )} | 1854 | )} |
| 1859 | {renderablePatch?.kind === "files" && | 1855 | {renderablePatch?.kind === "files" && |
| ... | @@ -1978,24 +1974,24 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { | ... | @@ -1978,24 +1974,24 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { |
| 1978 | if (tone === "error") { | 1974 | if (tone === "error") { |
| 1979 | return { | 1975 | return { |
| 1980 | iconName: "circle-alert", | 1976 | iconName: "circle-alert", |
| 1981 | className: "text-foreground/92", | 1977 | className: "text-foreground", |
| 1982 | }; | 1978 | }; |
| 1983 | } | 1979 | } |
| 1984 | if (tone === "thinking") { | 1980 | if (tone === "thinking") { |
| 1985 | return { | 1981 | return { |
| 1986 | iconName: "bot", | 1982 | iconName: "bot", |
| 1987 | className: "text-foreground/92", | 1983 | className: "text-foreground", |
| 1988 | }; | 1984 | }; |
| 1989 | } | 1985 | } |
| 1990 | if (tone === "info") { | 1986 | if (tone === "info") { |
| 1991 | return { | 1987 | return { |
| 1992 | iconName: "check", | 1988 | iconName: "check", |
| 1993 | className: "text-muted-foreground", | 1989 | className: "text-icon-muted", |
| 1994 | }; | 1990 | }; |
| 1995 | } | 1991 | } |
| 1996 | return { | 1992 | return { |
| 1997 | iconName: "zap", | 1993 | iconName: "zap", |
| 1998 | className: "text-foreground/92", | 1994 | className: "text-foreground", |
| 1999 | }; | 1995 | }; |
| 2000 | } | 1996 | } |
| 2001 | 1997 | ||
| ... | @@ -2244,14 +2240,14 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { | ... | @@ -2244,14 +2240,14 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2244 | : showDestructiveRowStyle | 2240 | : showDestructiveRowStyle |
| 2245 | ? "text-destructive" | 2241 | ? "text-destructive" |
| 2246 | : workEntry.tone === "tool" || showFailedIndicator | 2242 | : workEntry.tone === "tool" || showFailedIndicator |
| 2247 | ? "text-muted-foreground/65" | 2243 | ? "text-icon-muted" |
| 2248 | : iconConfig.className, | 2244 | : iconConfig.className, |
| 2249 | ); | 2245 | ); |
| 2250 | const headingClass = showWarningIndicator | 2246 | const headingClass = showWarningIndicator |
| 2251 | ? "font-medium text-warning" | 2247 | ? "font-medium text-warning" |
| 2252 | : showDestructiveRowStyle | 2248 | : showDestructiveRowStyle |
| 2253 | ? "font-medium text-destructive" | 2249 | ? "font-medium text-destructive" |
| 2254 | : "font-medium text-foreground/82"; | 2250 | : "font-medium text-foreground"; |
| 2255 | const turnSettled = !activity.activeTurnInProgress; | 2251 | const turnSettled = !activity.activeTurnInProgress; |
| 2256 | const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry); | 2252 | const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry); |
| 2257 | const showSuccessIndicator = | 2253 | const showSuccessIndicator = |
| ... | @@ -2293,11 +2289,11 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { | ... | @@ -2293,11 +2289,11 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2293 | <p className="flex min-w-0 w-full items-baseline gap-1.5 text-[12px] leading-5"> | 2289 | <p className="flex min-w-0 w-full items-baseline gap-1.5 text-[12px] leading-5"> |
| 2294 | <span className={cn("min-w-0 shrink truncate", headingClass)}>{heading}</span> | 2290 | <span className={cn("min-w-0 shrink truncate", headingClass)}>{heading}</span> |
| 2295 | {preview && ( | 2291 | {preview && ( |
| 2296 | <span className="min-w-0 flex-1 truncate text-muted-foreground/55">{preview}</span> | 2292 | <span className="min-w-0 flex-1 truncate text-secondary-label">{preview}</span> |
| 2297 | )} | 2293 | )} |
| 2298 | </p> | 2294 | </p> |
| 2299 | </div> | 2295 | </div> |
| 2300 | <div className="flex shrink-0 items-center gap-px text-muted-foreground/55"> | 2296 | <div className="flex shrink-0 items-center gap-px text-icon-muted"> |
| 2301 | <span | 2297 | <span |
| 2302 | className="flex size-4 shrink-0 items-center justify-center" | 2298 | className="flex size-4 shrink-0 items-center justify-center" |
| 2303 | aria-hidden={!canExpand} | 2299 | aria-hidden={!canExpand} |
| ... | @@ -2362,7 +2358,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { | ... | @@ -2362,7 +2358,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2362 | onClick={stopRowToggle} | 2358 | onClick={stopRowToggle} |
| 2363 | onPointerDown={stopRowToggle} | 2359 | onPointerDown={stopRowToggle} |
| 2364 | > | 2360 | > |
| 2365 | <pre className="max-h-64 cursor-text overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-muted-foreground select-text"> | 2361 | <pre className="max-h-64 cursor-text overflow-auto whitespace-pre-wrap break-words font-mono text-secondary-label text-[11px] leading-relaxed select-text"> |
| 2366 | {expandedBody} | 2362 | {expandedBody} |
| 2367 | </pre> | 2363 | </pre> |
| 2368 | </div> | 2364 | </div> |
apps/web/src/components/chat/ModelListRow.tsx+1-1| ... | @@ -69,7 +69,7 @@ export const ModelListRow = memo(function ModelListRow(props: { | ... | @@ -69,7 +69,7 @@ export const ModelListRow = memo(function ModelListRow(props: { |
| 69 | </div> | 69 | </div> |
| 70 | {props.showNewBadge ? ( | 70 | {props.showNewBadge ? ( |
| 71 | <span | 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 | aria-label="New model" | 73 | aria-label="New model" |
| 74 | > | 74 | > |
| 75 | New | 75 | New |
apps/web/src/components/chat/ModelPickerSidebar.tsx+1-1| ... | @@ -29,7 +29,7 @@ const SELECTED_INDICATOR_CLASS = | ... | @@ -29,7 +29,7 @@ const SELECTED_INDICATOR_CLASS = |
| 29 | "pointer-events-none absolute -right-1 top-1/2 z-10 h-5 w-0.75 -translate-y-1/2 rounded-l-full bg-primary"; | 29 | "pointer-events-none absolute -right-1 top-1/2 z-10 h-5 w-0.75 -translate-y-1/2 rounded-l-full bg-primary"; |
| 30 | const BADGE_BASE_CLASS = | 30 | const BADGE_BASE_CLASS = |
| 31 | "pointer-events-none absolute -right-0.5 top-0.5 z-10 flex size-3.5 items-center justify-center rounded-full bg-transparent shadow-sm "; | 31 | "pointer-events-none absolute -right-0.5 top-0.5 z-10 flex size-3.5 items-center justify-center rounded-full bg-transparent shadow-sm "; |
| 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 | /** Opens toward the rail so the list stays readable (not over the model names). */ | 34 | /** Opens toward the rail so the list stays readable (not over the model names). */ |
| 35 | const PICKER_TOOLTIP_SIDE = "left" as const; | 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,9 +73,9 @@ export const PierreEntryIcon = memo(function PierreEntryIcon(props: { |
| 73 | 73 | ||
| 74 | if (!icon) { | 74 | if (!icon) { |
| 75 | return props.kind === "directory" ? ( | 75 | return props.kind === "directory" ? ( |
| 76 | <FolderIcon className={cn("size-4 text-muted-foreground/80", props.className)} /> | 76 | <FolderIcon className={cn("size-4 text-icon-muted", props.className)} /> |
| 77 | ) : ( | 77 | ) : ( |
| 78 | <FileIcon className={cn("size-4 text-muted-foreground/80", props.className)} /> | 78 | <FileIcon className={cn("size-4 text-icon-muted", props.className)} /> |
| 79 | ); | 79 | ); |
| 80 | } | 80 | } |
| 81 | 81 |
apps/web/src/components/files/FilePreviewPanel.tsx+12-2| ... | @@ -23,7 +23,7 @@ import { OpenInPicker } from "~/components/chat/OpenInPicker"; | ... | @@ -23,7 +23,7 @@ import { OpenInPicker } from "~/components/chat/OpenInPicker"; |
| 23 | import { useClientSettings } from "~/hooks/useSettings"; | 23 | import { useClientSettings } from "~/hooks/useSettings"; |
| 24 | import { useTheme } from "~/hooks/useTheme"; | 24 | import { useTheme } from "~/hooks/useTheme"; |
| 25 | import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; | 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 | import { cn } from "~/lib/utils"; | 27 | import { cn } from "~/lib/utils"; |
| 28 | import { isPreviewSupportedInRuntime } from "~/previewStateStore"; | 28 | import { isPreviewSupportedInRuntime } from "~/previewStateStore"; |
| 29 | import { resolvePathLinkTarget } from "~/terminal-links"; | 29 | import { resolvePathLinkTarget } from "~/terminal-links"; |
| ... | @@ -84,6 +84,16 @@ const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown"; | ... | @@ -84,6 +84,16 @@ const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown"; |
| 84 | const FILE_SAVE_DEBOUNCE_MS = 500; | 84 | const FILE_SAVE_DEBOUNCE_MS = 500; |
| 85 | const FILE_LINK_REVEAL_ATTRIBUTE = "data-file-link-reveal"; | 85 | const FILE_LINK_REVEAL_ATTRIBUTE = "data-file-link-reveal"; |
| 86 | const FILE_LINK_REVEAL_UNSAFE_CSS = ` | 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 | [${FILE_LINK_REVEAL_ATTRIBUTE}][data-line] { | 97 | [${FILE_LINK_REVEAL_ATTRIBUTE}][data-line] { |
| 88 | background-color: light-dark( | 98 | background-color: light-dark( |
| 89 | color-mix( | 99 | color-mix( |
| ... | @@ -959,7 +969,7 @@ export default function FilePreviewPanel({ | ... | @@ -959,7 +969,7 @@ export default function FilePreviewPanel({ |
| 959 | </div> | 969 | </div> |
| 960 | ) : null} | 970 | ) : null} |
| 961 | {relativePath && file.data?.truncated ? ( | 971 | {relativePath && file.data?.truncated ? ( |
| 962 | <div className="shrink-0 border-b border-amber-500/20 bg-amber-500/8 px-3 py-1.5 text-[11px] text-amber-700 dark:text-amber-300"> | 972 | <div className="shrink-0 border-b border-warning/20 bg-warning-surface px-3 py-1.5 text-[11px] text-warning-foreground"> |
| 963 | Preview limited to the first 1 MB of a {file.data.byteLength.toLocaleString()} byte file. | 973 | Preview limited to the first 1 MB of a {file.data.byteLength.toLocaleString()} byte file. |
| 964 | </div> | 974 | </div> |
| 965 | ) : null} | 975 | ) : null} |
apps/web/src/components/settings/KeybindingsSettings.logic.test.ts+1| ... | @@ -122,6 +122,7 @@ describe("KeybindingsSettings.logic", () => { | ... | @@ -122,6 +122,7 @@ describe("KeybindingsSettings.logic", () => { |
| 122 | 122 | ||
| 123 | it("formats static and project script command labels", () => { | 123 | it("formats static and project script command labels", () => { |
| 124 | expect(commandLabel("commandPalette.toggle")).toBe("Command Palette: Toggle"); | 124 | expect(commandLabel("commandPalette.toggle")).toBe("Command Palette: Toggle"); |
| 125 | expect(commandLabel("themeEditor.toggle")).toBe("Theme Editor: Toggle"); | ||
| 125 | expect(commandLabel("script.setup-db.run")).toBe("Run Script: Setup Db"); | 126 | expect(commandLabel("script.setup-db.run")).toBe("Run Script: Setup Db"); |
| 126 | }); | 127 | }); |
| 127 | 128 |
apps/web/src/components/settings/ProviderInstanceCard.tsx+1-1| ... | @@ -619,7 +619,7 @@ export function ProviderInstanceCard({ | ... | @@ -619,7 +619,7 @@ export function ProviderInstanceCard({ |
| 619 | "size-5 rounded-sm p-0", | 619 | "size-5 rounded-sm p-0", |
| 620 | versionAdvisory.emphasis === "strong" | 620 | versionAdvisory.emphasis === "strong" |
| 621 | ? "text-warning hover:text-warning" | 621 | ? "text-warning hover:text-warning" |
| 622 | : "text-primary hover:text-primary", | 622 | : "text-update hover:text-update", |
| 623 | )} | 623 | )} |
| 624 | aria-label="Update available — view details" | 624 | aria-label="Update available — view details" |
| 625 | > | 625 | > |
apps/web/src/components/settings/SettingsFontPreviews.tsx+27-2| ... | @@ -85,6 +85,26 @@ function loadDiffPreviewHtml(theme: DiffThemeName): Promise<readonly string[]> { | ... | @@ -85,6 +85,26 @@ function loadDiffPreviewHtml(theme: DiffThemeName): Promise<readonly string[]> { |
| 85 | return promise; | 85 | return promise; |
| 86 | } | 86 | } |
| 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 | function StaticDiffHtml({ html }: { html: string }) { | 108 | function StaticDiffHtml({ html }: { html: string }) { |
| 89 | const hostRef = useRef<HTMLDivElement | null>(null); | 109 | const hostRef = useRef<HTMLDivElement | null>(null); |
| 90 | useEffect(() => { | 110 | useEffect(() => { |
| ... | @@ -92,6 +112,9 @@ function StaticDiffHtml({ html }: { html: string }) { | ... | @@ -92,6 +112,9 @@ function StaticDiffHtml({ html }: { html: string }) { |
| 92 | if (host === null) return; | 112 | if (host === null) return; |
| 93 | const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" }); | 113 | const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" }); |
| 94 | shadow.innerHTML = html; | 114 | shadow.innerHTML = html; |
| 115 | const bridge = document.createElement("style"); | ||
| 116 | bridge.textContent = DIFF_PREVIEW_THEME_BRIDGE; | ||
| 117 | shadow.append(bridge); | ||
| 95 | }, [html]); | 118 | }, [html]); |
| 96 | return <div ref={hostRef} />; | 119 | return <div ref={hostRef} />; |
| 97 | } | 120 | } |
| ... | @@ -158,7 +181,7 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu | ... | @@ -158,7 +181,7 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu |
| 158 | const mountRef = useRef<HTMLDivElement>(null); | 181 | const mountRef = useRef<HTMLDivElement>(null); |
| 159 | const surfaceRef = useRef<GhosttyTerminalSurface | null>(null); | 182 | const surfaceRef = useRef<GhosttyTerminalSurface | null>(null); |
| 160 | const fontRef = useRef({ family, size }); | 183 | const fontRef = useRef({ family, size }); |
| 161 | const { resolvedTheme } = useTheme(); | 184 | const { theme, resolvedTheme } = useTheme(); |
| 162 | 185 | ||
| 163 | useEffect(() => { | 186 | useEffect(() => { |
| 164 | const current = fontRef.current; | 187 | const current = fontRef.current; |
| ... | @@ -167,12 +190,14 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu | ... | @@ -167,12 +190,14 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu |
| 167 | void surfaceRef.current?.setFont(previewTerminalFont(family, size)); | 190 | void surfaceRef.current?.setFont(previewTerminalFont(family, size)); |
| 168 | }, [family, size]); | 191 | }, [family, size]); |
| 169 | 192 | ||
| 193 | // Re-read the terminal tokens on any theme change — switching between two | ||
| 194 | // palettes can leave resolvedTheme (light/dark) untouched. | ||
| 170 | useEffect(() => { | 195 | useEffect(() => { |
| 171 | const mount = mountRef.current; | 196 | const mount = mountRef.current; |
| 172 | const surface = surfaceRef.current; | 197 | const surface = surfaceRef.current; |
| 173 | if (!mount || !surface) return; | 198 | if (!mount || !surface) return; |
| 174 | surface.setTheme(terminalThemeFromApp(mount)); | 199 | surface.setTheme(terminalThemeFromApp(mount)); |
| 175 | }, [resolvedTheme]); | 200 | }, [theme, resolvedTheme]); |
| 176 | 201 | ||
| 177 | useEffect(() => { | 202 | useEffect(() => { |
| 178 | const mount = mountRef.current; | 203 | const mount = mountRef.current; |
apps/web/src/components/settings/SettingsPanels.tsx+109-52| ... | @@ -52,7 +52,13 @@ import { | ... | @@ -52,7 +52,13 @@ import { |
| 52 | } from "../SidebarStageBackdrop"; | 52 | } from "../SidebarStageBackdrop"; |
| 53 | import { isElectron } from "../../env"; | 53 | import { isElectron } from "../../env"; |
| 54 | import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; | 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 | import { useLocalStorage } from "../../hooks/useLocalStorage"; | 62 | import { useLocalStorage } from "../../hooks/useLocalStorage"; |
| 57 | import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; | 63 | import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; |
| 58 | import { useThreadActions } from "../../hooks/useThreadActions"; | 64 | import { useThreadActions } from "../../hooks/useThreadActions"; |
| ... | @@ -106,6 +112,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ | ... | @@ -106,6 +112,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ |
| 106 | import { Switch } from "../ui/switch"; | 112 | import { Switch } from "../ui/switch"; |
| 107 | import { stackedThreadToast, toastManager } from "../ui/toast"; | 113 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 108 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 114 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 115 | import { ThemeLibrary } from "./ThemeSettings"; | ||
| 109 | import { | 116 | import { |
| 110 | backgroundActivityOverrideSettings, | 117 | backgroundActivityOverrideSettings, |
| 111 | backgroundActivitySharedPolicySettings, | 118 | backgroundActivitySharedPolicySettings, |
| ... | @@ -131,21 +138,6 @@ import { | ... | @@ -131,21 +138,6 @@ import { |
| 131 | import { searchableSetting } from "./settingsSearch"; | 138 | import { searchableSetting } from "./settingsSearch"; |
| 132 | import { ProjectFavicon } from "../ProjectFavicon"; | 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 | const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, string> = { | 141 | const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, string> = { |
| 150 | artwork: "Artwork", | 142 | artwork: "Artwork", |
| 151 | pill: "Version pill", | 143 | pill: "Version pill", |
| ... | @@ -432,7 +424,15 @@ function AboutVersionSection() { | ... | @@ -432,7 +424,15 @@ function AboutVersionSection() { |
| 432 | } | 424 | } |
| 433 | 425 | ||
| 434 | export function useSettingsRestore(onRestored?: () => void) { | 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 | const settings = usePrimarySettings(); | 436 | const settings = usePrimarySettings(); |
| 437 | const updateSettings = useUpdatePrimarySettings(); | 437 | const updateSettings = useUpdatePrimarySettings(); |
| 438 | 438 | ||
| ... | @@ -445,6 +445,8 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -445,6 +445,8 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 445 | const changedSettingLabels = useMemo( | 445 | const changedSettingLabels = useMemo( |
| 446 | () => [ | 446 | () => [ |
| 447 | ...(theme !== "system" ? ["Theme"] : []), | 447 | ...(theme !== "system" ? ["Theme"] : []), |
| 448 | ...(!followSystem ? ["Follow system"] : []), | ||
| 449 | ...(themeHalves !== null ? ["Theme mix"] : []), | ||
| 448 | ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []), | 450 | ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []), |
| 449 | ...(settings.environmentIdentificationMode !== | 451 | ...(settings.environmentIdentificationMode !== |
| 450 | DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode | 452 | DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode |
| ... | @@ -525,6 +527,7 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -525,6 +527,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 525 | settings.sidebarThreadPreviewCount, | 527 | settings.sidebarThreadPreviewCount, |
| 526 | settings.timestampFormat, | 528 | settings.timestampFormat, |
| 527 | settings.wordWrap, | 529 | settings.wordWrap, |
| 530 | followSystem, | ||
| 528 | theme, | 531 | theme, |
| 529 | ], | 532 | ], |
| 530 | ); | 533 | ); |
| ... | @@ -539,7 +542,57 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -539,7 +542,57 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 539 | ); | 542 | ); |
| 540 | if (!confirmed) return; | 543 | if (!confirmed) return; |
| 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 | updateSettings({ | 596 | updateSettings({ |
| 544 | timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, | 597 | timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, |
| 545 | wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, | 598 | wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, |
| ... | @@ -566,7 +619,17 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -566,7 +619,17 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 566 | fontFamilyTerminal: DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal, | 619 | fontFamilyTerminal: DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal, |
| 567 | }); | 620 | }); |
| 568 | onRestored?.(); | 621 | onRestored?.(); |
| 569 | }, [changedSettingLabels, onRestored, setTheme, updateSettings]); | 622 | }, [ |
| 623 | changedSettingLabels, | ||
| 624 | clearThemeHalves, | ||
| 625 | onRestored, | ||
| 626 | setFollowSystem, | ||
| 627 | setTheme, | ||
| 628 | setThemeHalf, | ||
| 629 | theme, | ||
| 630 | themeHalves, | ||
| 631 | updateSettings, | ||
| 632 | ]); | ||
| 570 | 633 | ||
| 571 | return { | 634 | return { |
| 572 | changedSettingLabels, | 635 | changedSettingLabels, |
| ... | @@ -841,7 +904,18 @@ function BackgroundActivityAdvancedDialog({ | ... | @@ -841,7 +904,18 @@ function BackgroundActivityAdvancedDialog({ |
| 841 | } | 904 | } |
| 842 | 905 | ||
| 843 | export function AppearanceSettingsPanel() { | 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 | const settings = usePrimarySettings(); | 919 | const settings = usePrimarySettings(); |
| 846 | const updateSettings = useUpdatePrimarySettings(); | 920 | const updateSettings = useUpdatePrimarySettings(); |
| 847 | const environmentStageLabel = useEnvironmentStageLabel(); | 921 | const environmentStageLabel = useEnvironmentStageLabel(); |
| ... | @@ -857,38 +931,21 @@ export function AppearanceSettingsPanel() { | ... | @@ -857,38 +931,21 @@ export function AppearanceSettingsPanel() { |
| 857 | return ( | 931 | return ( |
| 858 | <SettingsPageContainer> | 932 | <SettingsPageContainer> |
| 859 | <SettingsSection id="appearance" title="Appearance"> | 933 | <SettingsSection id="appearance" title="Appearance"> |
| 860 | <SettingsRow | 934 | <div id={searchableSetting("theme").id}> |
| 861 | {...searchableSetting("theme")} | 935 | <ThemeLibrary |
| 862 | description="Choose how T3 Code looks across the app." | 936 | appearanceMode={appearanceMode} |
| 863 | resetAction={ | 937 | customThemes={customThemes} |
| 864 | theme !== "system" ? ( | 938 | initialAppearance={resolvedTheme} |
| 865 | <SettingResetButton label="theme" onClick={() => setTheme("system")} /> | 939 | refreshTheme={refreshTheme} |
| 866 | ) : null | 940 | isImportOpen={isImportThemeOpen} |
| 867 | } | 941 | setAppearanceMode={setAppearanceMode} |
| 868 | control={ | 942 | setTheme={setTheme} |
| 869 | <Select | 943 | setThemeHalf={setThemeHalf} |
| 870 | value={theme} | 944 | theme={theme} |
| 871 | onValueChange={(value) => { | 945 | themeHalves={themeHalves} |
| 872 | if (value === "system" || value === "light" || value === "dark") { | 946 | onImportOpenChange={setIsImportThemeOpen} |
| 873 | setTheme(value); | 947 | /> |
| 874 | } | 948 | </div> |
| 875 | }} | ||
| 876 | > | ||
| 877 | <SelectTrigger className="w-full sm:w-40" aria-label="Theme preference"> | ||
| 878 | <SelectValue> | ||
| 879 | {THEME_OPTIONS.find((option) => option.value === theme)?.label ?? "System"} | ||
| 880 | </SelectValue> | ||
| 881 | </SelectTrigger> | ||
| 882 | <SelectPopup align="end" alignItemWithTrigger={false}> | ||
| 883 | {THEME_OPTIONS.map((option) => ( | ||
| 884 | <SelectItem hideIndicator key={option.value} value={option.value}> | ||
| 885 | {option.label} | ||
| 886 | </SelectItem> | ||
| 887 | ))} | ||
| 888 | </SelectPopup> | ||
| 889 | </Select> | ||
| 890 | } | ||
| 891 | /> | ||
| 892 | 949 | ||
| 893 | <SettingsRow | 950 | <SettingsRow |
| 894 | {...searchableSetting("setting-glass-opacity")} | 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,10 +37,20 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = { |
| 37 | * here once instead of separately in the panel and the index. | 37 | * here once instead of separately in the panel and the index. |
| 38 | */ | 38 | */ |
| 39 | export const SETTINGS_SEARCH_ITEMS = [ | 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 | id: "theme", | 48 | id: "theme", |
| 42 | title: "Theme", | 49 | title: "Themes", |
| 43 | to: "/settings/appearance", | 50 | to: "/settings/appearance", |
| 51 | // Theme cards live directly under the scheme tiles; the section is the | ||
| 52 | // stable scroll destination for both. | ||
| 53 | targetId: "appearance", | ||
| 44 | }, | 54 | }, |
| 45 | { | 55 | { |
| 46 | // Prefixed because the slider control already owns the `glass-opacity` id. | 56 | // Prefixed because the slider control already owns the `glass-opacity` id. |
apps/web/src/components/settings/themeEditorStore.test.ts created+40| ... | @@ -0,0 +1,40 @@ | ||
| 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,7 +13,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 13 | 13 | ||
| 14 | const PROVIDER_UPDATE_PILL_STYLES = { | 14 | const PROVIDER_UPDATE_PILL_STYLES = { |
| 15 | loading: | 15 | loading: |
| 16 | "bg-primary/15 text-primary group-has-[button.provider-update-main:hover]/provider-update:bg-primary/22", | 16 | "bg-update-surface text-update group-has-[button.provider-update-main:hover]/provider-update:bg-update/22", |
| 17 | success: | 17 | success: |
| 18 | "bg-success/12 text-success group-has-[button.provider-update-main:hover]/provider-update:bg-success/18", | 18 | "bg-success/12 text-success group-has-[button.provider-update-main:hover]/provider-update:bg-success/18", |
| 19 | warning: | 19 | warning: |
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+3-3| ... | @@ -159,11 +159,11 @@ export function SidebarUpdatePill() { | ... | @@ -159,11 +159,11 @@ export function SidebarUpdatePill() { |
| 159 | )} | 159 | )} |
| 160 | {visible && ( | 160 | {visible && ( |
| 161 | <div | 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 | disabled ? " cursor-not-allowed opacity-60" : "" | 163 | disabled ? " cursor-not-allowed opacity-60" : "" |
| 164 | }`} | 164 | }`} |
| 165 | > | 165 | > |
| 166 | <div className="pointer-events-none absolute inset-0 rounded-lg transition-colors group-has-[button.update-main:hover]/update:bg-primary/22" /> | 166 | <div className="pointer-events-none absolute inset-0 rounded-lg transition-colors group-has-[button.update-main:hover]/update:bg-update/12" /> |
| 167 | <Tooltip> | 167 | <Tooltip> |
| 168 | <TooltipTrigger | 168 | <TooltipTrigger |
| 169 | render={ | 169 | render={ |
| ... | @@ -224,7 +224,7 @@ export function SidebarUpdatePill() { | ... | @@ -224,7 +224,7 @@ export function SidebarUpdatePill() { |
| 224 | <button | 224 | <button |
| 225 | type="button" | 225 | type="button" |
| 226 | aria-label="Dismiss update" | 226 | aria-label="Dismiss update" |
| 227 | className="mr-1 inline-flex size-5 items-center justify-center rounded-md text-primary/60 transition-colors hover:text-primary" | 227 | className="mr-1 inline-flex size-5 items-center justify-center rounded-md text-update/60 transition-colors hover:text-update" |
| 228 | onClick={() => setDismissed(true)} | 228 | onClick={() => setDismissed(true)} |
| 229 | > | 229 | > |
| 230 | <XIcon className="size-3.5" /> | 230 | <XIcon className="size-3.5" /> |
apps/web/src/components/ui/alert.tsx+3-2| ... | @@ -12,10 +12,11 @@ const alertVariants = cva("relative rounded-xl border px-3.5 py-3 text-card-fore | ... | @@ -12,10 +12,11 @@ const alertVariants = cva("relative rounded-xl border px-3.5 py-3 text-card-fore |
| 12 | variant: { | 12 | variant: { |
| 13 | default: "bg-transparent dark:bg-input/32 [&_svg]:text-muted-foreground", | 13 | default: "bg-transparent dark:bg-input/32 [&_svg]:text-muted-foreground", |
| 14 | error: | 14 | error: |
| 15 | "border-destructive/32 bg-destructive/4 text-destructive-foreground [&_[data-slot=alert-description]]:text-destructive-foreground/80 [&_svg]:text-destructive", | 15 | "border-error/32 bg-error-surface text-error-foreground [&_[data-slot=alert-description]]:text-error-foreground/80 [&_svg]:text-error", |
| 16 | info: "border-info/32 bg-info/4 [&_svg]:text-info", | 16 | info: "border-info/32 bg-info/4 [&_svg]:text-info", |
| 17 | success: "border-success/32 bg-success/4 [&_svg]:text-success", | 17 | success: "border-success/32 bg-success/4 [&_svg]:text-success", |
| 18 | warning: "border-warning/32 bg-warning/4 [&_svg]:text-warning", | 18 | warning: |
| 19 | "border-warning/32 bg-warning-surface text-warning-foreground [&_[data-slot=alert-description]]:text-warning-foreground/80 [&_svg]:text-warning", | ||
| 19 | }, | 20 | }, |
| 20 | }, | 21 | }, |
| 21 | }); | 22 | }); |
apps/web/src/components/ui/command.tsx+3-3| ... | @@ -112,12 +112,12 @@ function CommandInput({ | ... | @@ -112,12 +112,12 @@ function CommandInput({ |
| 112 | <AutocompleteInput | 112 | <AutocompleteInput |
| 113 | autoFocus | 113 | autoFocus |
| 114 | className={cn( | 114 | className={cn( |
| 115 | "border-transparent! bg-transparent! shadow-none before:hidden has-focus-visible:ring-0 placeholder:text-muted-foreground/80 *:data-[slot=autocomplete-input]:ps-9! sm:*:data-[slot=autocomplete-input]:ps-[calc(var(--command-shell-inset)+1.5rem)]!", | 115 | "border-transparent! bg-transparent! shadow-none before:hidden has-focus-visible:ring-0 placeholder:text-placeholder *:data-[slot=autocomplete-input]:ps-9! sm:*:data-[slot=autocomplete-input]:ps-[calc(var(--command-shell-inset)+1.5rem)]!", |
| 116 | className, | 116 | className, |
| 117 | )} | 117 | )} |
| 118 | placeholder={placeholder} | 118 | placeholder={placeholder} |
| 119 | size="lg" | 119 | size="lg" |
| 120 | startAddon={<SearchIcon className="translate-x-0.5 text-muted-foreground" />} | 120 | startAddon={<SearchIcon className="translate-x-0.5 text-icon-muted" />} |
| 121 | {...props} | 121 | {...props} |
| 122 | /> | 122 | /> |
| 123 | </div> | 123 | </div> |
| ... | @@ -203,7 +203,7 @@ function CommandShortcut({ className, ...props }: React.ComponentProps<"kbd">) { | ... | @@ -203,7 +203,7 @@ function CommandShortcut({ className, ...props }: React.ComponentProps<"kbd">) { |
| 203 | return ( | 203 | return ( |
| 204 | <kbd | 204 | <kbd |
| 205 | className={cn( | 205 | className={cn( |
| 206 | "ms-auto font-medium font-sans text-muted-foreground/70 text-xs tracking-widest", | 206 | "ms-auto font-medium font-sans text-secondary-label text-xs tracking-widest", |
| 207 | className, | 207 | className, |
| 208 | )} | 208 | )} |
| 209 | data-slot="command-shortcut" | 209 | data-slot="command-shortcut" |
apps/web/src/components/ui/input.tsx+1-1| ... | @@ -19,7 +19,7 @@ function Input({ | ... | @@ -19,7 +19,7 @@ function Input({ |
| 19 | ...props | 19 | ...props |
| 20 | }: InputProps) { | 20 | }: InputProps) { |
| 21 | const inputClassName = cn( | 21 | const inputClassName = cn( |
| 22 | "h-8.5 w-full min-w-0 rounded-[inherit] px-[calc(--spacing(3)-1px)] leading-8.5 outline-none placeholder:text-muted-foreground/72 sm:h-7.5 sm:leading-7.5 [transition:background-color_5000000s_ease-in-out_0s]", | 22 | "h-8.5 w-full min-w-0 rounded-[inherit] px-[calc(--spacing(3)-1px)] leading-8.5 outline-none placeholder:text-placeholder sm:h-7.5 sm:leading-7.5 [transition:background-color_5000000s_ease-in-out_0s]", |
| 23 | size === "sm" && "h-7.5 px-[calc(--spacing(2.5)-1px)] leading-7.5 sm:h-6.5 sm:leading-6.5", | 23 | size === "sm" && "h-7.5 px-[calc(--spacing(2.5)-1px)] leading-7.5 sm:h-6.5 sm:leading-6.5", |
| 24 | size === "lg" && "h-9.5 leading-9.5 sm:h-8.5 sm:leading-8.5", | 24 | size === "lg" && "h-9.5 leading-9.5 sm:h-8.5 sm:leading-8.5", |
| 25 | props.type === "search" && | 25 | props.type === "search" && |
apps/web/src/components/ui/menu.tsx+1-1| ... | @@ -203,7 +203,7 @@ function MenuShortcut({ className, ...props }: React.ComponentProps<"kbd">) { | ... | @@ -203,7 +203,7 @@ function MenuShortcut({ className, ...props }: React.ComponentProps<"kbd">) { |
| 203 | return ( | 203 | return ( |
| 204 | <kbd | 204 | <kbd |
| 205 | className={cn( | 205 | className={cn( |
| 206 | "ms-auto font-medium font-sans text-muted-foreground/72 text-xs tracking-widest", | 206 | "ms-auto font-medium font-sans text-secondary-label text-xs tracking-widest", |
| 207 | className, | 207 | className, |
| 208 | )} | 208 | )} |
| 209 | data-slot="menu-shortcut" | 209 | data-slot="menu-shortcut" |
apps/web/src/components/ui/select.tsx+4-6| ... | @@ -21,9 +21,9 @@ const selectTriggerVariants = cva( | ... | @@ -21,9 +21,9 @@ const selectTriggerVariants = cva( |
| 21 | variants: { | 21 | variants: { |
| 22 | variant: { | 22 | variant: { |
| 23 | default: | 23 | default: |
| 24 | "w-full min-w-36 border-input bg-background not-dark:bg-clip-padding text-foreground shadow-xs/5 ring-ring/24 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-data-disabled:not-focus-visible:not-aria-invalid:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 focus-visible:border-ring focus-visible:ring-[3px] aria-invalid:border-destructive/36 focus-visible:aria-invalid:border-destructive/64 focus-visible:aria-invalid:ring-destructive/16 dark:bg-input/32 dark:aria-invalid:ring-destructive/24 dark:not-data-disabled:not-focus-visible:not-aria-invalid:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='text-'])]:text-muted-foreground [[data-disabled],:focus-visible,[aria-invalid],[data-pressed]]:shadow-none", | 24 | "w-full min-w-36 border-input bg-background not-dark:bg-clip-padding text-foreground shadow-xs/5 ring-ring/24 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-data-disabled:not-focus-visible:not-aria-invalid:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 focus-visible:border-ring focus-visible:ring-[3px] aria-invalid:border-destructive/36 focus-visible:aria-invalid:border-destructive/64 focus-visible:aria-invalid:ring-destructive/16 dark:bg-input/32 dark:aria-invalid:ring-destructive/24 dark:not-data-disabled:not-focus-visible:not-aria-invalid:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='text-'])]:text-icon-muted [[data-disabled],:focus-visible,[aria-invalid],[data-pressed]]:shadow-none", |
| 25 | ghost: | 25 | ghost: |
| 26 | "border-transparent text-muted-foreground/70 focus-visible:ring-2 focus-visible:ring-ring data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent [:hover,[data-pressed]]:text-foreground/80", | 26 | "border-transparent text-secondary-label focus-visible:ring-2 focus-visible:ring-ring data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent [:hover,[data-pressed]]:text-foreground", |
| 27 | }, | 27 | }, |
| 28 | size: { | 28 | size: { |
| 29 | default: "min-h-9 px-[calc(--spacing(3)-1px)] sm:min-h-8", | 29 | default: "min-h-9 px-[calc(--spacing(3)-1px)] sm:min-h-8", |
| ... | @@ -50,9 +50,7 @@ function SelectButton({ className, size, variant, render, children, ...props }: | ... | @@ -50,9 +50,7 @@ function SelectButton({ className, size, variant, render, children, ...props }: |
| 50 | const defaultProps = { | 50 | const defaultProps = { |
| 51 | children: ( | 51 | children: ( |
| 52 | <> | 52 | <> |
| 53 | <span className="flex-1 truncate in-data-placeholder:text-muted-foreground/72"> | 53 | <span className="flex-1 truncate in-data-placeholder:text-placeholder">{children}</span> |
| 54 | {children} | ||
| 55 | </span> | ||
| 56 | {variant === "ghost" ? ( | 54 | {variant === "ghost" ? ( |
| 57 | <ChevronDownIcon className="-me-1 size-3 opacity-50" /> | 55 | <ChevronDownIcon className="-me-1 size-3 opacity-50" /> |
| 58 | ) : ( | 56 | ) : ( |
| ... | @@ -98,7 +96,7 @@ function SelectTrigger({ | ... | @@ -98,7 +96,7 @@ function SelectTrigger({ |
| 98 | function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) { | 96 | function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) { |
| 99 | return ( | 97 | return ( |
| 100 | <SelectPrimitive.Value | 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 | data-slot="select-value" | 100 | data-slot="select-value" |
| 103 | {...props} | 101 | {...props} |
| 104 | /> | 102 | /> |
apps/web/src/hooks/useCustomThemes.ts created+9| ... | @@ -0,0 +1,9 @@ | ||
| 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,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 | it("falls back during initial theme application and logs only safe attributes", async () => { | 85 | it("falls back during initial theme application and logs only safe attributes", async () => { |
| 74 | const cause = new Error("private browsing storage failure"); | 86 | const cause = new Error("private browsing storage failure"); |
| 75 | const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); | 87 | const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); |
| ... | @@ -104,9 +116,10 @@ describe("theme failure handling", () => { | ... | @@ -104,9 +116,10 @@ describe("theme failure handling", () => { |
| 104 | 116 | ||
| 105 | it("retries a failed storage read only after a relevant storage event", async () => { | 117 | it("retries a failed storage read only after a relevant storage event", async () => { |
| 106 | const cause = new Error("persistent storage failure"); | 118 | const cause = new Error("persistent storage failure"); |
| 107 | const getItem = vi.fn(() => { | 119 | const themeGetItem = vi.fn((): string | null => { |
| 108 | throw cause; | 120 | throw cause; |
| 109 | }); | 121 | }); |
| 122 | const getItem = vi.fn((key: string) => (key === "t3code:theme" ? themeGetItem() : null)); | ||
| 110 | const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); | 123 | const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); |
| 111 | let readSnapshot: (() => unknown) | undefined; | 124 | let readSnapshot: (() => unknown) | undefined; |
| 112 | let subscribeToTheme: ((listener: () => void) => () => void) | undefined; | 125 | let subscribeToTheme: ((listener: () => void) => () => void) | undefined; |
| ... | @@ -141,14 +154,14 @@ describe("theme failure handling", () => { | ... | @@ -141,14 +154,14 @@ describe("theme failure handling", () => { |
| 141 | readSnapshot?.(); | 154 | readSnapshot?.(); |
| 142 | readSnapshot?.(); | 155 | readSnapshot?.(); |
| 143 | 156 | ||
| 144 | expect(getItem).toHaveBeenCalledTimes(1); | 157 | expect(themeGetItem).toHaveBeenCalledTimes(1); |
| 145 | expect(errorLog).toHaveBeenCalledTimes(1); | 158 | expect(errorLog).toHaveBeenCalledTimes(1); |
| 146 | 159 | ||
| 147 | const unsubscribe = subscribeToTheme?.(() => undefined); | 160 | const unsubscribe = subscribeToTheme?.(() => undefined); |
| 148 | storageHandler?.({ key: "t3code:theme" } as StorageEvent); | 161 | storageHandler?.({ key: "t3code:theme" } as StorageEvent); |
| 149 | readSnapshot?.(); | 162 | readSnapshot?.(); |
| 150 | 163 | ||
| 151 | expect(getItem).toHaveBeenCalledTimes(2); | 164 | expect(themeGetItem).toHaveBeenCalledTimes(2); |
| 152 | expect(errorLog).toHaveBeenCalledTimes(2); | 165 | expect(errorLog).toHaveBeenCalledTimes(2); |
| 153 | unsubscribe?.(); | 166 | unsubscribe?.(); |
| 154 | }); | 167 | }); |
apps/web/src/hooks/useTheme.ts+369-60| ... | @@ -2,12 +2,33 @@ import type { DesktopBridge } from "@t3tools/contracts"; | ... | @@ -2,12 +2,33 @@ import type { DesktopBridge } from "@t3tools/contracts"; |
| 2 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; | 2 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; |
| 3 | import * as Schema from "effect/Schema"; | 3 | import * as Schema from "effect/Schema"; |
| 4 | import { useCallback, useEffect, useSyncExternalStore } from "react"; | 4 | import { useCallback, useEffect, useSyncExternalStore } from "react"; |
| 5 | 5 | import { | |
| 6 | const ThemePreference = Schema.Literals(["light", "dark", "system"]); | 6 | applyThemePalette, |
| 7 | type Theme = typeof ThemePreference.Type; | 7 | CUSTOM_THEMES_STORAGE_KEY, |
| 8 | invalidateCustomThemes, | ||
| 9 | canonicalThemePreference, | ||
| 10 | isKnownThemePreference, | ||
| 11 | getThemePreferenceMode, | ||
| 12 | parseThemeHalves, | ||
| 13 | resolveDesktopTheme, | ||
| 14 | resolveThemeAppearance, | ||
| 15 | resolveThemeHalf, | ||
| 16 | THEME_APPEARANCE_MODE_STORAGE_KEY, | ||
| 17 | THEME_FOLLOW_SYSTEM_STORAGE_KEY, | ||
| 18 | THEME_HALVES_STORAGE_KEY, | ||
| 19 | ThemePreference, | ||
| 20 | type ThemeAppearance, | ||
| 21 | type ThemeHalves, | ||
| 22 | type ThemePreferenceMode, | ||
| 23 | } from "../themePalette"; | ||
| 24 | |||
| 25 | type Theme = ThemePreference; | ||
| 8 | type ThemeSnapshot = { | 26 | type ThemeSnapshot = { |
| 9 | theme: Theme; | 27 | theme: Theme; |
| 10 | systemDark: boolean; | 28 | systemDark: boolean; |
| 29 | followSystem: boolean; | ||
| 30 | appearanceMode: ThemePreferenceMode; | ||
| 31 | themeHalves: ThemeHalves | null; | ||
| 11 | }; | 32 | }; |
| 12 | 33 | ||
| 13 | type DesktopThemeBridge = Pick<DesktopBridge, "setTheme">; | 34 | type DesktopThemeBridge = Pick<DesktopBridge, "setTheme">; |
| ... | @@ -17,7 +38,29 @@ const MEDIA_QUERY = "(prefers-color-scheme: dark)"; | ... | @@ -17,7 +38,29 @@ const MEDIA_QUERY = "(prefers-color-scheme: dark)"; |
| 17 | const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { | 38 | const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { |
| 18 | theme: "system", | 39 | theme: "system", |
| 19 | systemDark: false, | 40 | systemDark: false, |
| 41 | followSystem: true, | ||
| 42 | appearanceMode: "system", | ||
| 43 | themeHalves: null, | ||
| 20 | }; | 44 | }; |
| 45 | |||
| 46 | /** Live read of the stored appearance mix, for callers that must not rely on | ||
| 47 | * a render-time snapshot (for example rollback after an async dialog). */ | ||
| 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 | const THEME_COLOR_META_NAME = "theme-color"; | 64 | const THEME_COLOR_META_NAME = "theme-color"; |
| 22 | const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`; | 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,11 +96,13 @@ export const isDesktopThemeSyncError = Schema.is(DesktopThemeSyncError); |
| 53 | 96 | ||
| 54 | let listeners: Array<() => void> = []; | 97 | let listeners: Array<() => void> = []; |
| 55 | let lastSnapshot: ThemeSnapshot | null = null; | 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 | let lastAppliedTheme: ThemeSnapshot | null = null; | 101 | let lastAppliedTheme: ThemeSnapshot | null = null; |
| 58 | let themeStorageReadFailure: ThemeStorageError | null = null; | 102 | let themeStorageReadFailure: ThemeStorageError | null = null; |
| 59 | 103 | ||
| 60 | function emitChange() { | 104 | function emitChange() { |
| 105 | snapshotStale = true; | ||
| 61 | for (const listener of listeners) listener(); | 106 | for (const listener of listeners) listener(); |
| 62 | } | 107 | } |
| 63 | 108 | ||
| ... | @@ -69,6 +114,53 @@ function getSystemDark() { | ... | @@ -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 | export function readThemePreference(): Theme { | 164 | export function readThemePreference(): Theme { |
| 73 | if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT.theme; | 165 | if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT.theme; |
| 74 | let raw: string | null; | 166 | let raw: string | null; |
| ... | @@ -81,7 +173,9 @@ export function readThemePreference(): Theme { | ... | @@ -81,7 +173,9 @@ export function readThemePreference(): Theme { |
| 81 | cause, | 173 | cause, |
| 82 | }); | 174 | }); |
| 83 | } | 175 | } |
| 84 | if (raw === "light" || raw === "dark" || raw === "system") return raw; | 176 | if (raw !== null && isKnownThemePreference(raw)) { |
| 177 | return canonicalThemePreference(raw); | ||
| 178 | } | ||
| 85 | return DEFAULT_THEME_SNAPSHOT.theme; | 179 | return DEFAULT_THEME_SNAPSHOT.theme; |
| 86 | } | 180 | } |
| 87 | 181 | ||
| ... | @@ -161,34 +255,66 @@ function resolveBrowserChromeSurface(): HTMLElement { | ... | @@ -161,34 +255,66 @@ function resolveBrowserChromeSurface(): HTMLElement { |
| 161 | 255 | ||
| 162 | export function syncBrowserChromeTheme() { | 256 | export function syncBrowserChromeTheme() { |
| 163 | if (typeof document === "undefined" || typeof getComputedStyle === "undefined") return; | 257 | if (typeof document === "undefined" || typeof getComputedStyle === "undefined") return; |
| 258 | const rootStyles = getComputedStyle(document.documentElement); | ||
| 259 | const themeChromeColor = document.documentElement.dataset.themeId | ||
| 260 | ? normalizeThemeColor(rootStyles.getPropertyValue("--app-chrome-background")) | ||
| 261 | : null; | ||
| 164 | const surfaceColor = normalizeThemeColor( | 262 | const surfaceColor = normalizeThemeColor( |
| 165 | getComputedStyle(resolveBrowserChromeSurface()).backgroundColor, | 263 | getComputedStyle(resolveBrowserChromeSurface()).backgroundColor, |
| 166 | ); | 264 | ); |
| 167 | const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor); | 265 | const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor); |
| 168 | const backgroundColor = surfaceColor ?? fallbackColor; | 266 | const backgroundColor = themeChromeColor ?? surfaceColor ?? fallbackColor; |
| 169 | if (!backgroundColor) return; | 267 | if (!backgroundColor) return; |
| 170 | 268 | ||
| 171 | document.documentElement.style.backgroundColor = backgroundColor; | 269 | document.documentElement.style.backgroundColor = backgroundColor; |
| 172 | document.body.style.backgroundColor = backgroundColor; | 270 | document.body.style.backgroundColor = backgroundColor; |
| 173 | ensureThemeColorMetaTag().setAttribute("content", backgroundColor); | 271 | // Update every theme-color meta so any element another layer added (for |
| 272 | // example a media-scoped one) carries the resolved color too. | ||
| 273 | const themeColorMetas = document.querySelectorAll<HTMLMetaElement>( | ||
| 274 | `meta[name="${THEME_COLOR_META_NAME}"]`, | ||
| 275 | ); | ||
| 276 | if (themeColorMetas.length === 0) { | ||
| 277 | ensureThemeColorMetaTag().setAttribute("content", backgroundColor); | ||
| 278 | return; | ||
| 279 | } | ||
| 280 | for (const element of themeColorMetas) { | ||
| 281 | element.setAttribute("content", backgroundColor); | ||
| 282 | } | ||
| 174 | } | 283 | } |
| 175 | 284 | ||
| 176 | function applyTheme(theme: Theme, suppressTransitions = false) { | 285 | function applyTheme(theme: Theme, suppressTransitions = false) { |
| 177 | if (typeof document === "undefined" || typeof window === "undefined") return; | 286 | if (typeof document === "undefined" || typeof window === "undefined") return; |
| 178 | const systemDark = theme === "system" ? getSystemDark() : false; | 287 | const appearanceMode = readAppearanceModePreference(theme); |
| 179 | if (lastAppliedTheme?.theme === theme && lastAppliedTheme.systemDark === systemDark) { | 288 | const followSystem = appearanceMode === "system"; |
| 180 | syncDesktopTheme(theme); | 289 | const systemDark = followSystem ? getSystemDark() : false; |
| 290 | const themeHalves = readStoredThemeHalves(); | ||
| 291 | if ( | ||
| 292 | lastAppliedTheme?.theme === theme && | ||
| 293 | lastAppliedTheme.systemDark === systemDark && | ||
| 294 | lastAppliedTheme.followSystem === followSystem && | ||
| 295 | lastAppliedTheme.appearanceMode === appearanceMode && | ||
| 296 | themeHalvesSignature(lastAppliedTheme.themeHalves) === themeHalvesSignature(themeHalves) | ||
| 297 | ) { | ||
| 298 | syncDesktopTheme(theme, followSystem, appearanceMode); | ||
| 181 | return; | 299 | return; |
| 182 | } | 300 | } |
| 183 | 301 | ||
| 184 | if (suppressTransitions) { | 302 | if (suppressTransitions) { |
| 185 | document.documentElement.classList.add("no-transitions"); | 303 | document.documentElement.classList.add("no-transitions"); |
| 186 | } | 304 | } |
| 187 | const isDark = theme === "dark" || (theme === "system" && systemDark); | 305 | const resolvedAppearance = resolveThemeAppearance( |
| 306 | theme, | ||
| 307 | systemDark, | ||
| 308 | followSystem, | ||
| 309 | appearanceMode, | ||
| 310 | themeHalves, | ||
| 311 | ); | ||
| 312 | applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance); | ||
| 313 | const isDark = resolvedAppearance === "dark"; | ||
| 188 | document.documentElement.classList.toggle("dark", isDark); | 314 | document.documentElement.classList.toggle("dark", isDark); |
| 189 | lastAppliedTheme = { theme, systemDark }; | 315 | lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves }; |
| 190 | syncBrowserChromeTheme(); | 316 | syncBrowserChromeTheme(); |
| 191 | syncDesktopTheme(theme); | 317 | syncDesktopTheme(theme, followSystem, appearanceMode); |
| 192 | if (suppressTransitions) { | 318 | if (suppressTransitions) { |
| 193 | // Force a reflow so the no-transitions class takes effect before removal | 319 | // Force a reflow so the no-transitions class takes effect before removal |
| 194 | // oxlint-disable-next-line no-unused-expressions | 320 | // oxlint-disable-next-line no-unused-expressions |
| ... | @@ -202,34 +328,45 @@ function applyTheme(theme: Theme, suppressTransitions = false) { | ... | @@ -202,34 +328,45 @@ function applyTheme(theme: Theme, suppressTransitions = false) { |
| 202 | export async function syncDesktopThemePreference( | 328 | export async function syncDesktopThemePreference( |
| 203 | bridge: DesktopThemeBridge, | 329 | bridge: DesktopThemeBridge, |
| 204 | theme: Theme, | 330 | theme: Theme, |
| 331 | followSystem?: boolean, | ||
| 332 | appearanceMode?: ThemePreferenceMode, | ||
| 333 | halves: ThemeHalves | null = readStoredThemeHalves(), | ||
| 205 | ): Promise<void> { | 334 | ): Promise<void> { |
| 206 | try { | 335 | try { |
| 207 | await bridge.setTheme(theme); | 336 | await bridge.setTheme(resolveDesktopTheme(theme, followSystem, appearanceMode, halves)); |
| 208 | } catch (cause) { | 337 | } catch (cause) { |
| 209 | throw new DesktopThemeSyncError({ theme, cause }); | 338 | throw new DesktopThemeSyncError({ theme, cause }); |
| 210 | } | 339 | } |
| 211 | } | 340 | } |
| 212 | 341 | ||
| 213 | export function syncDesktopTheme(theme: Theme) { | 342 | export function syncDesktopTheme( |
| 343 | theme: Theme, | ||
| 344 | followSystem?: boolean, | ||
| 345 | appearanceMode?: ThemePreferenceMode, | ||
| 346 | ) { | ||
| 214 | if (typeof window === "undefined") return; | 347 | if (typeof window === "undefined") return; |
| 215 | const bridge = window.desktopBridge; | 348 | const bridge = window.desktopBridge; |
| 216 | if (!bridge || typeof bridge.setTheme !== "function" || lastDesktopTheme === theme) { | 349 | const halves = readStoredThemeHalves(); |
| 350 | const desktopTheme = resolveDesktopTheme(theme, followSystem, appearanceMode, halves); | ||
| 351 | if (!bridge || typeof bridge.setTheme !== "function" || lastDesktopTheme === desktopTheme) { | ||
| 217 | return; | 352 | return; |
| 218 | } | 353 | } |
| 219 | 354 | ||
| 220 | lastDesktopTheme = theme; | 355 | lastDesktopTheme = desktopTheme; |
| 221 | void syncDesktopThemePreference(bridge, theme).catch((cause: unknown) => { | 356 | void syncDesktopThemePreference(bridge, theme, followSystem, appearanceMode, halves).catch( |
| 222 | const error = isDesktopThemeSyncError(cause) | 357 | (cause: unknown) => { |
| 223 | ? cause | 358 | const error = isDesktopThemeSyncError(cause) |
| 224 | : new DesktopThemeSyncError({ theme, cause }); | 359 | ? cause |
| 225 | console.error(error.message, { | 360 | : new DesktopThemeSyncError({ theme, cause }); |
| 226 | theme: error.theme, | 361 | console.error(error.message, { |
| 227 | ...safeErrorLogAttributes(error), | 362 | theme: error.theme, |
| 228 | }); | 363 | ...safeErrorLogAttributes(error), |
| 229 | if (lastDesktopTheme === theme) { | 364 | }); |
| 230 | lastDesktopTheme = null; | 365 | if (lastDesktopTheme === desktopTheme) { |
| 231 | } | 366 | lastDesktopTheme = null; |
| 232 | }); | 367 | } |
| 368 | }, | ||
| 369 | ); | ||
| 233 | } | 370 | } |
| 234 | 371 | ||
| 235 | // Apply immediately on module load to prevent flash | 372 | // Apply immediately on module load to prevent flash |
| ... | @@ -239,14 +376,28 @@ if (typeof document !== "undefined" && typeof window !== "undefined") { | ... | @@ -239,14 +376,28 @@ if (typeof document !== "undefined" && typeof window !== "undefined") { |
| 239 | 376 | ||
| 240 | function getSnapshot(): ThemeSnapshot { | 377 | function getSnapshot(): ThemeSnapshot { |
| 241 | if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT; | 378 | if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT; |
| 379 | // Reading the preference hits localStorage, so only recompute after a | ||
| 380 | // change was signalled; useTheme consumers call this on every render. | ||
| 381 | if (!snapshotStale && lastSnapshot) return lastSnapshot; | ||
| 382 | snapshotStale = false; | ||
| 242 | const theme = getStored(); | 383 | const theme = getStored(); |
| 243 | const systemDark = theme === "system" ? getSystemDark() : false; | 384 | const appearanceMode = readAppearanceModePreference(theme); |
| 385 | const followSystem = appearanceMode === "system"; | ||
| 386 | const systemDark = followSystem ? getSystemDark() : false; | ||
| 387 | const themeHalves = readStoredThemeHalves(); | ||
| 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 | return lastSnapshot; | 397 | return lastSnapshot; |
| 247 | } | 398 | } |
| 248 | 399 | ||
| 249 | lastSnapshot = { theme, systemDark }; | 400 | lastSnapshot = { theme, systemDark, followSystem, appearanceMode, themeHalves }; |
| 250 | return lastSnapshot; | 401 | return lastSnapshot; |
| 251 | } | 402 | } |
| 252 | 403 | ||
| ... | @@ -254,32 +405,56 @@ function getServerSnapshot() { | ... | @@ -254,32 +405,56 @@ function getServerSnapshot() { |
| 254 | return DEFAULT_THEME_SNAPSHOT; | 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 | function subscribe(listener: () => void): () => void { | 436 | function subscribe(listener: () => void): () => void { |
| 258 | if (typeof window === "undefined") return () => {}; | 437 | if (typeof window === "undefined") return () => {}; |
| 259 | listeners.push(listener); | 438 | listeners.push(listener); |
| 260 | 439 | ||
| 261 | // Listen for system preference changes | 440 | // The system-preference and cross-tab listeners are shared by all |
| 262 | const mq = typeof window.matchMedia === "function" ? window.matchMedia(MEDIA_QUERY) : null; | 441 | // subscribers; each event applies the theme once and notifies everyone. |
| 263 | const handleChange = () => { | 442 | if (!removeWindowListeners) { |
| 264 | if (getStored() === "system") applyTheme("system", true); | 443 | const mq = typeof window.matchMedia === "function" ? window.matchMedia(MEDIA_QUERY) : null; |
| 265 | emitChange(); | 444 | mq?.addEventListener("change", handleSystemAppearanceChange); |
| 266 | }; | 445 | window.addEventListener("storage", handleStorageChange); |
| 267 | mq?.addEventListener("change", handleChange); | 446 | removeWindowListeners = () => { |
| 268 | 447 | mq?.removeEventListener("change", handleSystemAppearanceChange); | |
| 269 | // Listen for storage changes from other tabs | 448 | window.removeEventListener("storage", handleStorageChange); |
| 270 | const handleStorage = (e: StorageEvent) => { | 449 | }; |
| 271 | if (e.key === STORAGE_KEY) { | 450 | } |
| 272 | themeStorageReadFailure = null; | ||
| 273 | applyTheme(getStored(), true); | ||
| 274 | emitChange(); | ||
| 275 | } | ||
| 276 | }; | ||
| 277 | window.addEventListener("storage", handleStorage); | ||
| 278 | 451 | ||
| 279 | return () => { | 452 | return () => { |
| 280 | listeners = listeners.filter((l) => l !== listener); | 453 | listeners = listeners.filter((l) => l !== listener); |
| 281 | mq?.removeEventListener("change", handleChange); | 454 | if (listeners.length === 0) { |
| 282 | window.removeEventListener("storage", handleStorage); | 455 | removeWindowListeners?.(); |
| 456 | removeWindowListeners = null; | ||
| 457 | } | ||
| 283 | }; | 458 | }; |
| 284 | } | 459 | } |
| 285 | 460 | ||
| ... | @@ -287,13 +462,34 @@ export function useTheme() { | ... | @@ -287,13 +462,34 @@ export function useTheme() { |
| 287 | const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); | 462 | const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); |
| 288 | const theme = snapshot.theme; | 463 | const theme = snapshot.theme; |
| 289 | 464 | ||
| 290 | const resolvedTheme: "light" | "dark" = | 465 | const resolvedTheme: "light" | "dark" = resolveThemeAppearance( |
| 291 | theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme; | 466 | theme, |
| 467 | snapshot.systemDark, | ||
| 468 | snapshot.followSystem, | ||
| 469 | snapshot.appearanceMode, | ||
| 470 | snapshot.themeHalves, | ||
| 471 | ); | ||
| 292 | 472 | ||
| 293 | const setTheme = useCallback((next: Theme) => { | 473 | const setTheme = useCallback((next: Theme): boolean => { |
| 294 | if (typeof window === "undefined") return; | 474 | if (typeof window === "undefined") return false; |
| 295 | try { | 475 | try { |
| 296 | writeThemePreference(next); | 476 | // Choosing a whole theme replaces any automatic-mode mix. The mix is |
| 477 | // captured first so a failed preference write can put it back instead | ||
| 478 | // of erasing it or leaving it attached to the new theme. | ||
| 479 | const previousHalvesRaw = window.localStorage.getItem(THEME_HALVES_STORAGE_KEY); | ||
| 480 | window.localStorage.removeItem(THEME_HALVES_STORAGE_KEY); | ||
| 481 | try { | ||
| 482 | writeThemePreference(next); | ||
| 483 | } catch (cause) { | ||
| 484 | if (previousHalvesRaw !== null) { | ||
| 485 | try { | ||
| 486 | window.localStorage.setItem(THEME_HALVES_STORAGE_KEY, previousHalvesRaw); | ||
| 487 | } catch { | ||
| 488 | // Storage is failing wholesale; the outer handler reports it. | ||
| 489 | } | ||
| 490 | } | ||
| 491 | throw cause; | ||
| 492 | } | ||
| 297 | } catch (cause) { | 493 | } catch (cause) { |
| 298 | const error = isThemeStorageError(cause) | 494 | const error = isThemeStorageError(cause) |
| 299 | ? cause | 495 | ? cause |
| ... | @@ -309,16 +505,129 @@ export function useTheme() { | ... | @@ -309,16 +505,129 @@ export function useTheme() { |
| 309 | theme: next, | 505 | theme: next, |
| 310 | ...safeErrorLogAttributes(error), | 506 | ...safeErrorLogAttributes(error), |
| 311 | }); | 507 | }); |
| 312 | return; | 508 | return false; |
| 313 | } | 509 | } |
| 314 | applyTheme(next, true); | 510 | applyTheme(next, true); |
| 315 | emitChange(); | 511 | emitChange(); |
| 512 | return true; | ||
| 513 | }, []); | ||
| 514 | |||
| 515 | const setAppearanceMode = useCallback((nextAppearanceMode: ThemePreferenceMode): boolean => { | ||
| 516 | if (typeof window === "undefined") return false; | ||
| 517 | try { | ||
| 518 | writeAppearanceModePreference(nextAppearanceMode); | ||
| 519 | } catch (cause) { | ||
| 520 | const error = isThemeStorageError(cause) | ||
| 521 | ? cause | ||
| 522 | : new ThemeStorageError({ | ||
| 523 | operation: "write", | ||
| 524 | storageKey: THEME_APPEARANCE_MODE_STORAGE_KEY, | ||
| 525 | cause, | ||
| 526 | }); | ||
| 527 | console.error(error.message, { | ||
| 528 | operation: error.operation, | ||
| 529 | storageKey: error.storageKey, | ||
| 530 | ...safeErrorLogAttributes(error), | ||
| 531 | }); | ||
| 532 | return false; | ||
| 533 | } | ||
| 534 | themeStorageReadFailure = null; | ||
| 535 | applyTheme(getStored(), true); | ||
| 536 | emitChange(); | ||
| 537 | return true; | ||
| 538 | }, []); | ||
| 539 | |||
| 540 | const setFollowSystem = useCallback( | ||
| 541 | (nextFollowSystem: boolean): boolean => { | ||
| 542 | const currentMode = readAppearanceModePreference(theme); | ||
| 543 | const nextMode = nextFollowSystem | ||
| 544 | ? "system" | ||
| 545 | : currentMode === "system" | ||
| 546 | ? (getThemePreferenceMode(theme) ?? "light") | ||
| 547 | : currentMode; | ||
| 548 | return setAppearanceMode(nextMode); | ||
| 549 | }, | ||
| 550 | [setAppearanceMode, theme], | ||
| 551 | ); | ||
| 552 | |||
| 553 | const setThemeHalf = useCallback( | ||
| 554 | (appearance: ThemeAppearance, themeId: string | null): boolean => { | ||
| 555 | if (typeof window === "undefined") return false; | ||
| 556 | try { | ||
| 557 | const current = readStoredThemeHalves() ?? {}; | ||
| 558 | const next: { light?: string; dark?: string } = { ...current }; | ||
| 559 | if (themeId === null) delete next[appearance]; | ||
| 560 | else next[appearance] = themeId; | ||
| 561 | if (next.light === undefined && next.dark === undefined) { | ||
| 562 | window.localStorage.removeItem(THEME_HALVES_STORAGE_KEY); | ||
| 563 | } else { | ||
| 564 | window.localStorage.setItem(THEME_HALVES_STORAGE_KEY, JSON.stringify(next)); | ||
| 565 | } | ||
| 566 | } catch (cause) { | ||
| 567 | const error = new ThemeStorageError({ | ||
| 568 | operation: "write", | ||
| 569 | storageKey: THEME_HALVES_STORAGE_KEY, | ||
| 570 | cause, | ||
| 571 | }); | ||
| 572 | console.error(error.message, { | ||
| 573 | operation: error.operation, | ||
| 574 | storageKey: error.storageKey, | ||
| 575 | ...safeErrorLogAttributes(error), | ||
| 576 | }); | ||
| 577 | return false; | ||
| 578 | } | ||
| 579 | applyTheme(getStored(), true); | ||
| 580 | emitChange(); | ||
| 581 | return true; | ||
| 582 | }, | ||
| 583 | [], | ||
| 584 | ); | ||
| 585 | |||
| 586 | const clearThemeHalves = useCallback((): boolean => { | ||
| 587 | if (typeof window === "undefined") return false; | ||
| 588 | try { | ||
| 589 | window.localStorage.removeItem(THEME_HALVES_STORAGE_KEY); | ||
| 590 | } catch (cause) { | ||
| 591 | const error = new ThemeStorageError({ | ||
| 592 | operation: "write", | ||
| 593 | storageKey: THEME_HALVES_STORAGE_KEY, | ||
| 594 | cause, | ||
| 595 | }); | ||
| 596 | console.error(error.message, { | ||
| 597 | operation: error.operation, | ||
| 598 | storageKey: error.storageKey, | ||
| 599 | ...safeErrorLogAttributes(error), | ||
| 600 | }); | ||
| 601 | return false; | ||
| 602 | } | ||
| 603 | applyTheme(getStored(), true); | ||
| 604 | emitChange(); | ||
| 605 | return true; | ||
| 606 | }, []); | ||
| 607 | |||
| 608 | const refreshTheme = useCallback(() => { | ||
| 609 | if (typeof window === "undefined") return; | ||
| 610 | lastAppliedTheme = null; | ||
| 611 | applyTheme(getStored(), true); | ||
| 612 | emitChange(); | ||
| 316 | }, []); | 613 | }, []); |
| 317 | 614 | ||
| 318 | // Keep DOM in sync on mount/change | 615 | // Keep DOM in sync on mount/change |
| 319 | useEffect(() => { | 616 | useEffect(() => { |
| 320 | applyTheme(theme); | 617 | applyTheme(theme); |
| 321 | }, [theme]); | 618 | }, [snapshot.appearanceMode, theme]); |
| 322 | 619 | ||
| 323 | return { theme, setTheme, resolvedTheme } as const; | 620 | return { |
| 621 | theme, | ||
| 622 | setTheme, | ||
| 623 | setAppearanceMode, | ||
| 624 | setFollowSystem, | ||
| 625 | setThemeHalf, | ||
| 626 | clearThemeHalves, | ||
| 627 | refreshTheme, | ||
| 628 | followSystem: snapshot.followSystem, | ||
| 629 | appearanceMode: snapshot.appearanceMode, | ||
| 630 | resolvedTheme, | ||
| 631 | themeHalves: snapshot.themeHalves, | ||
| 632 | } as const; | ||
| 324 | } | 633 | } |
apps/web/src/index.css+433-11| ... | @@ -148,6 +148,13 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil | ... | @@ -148,6 +148,13 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 148 | --animate-status-ping: status-ping 2s infinite; | 148 | --animate-status-ping: status-ping 2s infinite; |
| 149 | --color-warning-foreground: var(--warning-foreground); | 149 | --color-warning-foreground: var(--warning-foreground); |
| 150 | --color-warning: var(--warning); | 150 | --color-warning: var(--warning); |
| 151 | --color-warning-surface: var(--warning-surface); | ||
| 152 | --color-error-foreground: var(--error-foreground); | ||
| 153 | --color-error: var(--error); | ||
| 154 | --color-error-surface: var(--error-surface); | ||
| 155 | --color-update-foreground: var(--update-foreground); | ||
| 156 | --color-update: var(--update); | ||
| 157 | --color-update-surface: var(--update-surface); | ||
| 151 | --color-success-foreground: var(--success-foreground); | 158 | --color-success-foreground: var(--success-foreground); |
| 152 | --color-success: var(--success); | 159 | --color-success: var(--success); |
| 153 | --color-info-foreground: var(--info-foreground); | 160 | --color-info-foreground: var(--info-foreground); |
| ... | @@ -161,6 +168,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil | ... | @@ -161,6 +168,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 161 | --color-accent: var(--accent); | 168 | --color-accent: var(--accent); |
| 162 | --color-muted-foreground: var(--muted-foreground); | 169 | --color-muted-foreground: var(--muted-foreground); |
| 163 | --color-muted: var(--muted); | 170 | --color-muted: var(--muted); |
| 171 | --color-placeholder: var(--placeholder); | ||
| 172 | --color-secondary-label: var(--secondary-label); | ||
| 173 | --color-icon-muted: var(--icon-muted); | ||
| 164 | --color-secondary-foreground: var(--secondary-foreground); | 174 | --color-secondary-foreground: var(--secondary-foreground); |
| 165 | --color-secondary: var(--secondary); | 175 | --color-secondary: var(--secondary); |
| 166 | --color-primary-foreground: var(--primary-foreground); | 176 | --color-primary-foreground: var(--primary-foreground); |
| ... | @@ -172,6 +182,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil | ... | @@ -172,6 +182,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 172 | --color-foreground: var(--foreground); | 182 | --color-foreground: var(--foreground); |
| 173 | --color-background: var(--background); | 183 | --color-background: var(--background); |
| 174 | --color-surface-raised: var(--surface-raised); | 184 | --color-surface-raised: var(--surface-raised); |
| 185 | --color-message: var(--message-surface); | ||
| 186 | --color-message-foreground: var(--message-foreground); | ||
| 187 | --color-message-action: var(--message-action); | ||
| 188 | --color-message-action-foreground: var(--message-action-foreground); | ||
| 189 | --color-message-action-hover: var(--message-action-hover); | ||
| 175 | --color-sidebar: var(--sidebar); | 190 | --color-sidebar: var(--sidebar); |
| 176 | --color-sidebar-foreground: var(--sidebar-foreground); | 191 | --color-sidebar-foreground: var(--sidebar-foreground); |
| 177 | --color-sidebar-muted-foreground: var(--sidebar-muted-foreground); | 192 | --color-sidebar-muted-foreground: var(--sidebar-muted-foreground); |
| ... | @@ -833,7 +848,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil | ... | @@ -833,7 +848,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 833 | @utility surface-grain { | 848 | @utility surface-grain { |
| 834 | background-image: var(--surface-grain); | 849 | background-image: var(--surface-grain); |
| 835 | background-repeat: repeat; | 850 | background-repeat: repeat; |
| 836 | background-size: 256px 256px; | 851 | background-size: var(--surface-grain-size); |
| 837 | } | 852 | } |
| 838 | 853 | ||
| 839 | /* Suppress all transitions during theme changes */ | 854 | /* Suppress all transitions during theme changes */ |
| ... | @@ -850,6 +865,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil | ... | @@ -850,6 +865,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 850 | --radius: 0.625rem; | 865 | --radius: 0.625rem; |
| 851 | --background: var(--color-zinc-25); | 866 | --background: var(--color-zinc-25); |
| 852 | --app-chrome-background: var(--background); | 867 | --app-chrome-background: var(--background); |
| 868 | --toolbar-background: var(--app-chrome-background); | ||
| 869 | --toolbar-foreground: var(--foreground); | ||
| 870 | --toolbar-border: var(--border); | ||
| 871 | --toolbar-control: var(--popover); | ||
| 872 | --toolbar-control-foreground: var(--foreground); | ||
| 873 | --toolbar-control-hover: var(--accent); | ||
| 853 | --surface-raised: color-mix(in srgb, var(--card) 20%, transparent); | 874 | --surface-raised: color-mix(in srgb, var(--card) 20%, transparent); |
| 854 | --foreground: var(--color-zinc-800); | 875 | --foreground: var(--color-zinc-800); |
| 855 | --card: var(--color-white); | 876 | --card: var(--color-white); |
| ... | @@ -862,19 +883,34 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil | ... | @@ -862,19 +883,34 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 862 | --secondary-foreground: var(--color-zinc-800); | 883 | --secondary-foreground: var(--color-zinc-800); |
| 863 | --muted: var(--color-zinc-50); | 884 | --muted: var(--color-zinc-50); |
| 864 | --muted-foreground: var(--color-zinc-500); | 885 | --muted-foreground: var(--color-zinc-500); |
| 886 | --placeholder: var(--muted-foreground); | ||
| 887 | --secondary-label: var(--muted-foreground); | ||
| 888 | --icon-muted: var(--muted-foreground); | ||
| 889 | --message-surface: var(--accent); | ||
| 890 | --message-foreground: var(--foreground); | ||
| 891 | --message-action: var(--primary); | ||
| 892 | --message-action-foreground: var(--primary-foreground); | ||
| 893 | --message-action-hover: color-mix(in srgb, var(--primary) 90%, var(--background)); | ||
| 865 | --accent: var(--color-zinc-100); | 894 | --accent: var(--color-zinc-100); |
| 866 | --accent-foreground: var(--color-zinc-900); | 895 | --accent-foreground: var(--color-zinc-900); |
| 867 | --destructive: var(--color-red-500); | 896 | --error: var(--color-red-500); |
| 897 | --error-foreground: var(--color-red-700); | ||
| 898 | --error-surface: color-mix(in srgb, var(--error) 8%, transparent); | ||
| 899 | --destructive: var(--error); | ||
| 868 | --border: var(--color-zinc-200); | 900 | --border: var(--color-zinc-200); |
| 869 | --input: var(--color-zinc-300); | 901 | --input: var(--color-zinc-300); |
| 870 | --ring: oklch(0.488 0.217 264); | 902 | --ring: oklch(0.488 0.217 264); |
| 871 | --destructive-foreground: var(--color-red-700); | 903 | --destructive-foreground: var(--error-foreground); |
| 872 | --info: var(--color-blue-500); | 904 | --info: var(--color-blue-500); |
| 873 | --info-foreground: var(--color-blue-700); | 905 | --info-foreground: var(--color-blue-700); |
| 874 | --success: var(--color-emerald-500); | 906 | --success: var(--color-emerald-500); |
| 875 | --success-foreground: var(--color-emerald-700); | 907 | --success-foreground: var(--color-emerald-700); |
| 876 | --warning: var(--color-amber-500); | 908 | --warning: var(--color-amber-500); |
| 877 | --warning-foreground: var(--color-amber-700); | 909 | --warning-foreground: var(--color-amber-700); |
| 910 | --warning-surface: color-mix(in srgb, var(--warning) 8%, transparent); | ||
| 911 | --update: var(--primary); | ||
| 912 | --update-foreground: var(--primary); | ||
| 913 | --update-surface: color-mix(in srgb, var(--update) 12%, transparent); | ||
| 878 | /* Keep every sidebar primitive on the same light surface hierarchy, including | 914 | /* Keep every sidebar primitive on the same light surface hierarchy, including |
| 879 | portaled mobile sheets and settings navigation outside the app sidebar. */ | 915 | portaled mobile sheets and settings navigation outside the app sidebar. */ |
| 880 | --sidebar: var(--color-zinc-50); | 916 | --sidebar: var(--color-zinc-50); |
| ... | @@ -886,6 +922,14 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil | ... | @@ -886,6 +922,14 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 886 | --sidebar-row-selected: var(--color-white); | 922 | --sidebar-row-selected: var(--color-white); |
| 887 | --sidebar-border: var(--border); | 923 | --sidebar-border: var(--border); |
| 888 | --sidebar-stage-fade: var(--sidebar); | 924 | --sidebar-stage-fade: var(--sidebar); |
| 925 | --code-background: color-mix(in srgb, var(--card) 90%, var(--background)); | ||
| 926 | --code-foreground: var(--foreground); | ||
| 927 | --terminal-background: var(--background); | ||
| 928 | --terminal-foreground: var(--foreground); | ||
| 929 | --terminal-cursor: rgb(38 56 78); | ||
| 930 | --terminal-selection-background: rgb(37 63 99 / 20%); | ||
| 931 | --terminal-scrollbar: rgb(0 0 0 / 15%); | ||
| 932 | --terminal-scrollbar-hover: rgb(0 0 0 / 25%); | ||
| 889 | 933 | ||
| 890 | @variant dark { | 934 | @variant dark { |
| 891 | color-scheme: dark; | 935 | color-scheme: dark; |
| ... | @@ -905,19 +949,34 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil | ... | @@ -905,19 +949,34 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 905 | --secondary-foreground: var(--color-neutral-100); | 949 | --secondary-foreground: var(--color-neutral-100); |
| 906 | --muted: --alpha(var(--color-white) / 4%); | 950 | --muted: --alpha(var(--color-white) / 4%); |
| 907 | --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white)); | 951 | --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white)); |
| 952 | --placeholder: var(--muted-foreground); | ||
| 953 | --secondary-label: var(--muted-foreground); | ||
| 954 | --icon-muted: var(--muted-foreground); | ||
| 955 | --message-surface: var(--accent); | ||
| 956 | --message-foreground: var(--foreground); | ||
| 957 | --message-action: var(--primary); | ||
| 958 | --message-action-foreground: var(--primary-foreground); | ||
| 959 | --message-action-hover: color-mix(in srgb, var(--primary) 90%, var(--background)); | ||
| 908 | --accent: --alpha(var(--color-white) / 4%); | 960 | --accent: --alpha(var(--color-white) / 4%); |
| 909 | --accent-foreground: var(--color-neutral-100); | 961 | --accent-foreground: var(--color-neutral-100); |
| 910 | --destructive: color-mix(in srgb, var(--color-red-500) 90%, var(--color-white)); | 962 | --error: color-mix(in srgb, var(--color-red-500) 90%, var(--color-white)); |
| 963 | --error-foreground: var(--color-red-400); | ||
| 964 | --error-surface: color-mix(in srgb, var(--error) 16%, transparent); | ||
| 965 | --destructive: var(--error); | ||
| 911 | --border: --alpha(var(--color-white) / 6%); | 966 | --border: --alpha(var(--color-white) / 6%); |
| 912 | --input: --alpha(var(--color-white) / 8%); | 967 | --input: --alpha(var(--color-white) / 8%); |
| 913 | --ring: oklch(0.588 0.217 264); | 968 | --ring: oklch(0.588 0.217 264); |
| 914 | --destructive-foreground: var(--color-red-400); | 969 | --destructive-foreground: var(--error-foreground); |
| 915 | --info: var(--color-blue-500); | 970 | --info: var(--color-blue-500); |
| 916 | --info-foreground: var(--color-blue-400); | 971 | --info-foreground: var(--color-blue-400); |
| 917 | --success: var(--color-emerald-500); | 972 | --success: var(--color-emerald-500); |
| 918 | --success-foreground: var(--color-emerald-400); | 973 | --success-foreground: var(--color-emerald-400); |
| 919 | --warning: var(--color-amber-500); | 974 | --warning: var(--color-amber-500); |
| 920 | --warning-foreground: var(--color-amber-400); | 975 | --warning-foreground: var(--color-amber-400); |
| 976 | --warning-surface: color-mix(in srgb, var(--warning) 16%, transparent); | ||
| 977 | --update: var(--primary); | ||
| 978 | --update-foreground: var(--primary); | ||
| 979 | --update-surface: color-mix(in srgb, var(--update) 18%, transparent); | ||
| 921 | --sidebar: var(--card); | 980 | --sidebar: var(--card); |
| 922 | --sidebar-foreground: var(--foreground); | 981 | --sidebar-foreground: var(--foreground); |
| 923 | --sidebar-muted-foreground: var(--muted-foreground); | 982 | --sidebar-muted-foreground: var(--muted-foreground); |
| ... | @@ -927,6 +986,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil | ... | @@ -927,6 +986,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 927 | --sidebar-row-selected: var(--muted); | 986 | --sidebar-row-selected: var(--muted); |
| 928 | --sidebar-border: var(--border); | 987 | --sidebar-border: var(--border); |
| 929 | --sidebar-stage-fade: var(--card); | 988 | --sidebar-stage-fade: var(--card); |
| 989 | --terminal-background: var(--background); | ||
| 990 | --terminal-foreground: var(--foreground); | ||
| 991 | --terminal-cursor: rgb(180 203 255); | ||
| 992 | --terminal-selection-background: rgb(180 203 255 / 25%); | ||
| 993 | --terminal-scrollbar: rgb(255 255 255 / 10%); | ||
| 994 | --terminal-scrollbar-hover: rgb(255 255 255 / 18%); | ||
| 930 | } | 995 | } |
| 931 | } | 996 | } |
| 932 | 997 | ||
| ... | @@ -984,6 +1049,335 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil | ... | @@ -984,6 +1049,335 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 984 | --sidebar-stage-fade: var(--card); | 1049 | --sidebar-stage-fade: var(--card); |
| 985 | } | 1050 | } |
| 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 | body { | 1381 | body { |
| 988 | /* Reference the theme token (not a literal stack) so the Settings -> | 1382 | /* Reference the theme token (not a literal stack) so the Settings -> |
| 989 | Appearance runtime override of --font-sans reaches all interface text. */ | 1383 | Appearance runtime override of --font-sans reaches all interface text. */ |
| ... | @@ -1028,12 +1422,21 @@ body { | ... | @@ -1028,12 +1422,21 @@ body { |
| 1028 | grain's subtle brightening and stand out against the body. */ | 1422 | grain's subtle brightening and stand out against the body. */ |
| 1029 | :root { | 1423 | :root { |
| 1030 | --surface-grain: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.035'/%3E%3C/svg%3E"); | 1424 | --surface-grain: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.035'/%3E%3C/svg%3E"); |
| 1425 | --surface-grain-size: 256px 256px; | ||
| 1426 | } | ||
| 1427 | |||
| 1428 | /* The live T3 Chat texture is a 128px grayscale tile with a constant 7/255 | ||
| 1429 | alpha. Keep our procedural texture cheap while matching that scale and | ||
| 1430 | strength for the branded preset. */ | ||
| 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 | body { | 1436 | body { |
| 1034 | background-image: var(--surface-grain); | 1437 | background-image: var(--surface-grain); |
| 1035 | background-repeat: repeat; | 1438 | background-repeat: repeat; |
| 1036 | background-size: 256px 256px; | 1439 | background-size: var(--surface-grain-size); |
| 1037 | } | 1440 | } |
| 1038 | 1441 | ||
| 1039 | pre, | 1442 | pre, |
| ... | @@ -1584,15 +1987,34 @@ label:has(> select#reasoning-effort) select { | ... | @@ -1584,15 +1987,34 @@ label:has(> select#reasoning-effort) select { |
| 1584 | 1987 | ||
| 1585 | /* Diffs theme bridge (match diff surfaces to app palette) */ | 1988 | /* Diffs theme bridge (match diff surfaces to app palette) */ |
| 1586 | .diff-panel-viewport { | 1989 | .diff-panel-viewport { |
| 1587 | background: color-mix(in srgb, var(--background) 94%, var(--card)); | 1990 | background: var(--background); |
| 1991 | } | ||
| 1992 | |||
| 1993 | /* Diffs live directly on the panel canvas. Normal chat code blocks may use a | ||
| 1994 | raised code surface, but carrying that fill into the diff creates a card-like | ||
| 1995 | rectangle that does not belong in the panel. */ | ||
| 1996 | .diff-render-surface { | ||
| 1997 | --code-background: var(--background); | ||
| 1588 | } | 1998 | } |
| 1589 | 1999 | ||
| 1590 | .diff-render-file, | 2000 | .diff-render-file, |
| 1591 | .diff-render-surface > diffs-container { | 2001 | .diff-render-surface diffs-container { |
| 1592 | border: 1px solid var(--border); | 2002 | border: 0; |
| 1593 | border-radius: 0.5rem; | 2003 | border-radius: 0; |
| 1594 | overflow: clip; | 2004 | overflow: clip; |
| 1595 | background: color-mix(in srgb, var(--card) 92%, var(--background)); | 2005 | background: var(--code-background); |
| 2006 | color: var(--code-foreground); | ||
| 2007 | } | ||
| 2008 | |||
| 2009 | /* Pierre's file renderer owns its own surface tokens. Keep the root of a file | ||
| 2010 | preview on the same themeable code surface as the diff panel. */ | ||
| 2011 | .diff-render-surface diffs-container, | ||
| 2012 | .file-preview-virtualizer diffs-container { | ||
| 2013 | --diffs-bg: var(--code-background) !important; | ||
| 2014 | --diffs-light-bg: var(--code-background) !important; | ||
| 2015 | --diffs-dark-bg: var(--code-background) !important; | ||
| 2016 | background-color: var(--code-background) !important; | ||
| 2017 | color: var(--code-foreground) !important; | ||
| 1596 | } | 2018 | } |
| 1597 | 2019 | ||
| 1598 | @keyframes ultrathink-rainbow { | 2020 | @keyframes ultrathink-rainbow { |
apps/web/src/keybindings.test.ts+23| ... | @@ -128,6 +128,10 @@ const DEFAULT_BINDINGS = compile([ | ... | @@ -128,6 +128,10 @@ const DEFAULT_BINDINGS = compile([ |
| 128 | command: "projectSearch.toggle", | 128 | command: "projectSearch.toggle", |
| 129 | whenAst: whenNot(whenIdentifier("terminalFocus")), | 129 | whenAst: whenNot(whenIdentifier("terminalFocus")), |
| 130 | }, | 130 | }, |
| 131 | { | ||
| 132 | shortcut: modShortcut("t", { altKey: true, shiftKey: true }), | ||
| 133 | command: "themeEditor.toggle", | ||
| 134 | }, | ||
| 131 | { | 135 | { |
| 132 | shortcut: modShortcut("m", { shiftKey: true }), | 136 | shortcut: modShortcut("m", { shiftKey: true }), |
| 133 | command: "modelPicker.toggle", | 137 | command: "modelPicker.toggle", |
| ... | @@ -566,6 +570,25 @@ describe("chat/editor shortcuts", () => { | ... | @@ -566,6 +570,25 @@ describe("chat/editor shortcuts", () => { |
| 566 | ); | 570 | ); |
| 567 | }); | 571 | }); |
| 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 | it("matches diff.toggle shortcut outside terminal focus", () => { | 592 | it("matches diff.toggle shortcut outside terminal focus", () => { |
| 570 | assert.isTrue( | 593 | assert.isTrue( |
| 571 | isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { | 594 | isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { |
apps/web/src/lib/diffRendering.ts+71| ... | @@ -172,3 +172,74 @@ export function getDiffCollapseIconClassName(fileDiff: FileDiffMetadata): string | ... | @@ -172,3 +172,74 @@ export function getDiffCollapseIconClassName(fileDiff: FileDiffMetadata): string |
| 172 | return "text-muted-foreground/80"; | 172 | return "text-muted-foreground/80"; |
| 173 | } | 173 | } |
| 174 | } | 174 | } |
| 175 | |||
| 176 | /** | ||
| 177 | * Maps every diff/file surface the @pierre/diffs renderer paints onto the | ||
| 178 | * app's code tokens, so themed palettes reach the code body, gutter, and | ||
| 179 | * row tints instead of the renderer's bundled colors. Shared by the diff | ||
| 180 | * panel and the file preview. | ||
| 181 | */ | ||
| 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,6 +19,7 @@ import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstall |
| 19 | import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; | 19 | import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; |
| 20 | import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; | 20 | import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; |
| 21 | import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; | 21 | import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; |
| 22 | import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; | ||
| 22 | import { Button } from "../components/ui/button"; | 23 | import { Button } from "../components/ui/button"; |
| 23 | import { | 24 | import { |
| 24 | AnchoredToastProvider, | 25 | AnchoredToastProvider, |
| ... | @@ -139,6 +140,9 @@ function RootRouteView() { | ... | @@ -139,6 +140,9 @@ function RootRouteView() { |
| 139 | {primaryEnvironmentAuthenticated ? <EventRouter /> : null} | 140 | {primaryEnvironmentAuthenticated ? <EventRouter /> : null} |
| 140 | {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null} | 141 | {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null} |
| 141 | {appShell} | 142 | {appShell} |
| 143 | {/* Above the router: a theme draft is judged by walking the app, so the | ||
| 144 | editor has to survive navigation away from settings. */} | ||
| 145 | <ThemeEditorHost /> | ||
| 142 | </AnchoredToastProvider> | 146 | </AnchoredToastProvider> |
| 143 | </ToastProvider> | 147 | </ToastProvider> |
| 144 | ); | 148 | ); |
apps/web/src/themeBoot.test.ts created+455| ... | @@ -0,0 +1,455 @@ | ||
| 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,6 +40,12 @@ Commands are IDs like `terminal.toggle`, `commandPalette.toggle`, `preview.refre |
| 40 | `filePicker.toggle` opens file search for the active project and defaults to `mod+p`. | 40 | `filePicker.toggle` opens file search for the active project and defaults to `mod+p`. |
| 41 | `projectSearch.toggle` searches inside the active project's files and defaults to `mod+shift+f`. | 41 | `projectSearch.toggle` searches inside the active project's files and defaults to `mod+shift+f`. |
| 42 | Repeating either shortcut closes that search, and switching shortcuts replaces the open search. | 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 | The command palette searches active thread titles, projects, branches, user messages, and final | 50 | The command palette searches active thread titles, projects, branches, user messages, and final |
| 45 | agent responses across connected environments. Message matches show one labeled excerpt while | 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,6 +440,23 @@ export const PickFolderOptionsSchema = Schema.Struct({ |
| 440 | targetEnvironmentId: Schema.optionalKey(Schema.String), | 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 | export interface DesktopWslDistro { | 460 | export interface DesktopWslDistro { |
| 444 | name: string; | 461 | name: string; |
| 445 | isDefault: boolean; | 462 | isDefault: boolean; |
| ... | @@ -1019,6 +1036,12 @@ export interface DesktopBridge { | ... | @@ -1019,6 +1036,12 @@ export interface DesktopBridge { |
| 1019 | setWslDistro: (distro: string | null) => Promise<DesktopWslState>; | 1036 | setWslDistro: (distro: string | null) => Promise<DesktopWslState>; |
| 1020 | setWslOnly: (enabled: boolean) => Promise<DesktopWslState>; | 1037 | setWslOnly: (enabled: boolean) => Promise<DesktopWslState>; |
| 1021 | pickFolder: (options?: PickFolderOptions) => Promise<string | null>; | 1038 | pickFolder: (options?: PickFolderOptions) => Promise<string | null>; |
| 1039 | /** | ||
| 1040 | * Multi-select JSON file picker that opens in the VS Code extensions | ||
| 1041 | * directory when one exists. Optional: older desktop builds lack it, and | ||
| 1042 | * web callers fall back to a plain file input. | ||
| 1043 | */ | ||
| 1044 | pickThemeFiles?: () => Promise<readonly PickedThemeFile[] | null>; | ||
| 1022 | confirm: (message: string) => Promise<boolean>; | 1045 | confirm: (message: string) => Promise<boolean>; |
| 1023 | setTheme: (theme: DesktopTheme) => Promise<void>; | 1046 | setTheme: (theme: DesktopTheme) => Promise<void>; |
| 1024 | showContextMenu: <T extends string>( | 1047 | showContextMenu: <T extends string>( |
packages/contracts/src/keybindings.test.ts+6| ... | @@ -72,6 +72,12 @@ it.effect("parses keybinding rules", () => | ... | @@ -72,6 +72,12 @@ it.effect("parses keybinding rules", () => |
| 72 | }); | 72 | }); |
| 73 | assert.strictEqual(parsedProjectSearch.command, "projectSearch.toggle"); | 73 | assert.strictEqual(parsedProjectSearch.command, "projectSearch.toggle"); |
| 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 | const parsedLocal = yield* decode(KeybindingRule, { | 81 | const parsedLocal = yield* decode(KeybindingRule, { |
| 76 | key: "mod+shift+n", | 82 | key: "mod+shift+n", |
| 77 | command: "chat.newLocal", | 83 | command: "chat.newLocal", |
packages/contracts/src/keybindings.ts+1| ... | @@ -65,6 +65,7 @@ const STATIC_KEYBINDING_COMMANDS = [ | ... | @@ -65,6 +65,7 @@ const STATIC_KEYBINDING_COMMANDS = [ |
| 65 | "commandPalette.toggle", | 65 | "commandPalette.toggle", |
| 66 | "filePicker.toggle", | 66 | "filePicker.toggle", |
| 67 | "projectSearch.toggle", | 67 | "projectSearch.toggle", |
| 68 | "themeEditor.toggle", | ||
| 68 | "composer.stash", | 69 | "composer.stash", |
| 69 | "chat.new", | 70 | "chat.new", |
| 70 | "chat.newLocal", | 71 | "chat.newLocal", |
packages/shared/src/keybindings.ts+1| ... | @@ -37,6 +37,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ | ... | @@ -37,6 +37,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ |
| 37 | { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, | 37 | { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, |
| 38 | { key: "mod+p", command: "filePicker.toggle", when: "!terminalFocus" }, | 38 | { key: "mod+p", command: "filePicker.toggle", when: "!terminalFocus" }, |
| 39 | { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" }, | 39 | { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" }, |
| 40 | { key: "mod+alt+shift+t", command: "themeEditor.toggle" }, | ||
| 40 | { key: "mod+s", command: "composer.stash", when: "!terminalFocus" }, | 41 | { key: "mod+s", command: "composer.stash", when: "!terminalFocus" }, |
| 41 | { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, | 42 | { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, |
| 42 | { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, | 43 | { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, |