Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/src/clientPersistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as os from "node:os";
import * as path from "node:path";

import {
DEFAULT_THEME_PALETTE,
EnvironmentId,
type ClientSettings,
type PersistedSavedEnvironmentRecord,
Expand Down Expand Up @@ -63,6 +64,7 @@ const clientSettings: ClientSettings = {
sidebarProjectSortOrder: "manual",
sidebarThreadSortOrder: "created_at",
timestampFormat: "24-hour",
themePalette: DEFAULT_THEME_PALETTE,
};

const savedRegistryRecord: PersistedSavedEnvironmentRecord = {
Expand Down
14 changes: 14 additions & 0 deletions apps/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const isDark = theme === "dark" || (theme === "system" && prefersDark);
document.documentElement.classList.toggle("dark", isDark);
const rawSettings = window.localStorage.getItem("t3code:client-settings:v1");
const palette = rawSettings ? JSON.parse(rawSettings).themePalette : null;
if (palette && /^#[0-9a-fA-F]{6}$/.test(palette.primaryColor)) {
document.documentElement.style.setProperty(
"--theme-primary-seed",
palette.primaryColor.toLowerCase(),
);
}
if (palette && /^#[0-9a-fA-F]{6}$/.test(palette.neutralColor)) {
document.documentElement.style.setProperty(
"--theme-neutral-seed",
palette.neutralColor.toLowerCase(),
);
}
const chromeColor = isDark ? DARK_BACKGROUND : LIGHT_BACKGROUND;
document.documentElement.style.backgroundColor = chromeColor;
themeColorMeta?.setAttribute("content", chromeColor);
Expand Down
47 changes: 46 additions & 1 deletion apps/web/src/clientPersistenceStorage.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts";
import {
DEFAULT_CLIENT_SETTINGS,
DEFAULT_THEME_PALETTE,
EnvironmentId,
type PersistedSavedEnvironmentRecord,
} from "@t3tools/contracts";
import { afterEach, describe, expect, it, vi } from "vitest";

const testEnvironmentId = EnvironmentId.make("environment-1");
Expand Down Expand Up @@ -55,6 +60,46 @@ afterEach(() => {
});

describe("clientPersistenceStorage", () => {
it("reads the persisted theme palette for boot-time theme application", async () => {
const testWindow = getTestWindow();
const { CLIENT_SETTINGS_STORAGE_KEY, readBootThemePalette } =
await import("./clientPersistenceStorage");

testWindow.localStorage.setItem(
CLIENT_SETTINGS_STORAGE_KEY,
JSON.stringify({
...DEFAULT_CLIENT_SETTINGS,
themePalette: {
primaryColor: "#db2777",
neutralColor: "#0f172a",
},
}),
);

expect(readBootThemePalette()).toEqual({
primaryColor: "#db2777",
neutralColor: "#0f172a",
});
});

it("falls back to the default theme palette when persisted data is invalid", async () => {
const testWindow = getTestWindow();
const { CLIENT_SETTINGS_STORAGE_KEY, readBootThemePalette } =
await import("./clientPersistenceStorage");

testWindow.localStorage.setItem(
CLIENT_SETTINGS_STORAGE_KEY,
JSON.stringify({
themePalette: {
primaryColor: "pink",
neutralColor: "#0f172a",
},
}),
);

expect(readBootThemePalette()).toEqual(DEFAULT_THEME_PALETTE);
});

it("stores browser secrets inline with the saved environment record", async () => {
const testWindow = getTestWindow();
const {
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/clientPersistenceStorage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
ClientSettingsSchema,
DEFAULT_THEME_PALETTE,
EnvironmentId,
type ClientSettings,
type EnvironmentId as EnvironmentIdValue,
Expand All @@ -12,6 +13,19 @@
export const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1";
export const SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY = "t3code:saved-environment-registry:v1";

export function readBootThemePalette(): ClientSettings["themePalette"] {
if (!hasWindow()) {
return DEFAULT_THEME_PALETTE;
}

try {
const parsed = getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema);
return parsed?.themePalette ?? DEFAULT_THEME_PALETTE;
} catch {
return DEFAULT_THEME_PALETTE;
}
}

const BrowserSavedEnvironmentRecordSchema = Schema.Struct({
environmentId: EnvironmentId,
label: Schema.String,
Expand Down Expand Up @@ -171,7 +185,7 @@
let found = false;
writeBrowserSavedEnvironmentRegistryDocument({
version: document.version ?? 1,
records: records.map((record) => {

Check warning on line 188 in apps/web/src/clientPersistenceStorage.ts

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

oxc(no-map-spread)

Spreading to modify object properties in `map` calls is inefficient
if (record.environmentId !== environmentId) {
return record;
}
Expand Down
153 changes: 151 additions & 2 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useMemo, useRef, useState } from "react";
import { type CSSProperties, useCallback, useMemo, useRef, useState } from "react";
import {
defaultInstanceIdForDriver,
type DesktopUpdateChannel,
Expand All @@ -10,7 +10,11 @@ import {
type ScopedThreadRef,
} from "@t3tools/contracts";
import { scopeThreadRef } from "@t3tools/client-runtime";
import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
import {
DEFAULT_THEME_PALETTE,
DEFAULT_UNIFIED_SETTINGS,
type ThemePaletteSettings,
} from "@t3tools/contracts/settings";
import { createModelSelection } from "@t3tools/shared/model";
import { Equal } from "effect";
import { APP_VERSION } from "../../branding";
Expand Down Expand Up @@ -40,7 +44,14 @@ import {
deriveProviderInstanceEntries,
sortProviderInstanceEntries,
} from "../../providerInstances";
import {
getThemePaletteCustomProperties,
normalizeThemePaletteColor,
THEME_PALETTE_PREVIEW_SWATCHES,
THEME_PALETTE_SWATCHES,
} from "../../themePalette";
import { ensureLocalApi, readLocalApi } from "../../localApi";
import { cn } from "../../lib/utils";
import { useShallow } from "zustand/react/shallow";
import {
selectProjectsAcrossEnvironments,
Expand Down Expand Up @@ -117,6 +128,91 @@ const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({
provider: definition.value,
}));

function ThemePaletteColorControl({
label,
value,
fallbackColor,
onChange,
}: {
label: string;
value: string;
fallbackColor: string;
onChange: (value: string) => void;
}) {
const normalizedValue = normalizeThemePaletteColor(value) ?? fallbackColor;

return (
<div className="grid min-w-0 gap-2">
<span className="text-xs font-medium text-foreground">{label}</span>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<input
type="color"
value={normalizedValue}
onChange={(event) => onChange(event.target.value)}
aria-label={`${label} theme color`}
className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5"
/>
<div className="flex flex-wrap gap-1.5">
{THEME_PALETTE_SWATCHES.map((swatch) => {
const selected = normalizedValue === swatch;
return (
<button
key={swatch}
type="button"
className={cn(
"size-6 cursor-pointer rounded-full border transition",
selected
? "scale-110 border-foreground ring-2 ring-ring ring-offset-1 ring-offset-background"
: "border-black/10 hover:scale-105 dark:border-white/20",
)}
style={{ backgroundColor: swatch }}
onClick={() => onChange(swatch)}
aria-label={`Use ${swatch} ${label.toLowerCase()} theme color`}
/>
);
})}
</div>
</div>
</div>
);
}

function ThemePalettePreview({ palette }: { palette: ThemePaletteSettings }) {
const previewStyle = getThemePaletteCustomProperties(palette) as CSSProperties;

return (
<div
className="mt-4 rounded-xl border bg-background p-3 text-foreground"
style={previewStyle}
aria-label="Generated theme palette preview"
>
<div className="grid gap-2 sm:grid-cols-4">
{THEME_PALETTE_PREVIEW_SWATCHES.map((swatch) => (
<div key={swatch.variable} className="grid gap-1.5">
<span
className="h-9 rounded-lg border border-border shadow-xs"
style={{ backgroundColor: `var(${swatch.variable})` }}
aria-hidden
/>
<span className="text-[11px] text-muted-foreground">{swatch.label}</span>
</div>
))}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="rounded-full bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground">
Primary
</span>
<span className="rounded-full bg-accent px-2.5 py-1 text-xs font-medium text-accent-foreground">
Accent
</span>
<span className="rounded-full bg-muted px-2.5 py-1 text-xs font-medium text-muted-foreground">
Muted
</span>
</div>
</div>
);
}

function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) {
useRelativeTimeTick();
const lastCheckedRelative = lastCheckedAt ? formatRelativeTime(lastCheckedAt) : null;
Expand Down Expand Up @@ -347,6 +443,10 @@ export function useSettingsRestore(onRestored?: () => void) {
const settings = useSettings();
const { resetSettings } = useUpdateSettings();

const themePaletteDirty = !Equal.equals(
settings.themePalette,
DEFAULT_UNIFIED_SETTINGS.themePalette,
);
const isGitWritingModelDirty = !Equal.equals(
settings.textGenerationModelSelection ?? null,
DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
Expand Down Expand Up @@ -380,6 +480,7 @@ export function useSettingsRestore(onRestored?: () => void) {
const changedSettingLabels = useMemo(
() => [
...(theme !== "system" ? ["Theme"] : []),
...(themePaletteDirty ? ["Theme colors"] : []),
...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat
? ["Time format"]
: []),
Expand Down Expand Up @@ -413,6 +514,7 @@ export function useSettingsRestore(onRestored?: () => void) {
[
areProviderSettingsDirty,
isGitWritingModelDirty,
themePaletteDirty,
settings.autoOpenPlanSidebar,
settings.confirmThreadArchive,
settings.confirmThreadDelete,
Expand Down Expand Up @@ -827,6 +929,53 @@ export function GeneralSettingsPanel() {
}
/>

<SettingsRow
title="Theme colors"
description="Choose brand and surface colors. T3 Code generates the palette from them."
resetAction={
!Equal.equals(settings.themePalette, DEFAULT_UNIFIED_SETTINGS.themePalette) ? (
<SettingResetButton
label="theme colors"
onClick={() =>
updateSettings({
themePalette: DEFAULT_UNIFIED_SETTINGS.themePalette,
})
}
/>
) : null
}
>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<ThemePaletteColorControl
label="Primary color"
value={settings.themePalette.primaryColor}
fallbackColor={DEFAULT_THEME_PALETTE.primaryColor}
onChange={(primaryColor) =>
updateSettings({
themePalette: {
...settings.themePalette,
primaryColor,
},
})
}
/>
<ThemePaletteColorControl
label="Neutral color"
value={settings.themePalette.neutralColor}
fallbackColor={DEFAULT_THEME_PALETTE.neutralColor}
onChange={(neutralColor) =>
updateSettings({
themePalette: {
...settings.themePalette,
neutralColor,
},
})
}
/>
</div>
<ThemePalettePreview palette={settings.themePalette} />
</SettingsRow>

<SettingsRow
title="Time format"
description="System default follows your browser or OS clock preference."
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/hooks/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { ensureLocalApi } from "~/localApi";
import { Struct } from "effect";
import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState";
import { applyThemePalette } from "~/themePalette";

const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]";

Expand Down Expand Up @@ -65,7 +66,9 @@ async function hydrateClientSettings(): Promise<void> {
try {
const persistedSettings = await ensureLocalApi().persistence.getClientSettings();
if (persistedSettings) {
replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings });
const nextSettings = { ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings };
replaceClientSettingsSnapshot(nextSettings);
applyThemePalette(nextSettings.themePalette);
}
} catch (error) {
console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, error);
Expand All @@ -86,6 +89,7 @@ async function hydrateClientSettings(): Promise<void> {

function persistClientSettings(settings: ClientSettings): void {
replaceClientSettingsSnapshot(settings);
applyThemePalette(settings.themePalette);
void ensureLocalApi()
.persistence.setClientSettings(settings)
.catch((error) => {
Expand Down
Loading
Loading