History

Add user theme palette settings

Cursor Agent committed 6 months ago

Commit 8a815ad1 parent 35721d913 files · +441 −41Browse files

Unified · Split

13 files
  1. apps/desktop/src/clientPersistence.test.ts +2 −0
  2. apps/web/index.html +14 −0
  3. apps/web/src/clientPersistenceStorage.test.ts +46 −1
  4. apps/web/src/clientPersistenceStorage.ts +14 −0
  5. apps/web/src/components/settings/SettingsPanels.tsx +151 −2
  6. apps/web/src/hooks/useSettings.ts +5 −1
  7. apps/web/src/index.css +38 −36
  8. apps/web/src/localApi.test.ts +3 −0
  9. apps/web/src/main.tsx +3 −0
  10. apps/web/src/themePalette.test.ts +37 −0
  11. apps/web/src/themePalette.ts +82 −0
  12. packages/contracts/src/settings.test.ts +27 −1
  13. packages/contracts/src/settings.ts +19 −0
apps/desktop/src/clientPersistence.test.ts+2−0
@@ -3,6 +3,7 @@ import * as os from "node:os"; · 2 unmodified lines
33 import * as path from "node:path";
44
55 import {
6+ DEFAULT_THEME_PALETTE,
67 EnvironmentId,
78 type ClientSettings,
89 type PersistedSavedEnvironmentRecord,
@@ -63,6 +64,7 @@ const clientSettings: ClientSettings = { · 54 unmodified lines
6364 sidebarProjectSortOrder: "manual",
6465 sidebarThreadSortOrder: "created_at",
6566 timestampFormat: "24-hour",
67+ themePalette: DEFAULT_THEME_PALETTE,
6668 };
6769
6870 const savedRegistryRecord: PersistedSavedEnvironmentRecord = {
apps/web/index.html+14−0
@@ -25,6 +25,20 @@ · 24 unmodified lines
2525 const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
2626 const isDark = theme === "dark" || (theme === "system" && prefersDark);
2727 document.documentElement.classList.toggle("dark", isDark);
28+ const rawSettings = window.localStorage.getItem("t3code:client-settings:v1");
29+ const palette = rawSettings ? JSON.parse(rawSettings).themePalette : null;
30+ if (palette && /^#[0-9a-fA-F]{6}$/.test(palette.primaryColor)) {
31+ document.documentElement.style.setProperty(
32+ "--theme-primary-seed",
33+ palette.primaryColor.toLowerCase(),
34+ );
35+ }
36+ if (palette && /^#[0-9a-fA-F]{6}$/.test(palette.neutralColor)) {
37+ document.documentElement.style.setProperty(
38+ "--theme-neutral-seed",
39+ palette.neutralColor.toLowerCase(),
40+ );
41+ }
2842 const chromeColor = isDark ? DARK_BACKGROUND : LIGHT_BACKGROUND;
2943 document.documentElement.style.backgroundColor = chromeColor;
3044 themeColorMeta?.setAttribute("content", chromeColor);
apps/web/src/clientPersistenceStorage.test.ts+46−1
@@ -1,4 +1,9 @@
1−import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts";
1+import {
2+ DEFAULT_CLIENT_SETTINGS,
3+ DEFAULT_THEME_PALETTE,
4+ EnvironmentId,
5+ type PersistedSavedEnvironmentRecord,
6+} from "@t3tools/contracts";
27 import { afterEach, describe, expect, it, vi } from "vitest";
38
49 const testEnvironmentId = EnvironmentId.make("environment-1");
@@ -55,6 +60,46 @@ afterEach(() => { · 50 unmodified lines
5560 });
5661
5762 describe("clientPersistenceStorage", () => {
63+ it("reads the persisted theme palette for boot-time theme application", async () => {
64+ const testWindow = getTestWindow();
65+ const { CLIENT_SETTINGS_STORAGE_KEY, readBootThemePalette } =
66+ await import("./clientPersistenceStorage");
67+
68+ testWindow.localStorage.setItem(
69+ CLIENT_SETTINGS_STORAGE_KEY,
70+ JSON.stringify({
71+ ...DEFAULT_CLIENT_SETTINGS,
72+ themePalette: {
73+ primaryColor: "#db2777",
74+ neutralColor: "#0f172a",
75+ },
76+ }),
77+ );
78+
79+ expect(readBootThemePalette()).toEqual({
80+ primaryColor: "#db2777",
81+ neutralColor: "#0f172a",
82+ });
83+ });
84+
85+ it("falls back to the default theme palette when persisted data is invalid", async () => {
86+ const testWindow = getTestWindow();
87+ const { CLIENT_SETTINGS_STORAGE_KEY, readBootThemePalette } =
88+ await import("./clientPersistenceStorage");
89+
90+ testWindow.localStorage.setItem(
91+ CLIENT_SETTINGS_STORAGE_KEY,
92+ JSON.stringify({
93+ themePalette: {
94+ primaryColor: "pink",
95+ neutralColor: "#0f172a",
96+ },
97+ }),
98+ );
99+
100+ expect(readBootThemePalette()).toEqual(DEFAULT_THEME_PALETTE);
101+ });
102+
58103 it("stores browser secrets inline with the saved environment record", async () => {
59104 const testWindow = getTestWindow();
60105 const {
apps/web/src/clientPersistenceStorage.ts+14−0
@@ -1,5 +1,6 @@
11 import {
22 ClientSettingsSchema,
3+ DEFAULT_THEME_PALETTE,
34 EnvironmentId,
45 type ClientSettings,
56 type EnvironmentId as EnvironmentIdValue,
@@ -12,6 +13,19 @@ import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorag · 6 unmodified lines
1213 export const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1";
1314 export const SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY = "t3code:saved-environment-registry:v1";
1415
16+export function readBootThemePalette(): ClientSettings["themePalette"] {
17+ if (!hasWindow()) {
18+ return DEFAULT_THEME_PALETTE;
19+ }
20+
21+ try {
22+ const parsed = getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema);
23+ return parsed?.themePalette ?? DEFAULT_THEME_PALETTE;
24+ } catch {
25+ return DEFAULT_THEME_PALETTE;
26+ }
27+}
28+
1529 const BrowserSavedEnvironmentRecordSchema = Schema.Struct({
1630 environmentId: EnvironmentId,
1731 label: Schema.String,
apps/web/src/components/settings/SettingsPanels.tsx+151−2
@@ -1,6 +1,6 @@
11 import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react";
22 import { useQueryClient } from "@tanstack/react-query";
3−import { useCallback, useMemo, useRef, useState } from "react";
3+import { type CSSProperties, useCallback, useMemo, useRef, useState } from "react";
44 import {
55 defaultInstanceIdForDriver,
66 type DesktopUpdateChannel,
@@ -10,7 +10,11 @@ import { · 3 unmodified lines
1010 type ScopedThreadRef,
1111 } from "@t3tools/contracts";
1212 import { scopeThreadRef } from "@t3tools/client-runtime";
13−import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
13+import {
14+ DEFAULT_THEME_PALETTE,
15+ DEFAULT_UNIFIED_SETTINGS,
16+ type ThemePaletteSettings,
17+} from "@t3tools/contracts/settings";
1418 import { createModelSelection } from "@t3tools/shared/model";
1519 import { Equal } from "effect";
1620 import { APP_VERSION } from "../../branding";
@@ -40,7 +44,14 @@ import { · 23 unmodified lines
4044 deriveProviderInstanceEntries,
4145 sortProviderInstanceEntries,
4246 } from "../../providerInstances";
47+import {
48+ getThemePaletteCustomProperties,
49+ normalizeThemePaletteColor,
50+ THEME_PALETTE_PREVIEW_SWATCHES,
51+ THEME_PALETTE_SWATCHES,
52+} from "../../themePalette";
4353 import { ensureLocalApi, readLocalApi } from "../../localApi";
54+import { cn } from "../../lib/utils";
4455 import { useShallow } from "zustand/react/shallow";
4556 import {
4657 selectProjectsAcrossEnvironments,
@@ -117,6 +128,91 @@ const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({ · 70 unmodified lines
117128 provider: definition.value,
118129 }));
119130
131+function ThemePaletteColorControl({
132+ label,
133+ value,
134+ fallbackColor,
135+ onChange,
136+}: {
137+ label: string;
138+ value: string;
139+ fallbackColor: string;
140+ onChange: (value: string) => void;
141+}) {
142+ const normalizedValue = normalizeThemePaletteColor(value) ?? fallbackColor;
143+
144+ return (
145+ <div className="grid min-w-0 gap-2">
146+ <span className="text-xs font-medium text-foreground">{label}</span>
147+ <div className="flex min-w-0 flex-wrap items-center gap-2">
148+ <input
149+ type="color"
150+ value={normalizedValue}
151+ onChange={(event) => onChange(event.target.value)}
152+ aria-label={`${label} theme color`}
153+ className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5"
154+ />
155+ <div className="flex flex-wrap gap-1.5">
156+ {THEME_PALETTE_SWATCHES.map((swatch) => {
157+ const selected = normalizedValue === swatch;
158+ return (
159+ <button
160+ key={swatch}
161+ type="button"
162+ className={cn(
163+ "size-6 cursor-pointer rounded-full border transition",
164+ selected
165+ ? "scale-110 border-foreground ring-2 ring-ring ring-offset-1 ring-offset-background"
166+ : "border-black/10 hover:scale-105 dark:border-white/20",
167+ )}
168+ style={{ backgroundColor: swatch }}
169+ onClick={() => onChange(swatch)}
170+ aria-label={`Use ${swatch} ${label.toLowerCase()} theme color`}
171+ />
172+ );
173+ })}
174+ </div>
175+ </div>
176+ </div>
177+ );
178+}
179+
180+function ThemePalettePreview({ palette }: { palette: ThemePaletteSettings }) {
181+ const previewStyle = getThemePaletteCustomProperties(palette) as CSSProperties;
182+
183+ return (
184+ <div
185+ className="mt-4 rounded-xl border bg-background p-3 text-foreground"
186+ style={previewStyle}
187+ aria-label="Generated theme palette preview"
188+ >
189+ <div className="grid gap-2 sm:grid-cols-4">
190+ {THEME_PALETTE_PREVIEW_SWATCHES.map((swatch) => (
191+ <div key={swatch.variable} className="grid gap-1.5">
192+ <span
193+ className="h-9 rounded-lg border border-border shadow-xs"
194+ style={{ backgroundColor: `var(${swatch.variable})` }}
195+ aria-hidden
196+ />
197+ <span className="text-[11px] text-muted-foreground">{swatch.label}</span>
198+ </div>
199+ ))}
200+ </div>
201+ <div className="mt-3 flex flex-wrap items-center gap-2">
202+ <span className="rounded-full bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground">
203+ Primary
204+ </span>
205+ <span className="rounded-full bg-accent px-2.5 py-1 text-xs font-medium text-accent-foreground">
206+ Accent
207+ </span>
208+ <span className="rounded-full bg-muted px-2.5 py-1 text-xs font-medium text-muted-foreground">
209+ Muted
210+ </span>
211+ </div>
212+ </div>
213+ );
214+}
215+
120216 function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) {
121217 useRelativeTimeTick();
122218 const lastCheckedRelative = lastCheckedAt ? formatRelativeTime(lastCheckedAt) : null;
@@ -347,6 +443,10 @@ export function useSettingsRestore(onRestored?: () => void) { · 224 unmodified lines
347443 const settings = useSettings();
348444 const { resetSettings } = useUpdateSettings();
349445
446+ const themePaletteDirty = !Equal.equals(
447+ settings.themePalette,
448+ DEFAULT_UNIFIED_SETTINGS.themePalette,
449+ );
350450 const isGitWritingModelDirty = !Equal.equals(
351451 settings.textGenerationModelSelection ?? null,
352452 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
@@ -380,6 +480,7 @@ export function useSettingsRestore(onRestored?: () => void) { · 27 unmodified lines
380480 const changedSettingLabels = useMemo(
381481 () => [
382482 ...(theme !== "system" ? ["Theme"] : []),
483+ ...(themePaletteDirty ? ["Theme colors"] : []),
383484 ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat
384485 ? ["Time format"]
385486 : []),
@@ -413,6 +514,7 @@ export function useSettingsRestore(onRestored?: () => void) { · 27 unmodified lines
413514 [
414515 areProviderSettingsDirty,
415516 isGitWritingModelDirty,
517+ themePaletteDirty,
416518 settings.autoOpenPlanSidebar,
417519 settings.confirmThreadArchive,
418520 settings.confirmThreadDelete,
@@ -827,6 +929,53 @@ export function GeneralSettingsPanel() { · 408 unmodified lines
827929 }
828930 />
829931
932+ <SettingsRow
933+ title="Theme colors"
934+ description="Choose brand and surface colors. T3 Code generates the palette from them."
935+ resetAction={
936+ !Equal.equals(settings.themePalette, DEFAULT_UNIFIED_SETTINGS.themePalette) ? (
937+ <SettingResetButton
938+ label="theme colors"
939+ onClick={() =>
940+ updateSettings({
941+ themePalette: DEFAULT_UNIFIED_SETTINGS.themePalette,
942+ })
943+ }
944+ />
945+ ) : null
946+ }
947+ >
948+ <div className="mt-4 grid gap-4 sm:grid-cols-2">
949+ <ThemePaletteColorControl
950+ label="Primary color"
951+ value={settings.themePalette.primaryColor}
952+ fallbackColor={DEFAULT_THEME_PALETTE.primaryColor}
953+ onChange={(primaryColor) =>
954+ updateSettings({
955+ themePalette: {
956+ ...settings.themePalette,
957+ primaryColor,
958+ },
959+ })
960+ }
961+ />
962+ <ThemePaletteColorControl
963+ label="Neutral color"
964+ value={settings.themePalette.neutralColor}
965+ fallbackColor={DEFAULT_THEME_PALETTE.neutralColor}
966+ onChange={(neutralColor) =>
967+ updateSettings({
968+ themePalette: {
969+ ...settings.themePalette,
970+ neutralColor,
971+ },
972+ })
973+ }
974+ />
975+ </div>
976+ <ThemePalettePreview palette={settings.themePalette} />
977+ </SettingsRow>
978+
830979 <SettingsRow
831980 title="Time format"
832981 description="System default follows your browser or OS clock preference."
apps/web/src/hooks/useSettings.ts+5−1
@@ -22,6 +22,7 @@ import { ensureLocalApi } from "~/localApi"; · 21 unmodified lines
2222 import { Struct } from "effect";
2323 import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
2424 import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState";
25+import { applyThemePalette } from "~/themePalette";
2526
2627 const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]";
2728
@@ -65,7 +66,9 @@ async function hydrateClientSettings(): Promise<void> { · 37 unmodified lines
6566 try {
6667 const persistedSettings = await ensureLocalApi().persistence.getClientSettings();
6768 if (persistedSettings) {
68− replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings });
69+ const nextSettings = { ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings };
70+ replaceClientSettingsSnapshot(nextSettings);
71+ applyThemePalette(nextSettings.themePalette);
6972 }
7073 } catch (error) {
7174 console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, error);
@@ -86,6 +89,7 @@ async function hydrateClientSettings(): Promise<void> { · 14 unmodified lines
8689
8790 function persistClientSettings(settings: ClientSettings): void {
8891 replaceClientSettingsSnapshot(settings);
92+ applyThemePalette(settings.themePalette);
8993 void ensureLocalApi()
9094 .persistence.setClientSettings(settings)
9195 .catch((error) => {
apps/web/src/index.css+38−36
@@ -86,28 +86,30 @@ · 85 unmodified lines
8686 :root {
8787 color-scheme: light;
8888 --radius: 0.625rem;
89− --background: var(--color-white);
89+ --theme-primary-seed: #3b5bdb;
90+ --theme-neutral-seed: #737373;
91+ --background: color-mix(in srgb, var(--theme-neutral-seed) 3%, var(--color-white));
9092 --app-chrome-background: var(--background);
91− --foreground: var(--color-neutral-800);
92− --card: var(--color-white);
93− --card-foreground: var(--color-neutral-800);
94− --popover: var(--color-white);
95− --popover-foreground: var(--color-neutral-800);
96− --primary: oklch(0.488 0.217 264);
93+ --foreground: color-mix(in srgb, var(--theme-neutral-seed) 35%, var(--color-neutral-950));
94+ --card: color-mix(in srgb, var(--theme-neutral-seed) 1.5%, var(--color-white));
95+ --card-foreground: var(--foreground);
96+ --popover: var(--card);
97+ --popover-foreground: var(--foreground);
98+ --primary: color-mix(in oklch, var(--theme-primary-seed) 88%, var(--color-black));
9799 --primary-foreground: var(--color-white);
98− --secondary: --alpha(var(--color-black) / 4%);
99− --secondary-foreground: var(--color-neutral-800);
100− --muted: --alpha(var(--color-black) / 4%);
101− --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-black));
102− --accent: --alpha(var(--color-black) / 4%);
103− --accent-foreground: var(--color-neutral-800);
100+ --secondary: color-mix(in srgb, var(--theme-neutral-seed) 7%, transparent);
101+ --secondary-foreground: var(--foreground);
102+ --muted: color-mix(in srgb, var(--theme-neutral-seed) 7%, transparent);
103+ --muted-foreground: color-mix(in srgb, var(--theme-neutral-seed) 72%, var(--color-black));
104+ --accent: color-mix(in srgb, var(--theme-primary-seed) 8%, transparent);
105+ --accent-foreground: color-mix(in srgb, var(--theme-primary-seed) 38%, var(--foreground));
104106 --destructive: var(--color-red-500);
105− --border: --alpha(var(--color-black) / 8%);
106− --input: --alpha(var(--color-black) / 10%);
107− --ring: oklch(0.488 0.217 264);
107+ --border: color-mix(in srgb, var(--theme-neutral-seed) 16%, transparent);
108+ --input: color-mix(in srgb, var(--theme-neutral-seed) 19%, transparent);
109+ --ring: color-mix(in oklch, var(--theme-primary-seed) 88%, var(--color-black));
108110 --destructive-foreground: var(--color-red-700);
109− --info: var(--color-blue-500);
110− --info-foreground: var(--color-blue-700);
111+ --info: color-mix(in oklch, var(--theme-primary-seed) 84%, var(--color-blue-500));
112+ --info-foreground: color-mix(in oklch, var(--theme-primary-seed) 80%, var(--color-blue-700));
111113 --success: var(--color-emerald-500);
112114 --success-foreground: var(--color-emerald-700);
113115 --warning: var(--color-amber-500);
@@ -115,28 +117,28 @@ · 1 unmodified line
115117
116118 @variant dark {
117119 color-scheme: dark;
118− --background: color-mix(in srgb, var(--color-neutral-950) 95%, var(--color-white));
120+ --background: color-mix(in srgb, var(--theme-neutral-seed) 10%, var(--color-neutral-950));
119121 --app-chrome-background: var(--background);
120− --foreground: var(--color-neutral-100);
121− --card: color-mix(in srgb, var(--background) 98%, var(--color-white));
122− --card-foreground: var(--color-neutral-100);
123− --popover: color-mix(in srgb, var(--background) 98%, var(--color-white));
124− --popover-foreground: var(--color-neutral-100);
125− --primary: oklch(0.588 0.217 264);
122+ --foreground: color-mix(in srgb, var(--theme-neutral-seed) 10%, var(--color-neutral-100));
123+ --card: color-mix(in srgb, var(--theme-neutral-seed) 14%, var(--background));
124+ --card-foreground: var(--foreground);
125+ --popover: var(--card);
126+ --popover-foreground: var(--foreground);
127+ --primary: color-mix(in oklch, var(--theme-primary-seed) 86%, var(--color-white));
126128 --primary-foreground: var(--color-white);
127− --secondary: --alpha(var(--color-white) / 4%);
128− --secondary-foreground: var(--color-neutral-100);
129− --muted: --alpha(var(--color-white) / 4%);
130− --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white));
131− --accent: --alpha(var(--color-white) / 4%);
132− --accent-foreground: var(--color-neutral-100);
129+ --secondary: color-mix(in srgb, var(--theme-neutral-seed) 13%, transparent);
130+ --secondary-foreground: var(--foreground);
131+ --muted: color-mix(in srgb, var(--theme-neutral-seed) 13%, transparent);
132+ --muted-foreground: color-mix(in srgb, var(--theme-neutral-seed) 58%, var(--color-neutral-100));
133+ --accent: color-mix(in srgb, var(--theme-primary-seed) 16%, transparent);
134+ --accent-foreground: color-mix(in srgb, var(--theme-primary-seed) 20%, var(--foreground));
133135 --destructive: color-mix(in srgb, var(--color-red-500) 90%, var(--color-white));
134− --border: --alpha(var(--color-white) / 6%);
135− --input: --alpha(var(--color-white) / 8%);
136− --ring: oklch(0.588 0.217 264);
136+ --border: color-mix(in srgb, var(--theme-neutral-seed) 16%, transparent);
137+ --input: color-mix(in srgb, var(--theme-neutral-seed) 20%, transparent);
138+ --ring: color-mix(in oklch, var(--theme-primary-seed) 86%, var(--color-white));
137139 --destructive-foreground: var(--color-red-400);
138− --info: var(--color-blue-500);
139− --info-foreground: var(--color-blue-400);
140+ --info: color-mix(in oklch, var(--theme-primary-seed) 84%, var(--color-blue-400));
141+ --info-foreground: color-mix(in oklch, var(--theme-primary-seed) 70%, var(--color-blue-300));
140142 --success: var(--color-emerald-500);
141143 --success-foreground: var(--color-emerald-400);
142144 --warning: var(--color-amber-500);
apps/web/src/localApi.test.ts+3−0
@@ -1,6 +1,7 @@
11 import {
22 CommandId,
33 DEFAULT_SERVER_SETTINGS,
4+ DEFAULT_THEME_PALETTE,
45 type DesktopBridge,
56 EnvironmentId,
67 type VcsStatusResult,
@@ -584,6 +585,7 @@ describe("wsApi", () => { · 577 unmodified lines
584585 sidebarProjectSortOrder: "manual" as const,
585586 sidebarThreadSortOrder: "created_at" as const,
586587 timestampFormat: "24-hour" as const,
588+ themePalette: DEFAULT_THEME_PALETTE,
587589 };
588590 const getClientSettings = vi.fn().mockResolvedValue({
589591 ...clientSettings,
@@ -645,6 +647,7 @@ describe("wsApi", () => { · 55 unmodified lines
645647 sidebarProjectSortOrder: "manual" as const,
646648 sidebarThreadSortOrder: "created_at" as const,
647649 timestampFormat: "24-hour" as const,
650+ themePalette: DEFAULT_THEME_PALETTE,
648651 };
649652
650653 await api.persistence.setClientSettings(clientSettings);
apps/web/src/main.tsx+3−0
@@ -10,12 +10,15 @@ import { isElectron } from "./env"; · 9 unmodified lines
1010 import { getRouter } from "./router";
1111 import { APP_DISPLAY_NAME } from "./branding";
1212 import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay";
13+import { applyStoredThemePalette } from "./themePalette";
1314
1415 // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues.
1516 const history = isElectron ? createHashHistory() : createBrowserHistory();
1617
1718 const router = getRouter(history);
1819
20+applyStoredThemePalette();
21+
1922 if (isElectron) {
2023 syncDocumentWindowControlsOverlayClass();
2124 }
apps/web/src/themePalette.test.ts+37−0
@@ -0,0 +1,37 @@
1+import { describe, expect, it } from "vitest";
2+import { DEFAULT_THEME_PALETTE } from "@t3tools/contracts/settings";
3+
4+import {
5+ getThemePaletteCustomProperties,
6+ normalizeThemePalette,
7+ normalizeThemePaletteColor,
8+} from "./themePalette";
9+
10+describe("themePalette", () => {
11+ it("normalizes valid hex colors and rejects invalid input", () => {
12+ expect(normalizeThemePaletteColor(" #Aa33f0 ")).toBe("#aa33f0");
13+ expect(normalizeThemePaletteColor("#abc")).toBeUndefined();
14+ expect(normalizeThemePaletteColor("rebeccapurple")).toBeUndefined();
15+ });
16+
17+ it("generates CSS seed variables from selected colors", () => {
18+ const properties = getThemePaletteCustomProperties({
19+ primaryColor: "#ff0000",
20+ neutralColor: "#336699",
21+ });
22+
23+ expect(properties).toEqual({
24+ "--theme-primary-seed": "#ff0000",
25+ "--theme-neutral-seed": "#336699",
26+ });
27+ });
28+
29+ it("falls back to the default palette when persisted values are malformed", () => {
30+ const palette = normalizeThemePalette({
31+ primaryColor: "bad",
32+ neutralColor: "also-bad",
33+ });
34+
35+ expect(palette).toEqual(DEFAULT_THEME_PALETTE);
36+ });
37+});
apps/web/src/themePalette.ts+82−0
@@ -0,0 +1,82 @@
1+import { DEFAULT_THEME_PALETTE, type ThemePaletteSettings } from "@t3tools/contracts/settings";
2+
3+export const THEME_PALETTE_STORAGE_KEY = "t3code:client-settings:v1";
4+
5+export const THEME_PALETTE_SWATCHES = [
6+ "#3b5bdb",
7+ "#7c3aed",
8+ "#db2777",
9+ "#dc2626",
10+ "#ea580c",
11+ "#ca8a04",
12+ "#16a34a",
13+ "#0891b2",
14+] as const;
15+
16+export const THEME_PALETTE_PREVIEW_SWATCHES = [
17+ { label: "Primary", variable: "--primary" },
18+ { label: "Surface", variable: "--background" },
19+ { label: "Card", variable: "--card" },
20+ { label: "Muted", variable: "--muted" },
21+] as const;
22+
23+const THEME_HEX_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/u;
24+const THEME_PALETTE_CUSTOM_PROPERTIES = ["--theme-primary-seed", "--theme-neutral-seed"] as const;
25+
26+export function normalizeThemePaletteColor(value: string | undefined | null): string | undefined {
27+ const trimmed = value?.trim();
28+ if (!trimmed || !THEME_HEX_COLOR_PATTERN.test(trimmed)) {
29+ return undefined;
30+ }
31+ return trimmed.toLowerCase();
32+}
33+
34+export function normalizeThemePalette(
35+ value: Partial<ThemePaletteSettings> | null | undefined,
36+): ThemePaletteSettings {
37+ return {
38+ primaryColor:
39+ normalizeThemePaletteColor(value?.primaryColor) ?? DEFAULT_THEME_PALETTE.primaryColor,
40+ neutralColor:
41+ normalizeThemePaletteColor(value?.neutralColor) ?? DEFAULT_THEME_PALETTE.neutralColor,
42+ };
43+}
44+
45+export function getThemePaletteCustomProperties(
46+ value: Partial<ThemePaletteSettings> | null | undefined,
47+) {
48+ const palette = normalizeThemePalette(value);
49+ return {
50+ "--theme-primary-seed": palette.primaryColor,
51+ "--theme-neutral-seed": palette.neutralColor,
52+ } as const;
53+}
54+
55+export function applyThemePalette(value: Partial<ThemePaletteSettings> | null | undefined) {
56+ if (typeof document === "undefined") return;
57+ const properties = getThemePaletteCustomProperties(value);
58+ for (const propertyName of THEME_PALETTE_CUSTOM_PROPERTIES) {
59+ document.documentElement.style.setProperty(propertyName, properties[propertyName]);
60+ }
61+}
62+
63+export function readStoredThemePalette(): ThemePaletteSettings {
64+ if (typeof window === "undefined" || typeof localStorage === "undefined") {
65+ return DEFAULT_THEME_PALETTE;
66+ }
67+
68+ try {
69+ const raw = localStorage.getItem(THEME_PALETTE_STORAGE_KEY);
70+ if (!raw) {
71+ return DEFAULT_THEME_PALETTE;
72+ }
73+ const parsed = JSON.parse(raw) as { themePalette?: Partial<ThemePaletteSettings> };
74+ return normalizeThemePalette(parsed.themePalette);
75+ } catch {
76+ return DEFAULT_THEME_PALETTE;
77+ }
78+}
79+
80+export function applyStoredThemePalette() {
81+ applyThemePalette(readStoredThemePalette());
82+}
packages/contracts/src/settings.test.ts+27−1
@@ -2,10 +2,36 @@ import { describe, expect, it } from "vitest"; · 1 unmodified line
22 import { Schema } from "effect";
33
44 import { ProviderInstanceId } from "./providerInstance.ts";
5−import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./settings.ts";
5+import {
6+ ClientSettingsSchema,
7+ DEFAULT_CLIENT_SETTINGS,
8+ DEFAULT_SERVER_SETTINGS,
9+ DEFAULT_THEME_PALETTE,
10+ ServerSettings,
11+ ServerSettingsPatch,
12+} from "./settings.ts";
613
714 const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings);
815 const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
16+const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema);
17+
18+describe("ClientSettings.themePalette", () => {
19+ it("defaults theme palette colors for existing client settings", () => {
20+ expect(DEFAULT_CLIENT_SETTINGS.themePalette).toEqual(DEFAULT_THEME_PALETTE);
21+ expect(decodeClientSettings({}).themePalette).toEqual(DEFAULT_THEME_PALETTE);
22+ });
23+
24+ it("rejects invalid theme palette colors", () => {
25+ expect(() =>
26+ decodeClientSettings({
27+ themePalette: {
28+ primaryColor: "rebeccapurple",
29+ neutralColor: "#737373",
30+ },
31+ }),
32+ ).toThrow();
33+ });
34+});
935
1036 describe("ServerSettings.providerInstances (slice-2 invariant)", () => {
1137 it("defaults to an empty record so legacy configs without the key still decode", () => {
packages/contracts/src/settings.ts+19−0
@@ -28,6 +28,23 @@ export const SidebarProjectGroupingMode = Schema.Literals([ · 27 unmodified lines
2828 export type SidebarProjectGroupingMode = typeof SidebarProjectGroupingMode.Type;
2929 export const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository";
3030
31+const ThemeColor = TrimmedNonEmptyString.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/u));
32+
33+export const DEFAULT_THEME_PALETTE = {
34+ primaryColor: "#3b5bdb",
35+ neutralColor: "#737373",
36+} as const;
37+
38+export const ThemePaletteSettings = Schema.Struct({
39+ primaryColor: ThemeColor.pipe(
40+ Schema.withDecodingDefault(Effect.succeed(DEFAULT_THEME_PALETTE.primaryColor)),
41+ ),
42+ neutralColor: ThemeColor.pipe(
43+ Schema.withDecodingDefault(Effect.succeed(DEFAULT_THEME_PALETTE.neutralColor)),
44+ ),
45+}).pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_THEME_PALETTE)));
46+export type ThemePaletteSettings = typeof ThemePaletteSettings.Type;
47+
3148 export const ClientSettingsSchema = Schema.Struct({
3249 autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
3350 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
@@ -75,6 +92,7 @@ export const ClientSettingsSchema = Schema.Struct({ · 41 unmodified lines
7592 timestampFormat: TimestampFormat.pipe(
7693 Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)),
7794 ),
95+ themePalette: ThemePaletteSettings,
7896 });
7997 export type ClientSettings = typeof ClientSettingsSchema.Type;
8098
@@ -482,5 +500,6 @@ export const ClientSettingsPatch = Schema.Struct({ · 401 unmodified lines
482500 sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder),
483501 sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder),
484502 timestampFormat: Schema.optionalKey(TimestampFormat),
503+ themePalette: Schema.optionalKey(ThemePaletteSettings),
485504 });
486505 export type ClientSettingsPatch = typeof ClientSettingsPatch.Type;

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t