diff --git a/apps/web/index.html b/apps/web/index.html index dadef17d3bc..ca082a84bf8 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -16,6 +16,37 @@ 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(), + ); + } + 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"); const theme = diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 31ac4bba66e..6d94c02f1e3 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,129 @@ 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; + +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, + 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 +682,7 @@ function AboutVersionSection() { export function useSettingsRestore(onRestored?: () => void) { const { theme, setTheme } = useTheme(); + const { colors, setThemeColors } = useThemeColors(); const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); @@ -570,6 +695,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 +766,8 @@ export function useSettingsRestore(onRestored?: () => void) { settings.timestampFormat, settings.wordWrap, theme, + colors.accentColor, + colors.neutralColor, ], ); @@ -651,6 +782,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 +806,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 +1082,7 @@ function BackgroundActivityAdvancedDialog({ export function AppearanceSettingsPanel() { const { theme, setTheme } = useTheme(); + const { colors, setThemeColors } = useThemeColors(); const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); const environmentStageLabel = useEnvironmentStageLabel(); @@ -967,36 +1100,86 @@ export function AppearanceSettingsPanel() { setTheme("system")} /> ) : null } - control={ - + > +
+ {THEME_OPTIONS.map((option) => ( + setTheme(option.value)} + /> + ))} +
+
+ + setThemeColors(DEFAULT_THEME_COLORS)} + /> + ) : null } - /> + > +
+ + setThemeColors({ ...colors, accentColor } satisfies ThemeColors) + } + /> + + setThemeColors({ ...colors, neutralColor } satisfies ThemeColors) + } + /> +
+
+ + + setThemeColors({ + ...colors, + contrast: Number(event.currentTarget.value), + } satisfies ThemeColors) + } + className="min-w-0 flex-1 cursor-pointer" + /> + + {colors.contrast} + +
+
(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.test.ts b/apps/web/src/hooks/useThemeColors.test.ts new file mode 100644 index 00000000000..5e9d6b7f5d9 --- /dev/null +++ b/apps/web/src/hooks/useThemeColors.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +function createStorage(initial: Record = {}): 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", + contrast: 120, + }), + ).toEqual({ accentColor: "#db2777", neutralColor: "#737373", contrast: 100 }); + }); + + it("reads a valid palette from storage", async () => { + vi.stubGlobal( + "window", + createWindow({ + "t3code:theme-colors": JSON.stringify({ + accentColor: "#0891b2", + neutralColor: "#57534e", + contrast: 30, + }), + }), + ); + const { readThemeColors } = await import("./useThemeColors"); + + expect(readThemeColors()).toEqual({ + accentColor: "#0891b2", + neutralColor: "#57534e", + contrast: 30, + }); + }); + + 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", contrast: 50 }); + + expect(setProperty).toHaveBeenCalledWith("--theme-accent-seed", "#16a34a"); + expect(setProperty).toHaveBeenCalledWith("--theme-neutral-seed", "#78716c"); + expect(setProperty).toHaveBeenCalledWith("--theme-accent-strength", "16%"); + expect(setProperty).toHaveBeenCalledWith("--theme-border-strength", "16%"); + }); +}); diff --git a/apps/web/src/hooks/useThemeColors.ts b/apps/web/src/hooks/useThemeColors.ts new file mode 100644 index 00000000000..f8b54c1b31e --- /dev/null +++ b/apps/web/src/hooks/useThemeColors.ts @@ -0,0 +1,116 @@ +import { useCallback, useSyncExternalStore } from "react"; +import { syncBrowserChromeTheme } from "./browserChromeTheme"; + +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"; + +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, + contrast: + typeof colors.contrast === "number" && Number.isFinite(colors.contrast) + ? Math.round(Math.min(100, Math.max(0, colors.contrast))) + : DEFAULT_THEME_COLORS.contrast, + }; +} + +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}%`, + }; +} + +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; + const rootStyle = document.documentElement.style; + rootStyle.setProperty("--theme-accent-seed", 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 { + 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..a072f383f3b 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -853,42 +853,59 @@ 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; + --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: 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) 2%, var(--color-white)); + --card-foreground: var(--foreground); + --popover: var(--card); + --popover-foreground: var(--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: var(--color-zinc-50); - --secondary-foreground: var(--color-zinc-800); - --muted: var(--color-zinc-50); + --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) var(--theme-control-strength), 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) var(--theme-accent-strength), 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) 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(--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 +913,61 @@ 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) 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(--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) var(--theme-surface-strength), + var(--background) + ); + --card-foreground: var(--foreground); + --popover: color-mix( + in srgb, + var(--theme-neutral-seed) var(--theme-border-strength), + var(--background) + ); + --popover-foreground: var(--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), + transparent + ); + --secondary-foreground: var(--foreground); + --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: --alpha(var(--color-white) / 4%); - --accent-foreground: var(--color-neutral-100); + --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: --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) 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(--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,53 +984,64 @@ 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) var(--theme-background-strength), + 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) 2%, var(--color-white)); + --card-foreground: var(--foreground); + --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) var(--theme-control-strength), 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) var(--theme-border-strength), transparent); + --input: var(--border); + --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) var(--theme-background-strength), + var(--color-neutral-950) + ); + --foreground: var(--color-neutral-100); + --card: color-mix( + in srgb, + var(--theme-neutral-seed) var(--theme-surface-strength), + 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) var(--theme-accent-strength), transparent); + --accent-foreground: var(--foreground); + --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) 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 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";