Unified · Split
| @@ -3,6 +3,7 @@ import * as os from "node:os"; · 2 unmodified lines | ||
| 3 | 3 | import * as path from "node:path"; |
| 4 | 4 | |
| 5 | 5 | import { |
| 6 | + DEFAULT_THEME_PALETTE, | |
| 6 | 7 | EnvironmentId, |
| 7 | 8 | type ClientSettings, |
| 8 | 9 | type PersistedSavedEnvironmentRecord, |
| @@ -63,6 +64,7 @@ const clientSettings: ClientSettings = { · 54 unmodified lines | ||
| 63 | 64 | sidebarProjectSortOrder: "manual", |
| 64 | 65 | sidebarThreadSortOrder: "created_at", |
| 65 | 66 | timestampFormat: "24-hour", |
| 67 | + themePalette: DEFAULT_THEME_PALETTE, | |
| 66 | 68 | }; |
| 67 | 69 | |
| 68 | 70 | const savedRegistryRecord: PersistedSavedEnvironmentRecord = { |
| @@ -25,6 +25,20 @@ · 24 unmodified lines | ||
| 25 | 25 | const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; |
| 26 | 26 | const isDark = theme === "dark" || (theme === "system" && prefersDark); |
| 27 | 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 | 42 | const chromeColor = isDark ? DARK_BACKGROUND : LIGHT_BACKGROUND; |
| 29 | 43 | document.documentElement.style.backgroundColor = chromeColor; |
| 30 | 44 | themeColorMeta?.setAttribute("content", chromeColor); |
| @@ -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"; | |
| 2 | 7 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| 3 | 8 | |
| 4 | 9 | const testEnvironmentId = EnvironmentId.make("environment-1"); |
| @@ -55,6 +60,46 @@ afterEach(() => { · 50 unmodified lines | ||
| 55 | 60 | }); |
| 56 | 61 | |
| 57 | 62 | 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 | + | |
| 58 | 103 | it("stores browser secrets inline with the saved environment record", async () => { |
| 59 | 104 | const testWindow = getTestWindow(); |
| 60 | 105 | const { |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { |
| 2 | 2 | ClientSettingsSchema, |
| 3 | + DEFAULT_THEME_PALETTE, | |
| 3 | 4 | EnvironmentId, |
| 4 | 5 | type ClientSettings, |
| 5 | 6 | type EnvironmentId as EnvironmentIdValue, |
| @@ -12,6 +13,19 @@ import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorag · 6 unmodified lines | ||
| 12 | 13 | export const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; |
| 13 | 14 | export const SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY = "t3code:saved-environment-registry:v1"; |
| 14 | 15 | |
| 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 | + | |
| 15 | 29 | const BrowserSavedEnvironmentRecordSchema = Schema.Struct({ |
| 16 | 30 | environmentId: EnvironmentId, |
| 17 | 31 | label: Schema.String, |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; |
| 2 | 2 | import { useQueryClient } from "@tanstack/react-query"; |
| 3 | −import { useCallback, useMemo, useRef, useState } from "react"; | |
| 3 | +import { type CSSProperties, useCallback, useMemo, useRef, useState } from "react"; | |
| 4 | 4 | import { |
| 5 | 5 | defaultInstanceIdForDriver, |
| 6 | 6 | type DesktopUpdateChannel, |
| @@ -10,7 +10,11 @@ import { · 3 unmodified lines | ||
| 10 | 10 | type ScopedThreadRef, |
| 11 | 11 | } from "@t3tools/contracts"; |
| 12 | 12 | 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"; | |
| 14 | 18 | import { createModelSelection } from "@t3tools/shared/model"; |
| 15 | 19 | import { Equal } from "effect"; |
| 16 | 20 | import { APP_VERSION } from "../../branding"; |
| @@ -40,7 +44,14 @@ import { · 23 unmodified lines | ||
| 40 | 44 | deriveProviderInstanceEntries, |
| 41 | 45 | sortProviderInstanceEntries, |
| 42 | 46 | } from "../../providerInstances"; |
| 47 | +import { | |
| 48 | + getThemePaletteCustomProperties, | |
| 49 | + normalizeThemePaletteColor, | |
| 50 | + THEME_PALETTE_PREVIEW_SWATCHES, | |
| 51 | + THEME_PALETTE_SWATCHES, | |
| 52 | +} from "../../themePalette"; | |
| 43 | 53 | import { ensureLocalApi, readLocalApi } from "../../localApi"; |
| 54 | +import { cn } from "../../lib/utils"; | |
| 44 | 55 | import { useShallow } from "zustand/react/shallow"; |
| 45 | 56 | import { |
| 46 | 57 | selectProjectsAcrossEnvironments, |
| @@ -117,6 +128,91 @@ const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({ · 70 unmodified lines | ||
| 117 | 128 | provider: definition.value, |
| 118 | 129 | })); |
| 119 | 130 | |
| 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 | + | |
| 120 | 216 | function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { |
| 121 | 217 | useRelativeTimeTick(); |
| 122 | 218 | const lastCheckedRelative = lastCheckedAt ? formatRelativeTime(lastCheckedAt) : null; |
| @@ -347,6 +443,10 @@ export function useSettingsRestore(onRestored?: () => void) { · 224 unmodified lines | ||
| 347 | 443 | const settings = useSettings(); |
| 348 | 444 | const { resetSettings } = useUpdateSettings(); |
| 349 | 445 | |
| 446 | + const themePaletteDirty = !Equal.equals( | |
| 447 | + settings.themePalette, | |
| 448 | + DEFAULT_UNIFIED_SETTINGS.themePalette, | |
| 449 | + ); | |
| 350 | 450 | const isGitWritingModelDirty = !Equal.equals( |
| 351 | 451 | settings.textGenerationModelSelection ?? null, |
| 352 | 452 | DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, |
| @@ -380,6 +480,7 @@ export function useSettingsRestore(onRestored?: () => void) { · 27 unmodified lines | ||
| 380 | 480 | const changedSettingLabels = useMemo( |
| 381 | 481 | () => [ |
| 382 | 482 | ...(theme !== "system" ? ["Theme"] : []), |
| 483 | + ...(themePaletteDirty ? ["Theme colors"] : []), | |
| 383 | 484 | ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat |
| 384 | 485 | ? ["Time format"] |
| 385 | 486 | : []), |
| @@ -413,6 +514,7 @@ export function useSettingsRestore(onRestored?: () => void) { · 27 unmodified lines | ||
| 413 | 514 | [ |
| 414 | 515 | areProviderSettingsDirty, |
| 415 | 516 | isGitWritingModelDirty, |
| 517 | + themePaletteDirty, | |
| 416 | 518 | settings.autoOpenPlanSidebar, |
| 417 | 519 | settings.confirmThreadArchive, |
| 418 | 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 | 979 | <SettingsRow |
| 831 | 980 | title="Time format" |
| 832 | 981 | description="System default follows your browser or OS clock preference." |
| @@ -22,6 +22,7 @@ import { ensureLocalApi } from "~/localApi"; · 21 unmodified lines | ||
| 22 | 22 | import { Struct } from "effect"; |
| 23 | 23 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; |
| 24 | 24 | import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState"; |
| 25 | +import { applyThemePalette } from "~/themePalette"; | |
| 25 | 26 | |
| 26 | 27 | const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; |
| 27 | 28 | |
| @@ -65,7 +66,9 @@ async function hydrateClientSettings(): Promise<void> { · 37 unmodified lines | ||
| 65 | 66 | try { |
| 66 | 67 | const persistedSettings = await ensureLocalApi().persistence.getClientSettings(); |
| 67 | 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 | 73 | } catch (error) { |
| 71 | 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 | |
| 87 | 90 | function persistClientSettings(settings: ClientSettings): void { |
| 88 | 91 | replaceClientSettingsSnapshot(settings); |
| 92 | + applyThemePalette(settings.themePalette); | |
| 89 | 93 | void ensureLocalApi() |
| 90 | 94 | .persistence.setClientSettings(settings) |
| 91 | 95 | .catch((error) => { |
| @@ -86,28 +86,30 @@ · 85 unmodified lines | ||
| 86 | 86 | :root { |
| 87 | 87 | color-scheme: light; |
| 88 | 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 | 92 | --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)); | |
| 97 | 99 | --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)); | |
| 104 | 106 | --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)); | |
| 108 | 110 | --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)); | |
| 111 | 113 | --success: var(--color-emerald-500); |
| 112 | 114 | --success-foreground: var(--color-emerald-700); |
| 113 | 115 | --warning: var(--color-amber-500); |
| @@ -115,28 +117,28 @@ · 1 unmodified line | ||
| 115 | 117 | |
| 116 | 118 | @variant dark { |
| 117 | 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 | 121 | --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)); | |
| 126 | 128 | --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)); | |
| 133 | 135 | --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)); | |
| 137 | 139 | --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)); | |
| 140 | 142 | --success: var(--color-emerald-500); |
| 141 | 143 | --success-foreground: var(--color-emerald-400); |
| 142 | 144 | --warning: var(--color-amber-500); |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { |
| 2 | 2 | CommandId, |
| 3 | 3 | DEFAULT_SERVER_SETTINGS, |
| 4 | + DEFAULT_THEME_PALETTE, | |
| 4 | 5 | type DesktopBridge, |
| 5 | 6 | EnvironmentId, |
| 6 | 7 | type VcsStatusResult, |
| @@ -584,6 +585,7 @@ describe("wsApi", () => { · 577 unmodified lines | ||
| 584 | 585 | sidebarProjectSortOrder: "manual" as const, |
| 585 | 586 | sidebarThreadSortOrder: "created_at" as const, |
| 586 | 587 | timestampFormat: "24-hour" as const, |
| 588 | + themePalette: DEFAULT_THEME_PALETTE, | |
| 587 | 589 | }; |
| 588 | 590 | const getClientSettings = vi.fn().mockResolvedValue({ |
| 589 | 591 | ...clientSettings, |
| @@ -645,6 +647,7 @@ describe("wsApi", () => { · 55 unmodified lines | ||
| 645 | 647 | sidebarProjectSortOrder: "manual" as const, |
| 646 | 648 | sidebarThreadSortOrder: "created_at" as const, |
| 647 | 649 | timestampFormat: "24-hour" as const, |
| 650 | + themePalette: DEFAULT_THEME_PALETTE, | |
| 648 | 651 | }; |
| 649 | 652 | |
| 650 | 653 | await api.persistence.setClientSettings(clientSettings); |
| @@ -10,12 +10,15 @@ import { isElectron } from "./env"; · 9 unmodified lines | ||
| 10 | 10 | import { getRouter } from "./router"; |
| 11 | 11 | import { APP_DISPLAY_NAME } from "./branding"; |
| 12 | 12 | import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; |
| 13 | +import { applyStoredThemePalette } from "./themePalette"; | |
| 13 | 14 | |
| 14 | 15 | // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. |
| 15 | 16 | const history = isElectron ? createHashHistory() : createBrowserHistory(); |
| 16 | 17 | |
| 17 | 18 | const router = getRouter(history); |
| 18 | 19 | |
| 20 | +applyStoredThemePalette(); | |
| 21 | + | |
| 19 | 22 | if (isElectron) { |
| 20 | 23 | syncDocumentWindowControlsOverlayClass(); |
| 21 | 24 | } |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -2,10 +2,36 @@ import { describe, expect, it } from "vitest"; · 1 unmodified line | ||
| 2 | 2 | import { Schema } from "effect"; |
| 3 | 3 | |
| 4 | 4 | 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"; | |
| 6 | 13 | |
| 7 | 14 | const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); |
| 8 | 15 | 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 | +}); | |
| 9 | 35 | |
| 10 | 36 | describe("ServerSettings.providerInstances (slice-2 invariant)", () => { |
| 11 | 37 | it("defaults to an empty record so legacy configs without the key still decode", () => { |
| @@ -28,6 +28,23 @@ export const SidebarProjectGroupingMode = Schema.Literals([ · 27 unmodified lines | ||
| 28 | 28 | export type SidebarProjectGroupingMode = typeof SidebarProjectGroupingMode.Type; |
| 29 | 29 | export const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository"; |
| 30 | 30 | |
| 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 | + | |
| 31 | 48 | export const ClientSettingsSchema = Schema.Struct({ |
| 32 | 49 | autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 33 | 50 | confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| @@ -75,6 +92,7 @@ export const ClientSettingsSchema = Schema.Struct({ · 41 unmodified lines | ||
| 75 | 92 | timestampFormat: TimestampFormat.pipe( |
| 76 | 93 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), |
| 77 | 94 | ), |
| 95 | + themePalette: ThemePaletteSettings, | |
| 78 | 96 | }); |
| 79 | 97 | export type ClientSettings = typeof ClientSettingsSchema.Type; |
| 80 | 98 | |
| @@ -482,5 +500,6 @@ export const ClientSettingsPatch = Schema.Struct({ · 401 unmodified lines | ||
| 482 | 500 | sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), |
| 483 | 501 | sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), |
| 484 | 502 | timestampFormat: Schema.optionalKey(TimestampFormat), |
| 503 | + themePalette: Schema.optionalKey(ThemePaletteSettings), | |
| 485 | 504 | }); |
| 486 | 505 | export type ClientSettingsPatch = typeof ClientSettingsPatch.Type; |