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