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
3import * as path from "node:path";3import * as path from "node:path";
4 4
5import {5import {
6 DEFAULT_THEME_PALETTE,
6 EnvironmentId,7 EnvironmentId,
7 type ClientSettings,8 type ClientSettings,
8 type PersistedSavedEnvironmentRecord,9 type PersistedSavedEnvironmentRecord,
@@ -63,6 +64,7 @@ const clientSettings: ClientSettings = { · 54 unmodified lines
63 sidebarProjectSortOrder: "manual",64 sidebarProjectSortOrder: "manual",
64 sidebarThreadSortOrder: "created_at",65 sidebarThreadSortOrder: "created_at",
65 timestampFormat: "24-hour",66 timestampFormat: "24-hour",
67 themePalette: DEFAULT_THEME_PALETTE,
66};68};
67 69
68const savedRegistryRecord: PersistedSavedEnvironmentRecord = {70const savedRegistryRecord: PersistedSavedEnvironmentRecord = {
apps/web/index.html+14−0
@@ -25,6 +25,20 @@ · 24 unmodified lines
25 const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;25 const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
26 const isDark = theme === "dark" || (theme === "system" && prefersDark);26 const isDark = theme === "dark" || (theme === "system" && prefersDark);
27 document.documentElement.classList.toggle("dark", isDark);27 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 }
28 const chromeColor = isDark ? DARK_BACKGROUND : LIGHT_BACKGROUND;42 const chromeColor = isDark ? DARK_BACKGROUND : LIGHT_BACKGROUND;
29 document.documentElement.style.backgroundColor = chromeColor;43 document.documentElement.style.backgroundColor = chromeColor;
30 themeColorMeta?.setAttribute("content", chromeColor);44 themeColorMeta?.setAttribute("content", chromeColor);
apps/web/src/clientPersistenceStorage.test.ts+46−1
@@ -1,4 +1,9 @@
1import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts";1import {
2 DEFAULT_CLIENT_SETTINGS,
3 DEFAULT_THEME_PALETTE,
4 EnvironmentId,
5 type PersistedSavedEnvironmentRecord,
6} from "@t3tools/contracts";
2import { afterEach, describe, expect, it, vi } from "vitest";7import { afterEach, describe, expect, it, vi } from "vitest";
3 8
4const testEnvironmentId = EnvironmentId.make("environment-1");9const testEnvironmentId = EnvironmentId.make("environment-1");
@@ -55,6 +60,46 @@ afterEach(() => { · 50 unmodified lines
55});60});
56 61
57describe("clientPersistenceStorage", () => {62describe("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
58 it("stores browser secrets inline with the saved environment record", async () => {103 it("stores browser secrets inline with the saved environment record", async () => {
59 const testWindow = getTestWindow();104 const testWindow = getTestWindow();
60 const {105 const {
apps/web/src/clientPersistenceStorage.ts+14−0
@@ -1,5 +1,6 @@
1import {1import {
2 ClientSettingsSchema,2 ClientSettingsSchema,
3 DEFAULT_THEME_PALETTE,
3 EnvironmentId,4 EnvironmentId,
4 type ClientSettings,5 type ClientSettings,
5 type EnvironmentId as EnvironmentIdValue,6 type EnvironmentId as EnvironmentIdValue,
@@ -12,6 +13,19 @@ import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorag · 6 unmodified lines
12export const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1";13export const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1";
13export const SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY = "t3code:saved-environment-registry:v1";14export const SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY = "t3code:saved-environment-registry:v1";
14 15
16export 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
15const BrowserSavedEnvironmentRecordSchema = Schema.Struct({29const BrowserSavedEnvironmentRecordSchema = Schema.Struct({
16 environmentId: EnvironmentId,30 environmentId: EnvironmentId,
17 label: Schema.String,31 label: Schema.String,
apps/web/src/components/settings/SettingsPanels.tsx+151−2
@@ -1,6 +1,6 @@
1import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react";1import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react";
2import { useQueryClient } from "@tanstack/react-query";2import { useQueryClient } from "@tanstack/react-query";
3import { useCallback, useMemo, useRef, useState } from "react";3import { type CSSProperties, useCallback, useMemo, useRef, useState } from "react";
4import {4import {
5 defaultInstanceIdForDriver,5 defaultInstanceIdForDriver,
6 type DesktopUpdateChannel,6 type DesktopUpdateChannel,
@@ -10,7 +10,11 @@ import { · 3 unmodified lines
10 type ScopedThreadRef,10 type ScopedThreadRef,
11} from "@t3tools/contracts";11} from "@t3tools/contracts";
12import { scopeThreadRef } from "@t3tools/client-runtime";12import { scopeThreadRef } from "@t3tools/client-runtime";
13import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";13import {
14 DEFAULT_THEME_PALETTE,
15 DEFAULT_UNIFIED_SETTINGS,
16 type ThemePaletteSettings,
17} from "@t3tools/contracts/settings";
14import { createModelSelection } from "@t3tools/shared/model";18import { createModelSelection } from "@t3tools/shared/model";
15import { Equal } from "effect";19import { Equal } from "effect";
16import { APP_VERSION } from "../../branding";20import { APP_VERSION } from "../../branding";
@@ -40,7 +44,14 @@ import { · 23 unmodified lines
40 deriveProviderInstanceEntries,44 deriveProviderInstanceEntries,
41 sortProviderInstanceEntries,45 sortProviderInstanceEntries,
42} from "../../providerInstances";46} from "../../providerInstances";
47import {
48 getThemePaletteCustomProperties,
49 normalizeThemePaletteColor,
50 THEME_PALETTE_PREVIEW_SWATCHES,
51 THEME_PALETTE_SWATCHES,
52} from "../../themePalette";
43import { ensureLocalApi, readLocalApi } from "../../localApi";53import { ensureLocalApi, readLocalApi } from "../../localApi";
54import { cn } from "../../lib/utils";
44import { useShallow } from "zustand/react/shallow";55import { useShallow } from "zustand/react/shallow";
45import {56import {
46 selectProjectsAcrossEnvironments,57 selectProjectsAcrossEnvironments,
@@ -117,6 +128,91 @@ const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({ · 70 unmodified lines
117 provider: definition.value,128 provider: definition.value,
118}));129}));
119 130
131function 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
180function 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
120function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) {216function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) {
121 useRelativeTimeTick();217 useRelativeTimeTick();
122 const lastCheckedRelative = lastCheckedAt ? formatRelativeTime(lastCheckedAt) : null;218 const lastCheckedRelative = lastCheckedAt ? formatRelativeTime(lastCheckedAt) : null;
@@ -347,6 +443,10 @@ export function useSettingsRestore(onRestored?: () => void) { · 224 unmodified lines
347 const settings = useSettings();443 const settings = useSettings();
348 const { resetSettings } = useUpdateSettings();444 const { resetSettings } = useUpdateSettings();
349 445
446 const themePaletteDirty = !Equal.equals(
447 settings.themePalette,
448 DEFAULT_UNIFIED_SETTINGS.themePalette,
449 );
350 const isGitWritingModelDirty = !Equal.equals(450 const isGitWritingModelDirty = !Equal.equals(
351 settings.textGenerationModelSelection ?? null,451 settings.textGenerationModelSelection ?? null,
352 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,452 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
@@ -380,6 +480,7 @@ export function useSettingsRestore(onRestored?: () => void) { · 27 unmodified lines
380 const changedSettingLabels = useMemo(480 const changedSettingLabels = useMemo(
381 () => [481 () => [
382 ...(theme !== "system" ? ["Theme"] : []),482 ...(theme !== "system" ? ["Theme"] : []),
483 ...(themePaletteDirty ? ["Theme colors"] : []),
383 ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat484 ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat
384 ? ["Time format"]485 ? ["Time format"]
385 : []),486 : []),
@@ -413,6 +514,7 @@ export function useSettingsRestore(onRestored?: () => void) { · 27 unmodified lines
413 [514 [
414 areProviderSettingsDirty,515 areProviderSettingsDirty,
415 isGitWritingModelDirty,516 isGitWritingModelDirty,
517 themePaletteDirty,
416 settings.autoOpenPlanSidebar,518 settings.autoOpenPlanSidebar,
417 settings.confirmThreadArchive,519 settings.confirmThreadArchive,
418 settings.confirmThreadDelete,520 settings.confirmThreadDelete,
@@ -827,6 +929,53 @@ export function GeneralSettingsPanel() { · 408 unmodified lines
827 }929 }
828 />930 />
829 931
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
830 <SettingsRow979 <SettingsRow
831 title="Time format"980 title="Time format"
832 description="System default follows your browser or OS clock preference."981 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
22import { Struct } from "effect";22import { Struct } from "effect";
23import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";23import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
24import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState";24import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState";
25import { applyThemePalette } from "~/themePalette";
25 26
26const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]";27const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]";
27 28
@@ -65,7 +66,9 @@ async function hydrateClientSettings(): Promise<void> { · 37 unmodified lines
65 try {66 try {
66 const persistedSettings = await ensureLocalApi().persistence.getClientSettings();67 const persistedSettings = await ensureLocalApi().persistence.getClientSettings();
67 if (persistedSettings) {68 if (persistedSettings) {
68 replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings });69 const nextSettings = { ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings };
70 replaceClientSettingsSnapshot(nextSettings);
71 applyThemePalette(nextSettings.themePalette);
69 }72 }
70 } catch (error) {73 } catch (error) {
71 console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, error);74 console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, error);
@@ -86,6 +89,7 @@ async function hydrateClientSettings(): Promise<void> { · 14 unmodified lines
86 89
87function persistClientSettings(settings: ClientSettings): void {90function persistClientSettings(settings: ClientSettings): void {
88 replaceClientSettingsSnapshot(settings);91 replaceClientSettingsSnapshot(settings);
92 applyThemePalette(settings.themePalette);
89 void ensureLocalApi()93 void ensureLocalApi()
90 .persistence.setClientSettings(settings)94 .persistence.setClientSettings(settings)
91 .catch((error) => {95 .catch((error) => {
apps/web/src/index.css+38−36
@@ -86,28 +86,30 @@ · 85 unmodified lines
86:root {86:root {
87 color-scheme: light;87 color-scheme: light;
88 --radius: 0.625rem;88 --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));
90 --app-chrome-background: var(--background);92 --app-chrome-background: var(--background);
91 --foreground: var(--color-neutral-800);93 --foreground: color-mix(in srgb, var(--theme-neutral-seed) 35%, var(--color-neutral-950));
92 --card: var(--color-white);94 --card: color-mix(in srgb, var(--theme-neutral-seed) 1.5%, var(--color-white));
93 --card-foreground: var(--color-neutral-800);95 --card-foreground: var(--foreground);
94 --popover: var(--color-white);96 --popover: var(--card);
95 --popover-foreground: var(--color-neutral-800);97 --popover-foreground: var(--foreground);
96 --primary: oklch(0.488 0.217 264);98 --primary: color-mix(in oklch, var(--theme-primary-seed) 88%, var(--color-black));
97 --primary-foreground: var(--color-white);99 --primary-foreground: var(--color-white);
98 --secondary: --alpha(var(--color-black) / 4%);100 --secondary: color-mix(in srgb, var(--theme-neutral-seed) 7%, transparent);
99 --secondary-foreground: var(--color-neutral-800);101 --secondary-foreground: var(--foreground);
100 --muted: --alpha(var(--color-black) / 4%);102 --muted: color-mix(in srgb, var(--theme-neutral-seed) 7%, transparent);
101 --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-black));103 --muted-foreground: color-mix(in srgb, var(--theme-neutral-seed) 72%, var(--color-black));
102 --accent: --alpha(var(--color-black) / 4%);104 --accent: color-mix(in srgb, var(--theme-primary-seed) 8%, transparent);
103 --accent-foreground: var(--color-neutral-800);105 --accent-foreground: color-mix(in srgb, var(--theme-primary-seed) 38%, var(--foreground));
104 --destructive: var(--color-red-500);106 --destructive: var(--color-red-500);
105 --border: --alpha(var(--color-black) / 8%);107 --border: color-mix(in srgb, var(--theme-neutral-seed) 16%, transparent);
106 --input: --alpha(var(--color-black) / 10%);108 --input: color-mix(in srgb, var(--theme-neutral-seed) 19%, transparent);
107 --ring: oklch(0.488 0.217 264);109 --ring: color-mix(in oklch, var(--theme-primary-seed) 88%, var(--color-black));
108 --destructive-foreground: var(--color-red-700);110 --destructive-foreground: var(--color-red-700);
109 --info: var(--color-blue-500);111 --info: color-mix(in oklch, var(--theme-primary-seed) 84%, var(--color-blue-500));
110 --info-foreground: var(--color-blue-700);112 --info-foreground: color-mix(in oklch, var(--theme-primary-seed) 80%, var(--color-blue-700));
111 --success: var(--color-emerald-500);113 --success: var(--color-emerald-500);
112 --success-foreground: var(--color-emerald-700);114 --success-foreground: var(--color-emerald-700);
113 --warning: var(--color-amber-500);115 --warning: var(--color-amber-500);
@@ -115,28 +117,28 @@ · 1 unmodified line
115 117
116 @variant dark {118 @variant dark {
117 color-scheme: dark;119 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));
119 --app-chrome-background: var(--background);121 --app-chrome-background: var(--background);
120 --foreground: var(--color-neutral-100);122 --foreground: color-mix(in srgb, var(--theme-neutral-seed) 10%, var(--color-neutral-100));
121 --card: color-mix(in srgb, var(--background) 98%, var(--color-white));123 --card: color-mix(in srgb, var(--theme-neutral-seed) 14%, var(--background));
122 --card-foreground: var(--color-neutral-100);124 --card-foreground: var(--foreground);
123 --popover: color-mix(in srgb, var(--background) 98%, var(--color-white));125 --popover: var(--card);
124 --popover-foreground: var(--color-neutral-100);126 --popover-foreground: var(--foreground);
125 --primary: oklch(0.588 0.217 264);127 --primary: color-mix(in oklch, var(--theme-primary-seed) 86%, var(--color-white));
126 --primary-foreground: var(--color-white);128 --primary-foreground: var(--color-white);
127 --secondary: --alpha(var(--color-white) / 4%);129 --secondary: color-mix(in srgb, var(--theme-neutral-seed) 13%, transparent);
128 --secondary-foreground: var(--color-neutral-100);130 --secondary-foreground: var(--foreground);
129 --muted: --alpha(var(--color-white) / 4%);131 --muted: color-mix(in srgb, var(--theme-neutral-seed) 13%, transparent);
130 --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white));132 --muted-foreground: color-mix(in srgb, var(--theme-neutral-seed) 58%, var(--color-neutral-100));
131 --accent: --alpha(var(--color-white) / 4%);133 --accent: color-mix(in srgb, var(--theme-primary-seed) 16%, transparent);
132 --accent-foreground: var(--color-neutral-100);134 --accent-foreground: color-mix(in srgb, var(--theme-primary-seed) 20%, var(--foreground));
133 --destructive: color-mix(in srgb, var(--color-red-500) 90%, var(--color-white));135 --destructive: color-mix(in srgb, var(--color-red-500) 90%, var(--color-white));
134 --border: --alpha(var(--color-white) / 6%);136 --border: color-mix(in srgb, var(--theme-neutral-seed) 16%, transparent);
135 --input: --alpha(var(--color-white) / 8%);137 --input: color-mix(in srgb, var(--theme-neutral-seed) 20%, transparent);
136 --ring: oklch(0.588 0.217 264);138 --ring: color-mix(in oklch, var(--theme-primary-seed) 86%, var(--color-white));
137 --destructive-foreground: var(--color-red-400);139 --destructive-foreground: var(--color-red-400);
138 --info: var(--color-blue-500);140 --info: color-mix(in oklch, var(--theme-primary-seed) 84%, var(--color-blue-400));
139 --info-foreground: var(--color-blue-400);141 --info-foreground: color-mix(in oklch, var(--theme-primary-seed) 70%, var(--color-blue-300));
140 --success: var(--color-emerald-500);142 --success: var(--color-emerald-500);
141 --success-foreground: var(--color-emerald-400);143 --success-foreground: var(--color-emerald-400);
142 --warning: var(--color-amber-500);144 --warning: var(--color-amber-500);
apps/web/src/localApi.test.ts+3−0
@@ -1,6 +1,7 @@
1import {1import {
2 CommandId,2 CommandId,
3 DEFAULT_SERVER_SETTINGS,3 DEFAULT_SERVER_SETTINGS,
4 DEFAULT_THEME_PALETTE,
4 type DesktopBridge,5 type DesktopBridge,
5 EnvironmentId,6 EnvironmentId,
6 type VcsStatusResult,7 type VcsStatusResult,
@@ -584,6 +585,7 @@ describe("wsApi", () => { · 577 unmodified lines
584 sidebarProjectSortOrder: "manual" as const,585 sidebarProjectSortOrder: "manual" as const,
585 sidebarThreadSortOrder: "created_at" as const,586 sidebarThreadSortOrder: "created_at" as const,
586 timestampFormat: "24-hour" as const,587 timestampFormat: "24-hour" as const,
588 themePalette: DEFAULT_THEME_PALETTE,
587 };589 };
588 const getClientSettings = vi.fn().mockResolvedValue({590 const getClientSettings = vi.fn().mockResolvedValue({
589 ...clientSettings,591 ...clientSettings,
@@ -645,6 +647,7 @@ describe("wsApi", () => { · 55 unmodified lines
645 sidebarProjectSortOrder: "manual" as const,647 sidebarProjectSortOrder: "manual" as const,
646 sidebarThreadSortOrder: "created_at" as const,648 sidebarThreadSortOrder: "created_at" as const,
647 timestampFormat: "24-hour" as const,649 timestampFormat: "24-hour" as const,
650 themePalette: DEFAULT_THEME_PALETTE,
648 };651 };
649 652
650 await api.persistence.setClientSettings(clientSettings);653 await api.persistence.setClientSettings(clientSettings);
apps/web/src/main.tsx+3−0
@@ -10,12 +10,15 @@ import { isElectron } from "./env"; · 9 unmodified lines
10import { getRouter } from "./router";10import { getRouter } from "./router";
11import { APP_DISPLAY_NAME } from "./branding";11import { APP_DISPLAY_NAME } from "./branding";
12import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay";12import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay";
13import { applyStoredThemePalette } from "./themePalette";
13 14
14// Electron loads the app from a file-backed shell, so hash history avoids path resolution issues.15// Electron loads the app from a file-backed shell, so hash history avoids path resolution issues.
15const history = isElectron ? createHashHistory() : createBrowserHistory();16const history = isElectron ? createHashHistory() : createBrowserHistory();
16 17
17const router = getRouter(history);18const router = getRouter(history);
18 19
20applyStoredThemePalette();
21
19if (isElectron) {22if (isElectron) {
20 syncDocumentWindowControlsOverlayClass();23 syncDocumentWindowControlsOverlayClass();
21}24}
apps/web/src/themePalette.test.ts+37−0
@@ -0,0 +1,37 @@
1import { describe, expect, it } from "vitest";
2import { DEFAULT_THEME_PALETTE } from "@t3tools/contracts/settings";
3
4import {
5 getThemePaletteCustomProperties,
6 normalizeThemePalette,
7 normalizeThemePaletteColor,
8} from "./themePalette";
9
10describe("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 @@
1import { DEFAULT_THEME_PALETTE, type ThemePaletteSettings } from "@t3tools/contracts/settings";
2
3export const THEME_PALETTE_STORAGE_KEY = "t3code:client-settings:v1";
4
5export const THEME_PALETTE_SWATCHES = [
6 "#3b5bdb",
7 "#7c3aed",
8 "#db2777",
9 "#dc2626",
10 "#ea580c",
11 "#ca8a04",
12 "#16a34a",
13 "#0891b2",
14] as const;
15
16export 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
23const THEME_HEX_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/u;
24const THEME_PALETTE_CUSTOM_PROPERTIES = ["--theme-primary-seed", "--theme-neutral-seed"] as const;
25
26export 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
34export 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
45export 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
55export 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
63export 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
80export 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
2import { Schema } from "effect";2import { Schema } from "effect";
3 3
4import { ProviderInstanceId } from "./providerInstance.ts";4import { ProviderInstanceId } from "./providerInstance.ts";
5import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./settings.ts";5import {
6 ClientSettingsSchema,
7 DEFAULT_CLIENT_SETTINGS,
8 DEFAULT_SERVER_SETTINGS,
9 DEFAULT_THEME_PALETTE,
10 ServerSettings,
11 ServerSettingsPatch,
12} from "./settings.ts";
6 13
7const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings);14const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings);
8const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);15const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
16const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema);
17
18describe("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});
9 35
10describe("ServerSettings.providerInstances (slice-2 invariant)", () => {36describe("ServerSettings.providerInstances (slice-2 invariant)", () => {
11 it("defaults to an empty record so legacy configs without the key still decode", () => {37 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
28export type SidebarProjectGroupingMode = typeof SidebarProjectGroupingMode.Type;28export type SidebarProjectGroupingMode = typeof SidebarProjectGroupingMode.Type;
29export const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository";29export const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository";
30 30
31const ThemeColor = TrimmedNonEmptyString.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/u));
32
33export const DEFAULT_THEME_PALETTE = {
34 primaryColor: "#3b5bdb",
35 neutralColor: "#737373",
36} as const;
37
38export 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)));
46export type ThemePaletteSettings = typeof ThemePaletteSettings.Type;
47
31export const ClientSettingsSchema = Schema.Struct({48export const ClientSettingsSchema = Schema.Struct({
32 autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),49 autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
33 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),50 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
@@ -75,6 +92,7 @@ export const ClientSettingsSchema = Schema.Struct({ · 41 unmodified lines
75 timestampFormat: TimestampFormat.pipe(92 timestampFormat: TimestampFormat.pipe(
76 Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)),93 Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)),
77 ),94 ),
95 themePalette: ThemePaletteSettings,
78});96});
79export type ClientSettings = typeof ClientSettingsSchema.Type;97export type ClientSettings = typeof ClientSettingsSchema.Type;
80 98
@@ -482,5 +500,6 @@ export const ClientSettingsPatch = Schema.Struct({ · 401 unmodified lines
482 sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder),500 sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder),
483 sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder),501 sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder),
484 timestampFormat: Schema.optionalKey(TimestampFormat),502 timestampFormat: Schema.optionalKey(TimestampFormat),
503 themePalette: Schema.optionalKey(ThemePaletteSettings),
485});504});
486export type ClientSettingsPatch = typeof ClientSettingsPatch.Type;505export 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