From b8f0110d4cf49a51b90418dae25038b011cca964 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 06:13:27 +0000 Subject: [PATCH 1/4] feat(web): add two-color appearance palette --- apps/web/index.html | 18 +++ .../components/settings/SettingsPanels.tsx | 96 +++++++++++- .../src/components/settings/settingsSearch.ts | 5 + apps/web/src/hooks/useThemeColors.test.ts | 63 ++++++++ apps/web/src/hooks/useThemeColors.ts | 94 ++++++++++++ apps/web/src/index.css | 137 +++++++++--------- apps/web/src/main.tsx | 1 + 7 files changed, 346 insertions(+), 68 deletions(-) create mode 100644 apps/web/src/hooks/useThemeColors.test.ts create mode 100644 apps/web/src/hooks/useThemeColors.ts diff --git a/apps/web/index.html b/apps/web/index.html index dadef17d3bc..89f4eb77dcd 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -16,6 +16,24 @@ const LIGHT_BACKGROUND = "#ffffff"; const DARK_BACKGROUND = "#161616"; const themeColorMeta = document.querySelector('meta[name="theme-color"]'); + try { + const storedColors = window.localStorage.getItem("t3code:theme-colors"); + const colors = storedColors ? JSON.parse(storedColors) : null; + const isHexColor = (value) => + typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value.trim()); + if (isHexColor(colors?.accentColor)) { + document.documentElement.style.setProperty( + "--theme-accent-seed", + colors.accentColor.trim().toLowerCase(), + ); + } + if (isHexColor(colors?.neutralColor)) { + document.documentElement.style.setProperty( + "--theme-neutral-seed", + colors.neutralColor.trim().toLowerCase(), + ); + } + } catch {} try { const storedTheme = window.localStorage.getItem("t3code:theme"); const theme = diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 31ac4bba66e..27fb350b00b 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -64,6 +64,7 @@ import { import { isElectron } from "../../env"; import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; import { useTheme } from "../../hooks/useTheme"; +import { DEFAULT_THEME_COLORS, type ThemeColors, useThemeColors } from "../../hooks/useThemeColors"; import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; @@ -155,6 +156,57 @@ const THEME_OPTIONS = [ }, ] as const; +const THEME_COLOR_SWATCHES = [ + "#3b5bdb", + "#7c3aed", + "#db2777", + "#dc2626", + "#ea580c", + "#16a34a", + "#0891b2", +] as const; + +const THEME_NEUTRAL_SWATCHES = ["#737373", "#78716c", "#64748b", "#71717a"] as const; + +function ThemeColorControl({ + label, + value, + swatches, + onChange, +}: { + label: string; + value: string; + swatches: readonly string[]; + onChange: (value: string) => void; +}) { + return ( +
+ {label} +
+ onChange(event.currentTarget.value)} + aria-label={`Custom ${label.toLowerCase()}`} + className="h-8 w-10 cursor-pointer rounded-lg border border-input bg-background p-0.5" + /> + {swatches.map((swatch) => ( +
+
+ ); +} + const ENVIRONMENT_IDENTIFICATION_LABELS: Record = { artwork: "Artwork", pill: "Version pill", @@ -558,6 +610,7 @@ function AboutVersionSection() { export function useSettingsRestore(onRestored?: () => void) { const { theme, setTheme } = useTheme(); + const { colors, setThemeColors } = useThemeColors(); const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); @@ -570,6 +623,10 @@ export function useSettingsRestore(onRestored?: () => void) { const changedSettingLabels = useMemo( () => [ ...(theme !== "system" ? ["Theme"] : []), + ...(colors.accentColor !== DEFAULT_THEME_COLORS.accentColor || + colors.neutralColor !== DEFAULT_THEME_COLORS.neutralColor + ? ["Theme colors"] + : []), ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []), ...(settings.environmentIdentificationMode !== DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode @@ -637,6 +694,8 @@ export function useSettingsRestore(onRestored?: () => void) { settings.timestampFormat, settings.wordWrap, theme, + colors.accentColor, + colors.neutralColor, ], ); @@ -651,6 +710,7 @@ export function useSettingsRestore(onRestored?: () => void) { if (!confirmed) return; setTheme("system"); + setThemeColors(DEFAULT_THEME_COLORS); updateSettings({ timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, @@ -674,7 +734,7 @@ export function useSettingsRestore(onRestored?: () => void) { textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, }); onRestored?.(); - }, [changedSettingLabels, onRestored, setTheme, updateSettings]); + }, [changedSettingLabels, onRestored, setTheme, setThemeColors, updateSettings]); return { changedSettingLabels, @@ -950,6 +1010,7 @@ function BackgroundActivityAdvancedDialog({ export function AppearanceSettingsPanel() { const { theme, setTheme } = useTheme(); + const { colors, setThemeColors } = useThemeColors(); const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); const environmentStageLabel = useEnvironmentStageLabel(); @@ -998,6 +1059,39 @@ export function AppearanceSettingsPanel() { } /> + setThemeColors(DEFAULT_THEME_COLORS)} + /> + ) : null + } + > +
+ + setThemeColors({ ...colors, accentColor } satisfies ThemeColors) + } + /> + + setThemeColors({ ...colors, neutralColor } satisfies ThemeColors) + } + /> +
+
+ = {}): Storage { + const store = new Map(Object.entries(initial)); + return { + clear: () => store.clear(), + getItem: (key) => store.get(key) ?? null, + key: (index) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + removeItem: (key) => store.delete(key), + setItem: (key, value) => store.set(key, value), + }; +} + +function createWindow(initial: Record = {}) { + return { addEventListener: vi.fn(), localStorage: createStorage(initial) }; +} + +afterEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("theme colors", () => { + it("normalizes valid colors and falls back per invalid color", async () => { + vi.stubGlobal("window", createWindow()); + const { normalizeThemeColors } = await import("./useThemeColors"); + + expect(normalizeThemeColors({ accentColor: " #DB2777 ", neutralColor: "not-a-color" })).toEqual( + { accentColor: "#db2777", neutralColor: "#737373" }, + ); + }); + + it("reads a valid palette from storage", async () => { + vi.stubGlobal( + "window", + createWindow({ + "t3code:theme-colors": JSON.stringify({ + accentColor: "#0891b2", + neutralColor: "#57534e", + }), + }), + ); + const { readThemeColors } = await import("./useThemeColors"); + + expect(readThemeColors()).toEqual({ accentColor: "#0891b2", neutralColor: "#57534e" }); + }); + + it("applies both seeds to the document root", async () => { + const setProperty = vi.fn(); + vi.stubGlobal("window", createWindow()); + vi.stubGlobal("document", { documentElement: { style: { setProperty } } }); + const { applyThemeColors } = await import("./useThemeColors"); + + applyThemeColors({ accentColor: "#16a34a", neutralColor: "#78716c" }); + + expect(setProperty).toHaveBeenCalledWith("--theme-accent-seed", "#16a34a"); + expect(setProperty).toHaveBeenCalledWith("--theme-neutral-seed", "#78716c"); + }); +}); diff --git a/apps/web/src/hooks/useThemeColors.ts b/apps/web/src/hooks/useThemeColors.ts new file mode 100644 index 00000000000..25b59351cc9 --- /dev/null +++ b/apps/web/src/hooks/useThemeColors.ts @@ -0,0 +1,94 @@ +import { useCallback, useSyncExternalStore } from "react"; + +export type ThemeColors = { + accentColor: string; + neutralColor: string; +}; + +export const DEFAULT_THEME_COLORS: ThemeColors = { + accentColor: "#3b5bdb", + neutralColor: "#737373", +}; + +export const THEME_COLORS_STORAGE_KEY = "t3code:theme-colors"; + +const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/iu; +const listeners = new Set<() => void>(); +let currentColors = readThemeColors(); + +export function normalizeThemeColor(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const normalized = value.trim().toLowerCase(); + return HEX_COLOR_PATTERN.test(normalized) ? normalized : undefined; +} + +export function normalizeThemeColors(value: unknown): ThemeColors { + const colors = value && typeof value === "object" ? (value as Partial) : {}; + return { + accentColor: normalizeThemeColor(colors.accentColor) ?? DEFAULT_THEME_COLORS.accentColor, + neutralColor: normalizeThemeColor(colors.neutralColor) ?? DEFAULT_THEME_COLORS.neutralColor, + }; +} + +export function readThemeColors(): ThemeColors { + if (typeof window === "undefined") return DEFAULT_THEME_COLORS; + try { + const stored = window.localStorage.getItem(THEME_COLORS_STORAGE_KEY); + return stored ? normalizeThemeColors(JSON.parse(stored)) : DEFAULT_THEME_COLORS; + } catch { + return DEFAULT_THEME_COLORS; + } +} + +export function applyThemeColors(colors: ThemeColors): void { + if (typeof document === "undefined") return; + document.documentElement.style.setProperty("--theme-accent-seed", colors.accentColor); + document.documentElement.style.setProperty("--theme-neutral-seed", colors.neutralColor); +} + +function emitChange(): void { + for (const listener of listeners) listener(); +} + +function persistThemeColors(colors: ThemeColors): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(THEME_COLORS_STORAGE_KEY, JSON.stringify(colors)); + } catch { + // The colors still apply for this session when storage is unavailable. + } +} + +function setCurrentColors(colors: ThemeColors): void { + currentColors = colors; + applyThemeColors(colors); + emitChange(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +if (typeof window !== "undefined") { + applyThemeColors(currentColors); + window.addEventListener("storage", (event) => { + if (event.key === THEME_COLORS_STORAGE_KEY) setCurrentColors(readThemeColors()); + }); +} + +export function useThemeColors() { + const colors = useSyncExternalStore( + subscribe, + () => currentColors, + () => DEFAULT_THEME_COLORS, + ); + + const setThemeColors = useCallback((value: ThemeColors) => { + const normalized = normalizeThemeColors(value); + persistThemeColors(normalized); + setCurrentColors(normalized); + }, []); + + return { colors, setThemeColors } as const; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index b1ca197149a..ce2632ce338 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -853,42 +853,45 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil :root { color-scheme: light; --radius: 0.625rem; - --background: var(--color-zinc-25); + --theme-accent-seed: #3b5bdb; + --theme-neutral-seed: #737373; + --background: color-mix(in srgb, var(--theme-neutral-seed) 3%, var(--color-zinc-25)); --app-chrome-background: var(--background); --surface-raised: color-mix(in srgb, var(--card) 20%, transparent); --foreground: var(--color-zinc-800); - --card: var(--color-white); - --card-foreground: var(--color-zinc-800); - --popover: var(--color-white); - --popover-foreground: var(--color-zinc-800); - --primary: oklch(0.488 0.217 264); + --card: color-mix(in srgb, var(--theme-neutral-seed) 1.5%, var(--color-white)); + --card-foreground: var(--foreground); + --popover: var(--card); + --popover-foreground: var(--foreground); + /* Keep button text readable even when the seed itself is very light. */ + --primary: color-mix(in oklch, var(--theme-accent-seed) 46%, var(--color-black)); --primary-foreground: var(--color-white); - --secondary: var(--color-zinc-50); - --secondary-foreground: var(--color-zinc-800); - --muted: var(--color-zinc-50); + --secondary: color-mix(in srgb, var(--theme-neutral-seed) 7%, transparent); + --secondary-foreground: var(--foreground); + --muted: color-mix(in srgb, var(--theme-neutral-seed) 7%, transparent); --muted-foreground: var(--color-zinc-500); - --accent: var(--color-zinc-100); - --accent-foreground: var(--color-zinc-900); + --accent: color-mix(in srgb, var(--theme-accent-seed) 8%, transparent); + --accent-foreground: var(--foreground); --destructive: var(--color-red-500); - --border: var(--color-zinc-200); - --input: var(--color-zinc-300); - --ring: oklch(0.488 0.217 264); + --border: color-mix(in srgb, var(--theme-neutral-seed) 16%, transparent); + --input: color-mix(in srgb, var(--theme-neutral-seed) 19%, transparent); + --ring: var(--primary); --destructive-foreground: var(--color-red-700); - --info: var(--color-blue-500); - --info-foreground: var(--color-blue-700); + --info: var(--primary); + --info-foreground: var(--primary); --success: var(--color-emerald-500); --success-foreground: var(--color-emerald-700); --warning: var(--color-amber-500); --warning-foreground: var(--color-amber-700); - /* Keep every sidebar primitive on the same light surface hierarchy, including - portaled mobile sheets and settings navigation outside the app sidebar. */ - --sidebar: var(--color-zinc-50); + /* Keep portaled mobile sheets and settings navigation on the generated palette, + even when they render outside the main app sidebar. */ + --sidebar: var(--background); --sidebar-foreground: var(--foreground); --sidebar-muted-foreground: var(--muted-foreground); - --sidebar-control-surface: var(--color-zinc-100); - --sidebar-row-hover: var(--color-zinc-25); - --sidebar-row-active: var(--color-white); - --sidebar-row-selected: var(--color-white); + --sidebar-control-surface: var(--muted); + --sidebar-row-hover: var(--accent); + --sidebar-row-active: var(--card); + --sidebar-row-selected: var(--card); --sidebar-border: var(--border); --sidebar-stage-fade: var(--sidebar); @@ -896,29 +899,30 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil color-scheme: dark; /* Keep the workspace in the same neutral-black family as sidebar v2. Surfaces lift from this base instead of starting from a milky gray. */ - --background: var(--color-neutral-950); + --background: color-mix(in srgb, var(--theme-neutral-seed) 10%, var(--color-neutral-950)); --app-chrome-background: var(--background); --surface-raised: var(--secondary); --foreground: var(--color-neutral-100); - --card: color-mix(in srgb, var(--background) 97%, var(--color-white)); - --card-foreground: var(--color-neutral-100); - --popover: color-mix(in srgb, var(--background) 94%, var(--color-white)); - --popover-foreground: var(--color-neutral-100); - --primary: oklch(0.588 0.217 264); - --primary-foreground: var(--color-white); - --secondary: --alpha(var(--color-white) / 4%); - --secondary-foreground: var(--color-neutral-100); - --muted: --alpha(var(--color-white) / 4%); + --card: color-mix(in srgb, var(--theme-neutral-seed) 14%, var(--background)); + --card-foreground: var(--foreground); + --popover: color-mix(in srgb, var(--theme-neutral-seed) 18%, var(--background)); + --popover-foreground: var(--foreground); + /* Dark mode lifts the seed instead and uses dark text for the same reason. */ + --primary: color-mix(in oklch, var(--theme-accent-seed) 40%, var(--color-white)); + --primary-foreground: var(--color-black); + --secondary: color-mix(in srgb, var(--theme-neutral-seed) 13%, transparent); + --secondary-foreground: var(--foreground); + --muted: color-mix(in srgb, var(--theme-neutral-seed) 13%, transparent); --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white)); - --accent: --alpha(var(--color-white) / 4%); - --accent-foreground: var(--color-neutral-100); + --accent: color-mix(in srgb, var(--theme-accent-seed) 16%, transparent); + --accent-foreground: var(--foreground); --destructive: color-mix(in srgb, var(--color-red-500) 90%, var(--color-white)); - --border: --alpha(var(--color-white) / 6%); - --input: --alpha(var(--color-white) / 8%); - --ring: oklch(0.588 0.217 264); + --border: color-mix(in srgb, var(--theme-neutral-seed) 16%, transparent); + --input: color-mix(in srgb, var(--theme-neutral-seed) 20%, transparent); + --ring: var(--primary); --destructive-foreground: var(--color-red-400); - --info: var(--color-blue-500); - --info-foreground: var(--color-blue-400); + --info: var(--primary); + --info-foreground: var(--primary); --success: var(--color-emerald-500); --success-foreground: var(--color-emerald-400); --warning: var(--color-amber-500); @@ -935,46 +939,45 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } -/* Keep both navigation implementations on the same quiet zinc hierarchy: - zinc-50 navigation, zinc-25 hover, and white selected/raised surfaces. +/* Keep both navigation implementations on the same generated hierarchy. The version attribute remains useful for layout-specific styling without changing the color system when the beta is toggled. */ [data-sidebar-version="v1"], [data-sidebar-version="v2"] { - --background: var(--color-zinc-25); + --background: color-mix(in srgb, var(--theme-neutral-seed) 3%, var(--color-zinc-25)); --foreground: var(--color-zinc-800); - --card: var(--color-white); - --card-foreground: var(--color-zinc-800); - --accent: var(--color-zinc-100); - --accent-foreground: var(--color-zinc-900); - --muted: var(--color-zinc-50); + --card: color-mix(in srgb, var(--theme-neutral-seed) 1.5%, var(--color-white)); + --card-foreground: var(--foreground); + --accent: color-mix(in srgb, var(--theme-accent-seed) 8%, transparent); + --accent-foreground: var(--foreground); + --muted: color-mix(in srgb, var(--theme-neutral-seed) 7%, transparent); --muted-foreground: var(--color-zinc-500); - --border: var(--color-zinc-200); - --input: var(--color-zinc-300); - --sidebar: var(--color-zinc-50); - --sidebar-foreground: var(--color-zinc-800); - --sidebar-muted-foreground: var(--color-zinc-500); - --sidebar-control-surface: var(--color-zinc-100); - --sidebar-row-hover: var(--color-zinc-25); - --sidebar-row-active: var(--color-white); - --sidebar-row-selected: var(--color-white); - --sidebar-border: var(--color-zinc-200); + --border: color-mix(in srgb, var(--theme-neutral-seed) 16%, transparent); + --input: color-mix(in srgb, var(--theme-neutral-seed) 19%, transparent); + --sidebar: var(--background); + --sidebar-foreground: var(--foreground); + --sidebar-muted-foreground: var(--muted-foreground); + --sidebar-control-surface: var(--muted); + --sidebar-row-hover: var(--accent); + --sidebar-row-active: var(--card); + --sidebar-row-selected: var(--card); + --sidebar-border: var(--border); --sidebar-stage-fade: var(--sidebar); background-color: var(--sidebar); } .dark [data-sidebar-version="v1"], .dark [data-sidebar-version="v2"] { - --background: #000; - --foreground: #f1f3f7; - --card: #000; + --background: color-mix(in srgb, var(--theme-neutral-seed) 10%, var(--color-neutral-950)); + --foreground: var(--color-neutral-100); + --card: color-mix(in srgb, var(--theme-neutral-seed) 14%, var(--background)); --card-foreground: var(--foreground); - --accent: #191a1d; - --accent-foreground: #f7f9ff; - --muted: #0a0a0a; - --muted-foreground: #a3a3a3; - --border: rgb(255 255 255 / 8%); - --input: rgb(255 255 255 / 18%); + --accent: color-mix(in srgb, var(--theme-accent-seed) 16%, transparent); + --accent-foreground: var(--foreground); + --muted: color-mix(in srgb, var(--theme-neutral-seed) 13%, transparent); + --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white)); + --border: color-mix(in srgb, var(--theme-neutral-seed) 16%, transparent); + --input: color-mix(in srgb, var(--theme-neutral-seed) 20%, transparent); --sidebar: var(--card); --sidebar-foreground: var(--foreground); --sidebar-muted-foreground: var(--muted-foreground); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 7406f960cd3..891f9d768ea 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -9,6 +9,7 @@ import "@fontsource-variable/dm-sans/index.css"; import "@fontsource/jetbrains-mono/400.css"; import "@fontsource/jetbrains-mono/500.css"; import "./index.css"; +import "./hooks/useThemeColors"; import { isElectron } from "./env"; import { ManagedRelayAuthProvider } from "./cloud/managedAuth"; From d3c46447598549d314f6da269c7f9f8ad393a62c Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 06:45:06 +0000 Subject: [PATCH 2/4] feat(web): showcase palette controls --- apps/web/index.html | 27 +++- .../components/settings/SettingsPanels.tsx | 145 ++++++++++++++---- apps/web/src/hooks/useThemeColors.test.ts | 51 +++++- apps/web/src/hooks/useThemeColors.ts | 37 ++++- apps/web/src/index.css | 128 +++++++++++----- 5 files changed, 314 insertions(+), 74 deletions(-) diff --git a/apps/web/index.html b/apps/web/index.html index 89f4eb77dcd..d3a15c20528 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -22,9 +22,19 @@ const isHexColor = (value) => typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value.trim()); if (isHexColor(colors?.accentColor)) { + const accentColor = colors.accentColor.trim().toLowerCase(); + const channels = [1, 3, 5].map((offset) => + Number.parseInt(accentColor.slice(offset, offset + 2), 16), + ); + const [red, green, blue] = channels.map((channel) => { + const value = channel / 255; + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }); + const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue; + document.documentElement.style.setProperty("--theme-accent-seed", accentColor); document.documentElement.style.setProperty( - "--theme-accent-seed", - colors.accentColor.trim().toLowerCase(), + "--theme-accent-foreground", + luminance >= 0.179 ? "#000000" : "#ffffff", ); } if (isHexColor(colors?.neutralColor)) { @@ -33,6 +43,19 @@ colors.neutralColor.trim().toLowerCase(), ); } + if (typeof colors?.contrast === "number" && Number.isFinite(colors.contrast)) { + const contrast = Math.min(100, Math.max(0, colors.contrast)); + const properties = { + "--theme-background-strength": `${4 + contrast * 0.12}%`, + "--theme-surface-strength": `${6 + contrast * 0.16}%`, + "--theme-control-strength": `${3 + contrast * 0.08}%`, + "--theme-accent-strength": `${6 + contrast * 0.2}%`, + "--theme-border-strength": `${8 + contrast * 0.16}%`, + }; + for (const [property, value] of Object.entries(properties)) { + document.documentElement.style.setProperty(property, value); + } + } } catch {} try { const storedTheme = window.localStorage.getItem("t3code:theme"); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 27fb350b00b..6d94c02f1e3 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -168,6 +168,78 @@ const THEME_COLOR_SWATCHES = [ const THEME_NEUTRAL_SWATCHES = ["#737373", "#78716c", "#64748b", "#71717a"] as const; +type ThemeOption = (typeof THEME_OPTIONS)[number]; + +function ThemeModePreview({ + option, + selected, + accentColor, + onSelect, +}: { + option: ThemeOption; + selected: boolean; + accentColor: string; + onSelect: () => void; +}) { + const previewBackground = + option.value === "system" + ? "bg-[linear-gradient(to_right,#fafafa_0_50%,#18181b_50%)]" + : option.value === "light" + ? "bg-zinc-50" + : "bg-zinc-900"; + const previewForeground = option.value === "light" ? "bg-zinc-400" : "bg-zinc-500"; + const cardBackground = + option.value === "system" + ? "bg-[linear-gradient(to_right,rgba(255,255,255,0.9)_0_32%,rgba(39,39,42,0.95)_32%)]" + : option.value === "light" + ? "bg-white/90" + : "bg-zinc-800/95"; + + return ( + + ); +} + function ThemeColorControl({ label, value, @@ -1028,43 +1100,33 @@ export function AppearanceSettingsPanel() { setTheme("system")} /> ) : null } - control={ - - } - /> + > +
+ {THEME_OPTIONS.map((option) => ( + setTheme(option.value)} + /> + ))} +
+
setThemeColors(DEFAULT_THEME_COLORS)} @@ -1082,7 +1144,7 @@ export function AppearanceSettingsPanel() { } /> @@ -1090,6 +1152,33 @@ export function AppearanceSettingsPanel() { } /> +
+ + + setThemeColors({ + ...colors, + contrast: Number(event.currentTarget.value), + } satisfies ThemeColors) + } + className="min-w-0 flex-1 cursor-pointer" + /> + + {colors.contrast} + +
{ vi.stubGlobal("window", createWindow()); const { normalizeThemeColors } = await import("./useThemeColors"); - expect(normalizeThemeColors({ accentColor: " #DB2777 ", neutralColor: "not-a-color" })).toEqual( - { accentColor: "#db2777", neutralColor: "#737373" }, - ); + expect( + normalizeThemeColors({ + accentColor: " #DB2777 ", + neutralColor: "not-a-color", + contrast: 120, + }), + ).toEqual({ accentColor: "#db2777", neutralColor: "#737373", contrast: 100 }); }); it("reads a valid palette from storage", async () => { @@ -41,23 +45,58 @@ describe("theme colors", () => { "t3code:theme-colors": JSON.stringify({ accentColor: "#0891b2", neutralColor: "#57534e", + contrast: 30, }), }), ); const { readThemeColors } = await import("./useThemeColors"); - expect(readThemeColors()).toEqual({ accentColor: "#0891b2", neutralColor: "#57534e" }); + expect(readThemeColors()).toEqual({ + accentColor: "#0891b2", + neutralColor: "#57534e", + contrast: 30, + }); }); - it("applies both seeds to the document root", async () => { + it("migrates palettes saved before contrast was configurable", async () => { + vi.stubGlobal( + "window", + createWindow({ + "t3code:theme-colors": JSON.stringify({ + accentColor: "#0891b2", + neutralColor: "#57534e", + }), + }), + ); + const { readThemeColors } = await import("./useThemeColors"); + + expect(readThemeColors()).toEqual({ + accentColor: "#0891b2", + neutralColor: "#57534e", + contrast: 50, + }); + }); + + it("applies the palette and contrast properties to the document root", async () => { const setProperty = vi.fn(); vi.stubGlobal("window", createWindow()); vi.stubGlobal("document", { documentElement: { style: { setProperty } } }); const { applyThemeColors } = await import("./useThemeColors"); - applyThemeColors({ accentColor: "#16a34a", neutralColor: "#78716c" }); + applyThemeColors({ accentColor: "#16a34a", neutralColor: "#78716c", contrast: 50 }); expect(setProperty).toHaveBeenCalledWith("--theme-accent-seed", "#16a34a"); + expect(setProperty).toHaveBeenCalledWith("--theme-accent-foreground", "#000000"); expect(setProperty).toHaveBeenCalledWith("--theme-neutral-seed", "#78716c"); + expect(setProperty).toHaveBeenCalledWith("--theme-accent-strength", "16%"); + expect(setProperty).toHaveBeenCalledWith("--theme-border-strength", "16%"); + }); + + it("chooses a readable foreground for light and dark accents", async () => { + vi.stubGlobal("window", createWindow()); + const { getThemeAccentForeground } = await import("./useThemeColors"); + + expect(getThemeAccentForeground("#facc15")).toBe("#000000"); + expect(getThemeAccentForeground("#1d4ed8")).toBe("#ffffff"); }); }); diff --git a/apps/web/src/hooks/useThemeColors.ts b/apps/web/src/hooks/useThemeColors.ts index 25b59351cc9..d35a29880d8 100644 --- a/apps/web/src/hooks/useThemeColors.ts +++ b/apps/web/src/hooks/useThemeColors.ts @@ -3,11 +3,13 @@ import { useCallback, useSyncExternalStore } from "react"; export type ThemeColors = { accentColor: string; neutralColor: string; + contrast: number; }; export const DEFAULT_THEME_COLORS: ThemeColors = { accentColor: "#3b5bdb", neutralColor: "#737373", + contrast: 50, }; export const THEME_COLORS_STORAGE_KEY = "t3code:theme-colors"; @@ -27,6 +29,32 @@ export function normalizeThemeColors(value: unknown): ThemeColors { return { accentColor: normalizeThemeColor(colors.accentColor) ?? DEFAULT_THEME_COLORS.accentColor, neutralColor: normalizeThemeColor(colors.neutralColor) ?? DEFAULT_THEME_COLORS.neutralColor, + contrast: + typeof colors.contrast === "number" && Number.isFinite(colors.contrast) + ? Math.round(Math.min(100, Math.max(0, colors.contrast))) + : DEFAULT_THEME_COLORS.contrast, + }; +} + +export function getThemeAccentForeground(accentColor: string): "#000000" | "#ffffff" { + const channels = [1, 3, 5].map((offset) => + Number.parseInt(accentColor.slice(offset, offset + 2), 16), + ); + const [red = 0, green = 0, blue = 0] = channels.map((channel) => { + const value = channel / 255; + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }); + const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue; + return luminance >= 0.179 ? "#000000" : "#ffffff"; +} + +function getThemeContrastProperties(contrast: number): Record { + return { + "--theme-background-strength": `${4 + contrast * 0.12}%`, + "--theme-surface-strength": `${6 + contrast * 0.16}%`, + "--theme-control-strength": `${3 + contrast * 0.08}%`, + "--theme-accent-strength": `${6 + contrast * 0.2}%`, + "--theme-border-strength": `${8 + contrast * 0.16}%`, }; } @@ -42,8 +70,13 @@ export function readThemeColors(): ThemeColors { export function applyThemeColors(colors: ThemeColors): void { if (typeof document === "undefined") return; - document.documentElement.style.setProperty("--theme-accent-seed", colors.accentColor); - document.documentElement.style.setProperty("--theme-neutral-seed", colors.neutralColor); + const rootStyle = document.documentElement.style; + rootStyle.setProperty("--theme-accent-seed", colors.accentColor); + rootStyle.setProperty("--theme-accent-foreground", getThemeAccentForeground(colors.accentColor)); + rootStyle.setProperty("--theme-neutral-seed", colors.neutralColor); + for (const [property, value] of Object.entries(getThemeContrastProperties(colors.contrast))) { + rootStyle.setProperty(property, value); + } } function emitChange(): void { diff --git a/apps/web/src/index.css b/apps/web/src/index.css index ce2632ce338..be18480315e 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -854,27 +854,40 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil color-scheme: light; --radius: 0.625rem; --theme-accent-seed: #3b5bdb; + --theme-accent-foreground: #ffffff; --theme-neutral-seed: #737373; - --background: color-mix(in srgb, var(--theme-neutral-seed) 3%, var(--color-zinc-25)); + --theme-background-strength: 10%; + --theme-surface-strength: 14%; + --theme-control-strength: 7%; + --theme-accent-strength: 16%; + --theme-border-strength: 16%; + --background: color-mix( + in srgb, + var(--theme-neutral-seed) var(--theme-background-strength), + var(--color-zinc-25) + ); --app-chrome-background: var(--background); --surface-raised: color-mix(in srgb, var(--card) 20%, transparent); --foreground: var(--color-zinc-800); - --card: color-mix(in srgb, var(--theme-neutral-seed) 1.5%, var(--color-white)); + --card: color-mix(in srgb, var(--theme-neutral-seed) 2%, var(--color-white)); --card-foreground: var(--foreground); --popover: var(--card); --popover-foreground: var(--foreground); - /* Keep button text readable even when the seed itself is very light. */ - --primary: color-mix(in oklch, var(--theme-accent-seed) 46%, var(--color-black)); - --primary-foreground: var(--color-white); - --secondary: color-mix(in srgb, var(--theme-neutral-seed) 7%, transparent); + --primary: var(--theme-accent-seed); + --primary-foreground: var(--theme-accent-foreground); + --secondary: color-mix( + in srgb, + var(--theme-neutral-seed) var(--theme-control-strength), + transparent + ); --secondary-foreground: var(--foreground); - --muted: color-mix(in srgb, var(--theme-neutral-seed) 7%, transparent); + --muted: color-mix(in srgb, var(--theme-neutral-seed) var(--theme-control-strength), transparent); --muted-foreground: var(--color-zinc-500); - --accent: color-mix(in srgb, var(--theme-accent-seed) 8%, transparent); + --accent: color-mix(in srgb, var(--theme-accent-seed) var(--theme-accent-strength), transparent); --accent-foreground: var(--foreground); --destructive: var(--color-red-500); - --border: color-mix(in srgb, var(--theme-neutral-seed) 16%, transparent); - --input: color-mix(in srgb, var(--theme-neutral-seed) 19%, transparent); + --border: color-mix(in srgb, var(--theme-neutral-seed) var(--theme-border-strength), transparent); + --input: color-mix(in srgb, var(--theme-neutral-seed) var(--theme-border-strength), transparent); --ring: var(--primary); --destructive-foreground: var(--color-red-700); --info: var(--primary); @@ -899,26 +912,57 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil color-scheme: dark; /* Keep the workspace in the same neutral-black family as sidebar v2. Surfaces lift from this base instead of starting from a milky gray. */ - --background: color-mix(in srgb, var(--theme-neutral-seed) 10%, var(--color-neutral-950)); + --background: color-mix( + in srgb, + var(--theme-neutral-seed) var(--theme-background-strength), + var(--color-neutral-950) + ); --app-chrome-background: var(--background); --surface-raised: var(--secondary); --foreground: var(--color-neutral-100); - --card: color-mix(in srgb, var(--theme-neutral-seed) 14%, var(--background)); + --card: color-mix( + in srgb, + var(--theme-neutral-seed) var(--theme-surface-strength), + var(--background) + ); --card-foreground: var(--foreground); - --popover: color-mix(in srgb, var(--theme-neutral-seed) 18%, var(--background)); + --popover: color-mix( + in srgb, + var(--theme-neutral-seed) var(--theme-border-strength), + var(--background) + ); --popover-foreground: var(--foreground); - /* Dark mode lifts the seed instead and uses dark text for the same reason. */ - --primary: color-mix(in oklch, var(--theme-accent-seed) 40%, var(--color-white)); - --primary-foreground: var(--color-black); - --secondary: color-mix(in srgb, var(--theme-neutral-seed) 13%, transparent); + --primary: var(--theme-accent-seed); + --primary-foreground: var(--theme-accent-foreground); + --secondary: color-mix( + in srgb, + var(--theme-neutral-seed) var(--theme-surface-strength), + transparent + ); --secondary-foreground: var(--foreground); - --muted: color-mix(in srgb, var(--theme-neutral-seed) 13%, transparent); + --muted: color-mix( + in srgb, + var(--theme-neutral-seed) var(--theme-surface-strength), + transparent + ); --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white)); - --accent: color-mix(in srgb, var(--theme-accent-seed) 16%, transparent); + --accent: color-mix( + in srgb, + var(--theme-accent-seed) var(--theme-accent-strength), + transparent + ); --accent-foreground: var(--foreground); --destructive: color-mix(in srgb, var(--color-red-500) 90%, var(--color-white)); - --border: color-mix(in srgb, var(--theme-neutral-seed) 16%, transparent); - --input: color-mix(in srgb, var(--theme-neutral-seed) 20%, transparent); + --border: color-mix( + in srgb, + var(--theme-neutral-seed) var(--theme-border-strength), + transparent + ); + --input: color-mix( + in srgb, + var(--theme-neutral-seed) var(--theme-border-strength), + transparent + ); --ring: var(--primary); --destructive-foreground: var(--color-red-400); --info: var(--primary); @@ -944,16 +988,20 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil changing the color system when the beta is toggled. */ [data-sidebar-version="v1"], [data-sidebar-version="v2"] { - --background: color-mix(in srgb, var(--theme-neutral-seed) 3%, var(--color-zinc-25)); + --background: color-mix( + in srgb, + var(--theme-neutral-seed) var(--theme-background-strength), + var(--color-zinc-25) + ); --foreground: var(--color-zinc-800); - --card: color-mix(in srgb, var(--theme-neutral-seed) 1.5%, var(--color-white)); + --card: color-mix(in srgb, var(--theme-neutral-seed) 2%, var(--color-white)); --card-foreground: var(--foreground); - --accent: color-mix(in srgb, var(--theme-accent-seed) 8%, transparent); + --accent: color-mix(in srgb, var(--theme-accent-seed) var(--theme-accent-strength), transparent); --accent-foreground: var(--foreground); - --muted: color-mix(in srgb, var(--theme-neutral-seed) 7%, transparent); + --muted: color-mix(in srgb, var(--theme-neutral-seed) var(--theme-control-strength), transparent); --muted-foreground: var(--color-zinc-500); - --border: color-mix(in srgb, var(--theme-neutral-seed) 16%, transparent); - --input: color-mix(in srgb, var(--theme-neutral-seed) 19%, transparent); + --border: color-mix(in srgb, var(--theme-neutral-seed) var(--theme-border-strength), transparent); + --input: var(--border); --sidebar: var(--background); --sidebar-foreground: var(--foreground); --sidebar-muted-foreground: var(--muted-foreground); @@ -968,23 +1016,31 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil .dark [data-sidebar-version="v1"], .dark [data-sidebar-version="v2"] { - --background: color-mix(in srgb, var(--theme-neutral-seed) 10%, var(--color-neutral-950)); + --background: color-mix( + in srgb, + var(--theme-neutral-seed) var(--theme-background-strength), + var(--color-neutral-950) + ); --foreground: var(--color-neutral-100); - --card: color-mix(in srgb, var(--theme-neutral-seed) 14%, var(--background)); + --card: color-mix( + in srgb, + var(--theme-neutral-seed) var(--theme-surface-strength), + var(--background) + ); --card-foreground: var(--foreground); - --accent: color-mix(in srgb, var(--theme-accent-seed) 16%, transparent); + --accent: color-mix(in srgb, var(--theme-accent-seed) var(--theme-accent-strength), transparent); --accent-foreground: var(--foreground); - --muted: color-mix(in srgb, var(--theme-neutral-seed) 13%, transparent); + --muted: color-mix(in srgb, var(--theme-neutral-seed) var(--theme-surface-strength), transparent); --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white)); - --border: color-mix(in srgb, var(--theme-neutral-seed) 16%, transparent); - --input: color-mix(in srgb, var(--theme-neutral-seed) 20%, transparent); + --border: color-mix(in srgb, var(--theme-neutral-seed) var(--theme-border-strength), transparent); + --input: var(--border); --sidebar: var(--card); --sidebar-foreground: var(--foreground); --sidebar-muted-foreground: var(--muted-foreground); --sidebar-control-surface: var(--muted); - --sidebar-row-hover: color-mix(in srgb, var(--foreground) 8%, transparent); - --sidebar-row-active: color-mix(in srgb, var(--foreground) 11%, transparent); - --sidebar-row-selected: color-mix(in srgb, var(--foreground) 7%, transparent); + --sidebar-row-hover: var(--accent); + --sidebar-row-active: var(--accent); + --sidebar-row-selected: var(--muted); --sidebar-border: var(--border); /* The stage-channel header art must ramp to THIS panel's surface, not the global chrome background, or the fade shows a seam (same rule as the From f0e4ec40edd6bc862cc4d3ec8fde80a1560c36fc Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 06:53:48 +0000 Subject: [PATCH 3/4] fix(web): keep custom palettes readable --- apps/web/index.html | 14 ++------------ apps/web/src/hooks/useThemeColors.test.ts | 9 --------- apps/web/src/hooks/useThemeColors.ts | 15 ++------------- apps/web/src/index.css | 11 ++++++----- 4 files changed, 10 insertions(+), 39 deletions(-) diff --git a/apps/web/index.html b/apps/web/index.html index d3a15c20528..ca082a84bf8 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -22,19 +22,9 @@ const isHexColor = (value) => typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value.trim()); if (isHexColor(colors?.accentColor)) { - const accentColor = colors.accentColor.trim().toLowerCase(); - const channels = [1, 3, 5].map((offset) => - Number.parseInt(accentColor.slice(offset, offset + 2), 16), - ); - const [red, green, blue] = channels.map((channel) => { - const value = channel / 255; - return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; - }); - const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue; - document.documentElement.style.setProperty("--theme-accent-seed", accentColor); document.documentElement.style.setProperty( - "--theme-accent-foreground", - luminance >= 0.179 ? "#000000" : "#ffffff", + "--theme-accent-seed", + colors.accentColor.trim().toLowerCase(), ); } if (isHexColor(colors?.neutralColor)) { diff --git a/apps/web/src/hooks/useThemeColors.test.ts b/apps/web/src/hooks/useThemeColors.test.ts index 73fc52ebe6a..5e9d6b7f5d9 100644 --- a/apps/web/src/hooks/useThemeColors.test.ts +++ b/apps/web/src/hooks/useThemeColors.test.ts @@ -86,17 +86,8 @@ describe("theme colors", () => { applyThemeColors({ accentColor: "#16a34a", neutralColor: "#78716c", contrast: 50 }); expect(setProperty).toHaveBeenCalledWith("--theme-accent-seed", "#16a34a"); - expect(setProperty).toHaveBeenCalledWith("--theme-accent-foreground", "#000000"); expect(setProperty).toHaveBeenCalledWith("--theme-neutral-seed", "#78716c"); expect(setProperty).toHaveBeenCalledWith("--theme-accent-strength", "16%"); expect(setProperty).toHaveBeenCalledWith("--theme-border-strength", "16%"); }); - - it("chooses a readable foreground for light and dark accents", async () => { - vi.stubGlobal("window", createWindow()); - const { getThemeAccentForeground } = await import("./useThemeColors"); - - expect(getThemeAccentForeground("#facc15")).toBe("#000000"); - expect(getThemeAccentForeground("#1d4ed8")).toBe("#ffffff"); - }); }); diff --git a/apps/web/src/hooks/useThemeColors.ts b/apps/web/src/hooks/useThemeColors.ts index d35a29880d8..2d6860cb346 100644 --- a/apps/web/src/hooks/useThemeColors.ts +++ b/apps/web/src/hooks/useThemeColors.ts @@ -1,4 +1,5 @@ import { useCallback, useSyncExternalStore } from "react"; +import { syncBrowserChromeTheme } from "./useTheme"; export type ThemeColors = { accentColor: string; @@ -36,18 +37,6 @@ export function normalizeThemeColors(value: unknown): ThemeColors { }; } -export function getThemeAccentForeground(accentColor: string): "#000000" | "#ffffff" { - const channels = [1, 3, 5].map((offset) => - Number.parseInt(accentColor.slice(offset, offset + 2), 16), - ); - const [red = 0, green = 0, blue = 0] = channels.map((channel) => { - const value = channel / 255; - return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; - }); - const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue; - return luminance >= 0.179 ? "#000000" : "#ffffff"; -} - function getThemeContrastProperties(contrast: number): Record { return { "--theme-background-strength": `${4 + contrast * 0.12}%`, @@ -72,11 +61,11 @@ export function applyThemeColors(colors: ThemeColors): void { if (typeof document === "undefined") return; const rootStyle = document.documentElement.style; rootStyle.setProperty("--theme-accent-seed", colors.accentColor); - rootStyle.setProperty("--theme-accent-foreground", getThemeAccentForeground(colors.accentColor)); rootStyle.setProperty("--theme-neutral-seed", colors.neutralColor); for (const [property, value] of Object.entries(getThemeContrastProperties(colors.contrast))) { rootStyle.setProperty(property, value); } + syncBrowserChromeTheme(); } function emitChange(): void { diff --git a/apps/web/src/index.css b/apps/web/src/index.css index be18480315e..a072f383f3b 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -854,7 +854,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil color-scheme: light; --radius: 0.625rem; --theme-accent-seed: #3b5bdb; - --theme-accent-foreground: #ffffff; --theme-neutral-seed: #737373; --theme-background-strength: 10%; --theme-surface-strength: 14%; @@ -873,8 +872,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --card-foreground: var(--foreground); --popover: var(--card); --popover-foreground: var(--foreground); - --primary: var(--theme-accent-seed); - --primary-foreground: var(--theme-accent-foreground); + /* Keep text, rings, and translucent primary controls readable for any seed. + Raw seed color still drives the more expressive accent surfaces below. */ + --primary: color-mix(in oklch, var(--theme-accent-seed) 46%, var(--color-black)); + --primary-foreground: var(--color-white); --secondary: color-mix( in srgb, var(--theme-neutral-seed) var(--theme-control-strength), @@ -932,8 +933,8 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil var(--background) ); --popover-foreground: var(--foreground); - --primary: var(--theme-accent-seed); - --primary-foreground: var(--theme-accent-foreground); + --primary: color-mix(in oklch, var(--theme-accent-seed) 40%, var(--color-white)); + --primary-foreground: var(--color-black); --secondary: color-mix( in srgb, var(--theme-neutral-seed) var(--theme-surface-strength), From 8ac9c0321d7774b7ad76066e55df0e5491c1a7da Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:01:14 +0000 Subject: [PATCH 4/4] fix(web): isolate browser chrome sync --- apps/web/src/hooks/browserChromeTheme.ts | 49 +++++++++++++++++++++ apps/web/src/hooks/useTheme.ts | 54 ++---------------------- apps/web/src/hooks/useThemeColors.ts | 2 +- 3 files changed, 53 insertions(+), 52 deletions(-) create mode 100644 apps/web/src/hooks/browserChromeTheme.ts diff --git a/apps/web/src/hooks/browserChromeTheme.ts b/apps/web/src/hooks/browserChromeTheme.ts new file mode 100644 index 00000000000..1c901f03b23 --- /dev/null +++ b/apps/web/src/hooks/browserChromeTheme.ts @@ -0,0 +1,49 @@ +const THEME_COLOR_META_NAME = "theme-color"; +const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`; + +function ensureThemeColorMetaTag(): HTMLMetaElement { + let element = document.querySelector(DYNAMIC_THEME_COLOR_SELECTOR); + if (element) return element; + + element = document.createElement("meta"); + element.name = THEME_COLOR_META_NAME; + element.setAttribute("data-dynamic-theme-color", "true"); + document.head.append(element); + return element; +} + +function normalizeThemeColor(value: string | null | undefined): string | null { + const normalizedValue = value?.trim().toLowerCase(); + if ( + !normalizedValue || + normalizedValue === "transparent" || + normalizedValue === "rgba(0, 0, 0, 0)" || + normalizedValue === "rgba(0 0 0 / 0)" + ) { + return null; + } + + return value?.trim() ?? null; +} + +function resolveBrowserChromeSurface(): HTMLElement { + return ( + document.querySelector("main[data-slot='sidebar-inset']") ?? + document.querySelector("[data-slot='sidebar-inner']") ?? + document.body + ); +} + +export function syncBrowserChromeTheme(): void { + if (typeof document === "undefined" || typeof getComputedStyle === "undefined") return; + const surfaceColor = normalizeThemeColor( + getComputedStyle(resolveBrowserChromeSurface()).backgroundColor, + ); + const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor); + const backgroundColor = surfaceColor ?? fallbackColor; + if (!backgroundColor) return; + + document.documentElement.style.backgroundColor = backgroundColor; + document.body.style.backgroundColor = backgroundColor; + ensureThemeColorMetaTag().setAttribute("content", backgroundColor); +} diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index bdaf37f099d..0f0fa76dde9 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -2,6 +2,9 @@ import type { DesktopBridge } from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import * as Schema from "effect/Schema"; import { useCallback, useEffect, useSyncExternalStore } from "react"; +import { syncBrowserChromeTheme } from "./browserChromeTheme"; + +export { syncBrowserChromeTheme } from "./browserChromeTheme"; const ThemePreference = Schema.Literals(["light", "dark", "system"]); type Theme = typeof ThemePreference.Type; @@ -18,8 +21,6 @@ const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { theme: "system", systemDark: false, }; -const THEME_COLOR_META_NAME = "theme-color"; -const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`; export class ThemeStorageError extends Schema.TaggedErrorClass()( "ThemeStorageError", @@ -124,55 +125,6 @@ function getStored(): Theme { } } -function ensureThemeColorMetaTag(): HTMLMetaElement { - let element = document.querySelector(DYNAMIC_THEME_COLOR_SELECTOR); - if (element) { - return element; - } - - element = document.createElement("meta"); - element.name = THEME_COLOR_META_NAME; - element.setAttribute("data-dynamic-theme-color", "true"); - document.head.append(element); - return element; -} - -function normalizeThemeColor(value: string | null | undefined): string | null { - const normalizedValue = value?.trim().toLowerCase(); - if ( - !normalizedValue || - normalizedValue === "transparent" || - normalizedValue === "rgba(0, 0, 0, 0)" || - normalizedValue === "rgba(0 0 0 / 0)" - ) { - return null; - } - - return value?.trim() ?? null; -} - -function resolveBrowserChromeSurface(): HTMLElement { - return ( - document.querySelector("main[data-slot='sidebar-inset']") ?? - document.querySelector("[data-slot='sidebar-inner']") ?? - document.body - ); -} - -export function syncBrowserChromeTheme() { - if (typeof document === "undefined" || typeof getComputedStyle === "undefined") return; - const surfaceColor = normalizeThemeColor( - getComputedStyle(resolveBrowserChromeSurface()).backgroundColor, - ); - const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor); - const backgroundColor = surfaceColor ?? fallbackColor; - if (!backgroundColor) return; - - document.documentElement.style.backgroundColor = backgroundColor; - document.body.style.backgroundColor = backgroundColor; - ensureThemeColorMetaTag().setAttribute("content", backgroundColor); -} - function applyTheme(theme: Theme, suppressTransitions = false) { if (typeof document === "undefined" || typeof window === "undefined") return; const systemDark = theme === "system" ? getSystemDark() : false; diff --git a/apps/web/src/hooks/useThemeColors.ts b/apps/web/src/hooks/useThemeColors.ts index 2d6860cb346..f8b54c1b31e 100644 --- a/apps/web/src/hooks/useThemeColors.ts +++ b/apps/web/src/hooks/useThemeColors.ts @@ -1,5 +1,5 @@ import { useCallback, useSyncExternalStore } from "react"; -import { syncBrowserChromeTheme } from "./useTheme"; +import { syncBrowserChromeTheme } from "./browserChromeTheme"; export type ThemeColors = { accentColor: string;