Unified · Split
| @@ -3,6 +3,7 @@ import * as os from "node:os"; · 2 unmodified lines | |||
| 3 | import * as path from "node:path"; | 3 | import * as path from "node:path"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| 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 | ||
| 68 | const savedRegistryRecord: PersistedSavedEnvironmentRecord = { | 70 | const savedRegistryRecord: PersistedSavedEnvironmentRecord = { |
| @@ -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); |
| @@ -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 | import { afterEach, describe, expect, it, vi } from "vitest"; | 7 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| 3 | 8 | ||
| 4 | const testEnvironmentId = EnvironmentId.make("environment-1"); | 9 | const testEnvironmentId = EnvironmentId.make("environment-1"); |
| @@ -55,6 +60,46 @@ afterEach(() => { · 50 unmodified lines | |||
| 55 | }); | 60 | }); |
| 56 | 61 | ||
| 57 | describe("clientPersistenceStorage", () => { | 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 | 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 { |
| @@ -1,5 +1,6 @@ | |||
| 1 | import { | 1 | import { |
| 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 | |||
| 12 | export const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; | 13 | export const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; |
| 13 | export const SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY = "t3code:saved-environment-registry:v1"; | 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 | const BrowserSavedEnvironmentRecordSchema = Schema.Struct({ | 29 | const BrowserSavedEnvironmentRecordSchema = Schema.Struct({ |
| 16 | environmentId: EnvironmentId, | 30 | environmentId: EnvironmentId, |
| 17 | label: Schema.String, | 31 | label: Schema.String, |
| @@ -1,6 +1,6 @@ | |||
| 1 | import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; | 1 | import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; |
| 2 | import { useQueryClient } from "@tanstack/react-query"; | 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 | import { | 4 | import { |
| 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"; |
| 12 | import { scopeThreadRef } from "@t3tools/client-runtime"; | 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 | import { createModelSelection } from "@t3tools/shared/model"; | 18 | import { createModelSelection } from "@t3tools/shared/model"; |
| 15 | import { Equal } from "effect"; | 19 | import { Equal } from "effect"; |
| 16 | import { APP_VERSION } from "../../branding"; | 20 | import { 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"; |
| 47 | import { | ||
| 48 | getThemePaletteCustomProperties, | ||
| 49 | normalizeThemePaletteColor, | ||
| 50 | THEME_PALETTE_PREVIEW_SWATCHES, | ||
| 51 | THEME_PALETTE_SWATCHES, | ||
| 52 | } from "../../themePalette"; | ||
| 43 | import { ensureLocalApi, readLocalApi } from "../../localApi"; | 53 | import { ensureLocalApi, readLocalApi } from "../../localApi"; |
| 54 | import { cn } from "../../lib/utils"; | ||
| 44 | import { useShallow } from "zustand/react/shallow"; | 55 | import { useShallow } from "zustand/react/shallow"; |
| 45 | import { | 56 | import { |
| 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 | ||
| 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 | function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { | 216 | function 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.timestampFormat | 484 | ...(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 | <SettingsRow | 979 | <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." |
| @@ -22,6 +22,7 @@ import { ensureLocalApi } from "~/localApi"; · 21 unmodified lines | |||
| 22 | import { Struct } from "effect"; | 22 | import { Struct } from "effect"; |
| 23 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; | 23 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; |
| 24 | import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState"; | 24 | import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState"; |
| 25 | import { applyThemePalette } from "~/themePalette"; | ||
| 25 | 26 | ||
| 26 | const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; | 27 | const 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 | ||
| 87 | function persistClientSettings(settings: ClientSettings): void { | 90 | function 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) => { |
| @@ -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); |
| @@ -1,6 +1,7 @@ | |||
| 1 | import { | 1 | import { |
| 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); |
| @@ -10,12 +10,15 @@ import { isElectron } from "./env"; · 9 unmodified lines | |||
| 10 | import { getRouter } from "./router"; | 10 | import { getRouter } from "./router"; |
| 11 | import { APP_DISPLAY_NAME } from "./branding"; | 11 | import { APP_DISPLAY_NAME } from "./branding"; |
| 12 | import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; | 12 | import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; |
| 13 | import { 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. |
| 15 | const history = isElectron ? createHashHistory() : createBrowserHistory(); | 16 | const history = isElectron ? createHashHistory() : createBrowserHistory(); |
| 16 | 17 | ||
| 17 | const router = getRouter(history); | 18 | const router = getRouter(history); |
| 18 | 19 | ||
| 20 | applyStoredThemePalette(); | ||
| 21 | |||
| 19 | if (isElectron) { | 22 | if (isElectron) { |
| 20 | syncDocumentWindowControlsOverlayClass(); | 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 | import { Schema } from "effect"; | 2 | import { Schema } from "effect"; |
| 3 | 3 | ||
| 4 | import { ProviderInstanceId } from "./providerInstance.ts"; | 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 | const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); | 14 | const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); |
| 8 | const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); | 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 | describe("ServerSettings.providerInstances (slice-2 invariant)", () => { | 36 | describe("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", () => { |
| @@ -28,6 +28,23 @@ export const SidebarProjectGroupingMode = Schema.Literals([ · 27 unmodified lines | |||
| 28 | export type SidebarProjectGroupingMode = typeof SidebarProjectGroupingMode.Type; | 28 | export type SidebarProjectGroupingMode = typeof SidebarProjectGroupingMode.Type; |
| 29 | export const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository"; | 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 | export const ClientSettingsSchema = Schema.Struct({ | 48 | export 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 | }); |
| 79 | export type ClientSettings = typeof ClientSettingsSchema.Type; | 97 | export 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 | }); |
| 486 | export type ClientSettingsPatch = typeof ClientSettingsPatch.Type; | 505 | export type ClientSettingsPatch = typeof ClientSettingsPatch.Type; |