diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index be633971bea..f1add4c7cc7 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -23,6 +23,21 @@ export class ElectronDialogPickFolderError extends Schema.TaggedErrorClass()( + "ElectronDialogPickFilesError", + { + ownerWindowId: Schema.NullOr(Schema.Number), + defaultPath: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + const owner = this.ownerWindowId === null ? "the application" : `window ${this.ownerWindowId}`; + const defaultPath = this.defaultPath === null ? "no default path" : this.defaultPath; + return `Failed to open the Electron file picker for ${owner} with ${defaultPath}.`; + } +} + export class ElectronDialogConfirmError extends Schema.TaggedErrorClass()( "ElectronDialogConfirmError", { @@ -69,6 +84,7 @@ export class ElectronDialogShowErrorBoxError extends Schema.TaggedErrorClass; } +export interface ElectronDialogPickFilesInput { + readonly owner: Option.Option; + readonly defaultPath: Option.Option; + readonly filters: readonly Electron.FileFilter[]; +} + export interface ElectronDialogConfirmInput { readonly owner: Option.Option; readonly message: string; @@ -92,6 +114,9 @@ export class ElectronDialog extends Context.Service< readonly pickFolder: ( input: ElectronDialogPickFolderInput, ) => Effect.Effect, ElectronDialogPickFolderError>; + readonly pickFiles: ( + input: ElectronDialogPickFilesInput, + ) => Effect.Effect; readonly confirm: ( input: ElectronDialogConfirmInput, ) => Effect.Effect; @@ -137,6 +162,32 @@ export const make = ElectronDialog.of({ } return Option.fromNullishOr(result.filePaths[0]); }), + pickFiles: Effect.fn("desktop.electron.dialog.pickFiles")(function* (input) { + const ownerWindowId = Option.match(input.owner, { + onNone: () => null, + onSome: (owner) => owner.id, + }); + const defaultPath = Option.getOrNull(input.defaultPath); + const openDialogOptions: Electron.OpenDialogOptions = { + properties: ["openFile", "multiSelections"], + filters: [...input.filters], + ...(defaultPath === null ? {} : { defaultPath }), + }; + const result = yield* Effect.tryPromise({ + try: () => + Option.match(input.owner, { + onNone: () => Electron.dialog.showOpenDialog(openDialogOptions), + onSome: (owner) => Electron.dialog.showOpenDialog(owner, openDialogOptions), + }), + catch: (cause) => + new ElectronDialogPickFilesError({ + ownerWindowId, + defaultPath, + cause, + }), + }); + return result.canceled ? [] : result.filePaths; + }), confirm: Effect.fn("desktop.electron.dialog.confirm")(function* (input) { const normalizedMessage = input.message.trim(); if (normalizedMessage.length === 0) { diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index e478d0c6eff..503a586d9c5 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -38,6 +38,7 @@ import { getWindowFullscreenState, openExternal, pickFolder, + pickThemeFiles, setTheme, showContextMenu, } from "./methods/window.ts"; @@ -79,6 +80,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setWslOnly); yield* ipc.handle(pickFolder); + yield* ipc.handle(pickThemeFiles); yield* ipc.handle(confirm); yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 5988b1e42f9..4d8e783d122 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -1,4 +1,5 @@ export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; +export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; export const CONFIRM_CHANNEL = "desktop:confirm"; export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index a4e98aaabad..cfa854e7a16 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -3,10 +3,15 @@ import { DesktopAppBrandingSchema, DesktopEnvironmentBootstrapSchema, DesktopThemeSchema, + PickedThemeFileSchema, PickFolderOptionsSchema, PRIMARY_LOCAL_ENVIRONMENT_ID, type DesktopEnvironmentBootstrap, + type PickedThemeFile, } from "@t3tools/contracts"; +import * as NodeOS from "node:os"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -268,3 +273,49 @@ export const openExternal = DesktopIpc.makeIpcMethod({ return yield* shell.openExternal(url); }), }); + +/** Theme files are a few KB; anything larger returns empty text and lets the + * renderer reject it by size without the contents ever crossing the bridge. */ +const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024; + +export const pickThemeFiles = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PICK_THEME_FILES_CHANNEL, + payload: Schema.Undefined, + result: Schema.NullOr(Schema.Array(PickedThemeFileSchema)), + handler: Effect.fn("desktop.ipc.window.pickThemeFiles")(function* () { + const dialog = yield* ElectronDialog.ElectronDialog; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // The VS Code extensions directory is the same dotfolder on Windows, + // macOS, and Linux; when it is missing the picker opens wherever the + // platform would by default. + const extensionsDir = path.join(NodeOS.homedir(), ".vscode", "extensions"); + const defaultPath = yield* fileSystem + .exists(extensionsDir) + .pipe(Effect.orElseSucceed(() => false)); + const paths = yield* dialog.pickFiles({ + owner: yield* electronWindow.focusedMainOrFirst, + defaultPath: defaultPath ? Option.some(extensionsDir) : Option.none(), + filters: [{ name: "JSON", extensions: ["json"] }], + }); + if (paths.length === 0) { + return null; + } + return yield* Effect.forEach(paths, (filePath) => { + const name = path.basename(filePath); + return Effect.gen(function* () { + const info = yield* fileSystem.stat(filePath); + const size = Number(info.size); + if (size > PICKED_THEME_FILE_MAX_BYTES) { + return { name, size, text: "" } satisfies PickedThemeFile; + } + const text = yield* fileSystem.readFileString(filePath); + return { name, size, text } satisfies PickedThemeFile; + }).pipe( + // An unreadable file degrades to an entry the renderer reports. + Effect.orElseSucceed((): PickedThemeFile => ({ name, size: 0, text: "" })), + ); + }); + }), +}); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 9f01baeed90..7e8859359b3 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -97,6 +97,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { setWslDistro: (distro) => ipcRenderer.invoke(IpcChannels.SET_WSL_DISTRO_CHANNEL, distro), setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled), pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), + pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined), confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), showContextMenu: (items, position) => diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 136cf820417..22a24b908b6 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -52,6 +52,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { pickFolder: () => Effect.succeed(Option.none()), + pickFiles: () => Effect.succeed([]), confirm: () => Effect.succeed(false), showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }), showErrorBox: () => Effect.void, diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 252819adacf..b674688f041 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -198,6 +198,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); + assert.equal(defaultsByCommand.get("themeEditor.toggle"), "mod+alt+shift+t"); assert.equal(defaultsByCommand.get("filePicker.toggle"), "mod+p"); assert.equal(defaultsByCommand.get("projectSearch.toggle"), "mod+shift+f"); assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); diff --git a/apps/web/index.html b/apps/web/index.html index 021bcb4156c..f51d9432dae 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -6,8 +6,6 @@ name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" /> - - @@ -15,23 +13,373 @@ (() => { const LIGHT_BACKGROUND = "#ffffff"; const DARK_BACKGROUND = "#0a0a0a"; - const themeColorMeta = document.querySelector('meta[name="theme-color"]'); + const CUSTOM_THEMES_STORAGE_KEY = "t3code:themes:v1"; + const THEME_FOLLOW_SYSTEM_STORAGE_KEY = "t3code:theme-follow-system"; + const THEME_APPEARANCE_MODE_STORAGE_KEY = "t3code:theme-appearance-mode"; + const THEME_HALVES_STORAGE_KEY = "t3code:theme-halves:v1"; + const SPLASH_COLORS = { + light: { + background: "#ffffff", + foreground: "#262626", + accent: "#4f46e5", + }, + dark: { + background: "#0a0a0a", + foreground: "#f5f5f5", + accent: "#818cf8", + }, + }; + // These are the runtime defaults used to complete a custom palette + // when a stored theme omits a role. Keep them separate from the + // unselected splash so a partial custom theme does not flash generic + // app colors before React mounts. + const DEFAULT_THEME_PALETTES = { + light: { + background: "#fdf7fd", + foreground: "#501854", + accent: "#e33f86", + chrome: "#fdf7fd", + }, + dark: { + background: "#1f1a24", + foreground: "#f9f8fb", + accent: "#a3004c", + chrome: "#1f1a24", + }, + }; + // Keep this small boot-time copy in sync with the built-in palettes so + // the selected theme is visible before the app has mounted. + const BUILT_IN_THEME_PALETTES = { + "t3-chat": { + light: { + background: "#fdf7fd", + foreground: "#501854", + accent: "#e33f86", + chrome: "#fdf7fd", + }, + dark: { + background: "#1f1a24", + foreground: "#f9f8fb", + accent: "#a3004c", + chrome: "#1f1a24", + }, + }, + grove: { + light: { + background: "#f3f7f4", + foreground: "#241523", + accent: "#1b7d50", + chrome: "#f3f7f4", + }, + dark: { + background: "#1b2821", + foreground: "#fffaff", + accent: "#69d69a", + chrome: "#1b2821", + }, + }, + ocean: { + light: { + background: "#f5f7f8", + foreground: "#241523", + accent: "#2672af", + chrome: "#f5f7f8", + }, + dark: { + background: "#17212b", + foreground: "#fffaff", + accent: "#70b9ee", + chrome: "#17212b", + }, + }, + ember: { + light: { + background: "#f9f7f5", + foreground: "#241523", + accent: "#ae552a", + chrome: "#f9f7f5", + }, + dark: { + background: "#291e1a", + foreground: "#fffaff", + accent: "#f09a64", + chrome: "#291e1a", + }, + }, + iris: { + light: { + background: "#f8f7f9", + foreground: "#241523", + accent: "#7253b9", + chrome: "#f8f7f9", + }, + dark: { + background: "#1d1929", + foreground: "#fffaff", + accent: "#9d7df2", + chrome: "#1d1929", + }, + }, + }; + // Keep the built-in mode list in sync with the runtime so the selected + // T3 Chat palette is visible before React has mounted. + const BUILT_IN_THEME_MODES = Object.fromEntries( + Object.entries(BUILT_IN_THEME_PALETTES).map(([id, palettes]) => [ + id, + Object.keys(palettes), + ]), + ); + const RESERVED_THEME_IDS = [ + "system", + "light", + "dark", + "t3-chat", + "grove", + "ocean", + "ember", + "iris", + "t3-chat-dark", + "t3-grove", + "t3-ocean", + "t3-ember", + "t3-iris", + ]; + // Update every theme-color meta so any element another layer added + // carries the resolved color too. + const setThemeColor = (color) => { + for (const meta of document.querySelectorAll('meta[name="theme-color"]')) { + meta.setAttribute("content", color); + } + }; + + const isHexColor = (value) => + typeof value === "string" && + /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value); + const isRecord = (value) => + typeof value === "object" && value !== null && !Array.isArray(value); + const isThemeId = (value) => + typeof value === "string" && /^[a-z0-9](?:[a-z0-9-]{0,47})$/.test(value); + const isThemeLabel = (value) => + typeof value === "string" && value.trim().length > 0 && value.trim().length <= 48; + const isThemePreferenceMode = (value) => + value === "light" || value === "dark" || value === "system"; + const isThemeAppearance = (value) => value === "light" || value === "dark"; + const isStoredCustomTheme = (value) => { + if ( + !isRecord(value) || + !isThemeId(value.id) || + RESERVED_THEME_IDS.includes(value.id) || + !isThemeLabel(value.label) || + !isThemeAppearance(value.appearance) || + !isRecord(value.colors) + ) { + return false; + } + if (value.variants === undefined) return true; + if (!isRecord(value.variants)) return false; + return Object.entries(value.variants).every( + ([appearance, colors]) => + isThemeAppearance(appearance) && + (appearance === value.appearance || isRecord(colors)), + ); + }; + const findCustomTheme = (themeId) => { + if (RESERVED_THEME_IDS.includes(themeId)) return null; + try { + const parsed = JSON.parse( + window.localStorage.getItem(CUSTOM_THEMES_STORAGE_KEY) ?? "null", + ); + if (!Array.isArray(parsed)) return null; + return ( + parsed.find((value) => isStoredCustomTheme(value) && value.id === themeId) ?? null + ); + } catch { + return null; + } + }; + try { const storedTheme = window.localStorage.getItem("t3code:theme"); - const theme = - storedTheme === "light" || storedTheme === "dark" || storedTheme === "system" - ? storedTheme - : "system"; const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - const isDark = theme === "dark" || (theme === "system" && prefersDark); + // Older builds stored the dark T3 Chat palette as its own theme id + // and every maintainer theme under a t3- prefix; keep those + // preferences readable. Mirrors the runtime's legacy alias table. + const LEGACY_THEME_IDS = { + "t3-chat-dark": "t3-chat", + "t3-grove": "grove", + "t3-ocean": "ocean", + "t3-ember": "ember", + "t3-iris": "iris", + }; + const isLegacyT3ChatDark = storedTheme === "t3-chat-dark"; + const themeId = + storedTheme === null ? "" : (LEGACY_THEME_IDS[storedTheme] ?? storedTheme); + const legacyMode = isLegacyT3ChatDark ? "dark" : null; + const builtInModes = BUILT_IN_THEME_MODES[themeId] ?? null; + const customTheme = builtInModes ? null : findCustomTheme(themeId); + const themeModes = + builtInModes ?? + (customTheme + ? ["light", "dark"].filter( + (mode) => mode === customTheme.appearance || customTheme.variants?.[mode], + ) + : null); + // Mirrors the runtime's isKnownThemePreference. + const isKnownTheme = + storedTheme === "light" || + storedTheme === "dark" || + storedTheme === "system" || + themeModes !== null; + const theme = isKnownTheme && storedTheme ? storedTheme : "system"; + const isThemed = isKnownTheme && themeModes !== null; + const baseAppearance = customTheme ? customTheme.appearance : "light"; + const systemMode = prefersDark ? "dark" : "light"; + const storedAppearanceMode = window.localStorage.getItem( + THEME_APPEARANCE_MODE_STORAGE_KEY, + ); + const storedFollowSystem = window.localStorage.getItem(THEME_FOLLOW_SYSTEM_STORAGE_KEY); + // Mirrors the runtime's getThemePreferenceMode fallback: a bare + // "light"/"dark" preference names its own mode. + const preferenceMode = + theme === "light" || theme === "dark" ? theme : (legacyMode ?? baseAppearance); + const appearanceMode = isThemePreferenceMode(storedAppearanceMode) + ? storedAppearanceMode + : storedFollowSystem === "true" + ? "system" + : storedFollowSystem === "false" + ? preferenceMode + : theme === "system" + ? "system" + : preferenceMode; + // A valid mix half for the wanted appearance takes over: it decides + // light vs dark even when the base theme cannot render that mode. + // Mirrors the runtime's halves-aware resolveThemeAppearance. + const wantedMode = appearanceMode === "system" ? systemMode : appearanceMode; + let halfThemeId = null; + let halfCustomTheme = null; + try { + const halvesRaw = JSON.parse( + window.localStorage.getItem(THEME_HALVES_STORAGE_KEY) ?? "null", + ); + const candidate = isRecord(halvesRaw) ? halvesRaw[wantedMode] : null; + if (typeof candidate === "string") { + const halfId = LEGACY_THEME_IDS[candidate] ?? candidate; + const halfBuiltIn = BUILT_IN_THEME_MODES[halfId] ?? null; + const halfCustom = halfBuiltIn ? null : findCustomTheme(halfId); + const halfModes = + halfBuiltIn ?? + (halfCustom + ? ["light", "dark"].filter( + (mode) => mode === halfCustom.appearance || halfCustom.variants?.[mode], + ) + : null); + if (halfModes && halfModes.includes(wantedMode)) { + halfThemeId = halfId; + halfCustomTheme = halfCustom; + } + } + } catch { + // A malformed mix degrades to the base preference. + } + const themeMode = !isThemed + ? null + : appearanceMode === "system" + ? themeModes.includes(systemMode) + ? systemMode + : baseAppearance + : themeModes.includes(appearanceMode) + ? appearanceMode + : baseAppearance; + const isDark = + halfThemeId !== null + ? wantedMode === "dark" + : isThemed + ? themeMode === "dark" + : appearanceMode === "system" + ? prefersDark + : appearanceMode === "dark"; + const effectiveIsThemed = halfThemeId !== null || isThemed; + const effectiveThemeId = halfThemeId ?? themeId; + const effectiveCustomTheme = halfThemeId !== null ? halfCustomTheme : customTheme; + const effectiveThemeMode = halfThemeId !== null ? wantedMode : themeMode; + const customColors = + effectiveIsThemed && effectiveCustomTheme + ? effectiveThemeMode === effectiveCustomTheme.appearance + ? effectiveCustomTheme.colors + : (effectiveCustomTheme.variants?.[effectiveThemeMode] ?? + effectiveCustomTheme.colors) + : null; + const customDefaults = + effectiveCustomTheme && effectiveThemeMode + ? DEFAULT_THEME_PALETTES[effectiveThemeMode] + : null; + // The runtime tolerates individual malformed colors, so fall back + // per role rather than dropping the theme. + const builtInSplash = + effectiveIsThemed && !effectiveCustomTheme && effectiveThemeMode + ? (BUILT_IN_THEME_PALETTES[effectiveThemeId]?.[effectiveThemeMode] ?? null) + : null; + const fallbackSplash = isDark ? SPLASH_COLORS.dark : SPLASH_COLORS.light; + const customSplash = customColors + ? { + background: isHexColor(customColors.canvas) + ? customColors.canvas + : (customDefaults?.background ?? fallbackSplash.background), + foreground: isHexColor(customColors.text) + ? customColors.text + : (customDefaults?.foreground ?? fallbackSplash.foreground), + accent: isHexColor(customColors.accent) + ? customColors.accent + : (customDefaults?.accent ?? fallbackSplash.accent), + } + : null; + if (effectiveIsThemed) { + document.documentElement.dataset.themeId = effectiveCustomTheme + ? effectiveCustomTheme.id + : effectiveThemeId; + } else { + delete document.documentElement.dataset.themeId; + } + // A resolved half is a selection even when the base theme is gone + // (deleting the base leaves the mix pointing at a real theme). + const hasSelection = halfThemeId !== null || (isKnownTheme && storedTheme !== null); + if (hasSelection) { + document.documentElement.dataset.themeSelected = "true"; + } else { + delete document.documentElement.dataset.themeSelected; + } document.documentElement.classList.toggle("dark", isDark); - const chromeColor = isDark ? DARK_BACKGROUND : LIGHT_BACKGROUND; + const chromeColor = customColors + ? isHexColor(customColors.chrome) + ? customColors.chrome + : (customDefaults?.chrome ?? fallbackSplash.background) + : builtInSplash + ? builtInSplash.chrome + : isDark + ? DARK_BACKGROUND + : LIGHT_BACKGROUND; document.documentElement.style.backgroundColor = chromeColor; - themeColorMeta?.setAttribute("content", chromeColor); + if (hasSelection) { + const splashColors = customSplash ?? builtInSplash ?? fallbackSplash; + for (const [name, value] of Object.entries(splashColors)) { + document.documentElement.style.setProperty(`--boot-${name}`, value); + } + } + setThemeColor(chromeColor); } catch { - document.documentElement.classList.add("dark"); - document.documentElement.style.backgroundColor = DARK_BACKGROUND; - themeColorMeta?.setAttribute("content", DARK_BACKGROUND); + // Mirror the runtime's storage-failure fallback: follow the OS. + let prefersDark = true; + try { + prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + } catch { + // Keep the dark default when even matchMedia is unavailable. + } + delete document.documentElement.dataset.themeId; + delete document.documentElement.dataset.themeSelected; + document.documentElement.classList.toggle("dark", prefersDark); + const fallbackColor = prefersDark ? DARK_BACKGROUND : LIGHT_BACKGROUND; + document.documentElement.style.backgroundColor = fallbackColor; + setThemeColor(fallbackColor); } })(); @@ -56,14 +404,24 @@ } #boot-shell { + position: relative; + overflow: hidden; display: flex; min-height: 100%; align-items: center; justify-content: center; background: inherit; + color: inherit; + } + + html[data-theme-selected="true"] #boot-shell { + background: var(--boot-background); + color: var(--boot-foreground); } #boot-shell-card { + position: relative; + z-index: 1; display: flex; align-items: center; justify-content: center; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f0e25e64475..c5e4f7abb88 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5842,10 +5842,10 @@ function ChatViewContent(props: ChatViewProps) { const panelLayoutControls = (
{rightPanelOpen && !shouldUseRightPanelSheet ? ( diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index eee6ba5886e..c9ded5e7eee 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -12,7 +12,7 @@ import { formatRelativeTimeLabel } from "../timestampFormat"; import { type Project, type SidebarThreadSummary, type Thread } from "../types"; export const RECENT_THREAD_LIMIT = 12; -export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; +export const ITEM_ICON_CLASS = "size-4 text-icon-muted"; export const ADDON_ICON_CLASS = "size-4"; /** diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 60493063664..605127f9737 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -35,6 +35,7 @@ import { FolderPlusIcon, LinkIcon, MessageSquareIcon, + PaletteIcon, SettingsIcon, SquarePenIcon, TextSearchIcon, @@ -57,6 +58,7 @@ import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useClientSettings } from "../hooks/useSettings"; +import { useTheme } from "../hooks/useTheme"; import { readLocalApi } from "../localApi"; import { desktopLocalBackendId } from "../connection/desktopLocal"; import { filesystemEnvironment } from "../state/filesystem"; @@ -121,6 +123,7 @@ import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons" import { ProjectFavicon } from "./ProjectFavicon"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; +import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; import { resolveDefaultProviderModelSelection } from "../providerInstances"; @@ -386,6 +389,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const { theme, themeHalves, resolvedTheme } = useTheme(); const composerHandleRef = useRef(null); const routeTarget = useParams({ strict: false, @@ -428,6 +432,16 @@ export function CommandPalette({ children }: { children: ReactNode }) { previewOpen, }, }); + if (command === "themeEditor.toggle") { + event.preventDefault(); + event.stopPropagation(); + toggleThemeEditorForTheme({ + theme, + themeHalves, + initialAppearance: resolvedTheme, + }); + return; + } const mode = overlayModeForCommand(command); if (mode === null) { return; @@ -438,7 +452,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [keybindings, previewOpen, terminalOpen, toggleMode]); + }, [keybindings, previewOpen, resolvedTheme, terminalOpen, theme, themeHalves, toggleMode]); useEffect( () => @@ -567,6 +581,7 @@ function OpenCommandPaletteDialog(props: { const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const { theme, themeHalves, resolvedTheme } = useTheme(); const providers = useAtomValue(primaryServerProvidersAtom); const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; @@ -1463,6 +1478,22 @@ function OpenCommandPaletteDialog(props: { }); } + actionItems.push({ + kind: "action", + value: "action:theme-editor", + searchTerms: ["theme", "appearance", "colors", "palette", "customize"], + title: "Toggle theme editor", + icon: , + shortcutCommand: "themeEditor.toggle", + run: async () => { + toggleThemeEditorForTheme({ + theme, + themeHalves, + initialAppearance: resolvedTheme, + }); + }, + }); + actionItems.push({ kind: "action", value: "action:settings", diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 67b82388bbc..0489e8c79cd 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1765,7 +1765,7 @@ function ComposerPromptEditorInner({ } placeholder={ terminalContexts.length > 0 ? null : ( -
+
{placeholder}
) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index a10cdafd783..76191e6d4d7 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -36,6 +36,7 @@ import { getRenderablePatch, resolveDiffThemeName, resolveFileDiffPath, + DIFF_SURFACE_THEME_UNSAFE_CSS, } from "../lib/diffRendering"; import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; @@ -86,54 +87,7 @@ interface CollapsedDiffFilesState { const EMPTY_COLLAPSED_DIFF_FILE_KEYS: ReadonlySet = new Set(); -const DIFF_PANEL_UNSAFE_CSS = ` -[data-diffs-header], -[data-diff], -[data-file], -[data-error-wrapper], -[data-virtualizer-buffer] { - --diffs-header-font-family: var(--font-sans) !important; - --diffs-font-family: var(--font-mono) !important; - --diffs-bg: var(--background) !important; - --diffs-light-bg: var(--background) !important; - --diffs-dark-bg: var(--background) !important; - --diffs-token-light-bg: transparent; - --diffs-token-dark-bg: transparent; - - --diffs-bg-context-override: color-mix(in srgb, var(--background) 97%, var(--foreground)); - --diffs-bg-hover-override: color-mix(in srgb, var(--background) 94%, var(--foreground)); - --diffs-bg-separator-override: color-mix(in srgb, var(--background) 95%, var(--foreground)); - --diffs-bg-buffer-override: color-mix(in srgb, var(--background) 90%, var(--foreground)); - - --diffs-bg-addition-override: light-dark( - color-mix(in srgb, var(--background) 50%, var(--success)), - color-mix(in srgb, var(--background) 70%, var(--success)) - ); - --diffs-bg-addition-number-override: light-dark( - color-mix(in srgb, var(--background) 35%, var(--success)), - color-mix(in srgb, var(--background) 60%, var(--success)) - ); - --diffs-bg-addition-hover-override: color-mix(in srgb, var(--background) 85%, var(--success)); - --diffs-bg-addition-emphasis-override: color-mix(in srgb, var(--background) 80%, var(--success)); - - --diffs-bg-deletion-override: light-dark( - color-mix(in srgb, var(--background) 50%, var(--destructive)), - color-mix(in srgb, var(--background) 70%, var(--destructive)) - ); - --diffs-bg-deletion-number-override: light-dark( - color-mix(in srgb, var(--background) 35%, var(--destructive)), - color-mix(in srgb, var(--background) 60%, var(--destructive)) - ); - --diffs-bg-deletion-hover-override: color-mix(in srgb, var(--background) 85%, var(--destructive)); - --diffs-bg-deletion-emphasis-override: color-mix( - in srgb, - var(--background) 80%, - var(--destructive) - ); - - background-color: var(--diffs-bg) !important; -} - +const DIFF_PANEL_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS} :is( [data-line], [data-line-annotation], @@ -144,13 +98,13 @@ const DIFF_PANEL_UNSAFE_CSS = ` --diffs-line-bg: light-dark( color-mix( in lab, - var(--background) 88%, - color-mix(in srgb, var(--background) 50%, var(--diffs-modified-base)) + var(--code-background) 88%, + color-mix(in srgb, var(--code-background) 50%, var(--diffs-modified-base)) ), color-mix( in lab, - var(--background) 80%, - color-mix(in srgb, var(--background) 70%, var(--diffs-modified-base)) + var(--code-background) 80%, + color-mix(in srgb, var(--code-background) 70%, var(--diffs-modified-base)) ) ) !important; } @@ -159,13 +113,13 @@ const DIFF_PANEL_UNSAFE_CSS = ` --diffs-line-bg: light-dark( color-mix( in lab, - var(--background) 91%, - color-mix(in srgb, var(--background) 35%, var(--diffs-modified-base)) + var(--code-background) 91%, + color-mix(in srgb, var(--code-background) 35%, var(--diffs-modified-base)) ), color-mix( in lab, - var(--background) 85%, - color-mix(in srgb, var(--background) 60%, var(--diffs-modified-base)) + var(--code-background) 85%, + color-mix(in srgb, var(--code-background) 60%, var(--diffs-modified-base)) ) ) !important; } @@ -192,16 +146,16 @@ const DIFF_PANEL_UNSAFE_CSS = ` } [data-file-info] { - background-color: var(--background) !important; + background-color: var(--code-background) !important; border-block-color: transparent !important; - color: var(--foreground) !important; + color: var(--code-foreground) !important; } [data-diffs-header] { position: sticky !important; top: 0; z-index: 4; - background-color: var(--background) !important; + background-color: var(--code-background) !important; border-bottom-color: transparent !important; align-items: center !important; font-family: var(--font-sans) !important; @@ -213,13 +167,13 @@ const DIFF_PANEL_UNSAFE_CSS = ` } [data-diffs-header]:hover { - background-color: color-mix(in srgb, var(--background) 97%, var(--foreground)) !important; + background-color: color-mix(in srgb, var(--code-background) 97%, var(--code-foreground)) !important; } :is([data-separator="line-info"], [data-separator="line-info-basic"]) { height: 24px !important; margin-block: 0 !important; - background-color: var(--background) !important; + background-color: var(--code-background) !important; } :is([data-separator="line-info"], [data-separator="line-info-basic"]) @@ -233,7 +187,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` gap: 8px; padding-inline: 0 !important; background-color: transparent !important; - color: color-mix(in srgb, var(--foreground) 52%, var(--background)) !important; + color: color-mix(in srgb, var(--code-foreground) 52%, var(--code-background)) !important; font-family: var(--font-sans) !important; font-size: 11px !important; text-decoration: none !important; @@ -257,7 +211,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` height: 1px; flex: 1 1 auto; content: ""; - background-color: color-mix(in srgb, var(--background) 92%, var(--foreground)); + background-color: color-mix(in srgb, var(--code-background) 92%, var(--code-foreground)); } :is([data-separator="line-info"], [data-separator="line-info-basic"])[data-expand-index] @@ -286,7 +240,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` [data-expand-button] ):hover [data-separator-content] { - color: color-mix(in srgb, var(--foreground) 76%, var(--background)) !important; + color: color-mix(in srgb, var(--code-foreground) 76%, var(--code-background)) !important; } :is([data-separator="line-info"], [data-separator="line-info-basic"]):has( @@ -297,7 +251,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` [data-expand-button] ):hover [data-unmodified-lines]::after { - background-color: color-mix(in srgb, var(--background) 84%, var(--foreground)); + background-color: color-mix(in srgb, var(--code-background) 84%, var(--code-foreground)); } [data-diffs-header] [data-header-content] { @@ -337,7 +291,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` } [data-title]:hover { - color: color-mix(in srgb, var(--foreground) 84%, var(--primary)) !important; + color: color-mix(in srgb, var(--code-foreground) 84%, var(--primary)) !important; text-decoration-color: currentColor; } `; @@ -796,11 +750,11 @@ export default function DiffPanel({
{selectedScopeLabel} - + -

{selectedPatchError}

+

{selectedPatchError}

)} {!renderablePatch ? ( diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 1df19a64075..66216e10cb5 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -47,7 +47,7 @@ function ProjectFaviconFallback({ readonly className?: string | undefined; readonly icon: ComponentType<{ className?: string }>; }) { - return ; + return ; } function ProjectFaviconImage({ diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 3a1d7115098..7f21177e7b1 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -343,6 +343,9 @@ export default function ProjectScriptsControl({ variant="outline" className="w-7 px-0 sm:w-6 @3xl/header-actions:w-auto! @3xl/header-actions:px-[calc(--spacing(2)-1px)]" aria-label={`Run ${primaryScript.name}`} + // The tooltip wrapper replaces data-slot="button", so themed + // toolbar styling needs its own hook. + data-toolbar-control="" onClick={() => onRunScript(primaryScript)} /> } @@ -447,6 +450,9 @@ export default function ProjectScriptsControl({ variant="outline" className="w-7 px-0 sm:w-6 @3xl/header-actions:w-auto! @3xl/header-actions:px-[calc(--spacing(2)-1px)]" aria-label="Add action" + // The tooltip wrapper replaces data-slot="button", so themed + // toolbar styling needs its own hook. + data-toolbar-control="" onClick={openAddDialog} /> } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cffab8bd577..232ea0998ef 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -225,7 +225,7 @@ const PROJECT_GROUPING_MODE_LABELS: Record = separate: "Keep separate", }; const SIDEBAR_ICON_ACTION_BUTTON_CLASS = - "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"; + "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"; function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) { useEnvironmentThread(threadRef.environmentId, threadRef.threadId); @@ -857,9 +857,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr ) : ( {formatRelativeTimeLabel( @@ -2245,7 +2243,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }`} /> - + {projectStatus.label} @@ -2262,7 +2260,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec {project.displayName} {project.groupedProjectCount > 1 ? ( - + {project.groupedProjectCount} projects ) : null} @@ -2281,7 +2279,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ? "Local sandbox project" : "Remote project" } - 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" + 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" /> } > @@ -2600,7 +2598,7 @@ function ProjectSortMenu({ + } > @@ -2824,7 +2822,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( + // Lifted above the stage backdrop, whose fade bleeds below the + // header and would otherwise paint across the search row's outline. + } @@ -2977,9 +2977,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( )} {projectsLength === 0 && ( -
- No projects yet -
+
No projects yet
)}
diff --git a/apps/web/src/components/SidebarStageBackdrop.test.tsx b/apps/web/src/components/SidebarStageBackdrop.test.tsx index 114fd5f9241..c34eec58316 100644 --- a/apps/web/src/components/SidebarStageBackdrop.test.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.test.tsx @@ -5,7 +5,6 @@ import { resolveEnvironmentIdentificationPillLabel, resolveSidebarStageBackdropVariant, StageBackdropArt, - StageBackdropButtonArt, } from "./SidebarStageBackdrop"; describe("SidebarStageBackdrop", () => { @@ -29,7 +28,7 @@ describe("SidebarStageBackdrop", () => { const markup = renderToStaticMarkup( <> - + , ); const ids = Array.from(markup.matchAll(/\sid="([^"]+)"/g), (match) => match[1]); diff --git a/apps/web/src/components/SidebarStageBackdrop.tsx b/apps/web/src/components/SidebarStageBackdrop.tsx index 9fb448e940d..ee669e94bd4 100644 --- a/apps/web/src/components/SidebarStageBackdrop.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.tsx @@ -62,10 +62,6 @@ export function StageBackdropArt({ variant }: { variant: SidebarStageBackdropVar return variant === "nightly" ? : ; } -export function StageBackdropButtonArt({ variant }: { variant: SidebarStageBackdropVariant }) { - return variant === "nightly" ? : ; -} - const NIGHTLY_STARS: ReadonlyArray<{ cx: number; cy: number; @@ -97,7 +93,7 @@ const NIGHTLY_SPARKLES: ReadonlyArray<{ x: number; y: number }> = [ { x: 246, y: 26 }, ]; -function NightlySkyArt({ compact = false }: { compact?: boolean }) { +function NightlySkyArt() { const idPrefix = useId().replaceAll(":", ""); const skyId = `${idPrefix}-stage-night-sky`; const glowId = `${idPrefix}-stage-night-glow`; @@ -111,7 +107,7 @@ function NightlySkyArt({ compact = false }: { compact?: boolean }) { className="h-full w-full" fill="none" preserveAspectRatio="xMinYMin slice" - viewBox={compact ? "96 0 8192 96" : STAGE_BACKDROP_VIEW_BOX} + viewBox={STAGE_BACKDROP_VIEW_BOX} xmlns="http://www.w3.org/2000/svg" > @@ -195,7 +191,7 @@ function NightlySkyArt({ compact = false }: { compact?: boolean }) { ); } -function DevBlueprintArt({ compact = false }: { compact?: boolean }) { +function DevBlueprintArt() { const idPrefix = useId().replaceAll(":", ""); const paperId = `${idPrefix}-stage-bp-paper`; const glowId = `${idPrefix}-stage-bp-glow`; @@ -212,7 +208,7 @@ function DevBlueprintArt({ compact = false }: { compact?: boolean }) { className="stage-blueprint h-full w-full" fill="none" preserveAspectRatio="xMinYMin slice" - viewBox={compact ? "64 0 8192 96" : STAGE_BACKDROP_VIEW_BOX} + viewBox={STAGE_BACKDROP_VIEW_BOX} xmlns="http://www.w3.org/2000/svg" > diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index f918480e1a5..42633f7d5f7 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -764,7 +764,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { isUnread || isWoke ? "text-foreground" : shouldRecede - ? "text-muted-foreground/80" + ? "text-secondary-label" : status === "failed" ? "text-foreground/95" : "text-foreground/90", @@ -775,7 +775,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ? "text-foreground" : isUnread ? "text-muted-foreground" - : "text-muted-foreground/70", + : "text-secondary-label/70", ), isRegeneratingTitle && "opacity-[0.55]", )} @@ -795,8 +795,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { "shrink-0 text-xs tabular-nums hover:underline", variant === "slim" && variantAction === "unsettle" ? props.isActive - ? "text-muted-foreground/70" - : cn("text-muted-foreground/35 transition-colors", settledPrHoverClass) + ? "text-secondary-label" + : cn("text-secondary-label transition-colors", settledPrHoverClass) : prStatus.colorClass, )} aria-label={prStatus.tooltip} @@ -867,7 +867,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { @@ -979,7 +979,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { {props.projectTitle ? ( @@ -1008,7 +1008,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { isWokeStatus ? "pointer-events-auto" : "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", - "self-center justify-self-end tabular-nums text-muted-foreground/65 transition-opacity", + "self-center justify-self-end tabular-nums text-secondary-label transition-opacity", snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0", )} > @@ -1097,14 +1097,14 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : null}
-
+
{/* While working, the current plan step outranks the branch: it's the one line that says what the thread is doing. */} {status === "working" && thread.planProgress ? ( {thread.planProgress.step} {/* Completed count, matching the transcript chip's n/m. */} - + {" "} {thread.planProgress.completedSteps}/{thread.planProgress.totalSteps} @@ -2830,7 +2830,9 @@ export default function SidebarV2() { + // Lifted above the stage backdrop, whose fade bleeds below the + // header and would otherwise paint across the search row's outline. +
@@ -2965,7 +2967,7 @@ export default function SidebarV2() { type="button" aria-label={`Project actions for ${project.displayName}`} title={`Project actions for ${project.displayName}`} - 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" + 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" onPointerDown={(event) => event.stopPropagation()} onClick={(event) => { void handleProjectActions(event, project); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 072241426e2..25b4abb3fbe 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -135,6 +135,10 @@ function normalizeComputedColor(value: string | null | undefined, fallback: stri return value ?? fallback; } +function readThemeColor(styles: CSSStyleDeclaration, variable: string, fallback: string): string { + return normalizeComputedColor(styles.getPropertyValue(variable), fallback); +} + /** The surface treats an omitted family or size as "use the built-in default". */ function terminalFontOptions(family: string, size: number): { family?: string; size: number } { const trimmed = family.trim(); @@ -151,6 +155,7 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty document.body; const drawerStyles = getComputedStyle(drawerSurface); const bodyStyles = getComputedStyle(document.body); + const themeStyles = getComputedStyle(document.documentElement); const background = normalizeComputedColor( drawerStyles.backgroundColor, normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground), @@ -159,20 +164,32 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty drawerStyles.color, normalizeComputedColor(bodyStyles.color, fallbackForeground), ); - + const terminalBackground = readThemeColor(themeStyles, "--terminal-background", background); + const terminalForeground = readThemeColor(themeStyles, "--terminal-foreground", foreground); + const terminalCursor = readThemeColor( + themeStyles, + "--terminal-cursor", + isDark ? "rgb(180, 203, 255)" : "rgb(38, 56, 78)", + ); + const terminalSelection = readThemeColor( + themeStyles, + "--terminal-selection-background", + isDark ? "rgba(180, 203, 255, 0.25)" : "rgba(37, 63, 99, 0.2)", + ); return { background: parseTerminalColor( - background, + terminalBackground, isDark ? { r: 14, g: 18, b: 24 } : { r: 255, g: 255, b: 255 }, ), foreground: parseTerminalColor( - foreground, + terminalForeground, isDark ? { r: 237, g: 241, b: 247 } : { r: 28, g: 33, b: 41 }, ), - cursor: isDark ? { r: 180, g: 203, b: 255 } : { r: 38, g: 56, b: 78 }, - // Matches the xterm selection overlays this renderer replaced; the text - // color underneath is left unchanged for contrast in both themes. - selectionBackground: isDark ? "rgba(180, 203, 255, 0.25)" : "rgba(37, 63, 99, 0.2)", + cursor: parseTerminalColor( + terminalCursor, + isDark ? { r: 180, g: 203, b: 255 } : { r: 38, g: 56, b: 78 }, + ), + selectionBackground: terminalSelection, }; } diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index 3a705eef36d..0955bd3abcb 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -62,6 +62,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { } >
) : null} {props.isPreparingWorktree ? ( - Preparing worktree... + Preparing worktree... ) : null} event.preventDefault()} @@ -2814,7 +2812,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none", (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim()) ? "text-foreground" - : "text-muted-foreground/35", + : "text-placeholder", )} onPointerDown={(event) => event.preventDefault()} onClick={expandMobileComposer} @@ -2828,7 +2826,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) : ( -
+
{image.name}
)} @@ -3116,7 +3114,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) variant="ghost" disabled data-chat-provider-unavailable="true" - className="shrink-0 gap-2 px-2 text-muted-foreground/70 sm:px-3" + className="shrink-0 gap-2 px-2 text-secondary-label sm:px-3" > No provider available diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 0adeed6ffa6..b11e2136770 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -114,7 +114,7 @@ export const ChatHeader = memo(function ChatHeader({ New thread in {activeProjectName} - + / diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 73fc6348905..3ed2a9432e4 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -150,7 +150,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { {groupIndex > 0 ? : null} {group.label ? ( - + {group.label} ) : null} @@ -172,10 +172,10 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
{props.triggerKind === "skill" ? ( - + Skills -

+

{props.isLoading ? "Searching workspace skills..." : (props.emptyStateText ?? @@ -183,7 +183,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {

) : ( -

+

{props.isLoading ? "Searching workspace files..." : (props.emptyStateText ?? @@ -235,26 +235,26 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { /> ) : null} {props.item.type === "slash-command" ? ( - + ) : null} {props.item.type === "provider-slash-command" ? ( - + ) : null} {props.item.type === "skill" ? ( - + ) : null} {props.item.label} - + {props.item.description} {skillSourceLabel ? ( - {skillSourceLabel} + {skillSourceLabel} ) : null} ); diff --git a/apps/web/src/components/chat/ComposerControl.tsx b/apps/web/src/components/chat/ComposerControl.tsx index 8eab75171c8..a7ba4058145 100644 --- a/apps/web/src/components/chat/ComposerControl.tsx +++ b/apps/web/src/components/chat/ComposerControl.tsx @@ -6,7 +6,7 @@ import { Button } from "../ui/button"; import { SelectTrigger } from "../ui/select"; const composerControlClassName = - "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"; + "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"; export function ComposerControl({ className, @@ -46,7 +46,7 @@ export function ComposerControlChevron() { return (

- + {activeQuestion.header} {prompt.questions.length > 1 ? ( - + {questionIndex + 1}/{prompt.questions.length} ) : null}

{activeQuestion.question}

{activeQuestion.multiSelect ? ( -

Select one or more options.

+

Select one or more options.

) : null}
{activeQuestion.options.map((option, index) => { @@ -190,7 +190,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
{option.label} {option.description && option.description !== option.label ? ( - {option.description} + {option.description} ) : null}
{isSelected ? ( @@ -199,7 +199,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( {shortcutKey} diff --git a/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx b/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx index 602ad114464..5e9e43dcf21 100644 --- a/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx +++ b/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx @@ -63,13 +63,13 @@ export function ComposerPreviewAnnotationCards({ /> ) : ( - + )}
{annotation.comment.trim() ? ( -

+

{annotation.comment.trim()}

) : null} @@ -84,13 +84,13 @@ export function ComposerPreviewAnnotationCards({ {elementLabels.slice(0, 2).map(({ id, label }) => ( {label} ))} {elementLabels.length > 2 ? ( - + +{elementLabels.length - 2} ) : null} @@ -131,7 +131,7 @@ export function ComposerPreviewAnnotationCards({ ) : ( -
+
{image.name}
)} @@ -1270,7 +1268,7 @@ function WorkingTimelineRow({ row }: { row: Extract -
+
@@ -1349,9 +1347,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ return (
{!onlyToolEntries && ( -

- {groupLabel} -

+

{groupLabel}

)}
{nonEmptyEntries.map((workEntry) => ( @@ -1391,7 +1387,7 @@ function WorkGroupToggleTimelineRow({ ctx.onToggleWorkGroup(row.groupId, anchorElement); }} > - + {row.expanded ? ( - + Show fewer {row.onlyToolEntries ? "tool calls" : "log entries"} ) : ( - + +{row.hiddenCount} previous {labelNoun} )} @@ -1509,7 +1505,7 @@ const UserMessageElementContextChip = memo(function UserMessageElementContextChi + {props.context.header} @@ -1549,13 +1545,13 @@ function UserMessagePreviewAnnotationCard(props: { ) : null}
{props.annotation.comment ? ( -
+
{props.annotation.comment}
) : null}
@@ -1644,7 +1640,7 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop aria-expanded={expanded} data-scroll-anchor-ignore onClick={() => setExpanded((value) => !value)} - className="-ml-1 h-6 rounded-md px-1.5 text-xs text-muted-foreground/72 hover:bg-muted/55 hover:text-foreground/85" + className="-ml-1 h-6 rounded-md px-1.5 text-secondary-label text-xs hover:bg-muted/55 hover:text-message-foreground" > {expanded ? "Show less" : "Show full message"} @@ -1683,7 +1679,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { cwd={props.markdownCwd} threadRef={ctx.threadRef ?? undefined} skills={props.skills} - className="text-foreground" + className="text-message-foreground" lineBreaks /> ) : null} @@ -1695,7 +1691,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { const reviewCommentSegments = parseReviewCommentMessageSegments(props.text); if (reviewCommentSegments.some((segment) => segment.kind === "review-comment")) { return ( -
+
{reviewCommentSegments.map((segment) => segment.kind === "text" ? ( segment.text.trim().length > 0 ? ( @@ -1705,7 +1701,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { cwd={props.markdownCwd} threadRef={ctx.threadRef ?? undefined} skills={props.skills} - className="text-foreground" + className="text-message-foreground" lineBreaks />
@@ -1764,7 +1760,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( -
+
{inlineNodes}
); @@ -1793,7 +1789,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { cwd={props.markdownCwd} threadRef={ctx.threadRef ?? undefined} skills={props.skills} - className="text-foreground" + className="text-message-foreground" lineBreaks />, ); @@ -1802,7 +1798,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( -
+
{inlineNodes}
); @@ -1818,7 +1814,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { cwd={props.markdownCwd} threadRef={ctx.threadRef ?? undefined} skills={props.skills} - className="text-foreground" + className="text-message-foreground" lineBreaks /> ); @@ -1835,10 +1831,10 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte return (
-
+
{formatWorkspaceRelativePath(comment.filePath, ctx.workspaceRoot)}
-
+
{comment.sectionTitle} · {comment.rangeLabel}
@@ -1853,7 +1849,7 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte cwd={ctx.markdownCwd} threadRef={ctx.threadRef ?? undefined} skills={ctx.skills} - className="text-foreground" + className="text-message-foreground" /> )} {renderablePatch?.kind === "files" && @@ -1978,24 +1974,24 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { if (tone === "error") { return { iconName: "circle-alert", - className: "text-foreground/92", + className: "text-foreground", }; } if (tone === "thinking") { return { iconName: "bot", - className: "text-foreground/92", + className: "text-foreground", }; } if (tone === "info") { return { iconName: "check", - className: "text-muted-foreground", + className: "text-icon-muted", }; } return { iconName: "zap", - className: "text-foreground/92", + className: "text-foreground", }; } @@ -2244,14 +2240,14 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { : showDestructiveRowStyle ? "text-destructive" : workEntry.tone === "tool" || showFailedIndicator - ? "text-muted-foreground/65" + ? "text-icon-muted" : iconConfig.className, ); const headingClass = showWarningIndicator ? "font-medium text-warning" : showDestructiveRowStyle ? "font-medium text-destructive" - : "font-medium text-foreground/82"; + : "font-medium text-foreground"; const turnSettled = !activity.activeTurnInProgress; const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry); const showSuccessIndicator = @@ -2293,11 +2289,11 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {

{heading} {preview && ( - {preview} + {preview} )}

-
+
-
+          
             {expandedBody}
           
diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index a74a4ebf8c2..70475ffd038 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -69,7 +69,7 @@ export const ModelListRow = memo(function ModelListRow(props: {
{props.showNewBadge ? ( New diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index 24ec66cd614..82ee33615b0 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -29,7 +29,7 @@ const SELECTED_INDICATOR_CLASS = "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"; const BADGE_BASE_CLASS = "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 "; -const NEW_BADGE_CLASS = `${BADGE_BASE_CLASS} text-amber-600 dark:text-amber-300 `; +const NEW_BADGE_CLASS = `${BADGE_BASE_CLASS} text-update `; /** Opens toward the rail so the list stays readable (not over the model names). */ const PICKER_TOOLTIP_SIDE = "left" as const; diff --git a/apps/web/src/components/chat/PierreEntryIcon.tsx b/apps/web/src/components/chat/PierreEntryIcon.tsx index 17dfa8362af..df41adb7dd5 100644 --- a/apps/web/src/components/chat/PierreEntryIcon.tsx +++ b/apps/web/src/components/chat/PierreEntryIcon.tsx @@ -73,9 +73,9 @@ export const PierreEntryIcon = memo(function PierreEntryIcon(props: { if (!icon) { return props.kind === "directory" ? ( - + ) : ( - + ); } diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index a736cf96cd3..090acdb9c02 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -23,7 +23,7 @@ import { OpenInPicker } from "~/components/chat/OpenInPicker"; import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; -import { resolveDiffThemeName } from "~/lib/diffRendering"; +import { DIFF_SURFACE_THEME_UNSAFE_CSS, resolveDiffThemeName } from "~/lib/diffRendering"; import { cn } from "~/lib/utils"; import { isPreviewSupportedInRuntime } from "~/previewStateStore"; import { resolvePathLinkTarget } from "~/terminal-links"; @@ -84,6 +84,16 @@ const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown"; const FILE_SAVE_DEBOUNCE_MS = 500; const FILE_LINK_REVEAL_ATTRIBUTE = "data-file-link-reveal"; const FILE_LINK_REVEAL_UNSAFE_CSS = ` + ${DIFF_SURFACE_THEME_UNSAFE_CSS} + + diffs-container { + --diffs-bg: var(--code-background, var(--background)) !important; + --diffs-light-bg: var(--code-background, var(--background)) !important; + --diffs-dark-bg: var(--code-background, var(--background)) !important; + background-color: var(--code-background, var(--background)) !important; + color: var(--code-foreground, var(--foreground)) !important; + } + [${FILE_LINK_REVEAL_ATTRIBUTE}][data-line] { background-color: light-dark( color-mix( @@ -959,7 +969,7 @@ export default function FilePreviewPanel({
) : null} {relativePath && file.data?.truncated ? ( -
+
Preview limited to the first 1 MB of a {file.data.byteLength.toLocaleString()} byte file.
) : null} diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 75235a05307..22a91b7c150 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -122,6 +122,7 @@ describe("KeybindingsSettings.logic", () => { it("formats static and project script command labels", () => { expect(commandLabel("commandPalette.toggle")).toBe("Command Palette: Toggle"); + expect(commandLabel("themeEditor.toggle")).toBe("Theme Editor: Toggle"); expect(commandLabel("script.setup-db.run")).toBe("Run Script: Setup Db"); }); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 2a691943df4..17b1ebdf33d 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -619,7 +619,7 @@ export function ProviderInstanceCard({ "size-5 rounded-sm p-0", versionAdvisory.emphasis === "strong" ? "text-warning hover:text-warning" - : "text-primary hover:text-primary", + : "text-update hover:text-update", )} aria-label="Update available — view details" > diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx index 9541a0f07e0..05ea2c9f04e 100644 --- a/apps/web/src/components/settings/SettingsFontPreviews.tsx +++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx @@ -85,6 +85,26 @@ function loadDiffPreviewHtml(theme: DiffThemeName): Promise { return promise; } +// Pierre's prerendered stylesheet bakes its own light/dark surface colors +// into the shadow root's @layer rules. These unlayered rules win the cascade +// without !important and re-point the surfaces at the app's code tokens +// (custom properties inherit across the shadow boundary), so the preview +// follows the active theme exactly like the real diff panel does. +const DIFF_PREVIEW_THEME_BRIDGE = ` + :host { + color: var(--code-foreground); + background-color: var(--code-background); + --diffs-fg: var(--code-foreground); + --diffs-bg: var(--code-background); + --diffs-light-bg: var(--code-background); + --diffs-dark-bg: var(--code-background); + } + [data-diffs-header] { + background-color: var(--code-background); + color: var(--code-foreground); + } +`; + function StaticDiffHtml({ html }: { html: string }) { const hostRef = useRef(null); useEffect(() => { @@ -92,6 +112,9 @@ function StaticDiffHtml({ html }: { html: string }) { if (host === null) return; const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" }); shadow.innerHTML = html; + const bridge = document.createElement("style"); + bridge.textContent = DIFF_PREVIEW_THEME_BRIDGE; + shadow.append(bridge); }, [html]); return
; } @@ -158,7 +181,7 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu const mountRef = useRef(null); const surfaceRef = useRef(null); const fontRef = useRef({ family, size }); - const { resolvedTheme } = useTheme(); + const { theme, resolvedTheme } = useTheme(); useEffect(() => { const current = fontRef.current; @@ -167,12 +190,14 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu void surfaceRef.current?.setFont(previewTerminalFont(family, size)); }, [family, size]); + // Re-read the terminal tokens on any theme change — switching between two + // palettes can leave resolvedTheme (light/dark) untouched. useEffect(() => { const mount = mountRef.current; const surface = surfaceRef.current; if (!mount || !surface) return; surface.setTheme(terminalThemeFromApp(mount)); - }, [resolvedTheme]); + }, [theme, resolvedTheme]); useEffect(() => { const mount = mountRef.current; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 00d9573a1ac..5b9347f0307 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -52,7 +52,13 @@ import { } from "../SidebarStageBackdrop"; import { isElectron } from "../../env"; import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; -import { useTheme } from "../../hooks/useTheme"; +import { useCustomThemes } from "../../hooks/useCustomThemes"; +import { + readAppearanceModePreference, + readThemeHalves, + readThemePreference, + useTheme, +} from "../../hooks/useTheme"; import { useLocalStorage } from "../../hooks/useLocalStorage"; import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; @@ -106,6 +112,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { ThemeLibrary } from "./ThemeSettings"; import { backgroundActivityOverrideSettings, backgroundActivitySharedPolicySettings, @@ -131,21 +138,6 @@ import { import { searchableSetting } from "./settingsSearch"; import { ProjectFavicon } from "../ProjectFavicon"; -const THEME_OPTIONS = [ - { - value: "system", - label: "System", - }, - { - value: "light", - label: "Light", - }, - { - value: "dark", - label: "Dark", - }, -] as const; - const ENVIRONMENT_IDENTIFICATION_LABELS: Record = { artwork: "Artwork", pill: "Version pill", @@ -432,7 +424,15 @@ function AboutVersionSection() { } export function useSettingsRestore(onRestored?: () => void) { - const { theme, setTheme } = useTheme(); + const { + theme, + setTheme, + followSystem, + setFollowSystem, + setThemeHalf, + clearThemeHalves, + themeHalves, + } = useTheme(); const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); @@ -445,6 +445,8 @@ export function useSettingsRestore(onRestored?: () => void) { const changedSettingLabels = useMemo( () => [ ...(theme !== "system" ? ["Theme"] : []), + ...(!followSystem ? ["Follow system"] : []), + ...(themeHalves !== null ? ["Theme mix"] : []), ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []), ...(settings.environmentIdentificationMode !== DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode @@ -525,6 +527,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.sidebarThreadPreviewCount, settings.timestampFormat, settings.wordWrap, + followSystem, theme, ], ); @@ -539,7 +542,57 @@ export function useSettingsRestore(onRestored?: () => void) { ); if (!confirmed) return; - setTheme("system"); + // Only touch the theme keys that are actually dirty, so a theme-storage + // failure cannot block restoring unrelated settings. Preferences are + // re-read after the confirmation dialog: they may have changed (another + // tab, an OS flip) while it was open, and rollback must restore the live + // values rather than the ones captured at render time. + let previousTheme = theme; + try { + previousTheme = readThemePreference(); + } catch { + // Storage is unreadable; the render-time value is the best rollback. + } + // The mix may have changed while the confirmation dialog was open; both + // the dirty check and the rollback must see the live value. + const liveHalves = readThemeHalves(); + const needsThemeReset = previousTheme !== "system"; + const needsMixReset = liveHalves !== null; + // Same for the appearance mode: trusting the render-time value would skip + // the reset and report success while a non-system mode stayed in storage. + const needsFollowSystemReset = readAppearanceModePreference(previousTheme) !== "system"; + const notifyThemeRestoreFailure = () => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Couldn’t restore theme settings", + description: "Try again.", + }), + ); + }; + // Rollback restores the base preference first (which clears any mix) and + // then re-applies the captured mix on top, so no failure path can leave + // the pair of keys half-restored. + const previousHalves = liveHalves; + const rollbackThemeState = () => { + if (needsThemeReset) setTheme(previousTheme); + if (previousHalves?.light) setThemeHalf("light", previousHalves.light); + if (previousHalves?.dark) setThemeHalf("dark", previousHalves.dark); + }; + if (needsThemeReset && !setTheme("system")) { + notifyThemeRestoreFailure(); + return; + } + if (needsMixReset && !clearThemeHalves()) { + rollbackThemeState(); + notifyThemeRestoreFailure(); + return; + } + if (needsFollowSystemReset && !setFollowSystem(true)) { + rollbackThemeState(); + notifyThemeRestoreFailure(); + return; + } updateSettings({ timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, @@ -566,7 +619,17 @@ export function useSettingsRestore(onRestored?: () => void) { fontFamilyTerminal: DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal, }); onRestored?.(); - }, [changedSettingLabels, onRestored, setTheme, updateSettings]); + }, [ + changedSettingLabels, + clearThemeHalves, + onRestored, + setFollowSystem, + setTheme, + setThemeHalf, + theme, + themeHalves, + updateSettings, + ]); return { changedSettingLabels, @@ -841,7 +904,18 @@ function BackgroundActivityAdvancedDialog({ } export function AppearanceSettingsPanel() { - const { theme, setTheme } = useTheme(); + const { + appearanceMode, + refreshTheme, + resolvedTheme, + setAppearanceMode, + setTheme, + setThemeHalf, + theme, + themeHalves, + } = useTheme(); + const customThemes = useCustomThemes(); + const [isImportThemeOpen, setIsImportThemeOpen] = useState(false); const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); const environmentStageLabel = useEnvironmentStageLabel(); @@ -857,38 +931,21 @@ export function AppearanceSettingsPanel() { return ( - setTheme("system")} /> - ) : null - } - control={ - - } - /> +
+ +
> = { + canvas: "Background", + toolbar: "Toolbar background", + toolbarForeground: "Toolbar text", + toolbarBorder: "Toolbar border", + toolbarControl: "Toolbar control", + toolbarControlForeground: "Toolbar control text", + toolbarControlHover: "Toolbar control hover", + accent: "Accent color", + errorForeground: "Error text", + errorSurface: "Error background", + warningForeground: "Warning text", + warningSurface: "Warning background", + updateForeground: "Update text", + updateSurface: "Update background", + }; + const label = labels[role]; + if (label) return label; + return role.replace(/([A-Z])/g, " $1").replace(/^./, (character) => character.toUpperCase()); +} + +type ThemeColorHsv = { + h: number; + s: number; + v: number; +}; + +function clampThemeColor(value: number, min = 0, max = 1) { + return Math.min(max, Math.max(min, value)); +} + +/** + * The picker's plane and sliders operate on opaque six-digit hex, but theme + * colors may carry alpha. The suffix is preserved separately and re-attached + * on commit so adjusting hue or brightness cannot change transparency. + */ +function themePickerAlphaSuffix(value: string): string { + const trimmed = value.trim().toLowerCase(); + const alpha = /^#[0-9a-f]{4}$/.test(trimmed) + ? trimmed.slice(4).repeat(2) + : /^#[0-9a-f]{8}$/.test(trimmed) + ? trimmed.slice(7) + : ""; + return alpha === "ff" ? "" : alpha; +} + +function normalizeThemePickerColor(value: string): string { + const trimmed = value.trim(); + if (/^#[0-9a-f]{3}$/i.test(trimmed)) { + return `#${trimmed + .slice(1) + .split("") + .map((character) => `${character}${character}`) + .join("")}`; + } + if (/^#[0-9a-f]{4}$/i.test(trimmed)) { + return `#${trimmed + .slice(1, 4) + .split("") + .map((character) => `${character}${character}`) + .join("")}`; + } + if (/^#[0-9a-f]{6}$/i.test(trimmed)) return trimmed; + if (/^#[0-9a-f]{8}$/i.test(trimmed)) return trimmed.slice(0, 7); + return "#000000"; +} + +function themeHexToHsv(hex: string): ThemeColorHsv { + const normalized = normalizeThemePickerColor(hex); + const numeric = Number.parseInt(normalized.slice(1), 16); + const red = ((numeric >> 16) & 255) / 255; + const green = ((numeric >> 8) & 255) / 255; + const blue = (numeric & 255) / 255; + const max = Math.max(red, green, blue); + const min = Math.min(red, green, blue); + const delta = max - min; + + let hue = 0; + if (delta !== 0) { + if (max === red) { + hue = ((green - blue) / delta) % 6; + } else if (max === green) { + hue = (blue - red) / delta + 2; + } else { + hue = (red - green) / delta + 4; + } + hue *= 60; + if (hue < 0) hue += 360; + } + + return { + h: hue, + s: max === 0 ? 0 : delta / max, + v: max, + }; +} + +function themeHsvToHex(hue: number, saturation: number, value: number) { + const normalizedHue = ((hue % 360) + 360) % 360; + const chroma = value * saturation; + const x = chroma * (1 - Math.abs(((normalizedHue / 60) % 2) - 1)); + const match = value - chroma; + const [red, green, blue] = + normalizedHue < 60 + ? [chroma, x, 0] + : normalizedHue < 120 + ? [x, chroma, 0] + : normalizedHue < 180 + ? [0, chroma, x] + : normalizedHue < 240 + ? [0, x, chroma] + : normalizedHue < 300 + ? [x, 0, chroma] + : [chroma, 0, x]; + + return `#${[red, green, blue] + .map((channel) => + Math.round((channel + match) * 255) + .toString(16) + .padStart(2, "0"), + ) + .join("")}`; +} + +function themeHexToRgb(hex: string) { + const numeric = Number.parseInt(normalizeThemePickerColor(hex).slice(1), 16); + return [numeric >> 16, (numeric >> 8) & 255, numeric & 255] as const; +} + +function themeRgbToHex(value: string): string | null { + const normalized = value + .trim() + .replace(/^rgb\(\s*/i, "") + .replace(/\s*\)$/, ""); + const channels = normalized + .split(/[,\s]+/) + .filter(Boolean) + .map(Number); + if ( + channels.length !== 3 || + channels.some((channel) => !Number.isInteger(channel) || channel < 0 || channel > 255) + ) { + return null; + } + + return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; +} + +function themeRgbValue(hex: string) { + return themeHexToRgb(hex).join(", "); +} + +function ThemeColorPickerPanel({ + label, + value, + onChange, +}: { + label: string; + value: string; + onChange: (value: string) => void; +}) { + const normalizedValue = normalizeThemePickerColor(value); + const alphaSuffix = themePickerAlphaSuffix(value); + const [hsv, setHsv] = useState(() => themeHexToHsv(normalizedValue)); + const [hexDraft, setHexDraft] = useState(normalizedValue); + const [rgbDraft, setRgbDraft] = useState(() => themeRgbValue(normalizedValue)); + const [isDragging, setIsDragging] = useState(false); + const isEditingTextRef = useRef(false); + const currentColor = themeHsvToHex(hsv.h, hsv.s, hsv.v); + const currentRgb = themeRgbValue(currentColor); + + useEffect(() => { + // While a text field is focused, the incoming value may be the guided + // editor's readability-adjusted echo of what is being typed; rewriting the + // draft would fight the keystrokes. The swatch still tracks via hsv. + if (!isEditingTextRef.current) { + setHexDraft(normalizedValue); + setRgbDraft(themeRgbValue(normalizedValue)); + } + // Keep the current hue/saturation when the incoming value is just our own + // change echoed back; hex → HSV is lossy for greys, white, and black. + setHsv((current) => + themeHsvToHex(current.h, current.s, current.v) === normalizedValue + ? current + : themeHexToHsv(normalizedValue), + ); + }, [normalizedValue]); + + // Local state updates immediately for a smooth thumb; the parent commit + // (which can regenerate a whole guided palette) is batched to one call per + // animation frame. + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + const pendingCommitRef = useRef(null); + const commitFrameRef = useRef(null); + // The final drag frame must not be lost when the popover closes or the + // pointer lifts before the animation frame fires. + const flushPendingCommit = useCallback(() => { + if (commitFrameRef.current !== null) { + cancelAnimationFrame(commitFrameRef.current); + commitFrameRef.current = null; + } + const pending = pendingCommitRef.current; + pendingCommitRef.current = null; + if (pending !== null) onChangeRef.current(pending); + }, []); + useEffect(() => () => flushPendingCommit(), [flushPendingCommit]); + const scheduleCommit = useCallback((color: string) => { + pendingCommitRef.current = color; + commitFrameRef.current ??= requestAnimationFrame(() => { + commitFrameRef.current = null; + const pending = pendingCommitRef.current; + pendingCommitRef.current = null; + if (pending !== null) onChangeRef.current(pending); + }); + }, []); + + const commitHsv = useCallback( + (nextHsv: ThemeColorHsv) => { + setHsv(nextHsv); + const nextColor = themeHsvToHex(nextHsv.h, nextHsv.s, nextHsv.v); + setHexDraft(nextColor); + setRgbDraft(themeRgbValue(nextColor)); + scheduleCommit(nextColor + alphaSuffix); + }, + [alphaSuffix, scheduleCommit], + ); + + const updateFromPlane = useCallback( + (event: PointerEvent) => { + const bounds = event.currentTarget.getBoundingClientRect(); + const saturation = clampThemeColor((event.clientX - bounds.left) / bounds.width); + const value = 1 - clampThemeColor((event.clientY - bounds.top) / bounds.height); + commitHsv({ ...hsv, s: saturation, v: value }); + }, + [commitHsv, hsv], + ); + + const updateFromHue = useCallback( + (event: PointerEvent) => { + const bounds = event.currentTarget.getBoundingClientRect(); + const hue = clampThemeColor((event.clientX - bounds.left) / bounds.width) * 360; + commitHsv({ ...hsv, h: hue }); + }, + [commitHsv, hsv], + ); + + const handleHueKeyDown = (event: KeyboardEvent) => { + const step = event.shiftKey ? 10 : 1; + const direction = event.key === "ArrowRight" || event.key === "ArrowUp" ? 1 : -1; + if (!["ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp"].includes(event.key)) return; + event.preventDefault(); + commitHsv({ ...hsv, h: (hsv.h + direction * step + 360) % 360 }); + }; + + const handlePlaneKeyDown = (event: KeyboardEvent) => { + if (!["ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp"].includes(event.key)) return; + event.preventDefault(); + const step = event.shiftKey ? 0.1 : 0.02; + const nextHsv = { ...hsv }; + if (event.key === "ArrowLeft") nextHsv.s = clampThemeColor(hsv.s - step); + if (event.key === "ArrowRight") nextHsv.s = clampThemeColor(hsv.s + step); + if (event.key === "ArrowUp") nextHsv.v = clampThemeColor(hsv.v + step); + if (event.key === "ArrowDown") nextHsv.v = clampThemeColor(hsv.v - step); + commitHsv(nextHsv); + }; + + const handlePointerDown = (handler: (event: PointerEvent) => void) => { + return (event: PointerEvent) => { + event.currentTarget.setPointerCapture(event.pointerId); + setIsDragging(true); + handler(event); + }; + }; + + const stopDragging = () => { + setIsDragging(false); + flushPendingCommit(); + }; + + // Thumbs travel inside the control by half their own size so they never + // clip at the extremes; movement only animates for keyboard steps and + // click-to-jump, never while dragging. + const thumbTransition = isDragging + ? undefined + : "left 80ms linear, top 80ms linear, background-color 80ms linear"; + + const handleHexChange = (nextValue: string) => { + setHexDraft(nextValue); + if (!/^#[0-9a-f]{6}$/i.test(nextValue)) return; + const nextHsv = themeHexToHsv(nextValue); + setHsv(nextHsv); + setRgbDraft(themeRgbValue(nextValue)); + onChange(nextValue.toLowerCase()); + }; + + const handleRgbChange = (nextValue: string) => { + setRgbDraft(nextValue); + const nextColor = themeRgbToHex(nextValue); + if (!nextColor) return; + setHsv(themeHexToHsv(nextColor)); + setHexDraft(nextColor); + // RGB cannot express alpha, so a commit keeps the incoming suffix just + // like the plane and hue controls do. + onChange(nextColor + alphaSuffix); + }; + + return ( +
+
+
+

{label}

+

Choose a color

+
+ +
+
+
{ + if (event.currentTarget.hasPointerCapture(event.pointerId)) updateFromPlane(event); + }} + onPointerUp={stopDragging} + > + +
+
{ + if (event.currentTarget.hasPointerCapture(event.pointerId)) updateFromHue(event); + }} + onPointerUp={stopDragging} + > + + +
+
+ + +
+
+
+ ); +} + +function ThemeColorPicker({ + label, + value, + onChange, + onInteract, +}: { + label: string; + value: string; + onChange: (value: string) => void; + onInteract?: () => void; +}) { + return ( + + + + + } + /> + + + + + ); +} + +export const ThemeColorField = memo(function ThemeColorField({ + role, + value, + onChange, + onSelect, + onToggleSelected, + selected = false, + label: customLabel, +}: { + role: ThemeColorRole; + value: string; + onChange: (role: ThemeColorRole, value: string) => void; + onSelect?: (role: ThemeColorRole) => void; + onToggleSelected?: (role: ThemeColorRole) => void; + selected?: boolean; + label?: string; +}) { + const label = customLabel ?? getThemeRoleLabel(role); + const isColorValue = isThemeColor(value); + const swatchValue = isColorValue ? value : "#000000"; + + return ( +
+ +
+ onChange(role, nextValue)} + onInteract={() => onSelect?.(role)} + value={swatchValue} + /> + onChange(role, event.currentTarget.value)} + onFocus={() => onSelect?.(role)} + onPointerDown={() => onSelect?.(role)} + size="sm" + unstyled + value={value} + /> +
+
+ ); +}); diff --git a/apps/web/src/components/settings/ThemeEditorHost.tsx b/apps/web/src/components/settings/ThemeEditorHost.tsx new file mode 100644 index 00000000000..faf2d770e90 --- /dev/null +++ b/apps/web/src/components/settings/ThemeEditorHost.tsx @@ -0,0 +1,114 @@ +import { useCallback } from "react"; + +import { useTheme } from "../../hooks/useTheme"; +import { getThemeDefinition, type ThemeAppearance, type ThemeDefinition } from "../../themePalette"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { ThemeEditorPanel } from "./ThemeEditorPanel"; +import { useThemeEditorStore } from "./themeEditorStore"; + +/** + * Renders the theme editor above the router. The editor paints its draft on + * the live app, so it has to outlive the settings route: the point is to walk + * through threads, panels, and pages while the colors are being tuned. + */ +export function ThemeEditorHost() { + const session = useThemeEditorStore((store) => store.session); + const closeThemeEditor = useThemeEditorStore((store) => store.closeThemeEditor); + const { theme, setTheme, themeHalves, refreshTheme } = useTheme(); + + // The panel reports which path it actually took: a theme removed while its + // editor is open resolves to null there, so the save becomes a create even + // though the session still names it. + const handleSaved = useCallback( + ( + savedTheme: ThemeDefinition, + { created, mergedAppearance }: { created: boolean; mergedAppearance?: ThemeAppearance }, + ) => { + // A merge completed an existing theme's light/dark pair; activating the + // whole theme shows the new palette right away. + if (mergedAppearance) { + if (!setTheme(savedTheme.id)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not save your theme", + description: "Browser storage is unavailable, so the change was not kept.", + }), + ); + return false; + } + toastManager.add( + stackedThreadToast({ + type: "success", + title: `${savedTheme.label} updated`, + description: `Its ${mergedAppearance} palette was added.`, + }), + ); + return true; + } + if (!created) { + // The edited theme may be showing through the base preference or either + // half of the mix; the preference itself is untouched (a setTheme here + // would clear the mix), the palette just needs re-applying. + const wasActive = + getThemeDefinition(theme)?.id === savedTheme.id || + themeHalves?.light === savedTheme.id || + themeHalves?.dark === savedTheme.id; + if (wasActive) refreshTheme(); + toastManager.add( + stackedThreadToast({ + type: "success", + title: `${savedTheme.label} saved`, + description: wasActive ? "Your changes are now active." : "Your changes are saved.", + }), + ); + return true; + } + + if (!setTheme(savedTheme.id)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not save your theme", + description: "Browser storage is unavailable, so the change was not kept.", + }), + ); + return false; + } + toastManager.add( + stackedThreadToast({ + type: "success", + title: `${savedTheme.label} created`, + description: "It’s now active.", + }), + ); + return true; + }, + [refreshTheme, setTheme, theme, themeHalves], + ); + + if (!session) return null; + + // Resolve on every render: an edit or import can change the stored + // definitions while a session is open. + const editingTheme = session.editingThemeId + ? (getThemeDefinition(session.editingThemeId) ?? null) + : null; + const seedTheme = session.seedThemeId ? (getThemeDefinition(session.seedThemeId) ?? null) : null; + + return ( + { + if (!open) closeThemeEditor(); + }} + onSaved={handleSaved} + open + restoreTheme={refreshTheme} + seedName={session.seedName ?? undefined} + seedTheme={seedTheme} + /> + ); +} diff --git a/apps/web/src/components/settings/ThemeEditorPanel.tsx b/apps/web/src/components/settings/ThemeEditorPanel.tsx new file mode 100644 index 00000000000..0074ac89304 --- /dev/null +++ b/apps/web/src/components/settings/ThemeEditorPanel.tsx @@ -0,0 +1,1143 @@ +import { ChevronDownIcon, ChevronUpIcon, MousePointer2Icon, PlusIcon, XIcon } from "lucide-react"; +import { + useCallback, + useEffect, + useRef, + useState, + type PointerEvent as ReactPointerEvent, +} from "react"; +import { + applyThemeColorPreview, + THEME_COLOR_ROLES, + THEME_FILE_VERSION, + createVividThemeColors, + getCustomThemes, + getStandardThemeColors, + getThemeColorsForMode, + getThemeModes, + installCustomTheme, + isThemeColor, + parseThemeFile, + removeCustomTheme, + themeIdFromName, + updateCustomTheme, + type ThemeAppearance, + type ThemeColorRole, + type ThemeDefinition, +} from "../../themePalette"; +import { cn } from "../../lib/utils"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Switch } from "../ui/switch"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { getThemeRoleLabel, ThemeColorField } from "./ThemeColorPicker"; +import { + clearThemeInspectorHover, + clearThemeInspectorHighlights, + highlightThemeRoleUsage, + inspectThemeRoleAtElement, + inspectThemeRoleFromUtilitiesAtElement, + refreshThemeInspectorSpotlight, + showThemeInspectorHover, + type ThemeElementInspection, +} from "./themeInspector"; + +const THEME_EDITOR_PRIMARY_ROLES: ReadonlyArray = [ + "canvas", + "chrome", + "sidebar", + "surface", + "text", + "textMuted", + "placeholder", + "secondaryLabel", + "iconMuted", + "accent", + "messageSurface", + "messageAction", +]; + +const THEME_EDITOR_SIMPLE_ROLES: ReadonlyArray = ["canvas", "accent"]; + +const THEME_EDITOR_STATUS_ROLES: ReadonlyArray = [ + "error", + "errorForeground", + "errorSurface", + "warning", + "warningForeground", + "warningSurface", + "update", + "updateForeground", + "updateSurface", +]; + +const THEME_EDITOR_ADVANCED_ROLES = THEME_COLOR_ROLES.filter( + (role) => !THEME_EDITOR_PRIMARY_ROLES.includes(role) && !THEME_EDITOR_STATUS_ROLES.includes(role), +); + +const THEME_EDITOR_ROLE_GROUPS: ReadonlyArray<{ + id: string; + title: string; + roles: ReadonlyArray; +}> = [ + { + id: "main", + title: "Main colors", + roles: THEME_EDITOR_PRIMARY_ROLES, + }, + { + id: "status", + title: "Status colors", + roles: THEME_EDITOR_STATUS_ROLES, + }, + { + id: "additional", + title: "Other colors", + roles: THEME_EDITOR_ADVANCED_ROLES, + }, +]; + +type ThemeEditorColors = Record; +type ThemeEditorColorsByAppearance = Record; + +// A draft with no source theme starts as the standard T3 Code look — the +// palette on screen when no theme is installed — so creating from the default +// theme changes nothing until the user edits a color. +function getThemeEditorDefaults(appearance: ThemeAppearance): ThemeEditorColors { + return { ...getStandardThemeColors(appearance) }; +} + +function getThemeEditorColorsByAppearance(): ThemeEditorColorsByAppearance { + return { + light: getThemeEditorDefaults("light"), + dark: getThemeEditorDefaults("dark"), + }; +} + +function isThemeEditorColor(value: string): boolean { + return isThemeColor(value.trim()); +} + +function getManagedEditorColors( + appearance: ThemeAppearance, + colors: ThemeEditorColors, +): ThemeEditorColors { + const defaults = getStandardThemeColors(appearance); + // The editor keeps the user's exact picks and derives the rest through the + // perceptual vivid engine, so a two-color theme carries its own identity. + return createVividThemeColors( + appearance, + isThemeEditorColor(colors.canvas) ? colors.canvas : defaults.canvas, + isThemeEditorColor(colors.accent) ? colors.accent : defaults.accent, + ); +} + +export function ThemeEditorPanel({ + open, + onOpenChange, + onSaved, + editingTheme, + initialAppearance, + seedTheme, + seedName, + restoreTheme, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onSaved: ( + theme: ThemeDefinition, + context: { + created: boolean; + /** Set when a create merged its palette into an existing theme. */ + mergedAppearance?: ThemeAppearance; + }, + ) => boolean; + editingTheme: ThemeDefinition | null; + initialAppearance: ThemeAppearance; + /** The theme a new theme starts from, so tuning what you already use is a + * matter of editing rather than rebuilding. Null starts from the defaults. */ + seedTheme?: ThemeDefinition | null; + /** Prefilled name for an explicit duplicate; a plain create stays unnamed. */ + seedName?: string | undefined; + /** Reapplies the stored theme once the draft stops being previewed. */ + restoreTheme: () => void; +}) { + const isEditing = editingTheme !== null; + const [name, setName] = useState(""); + const [activeAppearance, setActiveAppearance] = useState(initialAppearance); + const [isAdvanced, setIsAdvanced] = useState(false); + const [colorsByAppearance, setColorsByAppearance] = useState(() => + getThemeEditorColorsByAppearance(), + ); + const [simpleColorsDirtyByAppearance, setSimpleColorsDirtyByAppearance] = useState< + Record + >({ light: false, dark: false }); + const [error, setError] = useState(null); + const [isMinimized, setIsMinimized] = useState(false); + const [roleQuery, setRoleQuery] = useState(""); + const [isInspecting, setIsInspecting] = useState(false); + const [selectedRole, setSelectedRole] = useState(null); + const [usageCount, setUsageCount] = useState(null); + // Null parks the panel at its default corner; a value is a dragged spot, + // kept clamped so the header can always be grabbed again. + const [position, setPosition] = useState<{ x: number; y: number } | null>(null); + // Null keeps the responsive default size; a value is a corner-grip resize. + const [size, setSize] = useState<{ width: number; height: number } | null>(null); + const panelRef = useRef(null); + const dragOffsetRef = useRef<{ dx: number; dy: number } | null>(null); + const resizeStartRef = useRef<{ + pointerX: number; + pointerY: number; + // Where the panel's top-left sits: the grip only moves the opposite + // corner, so the room to grow is measured from here. + left: number; + top: number; + width: number; + height: number; + } | null>(null); + useEffect(() => { + if (!open) return; + // A panel sized wider than the window can no longer be clamped back into + // view by position alone -- its right edge (close, minimize, the grip) + // stays off screen. So the size shrinks to fit first, then the position + // is re-clamped against the new size. + const clamp = () => { + const margin = 8; + let clampedWidth: number | undefined; + let clampedHeight: number | undefined; + setSize((current) => { + if (!current) return current; + clampedWidth = Math.max(280, Math.min(current.width, window.innerWidth - margin * 2)); + clampedHeight = Math.max(220, Math.min(current.height, window.innerHeight - margin * 2)); + return { width: clampedWidth, height: clampedHeight }; + }); + setPosition((current) => { + if (!current) return current; + const clamped = clampPosition(current.x, current.y, clampedWidth); + // Dragging may park the panel with only its header showing, but a + // window resize should pull the whole thing back into view when it + // fits -- otherwise the grip ends up below the fold. Minimized, the + // stored height is not applied (the panel hugs its header), so the + // rendered height is what has to fit. + const height = isMinimized + ? (panelRef.current?.offsetHeight ?? 0) + : (clampedHeight ?? panelRef.current?.offsetHeight ?? 0); + const maxY = Math.max(margin, window.innerHeight - height - margin); + return { x: clamped.x, y: Math.min(clamped.y, maxY) }; + }); + }; + window.addEventListener("resize", clamp); + return () => window.removeEventListener("resize", clamp); + // oxlint-disable-next-line exhaustive-deps -- clampPosition reads live layout only. + }, [isMinimized, open]); + + // The draft only reaches the live app once this open has been seeded; + // previewing in the seeding commit would paint the previous session's + // colors for a frame. + const [isDraftSeeded, setIsDraftSeeded] = useState(false); + const previousOpenRef = useRef(false); + + useEffect(() => { + if (open && !previousOpenRef.current) { + // Editing works on the theme itself; creating starts from the theme + // that is currently in use, so tuning what you already run is an edit + // away instead of a rebuild from the defaults. + const sourceTheme = editingTheme ?? seedTheme ?? null; + const nextColors = getThemeEditorColorsByAppearance(); + const nextAppearance = sourceTheme + ? getThemeColorsForMode(sourceTheme, initialAppearance) + ? initialAppearance + : sourceTheme.appearance + : initialAppearance; + if (sourceTheme) { + nextColors[sourceTheme.appearance] = { ...sourceTheme.colors }; + for (const appearance of ["light", "dark"] as const) { + const variantColors = sourceTheme.variants?.[appearance]; + if (variantColors) nextColors[appearance] = { ...variantColors }; + } + } + + setName(editingTheme?.label ?? seedName ?? ""); + setActiveAppearance(nextAppearance); + // Themes saved by the guided editor carry the managed flag; anything + // else (imports, hand-edited files, older saves) opens in advanced mode + // so guided regeneration cannot silently discard hand-tuned colors. A + // seeded new theme follows the same rule: its palette is only safe to + // regenerate when the guided editor produced it. + setIsAdvanced(sourceTheme !== null && sourceTheme.managed !== true); + setSimpleColorsDirtyByAppearance({ light: false, dark: false }); + setColorsByAppearance(nextColors); + setSelectedRole(null); + setUsageCount(null); + setIsInspecting(false); + setError(null); + setIsDraftSeeded(true); + } + if (!open && isDraftSeeded) setIsDraftSeeded(false); + previousOpenRef.current = open; + }, [editingTheme, initialAppearance, isDraftSeeded, open, seedName, seedTheme]); + + // A name an installed theme already uses combines instead of failing: + // creating adds the new palette to that theme, and renaming an existing + // theme onto it folds the edited palette in and retires the old entry — + // light "My Theme" plus a dark "My Theme" become one theme with both modes. + // Labels are matched as well as derived ids: a rename keeps a theme's + // original id, so its label is the only name a user can see and retype. + const nameTargetId = themeIdFromName(name); + const normalizedName = name.trim().toLowerCase(); + const mergeTarget = + normalizedName === "" + ? null + : (getCustomThemes().find( + (theme) => + theme.id !== editingTheme?.id && + (theme.id === nameTargetId || theme.label.trim().toLowerCase() === normalizedName), + ) ?? null); + const takenAppearances = mergeTarget ? getThemeModes(mergeTarget) : []; + const editableAppearances = editingTheme ? getThemeModes(editingTheme) : null; + + // The appearance a mode button would produce can be blocked two ways: the + // merge target already has that palette, or the theme being edited never + // had it (adding one is a create-with-same-name away). + const appearanceLockReason = (appearance: ThemeAppearance): string | null => { + if (editableAppearances && !editableAppearances.includes(appearance)) { + return `“${editingTheme?.label}” has no ${appearance} palette. Create a theme with the same name to add one.`; + } + if (!isEditing && takenAppearances.includes(appearance)) { + return `“${mergeTarget?.label}” already has a ${appearance} palette.`; + } + return null; + }; + + // Typing a name whose theme already owns the selected appearance flips the + // draft to the free side, so the merge affordance works without a manual + // toggle. Both sides taken leaves the selection alone; save is blocked with + // an explanation instead. + const mergeTargetId = mergeTarget?.id ?? null; + const takenAppearancesKey = takenAppearances.join(","); + useEffect(() => { + if (isEditing || mergeTargetId === null) return; + const taken = takenAppearancesKey.split(",").filter(Boolean) as ThemeAppearance[]; + if (taken.length !== 1) return; + setActiveAppearance((current) => { + if (!taken.includes(current)) return current; + return taken[0] === "light" ? "dark" : "light"; + }); + }, [isEditing, mergeTargetId, takenAppearancesKey]); + + // The whole app wears the draft while the editor is open, so a role change + // is judged on the real interface rather than a miniature. The stored theme + // comes back when the editor closes, including on cancel. + useEffect(() => { + if (!open || !isDraftSeeded) return; + applyThemeColorPreview(colorsByAppearance[activeAppearance], activeAppearance); + }, [activeAppearance, colorsByAppearance, isDraftSeeded, open]); + + useEffect(() => { + if (!open) return; + return () => { + restoreTheme(); + }; + }, [open, restoreTheme]); + + const updateColor = useCallback( + (role: ThemeColorRole, value: string) => { + setColorsByAppearance((current) => { + const nextColors = { ...current[activeAppearance], [role]: value }; + const shouldManageColors = + !isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(role) && isThemeEditorColor(value); + + return { + ...current, + [activeAppearance]: shouldManageColors + ? getManagedEditorColors(activeAppearance, nextColors) + : nextColors, + }; + }); + if (!isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(role) && isThemeEditorColor(value)) { + setSimpleColorsDirtyByAppearance((current) => ({ + ...current, + [activeAppearance]: true, + })); + } + }, + [activeAppearance, isAdvanced], + ); + + const selectThemeRole = useCallback((role: ThemeColorRole, reveal = false) => { + setSelectedRole(role); + if (!THEME_EDITOR_SIMPLE_ROLES.includes(role)) { + setIsAdvanced(true); + setRoleQuery(""); + } + if (!reveal) return; + + requestAnimationFrame(() => { + panelRef.current + ?.querySelector(`[data-theme-color-role="${role}"]`) + ?.scrollIntoView({ behavior: "smooth", block: "nearest" }); + }); + }, []); + + const toggleThemeRole = useCallback((role: ThemeColorRole) => { + setSelectedRole((current) => (current === role ? null : role)); + }, []); + + const clearInspectorSelection = useCallback(() => { + setSelectedRole(null); + setUsageCount(null); + setIsInspecting(false); + }, []); + + const selectedHighlightRoles = selectedRole + ? !isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(selectedRole) + ? THEME_COLOR_ROLES.filter( + (role) => + colorsByAppearance[activeAppearance][role].trim().toLowerCase() === + colorsByAppearance[activeAppearance][selectedRole].trim().toLowerCase(), + ) + : [selectedRole] + : []; + const selectedHighlightRolesKey = selectedHighlightRoles.join(","); + + useEffect(() => { + clearThemeInspectorHighlights(); + if (!open || selectedRole === null) { + setUsageCount(null); + return; + } + // Picking a new element needs the unobscured app, so suspend the existing + // spotlight while the picker is armed. + if (isInspecting) return; + + const highlightedRoles = selectedHighlightRolesKey.split(",") as Array; + const refreshHighlights = () => setUsageCount(highlightThemeRoleUsage(highlightedRoles)); + refreshHighlights(); + // A refresh snapshots computed styles for the whole tree twice, so it is + // throttled rather than run per frame: a streaming reply or a virtualized + // list mutates the DOM continuously and would otherwise stall the main + // thread for as long as the inspector is open. + const MIN_REFRESH_INTERVAL_MS = 500; + let refreshFrame: number | null = null; + let refreshTimer: ReturnType | null = null; + let lastRefreshAt = performance.now(); + const scheduleRefresh = () => { + if (refreshFrame !== null || refreshTimer !== null) return; + const wait = Math.max(0, MIN_REFRESH_INTERVAL_MS - (performance.now() - lastRefreshAt)); + const run = () => { + refreshFrame = null; + refreshTimer = null; + lastRefreshAt = performance.now(); + refreshHighlights(); + }; + if (wait === 0) refreshFrame = requestAnimationFrame(run); + else refreshTimer = setTimeout(run, wait); + }; + const observer = new MutationObserver((mutations) => { + if ( + mutations.every( + (mutation) => + mutation.target instanceof Element && + (mutation.target.closest("#theme-inspector-spotlight") || + mutation.target.closest("[data-theme-editor-panel]")), + ) + ) { + return; + } + scheduleRefresh(); + }); + observer.observe(document.body, { childList: true, subtree: true }); + let spotlightFrame: number | null = null; + const scheduleSpotlightRefresh = () => { + spotlightFrame ??= requestAnimationFrame(() => { + spotlightFrame = null; + refreshThemeInspectorSpotlight(); + }); + }; + window.addEventListener("resize", scheduleSpotlightRefresh); + window.addEventListener("scroll", scheduleSpotlightRefresh, true); + return () => { + observer.disconnect(); + if (refreshFrame !== null) cancelAnimationFrame(refreshFrame); + if (refreshTimer !== null) clearTimeout(refreshTimer); + if (spotlightFrame !== null) cancelAnimationFrame(spotlightFrame); + window.removeEventListener("resize", scheduleSpotlightRefresh); + window.removeEventListener("scroll", scheduleSpotlightRefresh, true); + clearThemeInspectorHighlights(); + }; + }, [isInspecting, open, selectedHighlightRolesKey, selectedRole]); + + useEffect(() => { + if (!open || !isInspecting) { + clearThemeInspectorHover(); + return; + } + + let shouldDisarmAfterClick = false; + let hoverTarget: Element | null = null; + let hoverInspection: ThemeElementInspection | null = null; + let hoverTimer: number | null = null; + let hoverFrame: number | null = null; + const clearHoverTimer = () => { + if (hoverTimer === null) return; + window.clearTimeout(hoverTimer); + hoverTimer = null; + }; + const clearHover = () => { + clearHoverTimer(); + hoverTarget = null; + hoverInspection = null; + clearThemeInspectorHover(); + }; + const showInspection = (inspection: ThemeElementInspection) => { + hoverInspection = inspection; + showThemeInspectorHover(inspection, getThemeRoleLabel(inspection.role)); + }; + const handlePointerOver = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Element) || target.closest("[data-theme-editor-panel]")) { + clearHover(); + return; + } + + clearHoverTimer(); + hoverTarget = target; + hoverInspection = null; + const utilityInspection = inspectThemeRoleFromUtilitiesAtElement(target); + if (utilityInspection) { + showInspection(utilityInspection); + return; + } + + clearThemeInspectorHover(); + hoverTimer = window.setTimeout(() => { + hoverTimer = null; + if (hoverTarget !== target || !target.isConnected) return; + const inspection = inspectThemeRoleAtElement(target); + if (inspection) showInspection(inspection); + }, 140); + }; + const handlePointerOut = (event: PointerEvent) => { + if (event.relatedTarget === null) clearHover(); + }; + const handlePointerDown = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Element) || target.closest("[data-theme-editor-panel]")) return; + event.preventDefault(); + event.stopPropagation(); + clearHoverTimer(); + const inspection = + hoverTarget === target && hoverInspection + ? hoverInspection + : inspectThemeRoleAtElement(target); + if (!inspection) return; + clearHover(); + selectThemeRole(inspection.role, true); + shouldDisarmAfterClick = true; + }; + const blockInspectedClick = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Element) || target.closest("[data-theme-editor-panel]")) return; + event.preventDefault(); + event.stopPropagation(); + if (shouldDisarmAfterClick) setIsInspecting(false); + shouldDisarmAfterClick = false; + }; + const cancelInspection = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + clearHover(); + clearInspectorSelection(); + }; + const refreshHover = () => { + if (!hoverInspection) return; + hoverFrame ??= requestAnimationFrame(() => { + hoverFrame = null; + if (hoverInspection) { + showThemeInspectorHover(hoverInspection, getThemeRoleLabel(hoverInspection.role)); + } + }); + }; + const clearHoverOnScroll = () => clearHover(); + + document.addEventListener("pointerover", handlePointerOver, true); + document.addEventListener("pointerout", handlePointerOut, true); + document.addEventListener("pointerdown", handlePointerDown, true); + document.addEventListener("click", blockInspectedClick, true); + document.addEventListener("keydown", cancelInspection, true); + window.addEventListener("resize", refreshHover); + window.addEventListener("scroll", clearHoverOnScroll, true); + return () => { + document.removeEventListener("pointerover", handlePointerOver, true); + document.removeEventListener("pointerout", handlePointerOut, true); + document.removeEventListener("pointerdown", handlePointerDown, true); + document.removeEventListener("click", blockInspectedClick, true); + document.removeEventListener("keydown", cancelInspection, true); + window.removeEventListener("resize", refreshHover); + window.removeEventListener("scroll", clearHoverOnScroll, true); + clearHoverTimer(); + if (hoverFrame !== null) cancelAnimationFrame(hoverFrame); + clearThemeInspectorHover(); + }; + }, [clearInspectorSelection, isInspecting, open, selectThemeRole]); + + const handleAdvancedChange = useCallback( + (checked: boolean) => { + setIsAdvanced(checked); + if (checked) return; + if (selectedRole && !THEME_EDITOR_SIMPLE_ROLES.includes(selectedRole)) { + setSelectedRole(null); + } + + // Regenerate every appearance the theme will save, not just the visible + // one, so the palettes shown after toggling match what gets saved. + const managedAppearances: ReadonlyArray = + editingTheme && getThemeModes(editingTheme).length > 1 + ? ["light", "dark"] + : [activeAppearance]; + setSimpleColorsDirtyByAppearance((current) => { + const next = { ...current }; + for (const appearance of managedAppearances) next[appearance] = true; + return next; + }); + setColorsByAppearance((current) => { + const next = { ...current }; + for (const appearance of managedAppearances) { + next[appearance] = getManagedEditorColors(appearance, current[appearance]); + } + return next; + }); + }, + [activeAppearance, editingTheme, selectedRole], + ); + + const handleSubmit = useCallback(() => { + if (!name.trim()) { + setError("Name your theme first."); + return; + } + + try { + // Only regenerate palettes the user actually touched in guided mode, so + // untouched appearances save exactly what the editor displayed. + const colorsForSave = !isAdvanced + ? { + light: simpleColorsDirtyByAppearance.light + ? getManagedEditorColors("light", colorsByAppearance.light) + : colorsByAppearance.light, + dark: simpleColorsDirtyByAppearance.dark + ? getManagedEditorColors("dark", colorsByAppearance.dark) + : colorsByAppearance.dark, + } + : colorsByAppearance; + + let savedTheme: ThemeDefinition; + let mergedAppearance: ThemeAppearance | null = null; + let retiredTheme: ThemeDefinition | null = null; + if (editingTheme && mergeTarget) { + // Renamed onto another installed theme: this theme's palettes fold + // into it and the edited entry retires, so both cards become one. + // Colliding palettes cannot merge — neither side should be silently + // overwritten. + const editedModes = getThemeModes(editingTheme); + const collision = editedModes.find((mode) => takenAppearances.includes(mode)); + if (collision) { + setError(`“${mergeTarget.label}” already has a ${collision} palette. Pick another name.`); + return; + } + mergedAppearance = editedModes[0] ?? null; + savedTheme = updateCustomTheme( + parseThemeFile({ + version: THEME_FILE_VERSION, + id: mergeTarget.id, + name: mergeTarget.label, + appearance: mergeTarget.appearance, + colors: mergeTarget.colors, + variants: { + ...mergeTarget.variants, + ...Object.fromEntries(editedModes.map((mode) => [mode, colorsForSave[mode]])), + }, + ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}), + }), + ); + retiredTheme = editingTheme; + try { + removeCustomTheme(editingTheme.id); + } catch (cause) { + // The merge already persisted. Leaving it while the edited theme + // survives would collide on every retry, so the target goes back to + // its pre-merge palettes before the failure surfaces. + try { + updateCustomTheme(mergeTarget); + } catch { + // Storage is failing wholesale; the rethrow below reports it. + } + throw cause; + } + } else if (editingTheme) { + const baseAppearance = editingTheme.appearance; + const variantAppearance = baseAppearance === "light" ? "dark" : "light"; + savedTheme = updateCustomTheme( + parseThemeFile({ + version: THEME_FILE_VERSION, + id: editingTheme.id, + name, + appearance: baseAppearance, + colors: colorsForSave[baseAppearance], + ...(getThemeModes(editingTheme).length > 1 + ? { variants: { [variantAppearance]: colorsForSave[variantAppearance] } } + : {}), + ...(isAdvanced ? {} : { managed: true }), + }), + ); + } else if (mergeTarget) { + if (takenAppearances.includes(activeAppearance)) { + setError( + `“${mergeTarget.label}” already has light and dark palettes. Pick another name.`, + ); + return; + } + // The new palette joins the existing theme as its other mode; its + // stored palettes are untouched. The guided (managed) flag only + // survives when every palette in the theme came from the guided + // editor. + mergedAppearance = activeAppearance; + savedTheme = updateCustomTheme( + parseThemeFile({ + version: THEME_FILE_VERSION, + id: mergeTarget.id, + name: mergeTarget.label, + appearance: mergeTarget.appearance, + colors: mergeTarget.colors, + variants: { + ...mergeTarget.variants, + [activeAppearance]: colorsForSave[activeAppearance], + }, + ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}), + }), + ); + } else { + savedTheme = installCustomTheme( + parseThemeFile({ + version: THEME_FILE_VERSION, + name, + appearance: activeAppearance, + colors: colorsForSave[activeAppearance], + ...(isAdvanced ? {} : { managed: true }), + }), + ); + } + if ( + !onSaved(savedTheme, { + created: editingTheme === null && mergedAppearance === null, + ...(mergedAppearance ? { mergedAppearance } : {}), + }) + ) { + if (!editingTheme && mergedAppearance === null) { + // Roll the install back so a retry can run it again instead of + // failing on the already-taken theme id. + try { + removeCustomTheme(savedTheme.id); + } catch { + // Storage is failing wholesale; the error below covers it. + } + } else if (mergeTarget && mergedAppearance !== null) { + // Put the pre-merge definitions back for the same reason. + try { + updateCustomTheme(mergeTarget); + if (retiredTheme) installCustomTheme(retiredTheme); + } catch { + // Storage is failing wholesale; the error below covers it. + } + } + setError("Theme saved, but it could not be made active. Try again."); + return; + } + onOpenChange(false); + } catch (cause) { + setError( + cause instanceof Error + ? cause.message + : isEditing + ? "Could not save the theme." + : "Could not create the theme.", + ); + } + }, [ + activeAppearance, + colorsByAppearance, + editingTheme, + isAdvanced, + isEditing, + mergeTarget, + name, + onOpenChange, + onSaved, + simpleColorsDirtyByAppearance, + takenAppearances, + ]); + + const renderNameField = () => ( + + ); + + const renderAppearanceButton = (appearance: ThemeAppearance) => { + const isActive = activeAppearance === appearance; + const lockReason = appearanceLockReason(appearance); + // A locked mode stays hoverable so the tooltip can say why it is off; + // a real disabled attribute would swallow the pointer events. + const button = ( + + ); + if (lockReason === null) return button; + return ( + + + {lockReason} + + ); + }; + + const renderAppearanceButtons = () => ( +
+ Appearance +
+ {renderAppearanceButton("light")} + {renderAppearanceButton("dark")} +
+
+ ); + + const renderColorsHeader = () => ( +
+
+

Colors

+ {isAdvanced ? null : ( +

Two colors, rest derived

+ )} +
+
+ {isAdvanced ? ( + setRoleQuery(event.currentTarget.value)} + placeholder="Filter colors" + size="sm" + value={roleQuery} + /> + ) : null} + +
+
+ ); + + const renderRoleFields = ( + roles: ReadonlyArray, + gridClassName = "grid gap-2 sm:grid-cols-2", + ) => ( +
+ {roles.map((role) => ( + + ))} +
+ ); + + const renderColorFields = () => { + const query = roleQuery.trim().toLowerCase(); + const groups = THEME_EDITOR_ROLE_GROUPS.map((group) => ({ + ...group, + roles: group.roles.filter( + (role) => !query || getThemeRoleLabel(role).toLowerCase().includes(query), + ), + })).filter((group) => group.roles.length > 0); + return isAdvanced ? ( +
+ {groups.map((group) => ( +
+

{group.title}

+ {renderRoleFields(group.roles, "grid gap-1")} +
+ ))} + {groups.length === 0 ?

No matches.

: null} +
+ ) : ( +
+ {THEME_EDITOR_SIMPLE_ROLES.map((role) => ( + + ))} +
+ ); + }; + + const clampPosition = (x: number, y: number, widthOverride?: number) => { + const panel = panelRef.current; + const margin = 8; + // The caller passes a width when it has just shrunk the panel: the DOM + // still reports the old one until React commits. + const width = widthOverride ?? panel?.offsetWidth ?? 0; + return { + x: Math.min(Math.max(x, margin), Math.max(margin, window.innerWidth - width - margin)), + // Keep at least the header on screen even when dragged far down. + y: Math.min(Math.max(y, margin), Math.max(margin, window.innerHeight - 48)), + }; + }; + + const handleDragPointerDown = (event: ReactPointerEvent) => { + // Buttons in the header keep their own behavior. + if ((event.target as HTMLElement).closest("button, input, a")) return; + const rect = panelRef.current?.getBoundingClientRect(); + if (!rect) return; + dragOffsetRef.current = { dx: event.clientX - rect.x, dy: event.clientY - rect.y }; + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const handleDragPointerMove = (event: ReactPointerEvent) => { + const offset = dragOffsetRef.current; + if (!offset) return; + setPosition(clampPosition(event.clientX - offset.dx, event.clientY - offset.dy)); + }; + + const endDrag = () => { + dragOffsetRef.current = null; + }; + + const handleResizePointerDown = (event: ReactPointerEvent) => { + const rect = panelRef.current?.getBoundingClientRect(); + if (!rect) return; + event.preventDefault(); + // The grip drags the bottom-right corner, so the top-left must hold + // still; the default parking spot is anchored bottom-right and would + // slide, so it converts to an explicit position first. + if (position === null) setPosition(clampPosition(rect.x, rect.y)); + resizeStartRef.current = { + pointerX: event.clientX, + pointerY: event.clientY, + left: rect.x, + top: rect.y, + width: rect.width, + height: rect.height, + }; + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const handleResizePointerMove = (event: ReactPointerEvent) => { + const start = resizeStartRef.current; + if (!start) return; + const margin = 8; + const MIN_WIDTH = 280; + const MIN_HEIGHT = 220; + // Grow only into the space right of and below the panel's own corner, + // otherwise a panel parked away from the top-left pushes its far edges + // (and this grip) off screen. + const maxWidth = Math.max(MIN_WIDTH, window.innerWidth - margin - start.left); + const maxHeight = Math.max(MIN_HEIGHT, window.innerHeight - margin - start.top); + setSize({ + width: Math.min(Math.max(start.width + event.clientX - start.pointerX, MIN_WIDTH), maxWidth), + height: Math.min( + Math.max(start.height + event.clientY - start.pointerY, MIN_HEIGHT), + maxHeight, + ), + }); + }; + + const endResize = () => { + resizeStartRef.current = null; + }; + + return ( +
+
+
+

+ {isEditing ? "Edit theme" : "Create theme"} +

+ {isMinimized ? null : ( +

+ {isInspecting + ? "Select an element · Esc to cancel" + : selectedRole + ? `${getThemeRoleLabel(selectedRole)} · ${usageCount ?? 0} ${usageCount === 1 ? "use" : "uses"}` + : "Select a color below"} +

+ )} +
+ + { + if (isInspecting) { + clearInspectorSelection(); + return; + } + setIsInspecting(true); + }} + > + + {isInspecting ? "Cancel" : "Inspect"} + + } + /> + + {isInspecting ? "Cancel and clear the selection" : "Pick a color from the app"} + + + + +
+ + {isMinimized ? null : ( + <> +
+ {renderNameField()} + {/* Inline and above the color list: the panel scrolls, and an + error parked below every role would go unseen. */} + {error ? ( +

+ {error} +

+ ) : null} + {renderAppearanceButtons()} +
+ {renderColorsHeader()} + {renderColorFields()} +
+
+
+ + +
+
+ + + +
+ + )} +
+ ); +} diff --git a/apps/web/src/components/settings/ThemeImportDialog.test.ts b/apps/web/src/components/settings/ThemeImportDialog.test.ts new file mode 100644 index 00000000000..6cd51e9b77a --- /dev/null +++ b/apps/web/src/components/settings/ThemeImportDialog.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { describeOversizedThemeFile, MAX_THEME_FILE_BYTES } from "./ThemeImportDialog"; + +describe("theme import size guard", () => { + it("accepts anything a theme file could plausibly be", () => { + for (const bytes of [0, 4_096, MAX_THEME_FILE_BYTES]) { + expect(describeOversizedThemeFile(bytes)).toBeNull(); + } + }); + + it("rejects a file too large to be a theme and names its size", () => { + const message = describeOversizedThemeFile(100 * 1024 * 1024); + expect(message).toContain("100.0 MB"); + expect(message).toContain("256 KB"); + }); + + it("reports sizes just past the limit in KB", () => { + expect(describeOversizedThemeFile(MAX_THEME_FILE_BYTES + 1)).toContain("256 KB"); + }); +}); diff --git a/apps/web/src/components/settings/ThemeImportDialog.tsx b/apps/web/src/components/settings/ThemeImportDialog.tsx new file mode 100644 index 00000000000..a74842acac3 --- /dev/null +++ b/apps/web/src/components/settings/ThemeImportDialog.tsx @@ -0,0 +1,537 @@ +import { PlusIcon, UploadIcon } from "lucide-react"; +import type { ChangeEvent, DragEvent, UIEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { cn } from "../../lib/utils"; +import { + getCustomThemes, + installCustomTheme, + parseThemeFile, + removeCustomTheme, + THEME_FILE_VERSION, + updateCustomTheme, + type ThemeDefinition, +} from "../../themePalette"; +import { + humanizeThemeName, + isVsCodeThemeFile, + pairVsCodeThemes, + parseVsCodeThemeFile, + resolveThemeLabelCollisions, +} from "../../vscodeThemeImport"; +import { Alert } from "../ui/alert"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; + +/** + * A full theme export is a few KB, so anything past this is not a theme file. + * The guard runs on the size before the bytes are ever read: a large file + * would otherwise be pulled into memory, highlighted, and rendered, which + * locks the UI for as long as that takes. + */ +export const MAX_THEME_FILE_BYTES = 256 * 1024; + +/** Highlighting rebuilds the whole markup on every keystroke, so oversized + * pastes fall back to plain text instead of freezing the editor. */ +const MAX_HIGHLIGHTED_JSON_LENGTH = 20_000; + +function formatByteSize(bytes: number): string { + if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`; + return `${bytes} bytes`; +} + +/** Returns the error to show for a file too large to be a theme, else null. */ +export function describeOversizedThemeFile(bytes: number): string | null { + if (bytes <= MAX_THEME_FILE_BYTES) return null; + 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)}).`; +} + +function escapeJsonHtml(value: string): string { + return value.replace( + /[&<>"']/g, + (character) => + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[character] ?? character, + ); +} + +function highlightJson(value: string): string { + const tokenPattern = + /"(?:\\.|[^"\\])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null/g; + let highlighted = ""; + let cursor = 0; + + for (const match of value.matchAll(tokenPattern)) { + const token = match[0]; + const index = match.index ?? 0; + highlighted += escapeJsonHtml(value.slice(cursor, index)); + + let tokenClass = "theme-json-number"; + if (token.startsWith('"')) { + tokenClass = /^\s*:/.test(value.slice(index + token.length)) + ? "theme-json-key" + : "theme-json-string"; + } else if (token === "true" || token === "false" || token === "null") { + tokenClass = "theme-json-constant"; + } + highlighted += `${escapeJsonHtml(token)}`; + cursor = index + token.length; + } + + return highlighted + escapeJsonHtml(value.slice(cursor)); +} + +function ThemeJsonEditor({ + id, + value, + onChange, +}: { + id: string; + value: string; + onChange: (value: string) => void; +}) { + const highlightRef = useRef(null); + const isPlainText = value.length > MAX_HIGHLIGHTED_JSON_LENGTH; + const highlightedJson = useMemo( + () => (value.length > MAX_HIGHLIGHTED_JSON_LENGTH ? "" : highlightJson(value)), + [value], + ); + + const syncScroll = useCallback((event: UIEvent) => { + const highlightElement = highlightRef.current; + if (!highlightElement) return; + highlightElement.scrollTop = event.currentTarget.scrollTop; + highlightElement.scrollLeft = event.currentTarget.scrollLeft; + }, []); + + return ( +
+ {isPlainText ? null : ( +
+          
+        
+ )} +