From 8014c0ae97490cdb51ad58cb8cde3c273e0d2d9a Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:47:59 +0200 Subject: [PATCH 01/25] feat(web): add modular theme library Adds semantic theme roles, persisted light and dark variants, personal theme import and creation, contrast-safe surfaces, and themed message actions. Includes the T3 Chat palette, follow-system behavior, splash handling, and the theme library UI. --- apps/web/index.html | 198 +++- .../src/components/CommandPalette.logic.ts | 2 +- .../src/components/ComposerPromptEditor.tsx | 2 +- apps/web/src/components/ProjectFavicon.tsx | 2 +- apps/web/src/components/Sidebar.tsx | 20 +- apps/web/src/components/SidebarV2.tsx | 20 +- .../src/components/ThreadTerminalDrawer.tsx | 92 +- apps/web/src/components/chat/ChatComposer.tsx | 22 +- apps/web/src/components/chat/ChatHeader.tsx | 2 +- .../components/chat/ComposerCommandMenu.tsx | 18 +- .../src/components/chat/ComposerControl.tsx | 4 +- .../chat/ComposerPendingUserInputPanel.tsx | 10 +- .../chat/ComposerPreviewAnnotationCards.tsx | 10 +- .../chat/ComposerPrimaryActions.tsx | 31 +- .../src/components/chat/ComposerStashMenu.tsx | 10 +- .../components/chat/ContextWindowMeter.tsx | 10 +- .../src/components/chat/MessagesTimeline.tsx | 66 +- .../src/components/chat/PierreEntryIcon.tsx | 4 +- .../components/settings/SettingsPanels.tsx | 1009 ++++++++++++++++- apps/web/src/components/ui/command.tsx | 6 +- apps/web/src/components/ui/input.tsx | 2 +- apps/web/src/components/ui/menu.tsx | 2 +- apps/web/src/components/ui/select.tsx | 10 +- apps/web/src/hooks/useCustomThemes.ts | 9 + apps/web/src/hooks/useTheme.test.ts | 12 + apps/web/src/hooks/useTheme.ts | 154 ++- apps/web/src/index.css | 140 +++ apps/web/src/themePalette.test.ts | 115 ++ apps/web/src/themePalette.ts | 659 +++++++++++ 29 files changed, 2409 insertions(+), 232 deletions(-) create mode 100644 apps/web/src/hooks/useCustomThemes.ts create mode 100644 apps/web/src/themePalette.test.ts create mode 100644 apps/web/src/themePalette.ts diff --git a/apps/web/index.html b/apps/web/index.html index dadef17d3bc..cda8e9a64e9 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -15,20 +15,185 @@ (() => { const LIGHT_BACKGROUND = "#ffffff"; const DARK_BACKGROUND = "#161616"; + const T3_CHAT_BACKGROUND = "#fffaff"; + const T3_CHAT_DARK_BACKGROUND = "#180f1b"; + 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: "#fffaff", + foreground: "#5c205f", + accent: "#c52d7b", + }, + t3ChatDark: { + background: "#180f1b", + foreground: "#faeaf9", + accent: "#f06cab", + }, + }; const themeColorMeta = document.querySelector('meta[name="theme-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 readCustomTheme = (themePreference, systemDark, followSystem) => { + if (!themePreference) return null; + const preference = splitThemePreference(themePreference); + if (preference.invalidMode) return null; + try { + const parsed = JSON.parse( + window.localStorage.getItem(CUSTOM_THEMES_STORAGE_KEY) ?? "null", + ); + if (!Array.isArray(parsed)) return null; + const candidate = parsed.find( + (value) => + value && + value.id === preference.id && + isThemeAppearance(value.appearance) && + value.colors, + ); + if (!candidate) return null; + + const preferredMode = + followSystem || preference.mode === "system" + ? systemDark + ? "dark" + : "light" + : (preference.mode ?? candidate.appearance); + const mode = + preferredMode === candidate.appearance || candidate.variants?.[preferredMode] + ? preferredMode + : candidate.appearance; + const colors = + mode === candidate.appearance + ? candidate.colors + : (candidate.variants?.[mode] ?? candidate.colors); + if ( + !colors || + !isHexColor(colors.canvas) || + !isHexColor(colors.text) || + !isHexColor(colors.accent) + ) { + return null; + } + return { theme: candidate, colors, mode }; + } 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 preference = splitThemePreference(storedTheme ?? ""); + const storedFollowSystem = window.localStorage.getItem(THEME_FOLLOW_SYSTEM_STORAGE_KEY); + const followSystem = + storedFollowSystem === "true" || + (storedFollowSystem === null && + (storedTheme === null || storedTheme === "system" || preference.mode === "system")); + const customTheme = readCustomTheme(storedTheme, prefersDark, followSystem); + const isLegacyT3ChatDark = storedTheme === "t3-chat-dark"; + const isT3Chat = + isLegacyT3ChatDark || + (!preference.invalidMode && + (preference.id === "t3-chat" || preference.id === "t3-chat-dark")); + const t3ChatMode = isLegacyT3ChatDark + ? followSystem + ? prefersDark + ? "dark" + : "light" + : "dark" + : followSystem || preference.mode === "system" + ? prefersDark + ? "dark" + : "light" + : preference.id === "t3-chat-dark" + ? "dark" + : (preference.mode ?? "light"); + const hasStoredTheme = + storedTheme === "light" || + storedTheme === "dark" || + storedTheme === "system" || + isT3Chat || + customTheme !== null; + const theme = hasStoredTheme && storedTheme ? storedTheme : "system"; + const isDark = customTheme + ? customTheme.mode === "dark" + : isT3Chat + ? t3ChatMode === "dark" + : followSystem + ? prefersDark + : theme === "dark"; + if (isT3Chat || customTheme !== null) { + document.documentElement.dataset.themeId = isT3Chat ? "t3-chat" : customTheme.theme.id; + } else { + delete document.documentElement.dataset.themeId; + } + if (hasStoredTheme) { + 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 = + customTheme !== null + ? customTheme.colors.canvas + : isT3Chat + ? t3ChatMode === "dark" + ? T3_CHAT_DARK_BACKGROUND + : T3_CHAT_BACKGROUND + : isDark + ? DARK_BACKGROUND + : LIGHT_BACKGROUND; document.documentElement.style.backgroundColor = chromeColor; + if (hasStoredTheme) { + const splashColors = + customTheme !== null + ? { + background: customTheme.colors.canvas, + foreground: customTheme.colors.text, + accent: customTheme.colors.accent, + } + : isT3Chat + ? t3ChatMode === "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); + } + } themeColorMeta?.setAttribute("content", chromeColor); } catch { + delete document.documentElement.dataset.themeId; + delete document.documentElement.dataset.themeSelected; document.documentElement.classList.add("dark"); document.documentElement.style.backgroundColor = DARK_BACKGROUND; themeColorMeta?.setAttribute("content", DARK_BACKGROUND); @@ -63,14 +228,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/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/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 18fcc810f50..0f4652edf11 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -731,10 +731,10 @@ 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", + : "text-foreground", ) : cn( "truncate group-hover/v2-row:text-foreground", @@ -742,7 +742,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ? "text-foreground" : isUnread ? "text-muted-foreground" - : "text-muted-foreground/70", + : "text-secondary-label", ), 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..8419cd419c4 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -131,7 +131,11 @@ function normalizeComputedColor(value: string | null | undefined, fallback: stri return value ?? fallback; } -function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme { +function readThemeColor(styles: CSSStyleDeclaration, variable: string, fallback: string): string { + return normalizeComputedColor(styles.getPropertyValue(variable), fallback); +} + +function terminalThemeFromApp(mountElement?: HTMLElement | null): ITheme { const isDark = document.documentElement.classList.contains("dark"); const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; @@ -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,81 @@ 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)", + ); + const terminalScrollbar = readThemeColor( + themeStyles, + "--terminal-scrollbar", + isDark ? "rgba(255, 255, 255, 0.1)" : "rgba(0, 0, 0, 0.15)", + ); + const terminalScrollbarHover = readThemeColor( + themeStyles, + "--terminal-scrollbar-hover", + isDark ? "rgba(255, 255, 255, 0.18)" : "rgba(0, 0, 0, 0.25)", + ); + + if (isDark) { + return { + background: terminalBackground, + foreground: terminalForeground, + cursor: terminalCursor, + selectionBackground: terminalSelection, + scrollbarSliderBackground: terminalScrollbar, + scrollbarSliderHoverBackground: terminalScrollbarHover, + scrollbarSliderActiveBackground: terminalScrollbarHover, + black: "rgb(24, 30, 38)", + red: "rgb(255, 122, 142)", + green: "rgb(134, 231, 149)", + yellow: "rgb(244, 205, 114)", + blue: "rgb(137, 190, 255)", + magenta: "rgb(208, 176, 255)", + cyan: "rgb(124, 232, 237)", + white: "rgb(210, 218, 230)", + brightBlack: "rgb(110, 120, 136)", + brightRed: "rgb(255, 168, 180)", + brightGreen: "rgb(176, 245, 186)", + brightYellow: "rgb(255, 224, 149)", + brightBlue: "rgb(174, 210, 255)", + brightMagenta: "rgb(229, 203, 255)", + brightCyan: "rgb(167, 244, 247)", + brightWhite: "rgb(244, 247, 252)", + }; + } return { - background: parseTerminalColor( - background, - isDark ? { r: 14, g: 18, b: 24 } : { r: 255, g: 255, b: 255 }, - ), - foreground: parseTerminalColor( - foreground, - 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)", + background: terminalBackground, + foreground: terminalForeground, + cursor: terminalCursor, + selectionBackground: terminalSelection, + scrollbarSliderBackground: terminalScrollbar, + scrollbarSliderHoverBackground: terminalScrollbarHover, + scrollbarSliderActiveBackground: terminalScrollbarHover, + black: "rgb(44, 53, 66)", + red: "rgb(191, 70, 87)", + green: "rgb(60, 126, 86)", + yellow: "rgb(146, 112, 35)", + blue: "rgb(72, 102, 163)", + magenta: "rgb(132, 86, 149)", + cyan: "rgb(53, 127, 141)", + white: "rgb(210, 215, 223)", + brightBlack: "rgb(112, 123, 140)", + brightRed: "rgb(212, 95, 112)", + brightGreen: "rgb(85, 148, 111)", + brightYellow: "rgb(173, 133, 45)", + brightBlue: "rgb(91, 124, 194)", + brightMagenta: "rgb(153, 107, 172)", + brightCyan: "rgb(70, 149, 164)", + brightWhite: "rgb(236, 240, 246)", }; } diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e92ecd497e3..a02033d0993 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-accent text-accent-foreground hover:bg-accent/80" + : "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..f19ab572a84 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/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/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 31ac4bba66e..a780b1effa1 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,15 +1,20 @@ import { ArchiveIcon, ArchiveX, + DownloadIcon, InfoIcon, LoaderIcon, + MoonIcon, PlusIcon, RefreshCwIcon, SettingsIcon, + SunIcon, + Trash2Icon, + UploadIcon, } from "lucide-react"; import { Link } from "@tanstack/react-router"; -import type { CSSProperties } from "react"; -import { useCallback, useMemo, useRef, useState } from "react"; +import type { ChangeEvent, CSSProperties, UIEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import { defaultInstanceIdForDriver, @@ -63,7 +68,27 @@ import { } from "../SidebarStageBackdrop"; import { isElectron } from "../../env"; import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; +import { useCustomThemes } from "../../hooks/useCustomThemes"; import { useTheme } from "../../hooks/useTheme"; +import { cn } from "../../lib/utils"; +import { + THEME_COLOR_ROLES, + THEME_FILE_VERSION, + getThemeColorsForMode, + getThemeDefinition, + getThemeModes, + getThemePreferenceMode, + installCustomTheme, + parseThemeFile, + removeCustomTheme, + serializeThemeFile, + themePreferenceForMode, + type ThemeAppearance, + type ThemeColorRole, + type ThemeDefinition, + T3_CHAT_DARK_THEME, + T3_CHAT_THEME, +} from "../../themePalette"; import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; @@ -97,6 +122,7 @@ import { DialogTitle, } from "../ui/dialog"; import { DraftInput } from "../ui/draft-input"; +import { Input } from "../ui/input"; import { NumberField, NumberFieldDecrement, @@ -140,21 +166,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", @@ -948,8 +959,930 @@ function BackgroundActivityAdvancedDialog({ ); } +const THEME_PREVIEW_ROLES = [ + "sidebar", + "canvas", + "surface", + "accentSurface", + "accent", + "messageSurface", + "messageAction", +] as const; +type ThemePreviewRole = (typeof THEME_PREVIEW_ROLES)[number]; +type ThemeCardPreview = { + mode: ThemeAppearance; + colors: Readonly>; +}; +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_ADVANCED_ROLES = THEME_COLOR_ROLES.filter( + (role) => !THEME_EDITOR_PRIMARY_ROLES.includes(role), +); + +type ThemeEditorColors = Record; +type ThemeEditorModeSelection = "single" | "both"; +type ThemeEditorColorsByAppearance = Record; + +function getThemeEditorDefaults(appearance: ThemeAppearance): ThemeEditorColors { + return { + ...(appearance === "dark" ? T3_CHAT_DARK_THEME.colors : T3_CHAT_THEME.colors), + }; +} + +function getThemeEditorColorsByAppearance(): ThemeEditorColorsByAppearance { + return { + light: getThemeEditorDefaults("light"), + dark: getThemeEditorDefaults("dark"), + }; +} + +function getThemeRoleLabel(role: ThemeColorRole): string { + return role.replace(/([A-Z])/g, " $1").replace(/^./, (character) => character.toUpperCase()); +} + +function ThemeColorField({ + role, + value, + onChange, +}: { + role: ThemeColorRole; + value: string; + onChange: (value: string) => void; +}) { + const label = getThemeRoleLabel(role); + const pickerValue = /^#[0-9a-f]{6}$/i.test(value) ? value : "#000000"; + + return ( +
+ +
+ onChange(event.currentTarget.value)} + type="color" + value={pickerValue} + /> + onChange(event.currentTarget.value)} + size="sm" + value={value} + /> +
+
+ ); +} + +function CreateThemeDialog({ + open, + onOpenChange, + onCreated, + initialAppearance, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onCreated: (theme: ThemeDefinition) => void; + initialAppearance: ThemeAppearance; +}) { + const [name, setName] = useState(""); + const [modeSelection, setModeSelection] = useState("single"); + const [activeAppearance, setActiveAppearance] = useState(initialAppearance); + const [colorsByAppearance, setColorsByAppearance] = useState(() => + getThemeEditorColorsByAppearance(), + ); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setName(""); + setModeSelection("single"); + setActiveAppearance(initialAppearance); + setColorsByAppearance(getThemeEditorColorsByAppearance()); + setError(null); + }, [initialAppearance, open]); + + const updateColor = useCallback( + (role: ThemeColorRole, value: string) => { + setColorsByAppearance((current) => ({ + ...current, + [activeAppearance]: { ...current[activeAppearance], [role]: value }, + })); + }, + [activeAppearance], + ); + + const handleSubmit = useCallback(() => { + if (!name.trim()) { + setError("Give your theme a name before saving it."); + return; + } + + try { + const variantAppearance = activeAppearance === "light" ? "dark" : "light"; + const variants = + modeSelection === "both" + ? { [variantAppearance]: colorsByAppearance[variantAppearance] } + : undefined; + const createdTheme = installCustomTheme( + parseThemeFile({ + version: THEME_FILE_VERSION, + name, + appearance: activeAppearance, + colors: colorsByAppearance[activeAppearance], + ...(variants ? { variants } : {}), + }), + ); + onCreated(createdTheme); + onOpenChange(false); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Could not create the theme."); + } + }, [activeAppearance, colorsByAppearance, modeSelection, name, onCreated, onOpenChange]); + + return ( + { + if (!nextOpen) setError(null); + onOpenChange(nextOpen); + }} + > + + + Create 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"} + +
+ + +
+
+ +
+
+

Main colors

+

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

+
+
+ {THEME_EDITOR_PRIMARY_ROLES.map((role) => ( + updateColor(role, value)} + role={role} + value={colorsByAppearance[activeAppearance][role]} + /> + ))} +
+
+ +
+ + Advanced colors + + {THEME_EDITOR_ADVANCED_ROLES.length} more roles + + +
+ {THEME_EDITOR_ADVANCED_ROLES.map((role) => ( + updateColor(role, value)} + role={role} + value={colorsByAppearance[activeAppearance][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 ( +
+
+        
+      
+