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 (
+
+ );
+}
+
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"
+ />
+
+
+
(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";