diff --git a/apps/web/index.html b/apps/web/index.html index dadef17d3bc..b608fd0bbff 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -15,23 +15,255 @@ (() => { const LIGHT_BACKGROUND = "#ffffff"; const DARK_BACKGROUND = "#161616"; - const themeColorMeta = document.querySelector('meta[name="theme-color"]'); + const T3_CHAT_BACKGROUND = "#faf5fa"; + const T3_CHAT_DARK_BACKGROUND = "#180f1b"; + const T3_GROVE_BACKGROUND = "#f4f8f5"; + const T3_GROVE_DARK_BACKGROUND = "#111f19"; + const CUSTOM_THEMES_STORAGE_KEY = "t3code:themes:v1"; + const THEME_FOLLOW_SYSTEM_STORAGE_KEY = "t3code:theme-follow-system"; + const SPLASH_COLORS = { + light: { + background: "#ffffff", + foreground: "#262626", + accent: "#4f46e5", + }, + dark: { + background: "#161616", + foreground: "#f5f5f5", + accent: "#818cf8", + }, + t3Chat: { + background: "#faf5fa", + foreground: "#501854", + accent: "#a84370", + }, + t3ChatDark: { + background: "#180f1b", + foreground: "#faeaf9", + accent: "#f06cab", + }, + t3Grove: { + background: "#f4f8f5", + foreground: "#241523", + accent: "#1e7d52", + }, + t3GroveDark: { + background: "#111f19", + foreground: "#fffaff", + accent: "#5dd58e", + }, + }; + // Built-in themes and the modes they can render. T3 Chat is light-only, + // so a dark OS must not flip its splash; the runtime resolves the same + // way via getThemeColorsForMode. + const BUILT_IN_THEME_MODES = { + "t3-chat": ["light"], + "t3-grove": ["light", "dark"], + }; + const RESERVED_THEME_IDS = [ + "system", + "light", + "dark", + "t3-chat", + "t3-grove", + "t3-chat-dark", + ]; + // Update every theme-color meta (including the media-scoped ones) so + // the browser picks up the resolved color regardless of which element + // its media query matches. + 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 isThemePreferenceMode = (value) => + value === "light" || value === "dark" || value === "system"; + const isThemeAppearance = (value) => value === "light" || value === "dark"; + const splitThemePreference = (preference) => { + const separatorIndex = preference.lastIndexOf(":"); + if (separatorIndex <= 0) { + return { id: preference, mode: null, invalidMode: false }; + } + const rawMode = preference.slice(separatorIndex + 1); + return { + id: preference.slice(0, separatorIndex), + mode: isThemePreferenceMode(rawMode) ? rawMode : null, + invalidMode: !isThemePreferenceMode(rawMode), + }; + }; + 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) => + value && + value.id === themeId && + isThemeAppearance(value.appearance) && + value.colors, + ) ?? 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); + const rawPreference = splitThemePreference(storedTheme ?? ""); + // Older builds stored the dark T3 Chat palette as its own theme id; + // the runtime resolves it to the light-only T3 Chat theme. + const isLegacyT3ChatDark = + !rawPreference.invalidMode && rawPreference.id === "t3-chat-dark"; + const preference = isLegacyT3ChatDark + ? { + id: "t3-chat", + mode: rawPreference.mode === "system" ? "system" : null, + invalidMode: false, + } + : rawPreference; + const explicitMode = + preference.mode === "light" || preference.mode === "dark" ? preference.mode : null; + const builtInModes = preference.invalidMode + ? null + : (BUILT_IN_THEME_MODES[preference.id] ?? null); + const customTheme = + preference.invalidMode || builtInModes ? null : findCustomTheme(preference.id); + const themeModes = + builtInModes ?? + (customTheme + ? ["light", "dark"].filter( + (mode) => mode === customTheme.appearance || customTheme.variants?.[mode], + ) + : null); + // Mirrors the runtime's isKnownThemePreference: an explicit mode the + // theme cannot render makes the whole preference unknown, and the + // runtime falls back to the system default. + const isKnownTheme = + storedTheme === "light" || + storedTheme === "dark" || + storedTheme === "system" || + (themeModes !== null && (explicitMode === null || themeModes.includes(explicitMode))); + const theme = isKnownTheme && storedTheme ? storedTheme : "system"; + const storedFollowSystem = window.localStorage.getItem(THEME_FOLLOW_SYSTEM_STORAGE_KEY); + const followSystem = + storedFollowSystem === "true" + ? true + : storedFollowSystem === "false" + ? false + : theme === "system" || preference.mode === "system"; + const isThemed = isKnownTheme && themeModes !== null; + const baseAppearance = customTheme ? customTheme.appearance : "light"; + const systemMode = prefersDark ? "dark" : "light"; + // followSystem already accounts for a `:system` suffix when the + // follow-system key is unset; when that key is explicitly "false" + // the runtime ignores the suffix, so the boot script must too. + const themeMode = !isThemed + ? null + : followSystem + ? themeModes.includes(systemMode) + ? systemMode + : baseAppearance + : (explicitMode ?? baseAppearance); + const isDark = isThemed + ? themeMode === "dark" + : followSystem + ? prefersDark + : theme === "dark"; + const customColors = + isThemed && customTheme + ? themeMode === customTheme.appearance + ? customTheme.colors + : (customTheme.variants?.[themeMode] ?? customTheme.colors) + : null; + // The runtime tolerates individual malformed colors, so fall back + // per role rather than dropping the theme. + const customSplash = customColors + ? { + background: isHexColor(customColors.canvas) + ? customColors.canvas + : themeMode === "dark" + ? SPLASH_COLORS.t3ChatDark.background + : SPLASH_COLORS.t3Chat.background, + foreground: isHexColor(customColors.text) + ? customColors.text + : themeMode === "dark" + ? SPLASH_COLORS.t3ChatDark.foreground + : SPLASH_COLORS.t3Chat.foreground, + accent: isHexColor(customColors.accent) + ? customColors.accent + : themeMode === "dark" + ? SPLASH_COLORS.t3ChatDark.accent + : SPLASH_COLORS.t3Chat.accent, + } + : null; + if (isThemed) { + document.documentElement.dataset.themeId = customTheme ? customTheme.id : preference.id; + } else { + delete document.documentElement.dataset.themeId; + } + if (isKnownTheme && storedTheme !== null) { + 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 = customSplash + ? customSplash.background + : isThemed + ? themeMode === "dark" + ? preference.id === "t3-grove" + ? T3_GROVE_DARK_BACKGROUND + : T3_CHAT_DARK_BACKGROUND + : preference.id === "t3-grove" + ? T3_GROVE_BACKGROUND + : T3_CHAT_BACKGROUND + : isDark + ? DARK_BACKGROUND + : LIGHT_BACKGROUND; document.documentElement.style.backgroundColor = chromeColor; - themeColorMeta?.setAttribute("content", chromeColor); + if (isKnownTheme && storedTheme !== null) { + const splashColors = + customSplash ?? + (isThemed + ? preference.id === "t3-grove" + ? themeMode === "dark" + ? SPLASH_COLORS.t3GroveDark + : SPLASH_COLORS.t3Grove + : themeMode === "dark" + ? SPLASH_COLORS.t3ChatDark + : SPLASH_COLORS.t3Chat + : isDark + ? SPLASH_COLORS.dark + : SPLASH_COLORS.light); + 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); } })(); @@ -63,14 +295,35 @@ } #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); + } + + html[data-theme-selected="true"] #boot-shell::before { + position: absolute; + inset: -20%; + content: ""; + background: radial-gradient( + circle at 50% 38%, + color-mix(in srgb, var(--boot-accent) 18%, transparent), + transparent 42% + ); } #boot-shell-card { + position: relative; + z-index: 1; display: flex; align-items: center; justify-content: center; 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/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 169126788ae..078fc130297 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1763,7 +1763,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 d10cb39f0e3..a1d96231c3e 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -91,9 +91,9 @@ const DIFF_PANEL_UNSAFE_CSS = ` [data-virtualizer-buffer] { --diffs-header-font-family: var(--font-sans) !important; --diffs-font-family: var(--font-mono) !important; - --diffs-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; - --diffs-light-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; - --diffs-dark-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; + --diffs-bg: var(--code-background) !important; + --diffs-light-bg: var(--code-background) !important; + --diffs-dark-bg: var(--code-background) !important; --diffs-token-light-bg: transparent; --diffs-token-dark-bg: transparent; @@ -117,19 +117,20 @@ const DIFF_PANEL_UNSAFE_CSS = ` ); background-color: var(--diffs-bg) !important; + color: var(--code-foreground) !important; } [data-file-info] { - background-color: color-mix(in srgb, var(--card) 94%, var(--foreground)) !important; + background-color: color-mix(in srgb, var(--code-background) 94%, var(--code-foreground)) !important; border-block-color: var(--border) !important; - color: var(--foreground) !important; + color: var(--code-foreground) !important; } [data-diffs-header] { position: sticky !important; top: 0; z-index: 4; - background-color: color-mix(in srgb, var(--card) 94%, var(--foreground)) !important; + background-color: color-mix(in srgb, var(--code-background) 94%, var(--code-foreground)) !important; border-bottom: 1px solid var(--border) !important; align-items: center !important; font-family: var(--font-sans) !important; @@ -838,7 +839,7 @@ export default function DiffPanel({ )} {selectedPatchError && !renderablePatch && (
-

{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/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cffab8bd577..00c9795b935 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({ + } > @@ -2891,7 +2889,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( type="button" aria-label="Add project" data-testid="sidebar-add-project-trigger" - className="inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" + className="inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-icon-muted transition-colors hover:bg-accent hover:text-foreground" onClick={openAddProject} /> } @@ -2977,9 +2975,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 9e86e5fb6b4..377c6f419ed 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -731,7 +731,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", @@ -742,7 +742,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]", )} @@ -760,8 +760,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { "shrink-0 font-mono text-xs 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} @@ -830,7 +830,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { the time/jump label yields to the settle affordance. */} {prBadge} - + {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( // Snoozed rows show when they come BACK, not when they were // last touched — the return ticket is the row's whole story. @@ -928,7 +928,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { {props.projectTitle ? ( @@ -947,7 +947,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { buttons; without it the invisible label eats their clicks. */} @@ -1021,7 +1021,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : null}
-
+
{thread.branch ? ( {thread.branch} ) : ( @@ -2726,7 +2726,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 dd7da738626..51fc14cad5a 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -131,6 +131,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); +} + function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme { const isDark = document.documentElement.classList.contains("dark"); const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; @@ -141,6 +145,7 @@ function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme { 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), @@ -149,20 +154,32 @@ function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme { 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/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c26f52cbc0c..1d931c42118 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -304,8 +304,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop className={cn( "shrink-0 whitespace-nowrap", props.interactionMode === "plan" - ? "bg-blue-500/10 text-blue-400 hover:bg-blue-500/15 hover:text-blue-300" - : "text-muted-foreground/70 hover:text-foreground/80", + ? "bg-accent text-accent-foreground hover:bg-accent/80" + : "text-secondary-label hover:text-foreground", )} type="button" onClick={props.onToggleInteractionMode} @@ -379,8 +379,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop className={cn( "shrink-0 whitespace-nowrap", props.planSidebarOpen - ? "bg-blue-500/10 text-blue-400 hover:bg-blue-500/15 hover:text-blue-300" - : "text-muted-foreground/70 hover:text-foreground/80", + ? "bg-primary/10 text-primary hover:bg-primary/15" + : "text-secondary-label hover:text-foreground", )} type="button" onClick={props.onTogglePlanSidebar} @@ -436,7 +436,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( /> ) : null} {props.isPreparingWorktree ? ( - Preparing worktree... + Preparing worktree... ) : null} event.preventDefault()} @@ -2828,7 +2826,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} @@ -2842,7 +2840,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) : ( -
+
{image.name}
)} @@ -3130,7 +3128,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}
)} @@ -1094,7 +1092,7 @@ function ProposedPlanTimelineRow({ function WorkingTimelineRow({ row }: { row: Extract }) { return (
-
+
@@ -1170,9 +1168,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ return (
{!onlyToolEntries && ( -

- {groupLabel} -

+

{groupLabel}

)}
{nonEmptyEntries.map((workEntry) => ( @@ -1212,7 +1208,7 @@ function WorkGroupToggleTimelineRow({ ctx.onToggleWorkGroup(row.groupId, anchorElement); }} > - + {row.expanded ? ( - + Show fewer {row.onlyToolEntries ? "tool calls" : "log entries"} ) : ( - + +{row.hiddenCount} previous {labelNoun} )} @@ -1330,7 +1326,7 @@ const UserMessageElementContextChip = memo(function UserMessageElementContextChi + {props.context.header} @@ -1370,13 +1366,13 @@ function UserMessagePreviewAnnotationCard(props: { ) : null}
{props.annotation.comment ? ( -
+
{props.annotation.comment}
) : null}
@@ -1465,7 +1461,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"} @@ -1504,7 +1500,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} @@ -1516,7 +1512,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 ? ( @@ -1526,7 +1522,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 />
@@ -1585,7 +1581,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( -
+
{inlineNodes}
); @@ -1614,7 +1610,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 />, ); @@ -1623,7 +1619,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( -
+
{inlineNodes}
); @@ -1639,7 +1635,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 /> ); @@ -1656,10 +1652,10 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte return (
-
+
{formatWorkspaceRelativePath(comment.filePath, ctx.workspaceRoot)}
-
+
{comment.sectionTitle} · {comment.rangeLabel}
@@ -1674,7 +1670,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" && @@ -1799,24 +1795,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", }; } @@ -1953,14 +1949,14 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(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 = @@ -2002,11 +1998,11 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(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..f9b0999ca72 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -84,6 +84,14 @@ 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 = ` + 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 +967,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/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/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 31ac4bba66e..45d53e49893 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -63,6 +63,7 @@ import { } from "../SidebarStageBackdrop"; import { isElectron } from "../../env"; import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; +import { useCustomThemes } from "../../hooks/useCustomThemes"; import { useTheme } from "../../hooks/useTheme"; import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; @@ -109,6 +110,7 @@ import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; +import { ThemeLibrary } from "./ThemeSettings"; import { canOneClickUpdateProviderCandidate, collectProviderUpdateCandidates, @@ -140,21 +142,6 @@ import { searchableSetting } from "./settingsSearch"; import { ProjectFavicon } from "../ProjectFavicon"; import { useAtomCommand } from "../../state/use-atom-command"; -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", @@ -557,7 +544,7 @@ function AboutVersionSection() { } export function useSettingsRestore(onRestored?: () => void) { - const { theme, setTheme } = useTheme(); + const { theme, setTheme, followSystem, setFollowSystem } = useTheme(); const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); @@ -570,6 +557,7 @@ export function useSettingsRestore(onRestored?: () => void) { const changedSettingLabels = useMemo( () => [ ...(theme !== "system" ? ["Theme"] : []), + ...(!followSystem ? ["Follow system"] : []), ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []), ...(settings.environmentIdentificationMode !== DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode @@ -636,6 +624,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.sidebarThreadPreviewCount, settings.timestampFormat, settings.wordWrap, + followSystem, theme, ], ); @@ -651,6 +640,7 @@ export function useSettingsRestore(onRestored?: () => void) { if (!confirmed) return; setTheme("system"); + setFollowSystem(true); updateSettings({ timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, @@ -674,7 +664,7 @@ export function useSettingsRestore(onRestored?: () => void) { textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, }); onRestored?.(); - }, [changedSettingLabels, onRestored, setTheme, updateSettings]); + }, [changedSettingLabels, onRestored, setFollowSystem, setTheme, updateSettings]); return { changedSettingLabels, @@ -949,7 +939,9 @@ function BackgroundActivityAdvancedDialog({ } export function AppearanceSettingsPanel() { - const { theme, setTheme } = useTheme(); + const { theme, setTheme, setFollowSystem, refreshTheme, followSystem, resolvedTheme } = + useTheme(); + const customThemes = useCustomThemes(); const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); const environmentStageLabel = useEnvironmentStageLabel(); @@ -964,38 +956,15 @@ export function AppearanceSettingsPanel() { return ( - - setTheme("system")} /> - ) : null - } - control={ - - } + + >; +}; +type ThemeCardDefinition = { + id: string; + label: string; + previews: ReadonlyArray; +}; + +const STANDARD_THEME_PREVIEW_COLORS: Record< + ThemeAppearance, + Readonly> +> = { + light: { + sidebar: "#fafafa", + canvas: "#fcfcfc", + surface: "#ffffff", + accentSurface: "#f4f4f5", + accent: "#f4f4f5", + messageSurface: "#e4e4e7", + messageAction: "#4f46e5", + }, + dark: { + sidebar: "#0f0f10", + canvas: "#0a0a0a", + surface: "#121212", + accentSurface: "#27272a", + accent: "#1c1c1f", + messageSurface: "#27272a", + messageAction: "#8b9cff", + }, +}; + +function getStandardThemeCards(): ReadonlyArray { + return [ + { + id: "default", + label: "Default", + previews: (["light", "dark"] as const).map((mode) => ({ + mode, + colors: STANDARD_THEME_PREVIEW_COLORS[mode], + })), + }, + ]; +} + +function getThemeCardDefinition(theme: ThemeDefinition): ThemeCardDefinition { + return { + id: theme.id, + label: theme.label, + previews: getThemeModes(theme).map((mode) => { + const colors = getThemeColorsForMode(theme, mode) ?? theme.colors; + return { + mode, + colors: { + sidebar: colors.sidebar, + canvas: colors.canvas, + surface: colors.surface, + accentSurface: colors.accentSurface, + accent: colors.accent, + messageSurface: colors.messageSurface, + messageAction: colors.messageAction, + }, + }; + }), + }; +} + +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), +); + +type ThemeEditorColors = Record; +type ThemeEditorModeSelection = "single" | "both"; +type ThemeEditorColorsByAppearance = Record; + +function getThemeEditorDefaults(appearance: ThemeAppearance): ThemeEditorColors { + return { ...getDefaultThemeColors(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 = getDefaultThemeColors(appearance); + return createManagedThemeColors( + appearance, + isThemeEditorColor(colors.canvas) ? colors.canvas : defaults.canvas, + isThemeEditorColor(colors.accent) ? colors.accent : defaults.accent, + ); +} + +/** + * Whether a palette matches what the guided editor would generate from its + * canvas and accent, allowing one step of hex rounding drift per channel. + * Hand-tuned palettes must open in advanced mode so guided regeneration does + * not silently discard them. + */ +function areThemeEditorColorsManaged( + appearance: ThemeAppearance, + colors: ThemeEditorColors, +): boolean { + const managed = getManagedEditorColors(appearance, colors); + return THEME_COLOR_ROLES.every((role) => { + const actual = themeHexToRgb(colors[role]); + const expected = themeHexToRgb(managed[role]); + return actual.every((channel, index) => Math.abs(channel - (expected[index] ?? 0)) <= 2); + }); +} + +function getThemeRoleLabel(role: ThemeColorRole): string { + const labels: Partial> = { + canvas: "Background", + 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)); +} + +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 [hsv, setHsv] = useState(() => themeHexToHsv(normalizedValue)); + const [hexDraft, setHexDraft] = useState(normalizedValue); + const [rgbDraft, setRgbDraft] = useState(() => themeRgbValue(normalizedValue)); + const currentColor = themeHsvToHex(hsv.h, hsv.s, hsv.v); + const currentRgb = themeRgbValue(currentColor); + + useEffect(() => { + 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]); + + const commitHsv = useCallback( + (nextHsv: ThemeColorHsv) => { + setHsv(nextHsv); + const nextColor = themeHsvToHex(nextHsv.h, nextHsv.s, nextHsv.v); + setHexDraft(nextColor); + setRgbDraft(themeRgbValue(nextColor)); + onChange(nextColor); + }, + [onChange], + ); + + 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 handlePointerDown = (handler: (event: PointerEvent) => void) => { + return (event: PointerEvent) => { + event.currentTarget.setPointerCapture(event.pointerId); + handler(event); + }; + }; + + 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); + onChange(nextColor); + }; + + return ( +
+
+
+

{label}

+

Choose a color

+
+ +
+
+
{ + if (event.currentTarget.hasPointerCapture(event.pointerId)) updateFromPlane(event); + }} + > + +
+
{ + if (event.currentTarget.hasPointerCapture(event.pointerId)) updateFromHue(event); + }} + > + +
+
+ + +
+
+
+ ); +} + +function ThemeColorPicker({ + label, + value, + onChange, +}: { + label: string; + value: string; + onChange: (value: string) => void; +}) { + return ( + + + + + } + /> + + + + + ); +} + +const ThemeColorField = memo(function ThemeColorField({ + role, + value, + onChange, + label: customLabel, +}: { + role: ThemeColorRole; + value: string; + onChange: (role: ThemeColorRole, value: string) => void; + label?: string; +}) { + const label = customLabel ?? getThemeRoleLabel(role); + const isColorValue = isThemeColor(value); + const swatchValue = isColorValue ? value : "#000000"; + + return ( +
+ onChange(role, nextValue)} + value={swatchValue} + /> + {label} + onChange(role, event.currentTarget.value)} + size="sm" + unstyled + value={value} + /> +
+ ); +}); + +function ThemeEditorDialog({ + open, + onOpenChange, + onSaved, + editingTheme, + initialAppearance, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onSaved: (theme: ThemeDefinition) => void; + editingTheme: ThemeDefinition | null; + initialAppearance: ThemeAppearance; +}) { + const isEditing = editingTheme !== null; + const [name, setName] = useState(""); + const [modeSelection, setModeSelection] = useState("single"); + 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 previousOpenRef = useRef(false); + + useEffect(() => { + if (open && !previousOpenRef.current) { + const nextColors = getThemeEditorColorsByAppearance(); + const nextAppearance = editingTheme + ? getThemeColorsForMode(editingTheme, initialAppearance) + ? initialAppearance + : editingTheme.appearance + : initialAppearance; + if (editingTheme) { + nextColors[editingTheme.appearance] = { ...editingTheme.colors }; + for (const appearance of ["light", "dark"] as const) { + const variantColors = editingTheme.variants?.[appearance]; + if (variantColors) nextColors[appearance] = { ...variantColors }; + } + } + + setName(editingTheme?.label ?? ""); + setModeSelection(editingTheme && getThemeModes(editingTheme).length > 1 ? "both" : "single"); + setActiveAppearance(nextAppearance); + setIsAdvanced( + editingTheme !== null && + getThemeModes(editingTheme).some( + (mode) => !areThemeEditorColorsManaged(mode, nextColors[mode]), + ), + ); + setSimpleColorsDirtyByAppearance({ light: false, dark: false }); + setColorsByAppearance(nextColors); + setError(null); + } + previousOpenRef.current = open; + }, [editingTheme, initialAppearance, open]); + + 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 handleAdvancedChange = useCallback( + (checked: boolean) => { + setIsAdvanced(checked); + if (checked) return; + + // 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 = + modeSelection === "both" ? ["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, modeSelection], + ); + + const handleSubmit = useCallback(() => { + if (!name.trim()) { + setError("Give your theme a name before saving it."); + return; + } + + try { + const baseAppearance = + editingTheme && modeSelection === "both" ? editingTheme.appearance : activeAppearance; + const variantAppearance = baseAppearance === "light" ? "dark" : "light"; + // 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; + const variants = + modeSelection === "both" + ? { [variantAppearance]: colorsForSave[variantAppearance] } + : undefined; + const themeFile = { + version: THEME_FILE_VERSION, + ...(editingTheme ? { id: editingTheme.id } : {}), + name, + appearance: baseAppearance, + colors: colorsForSave[baseAppearance], + ...(variants ? { variants } : {}), + }; + const savedTheme = editingTheme + ? updateCustomTheme(parseThemeFile(themeFile)) + : installCustomTheme(parseThemeFile(themeFile)); + onSaved(savedTheme); + 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, + modeSelection, + name, + onOpenChange, + onSaved, + simpleColorsDirtyByAppearance, + ]); + + return ( + { + if (!nextOpen) setError(null); + onOpenChange(nextOpen); + }} + > + + + {isEditing ? "Edit theme" : "Create theme"} + + {isEditing + ? "Update the name and colors for this personal theme." + : "Pick a palette for T3 Code. Add a dark version if you need one."} + + + + + +
+ Modes +
+ + +
+

+ {modeSelection === "both" + ? "Use a separate palette for light and dark mode." + : "Use the same palette in both modes."} +

+
+ +
+ + {modeSelection === "both" ? "Colors" : "Appearance"} + +
+ + +
+
+ +
+
+
+

+ {isAdvanced ? "Theme colors" : "Guided colors"} +

+

+ {isAdvanced + ? "Customize every color role used by T3 Code." + : "Choose the mood. T3 Code keeps the palette balanced and readable."} +

+
+ +
+ + {isAdvanced ? ( + <> +
+
+

Main colors

+

+ Surfaces, text, accents, and message actions. +

+
+
+ {THEME_EDITOR_PRIMARY_ROLES.map((role) => ( + + ))} +
+
+ +
+
+

Status colors

+

+ Errors, warnings, and update notices. +

+
+
+ {THEME_EDITOR_STATUS_ROLES.map((role) => ( + + ))} +
+
+ +
+
+

Additional colors

+

+ Fine-tune the remaining interface, code, and terminal roles. +

+
+
+ {THEME_EDITOR_ADVANCED_ROLES.map((role) => ( + + ))} +
+
+ + ) : ( + <> +
+ {THEME_EDITOR_SIMPLE_ROLES.map((role) => ( + + ))} +
+ + )} +
+ + {error ? ( + + {error} + + ) : null} +
+ + + + +
+
+ ); +} + +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 highlightedJson = useMemo(() => 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 ( +
+
+        
+      
+