authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-08-07 15:41:59-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-07 15:41:59-04:00
logf0fb406acf419c1ab3d37d5f42093c07921484b2
tree03ad6198026af6472aae44c8979793dea36ebe26
parent82406bce99c2cf46d1d8fbad26bfefc2ab3169ce
signature Signed by PGP key B5690EEEBB952194

feat(web): make sidebar artwork theme-aware (#5636)


5 files changed, 185 insertions(+), 6 deletions(-)

apps/web/src/components/settings/ThemeEditorPanel.tsx+39-2
......@@ -165,6 +165,7 @@ export function ThemeEditorPanel({
165165 const isEditing = editingTheme !== null;
166166 const [name, setName] = useState("");
167167 const [activeAppearance, setActiveAppearance] = useState<ThemeAppearance>(initialAppearance);
168 const [sidebarArtwork, setSidebarArtwork] = useState(false);
168169 const [isAdvanced, setIsAdvanced] = useState(false);
169170 const [colorsByAppearance, setColorsByAppearance] = useState<ThemeEditorColorsByAppearance>(() =>
170171 getThemeEditorColorsByAppearance(),
......@@ -178,6 +179,7 @@ export function ThemeEditorPanel({
178179 const [isInspecting, setIsInspecting] = useState(false);
179180 const [selectedRole, setSelectedRole] = useState<ThemeColorRole | null>(null);
180181 const [usageCount, setUsageCount] = useState<number | null>(null);
182 const previousMergeTargetIdRef = useRef<string | null>(null);
181183 // Null parks the panel at its default corner; a value is a dragged spot,
182184 // kept clamped so the header can always be grabbed again.
183185 const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
......@@ -259,6 +261,9 @@ export function ThemeEditorPanel({
259261
260262 setName(editingTheme?.label ?? seedName ?? "");
261263 setActiveAppearance(nextAppearance);
264 // Artwork is opt-in for new themes, including duplicates. Editing keeps
265 // the theme's existing choice.
266 setSidebarArtwork(editingTheme?.sidebarArtwork === true);
262267 // Themes saved by the guided editor carry the managed flag; anything
263268 // else (imports, hand-edited files, older saves) opens in advanced mode
264269 // so guided regeneration cannot silently discard hand-tuned colors. A
......@@ -315,6 +320,18 @@ export function ThemeEditorPanel({
315320 // an explanation instead.
316321 const mergeTargetId = mergeTarget?.id ?? null;
317322 const takenAppearancesKey = takenAppearances.join(",");
323 useEffect(() => {
324 if (previousMergeTargetIdRef.current === mergeTargetId) return;
325 previousMergeTargetIdRef.current = mergeTargetId;
326 // A matching name makes that existing theme the surviving merge target.
327 // Seed theme-level options from it so adding a palette or renaming onto it
328 // does not silently reset them. Leaving the merge restores the edited
329 // theme's option (or the off-by-default choice for a new theme).
330 setSidebarArtwork(
331 mergeTarget ? mergeTarget.sidebarArtwork === true : editingTheme?.sidebarArtwork === true,
332 );
333 }, [editingTheme, mergeTarget, mergeTargetId]);
334
318335 useEffect(() => {
319336 if (isEditing || mergeTargetId === null) return;
320337 const taken = takenAppearancesKey.split(",").filter(Boolean) as ThemeAppearance[];
......@@ -330,8 +347,8 @@ export function ThemeEditorPanel({
330347 // comes back when the editor closes, including on cancel.
331348 useEffect(() => {
332349 if (!open || !isDraftSeeded) return;
333 applyThemeColorPreview(colorsByAppearance[activeAppearance], activeAppearance);
334 }, [activeAppearance, colorsByAppearance, isDraftSeeded, open]);
350 applyThemeColorPreview(colorsByAppearance[activeAppearance], activeAppearance, sidebarArtwork);
351 }, [activeAppearance, colorsByAppearance, isDraftSeeded, open, sidebarArtwork]);
335352
336353 useEffect(() => {
337354 if (!open) return;
......@@ -657,6 +674,7 @@ export function ThemeEditorPanel({
657674 ...mergeTarget.variants,
658675 ...Object.fromEntries(editedModes.map((mode) => [mode, colorsForSave[mode]])),
659676 },
677 ...(sidebarArtwork ? { sidebarArtwork: true } : {}),
660678 ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}),
661679 }),
662680 );
......@@ -687,6 +705,7 @@ export function ThemeEditorPanel({
687705 ...(getThemeModes(editingTheme).length > 1
688706 ? { variants: { [variantAppearance]: colorsForSave[variantAppearance] } }
689707 : {}),
708 ...(sidebarArtwork ? { sidebarArtwork: true } : {}),
690709 ...(isAdvanced ? {} : { managed: true }),
691710 }),
692711 );
......@@ -713,6 +732,7 @@ export function ThemeEditorPanel({
713732 ...mergeTarget.variants,
714733 [activeAppearance]: colorsForSave[activeAppearance],
715734 },
735 ...(sidebarArtwork ? { sidebarArtwork: true } : {}),
716736 ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}),
717737 }),
718738 );
......@@ -723,6 +743,7 @@ export function ThemeEditorPanel({
723743 name,
724744 appearance: activeAppearance,
725745 colors: colorsForSave[activeAppearance],
746 ...(sidebarArtwork ? { sidebarArtwork: true } : {}),
726747 ...(isAdvanced ? {} : { managed: true }),
727748 }),
728749 );
......@@ -773,6 +794,7 @@ export function ThemeEditorPanel({
773794 name,
774795 onOpenChange,
775796 onSaved,
797 sidebarArtwork,
776798 simpleColorsDirtyByAppearance,
777799 takenAppearances,
778800 ]);
......@@ -832,6 +854,20 @@ export function ThemeEditorPanel({
832854 </div>
833855 );
834856
857 const renderSidebarArtworkToggle = () => (
858 <label className="grid cursor-pointer grid-cols-[minmax(0,1fr)_minmax(0,2fr)] items-center gap-3">
859 <span className="text-sm font-medium">Sidebar artwork</span>
860 <span className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
861 <span>Show T3 Code environment artwork</span>
862 <Switch
863 aria-label="Allow sidebar artwork with this theme"
864 checked={sidebarArtwork}
865 onCheckedChange={(checked) => setSidebarArtwork(Boolean(checked))}
866 />
867 </span>
868 </label>
869 );
870
835871 const renderColorsHeader = () => (
836872 <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] items-start gap-3">
837873 <div>
......@@ -1088,6 +1124,7 @@ export function ThemeEditorPanel({
10881124 </p>
10891125 ) : null}
10901126 {renderAppearanceButtons()}
1127 {renderSidebarArtworkToggle()}
10911128 <div className="space-y-3">
10921129 {renderColorsHeader()}
10931130 {renderColorFields()}
apps/web/src/hooks/useSettings.test.ts+31
......@@ -17,6 +17,37 @@ describe("resolveEnvironmentIdentificationMode", () => {
1717 "pill",
1818 );
1919 });
20
21 it("uses a pill instead of artwork with a palette theme", () => {
22 expect(
23 resolveEnvironmentIdentificationMode({
24 mode: "artwork",
25 settingsHydrated: true,
26 paletteThemeActive: true,
27 }),
28 ).toBe("pill");
29 });
30
31 it("respects none with a palette theme", () => {
32 expect(
33 resolveEnvironmentIdentificationMode({
34 mode: "none",
35 settingsHydrated: true,
36 paletteThemeActive: true,
37 }),
38 ).toBe("none");
39 });
40
41 it("keeps artwork when the palette theme opts into it", () => {
42 expect(
43 resolveEnvironmentIdentificationMode({
44 mode: "artwork",
45 settingsHydrated: true,
46 paletteThemeActive: true,
47 paletteThemeAllowsArtwork: true,
48 }),
49 ).toBe("artwork");
50 });
2051});
2152
2253describe("mergeEnvironmentSettings", () => {
apps/web/src/hooks/useSettings.ts+30-2
......@@ -28,10 +28,17 @@ import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
2828import { APP_STAGE_LABEL } from "~/branding";
2929import { resolveSidebarV2Enabled } from "~/branding.logic";
3030import { ensureLocalApi } from "~/localApi";
31import {
32 getThemeDefinition,
33 getThemePreviewSidebarArtwork,
34 resolveThemeHalf,
35 subscribeToThemePreview,
36} from "~/themePalette";
3137import * as Struct from "effect/Struct";
3238import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server";
3339import { usePrimaryEnvironment } from "~/state/environments";
3440import { useAtomCommand } from "~/state/use-atom-command";
41import { useTheme } from "./useTheme";
3542
3643const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]";
3744
......@@ -226,15 +233,36 @@ export function useClientSettings<T = ClientSettings>(
226233export function resolveEnvironmentIdentificationMode(input: {
227234 mode: EnvironmentIdentificationMode;
228235 settingsHydrated: boolean;
236 paletteThemeActive?: boolean;
237 paletteThemeAllowsArtwork?: boolean;
229238}): EnvironmentIdentificationMode {
230239 // Avoid briefly rendering the default artwork before a persisted pill/none choice loads.
231 return input.settingsHydrated ? input.mode : "none";
240 if (!input.settingsHydrated) return "none";
241 // Stage artwork has fixed colors that can clash with palette themes. Keep an
242 // explicit "none", but use the theme-aware pill in place of artwork.
243 return input.paletteThemeActive && !input.paletteThemeAllowsArtwork && input.mode === "artwork"
244 ? "pill"
245 : input.mode;
232246}
233247
234248export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMode {
235249 const settingsHydrated = useClientSettingsHydrated();
236250 const mode = useClientSettingsValue().environmentIdentificationMode;
237 return resolveEnvironmentIdentificationMode({ mode, settingsHydrated });
251 const { resolvedTheme, theme, themeHalves } = useTheme();
252 const previewSidebarArtwork = useSyncExternalStore(
253 subscribeToThemePreview,
254 getThemePreviewSidebarArtwork,
255 () => null,
256 );
257 const activeTheme = resolveThemeHalf(theme, themeHalves, resolvedTheme);
258 const activeThemeDefinition = getThemeDefinition(activeTheme);
259 return resolveEnvironmentIdentificationMode({
260 mode,
261 settingsHydrated,
262 paletteThemeActive: previewSidebarArtwork !== null || activeThemeDefinition !== null,
263 paletteThemeAllowsArtwork:
264 previewSidebarArtwork ?? activeThemeDefinition?.sidebarArtwork === true,
265 });
238266}
239267
240268/**
apps/web/src/themePalette.test.ts+55-1
......@@ -1,9 +1,12 @@
11import { describe, expect, it, vi } from "vite-plus/test";
22
33import {
4 applyThemeColorPreview,
5 applyThemePalette,
46 getThemeColorsForMode,
57 getThemeDefinition,
68 getThemeModes,
9 getThemePreviewSidebarArtwork,
710 getThemePreferenceMode,
811 isKnownThemePreference,
912 getCustomThemes,
......@@ -15,6 +18,7 @@ import {
1518 resolveDesktopTheme,
1619 resolveThemeAppearance,
1720 serializeThemeFile,
21 subscribeToThemePreview,
1822 subscribeToCustomThemes,
1923 T3_CHAT_THEME,
2024 EMBER_THEME,
......@@ -200,6 +204,49 @@ describe("theme files", () => {
200204 });
201205 });
202206
207 it("keeps sidebar artwork opt-in through theme files", () => {
208 const withoutArtwork = parseThemeFile({
209 version: THEME_FILE_VERSION,
210 name: "Plain sidebar",
211 appearance: "light",
212 colors: { accent: "#5b6cff" },
213 });
214 const withArtwork = parseThemeFile({
215 version: THEME_FILE_VERSION,
216 name: "Art sidebar",
217 appearance: "light",
218 colors: { accent: "#5b6cff" },
219 sidebarArtwork: true,
220 });
221
222 expect(withoutArtwork.sidebarArtwork).toBeUndefined();
223 expect(withArtwork.sidebarArtwork).toBe(true);
224 expect(JSON.parse(serializeThemeFile(withArtwork)).sidebarArtwork).toBe(true);
225 });
226
227 it("publishes sidebar artwork changes from the live theme preview", () => {
228 const listener = vi.fn();
229 const unsubscribe = subscribeToThemePreview(listener);
230 vi.stubGlobal("document", {
231 documentElement: {
232 classList: { toggle: vi.fn() },
233 dataset: {},
234 style: { removeProperty: vi.fn(), setProperty: vi.fn() },
235 },
236 });
237
238 applyThemeColorPreview(T3_CHAT_THEME.colors, "light", true);
239 expect(getThemePreviewSidebarArtwork()).toBe(true);
240 expect(listener).toHaveBeenCalledTimes(1);
241
242 applyThemePalette("system");
243 expect(getThemePreviewSidebarArtwork()).toBeNull();
244 expect(listener).toHaveBeenCalledTimes(2);
245
246 unsubscribe();
247 vi.unstubAllGlobals();
248 });
249
203250 it("keeps optional light and dark palettes under one theme id", () => {
204251 const theme = parseThemeFile({
205252 version: THEME_FILE_VERSION,
......@@ -387,6 +434,7 @@ describe("theme files", () => {
387434 name: "Aurora",
388435 appearance: "light",
389436 colors: { canvas: "#f8fbff", accent: "#5b6cff" },
437 sidebarArtwork: true,
390438 }),
391439 );
392440 const updatedTheme = updateCustomTheme({
......@@ -395,11 +443,17 @@ describe("theme files", () => {
395443 colors: { ...createdTheme.colors, accent: "#7c3aed" },
396444 });
397445
398 expect(updatedTheme).toMatchObject({ id: "aurora", label: "Aurora Night" });
446 expect(updatedTheme).toMatchObject({
447 id: "aurora",
448 label: "Aurora Night",
449 sidebarArtwork: true,
450 });
451 invalidateCustomThemes();
399452 expect(getCustomThemes()).toEqual([updatedTheme]);
400453 expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")[0]).toMatchObject({
401454 id: "aurora",
402455 label: "Aurora Night",
456 sidebarArtwork: true,
403457 });
404458
405459 vi.unstubAllGlobals();
apps/web/src/themePalette.ts+30-1
......@@ -96,6 +96,8 @@ export type ThemeDefinition = Readonly<{
9696 appearance: ThemeAppearance;
9797 colors: ThemeColors;
9898 variants?: ThemeVariants;
99 /** Allows fixed Dev/Nightly artwork to render over this theme's sidebar. */
100 sidebarArtwork?: boolean;
99101 /** True when the palette was generated by the guided editor from its
100102 * canvas and accent; such themes reopen in guided mode. */
101103 managed?: boolean;
......@@ -107,6 +109,7 @@ export type ThemeFile = Readonly<{
107109 appearance: ThemeAppearance;
108110 colors: ThemeColorOverrides;
109111 variants?: ThemeVariantOverrides;
112 sidebarArtwork?: boolean;
110113 managed?: boolean;
111114}>;
112115
......@@ -128,6 +131,23 @@ const RESERVED_THEME_IDS = new Set([
128131
129132const customThemeListeners = new Set<() => void>();
130133let customThemesSnapshot: ReadonlyArray<ThemeDefinition> | null = null;
134const themePreviewListeners = new Set<() => void>();
135let themePreviewSidebarArtwork: boolean | null = null;
136
137export function getThemePreviewSidebarArtwork(): boolean | null {
138 return themePreviewSidebarArtwork;
139}
140
141export function subscribeToThemePreview(listener: () => void): () => void {
142 themePreviewListeners.add(listener);
143 return () => themePreviewListeners.delete(listener);
144}
145
146function setThemePreviewSidebarArtwork(next: boolean | null): void {
147 if (themePreviewSidebarArtwork === next) return;
148 themePreviewSidebarArtwork = next;
149 for (const listener of themePreviewListeners) listener();
150}
131151
132152function isRecord(value: unknown): value is Record<string, unknown> {
133153 return typeof value === "object" && value !== null && !Array.isArray(value);
......@@ -203,6 +223,7 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null {
203223 appearance: value.appearance,
204224 colors,
205225 ...(variants ? { variants } : {}),
226 ...(value.sidebarArtwork === true ? { sidebarArtwork: true } : {}),
206227 ...(value.managed === true ? { managed: true } : {}),
207228 };
208229}
......@@ -1541,6 +1562,7 @@ export function parseThemeFile(value: unknown): ThemeDefinition {
15411562 appearance,
15421563 colors: { ...fallback, ...overrides },
15431564 ...(Object.keys(variants).length > 0 ? { variants } : {}),
1565 ...(value.sidebarArtwork === true ? { sidebarArtwork: true } : {}),
15441566 ...(value.managed === true ? { managed: true } : {}),
15451567 };
15461568}
......@@ -1553,6 +1575,7 @@ export function serializeThemeFile(theme: ThemeDefinition): string {
15531575 appearance: theme.appearance,
15541576 colors: theme.colors,
15551577 ...(theme.variants ? { variants: theme.variants } : {}),
1578 ...(theme.sidebarArtwork ? { sidebarArtwork: true } : {}),
15561579 ...(theme.managed ? { managed: true } : {}),
15571580 };
15581581 return `${JSON.stringify(file, null, 2)}\n`;
......@@ -1630,11 +1653,16 @@ export const THEME_PREVIEW_ID = "__preview";
16301653 * can be judged against the real interface instead of a miniature. Callers
16311654 * restore the stored theme (refreshTheme) when the draft goes away.
16321655 */
1633export function applyThemeColorPreview(colors: ThemeColors, appearance: ThemeAppearance): void {
1656export function applyThemeColorPreview(
1657 colors: ThemeColors,
1658 appearance: ThemeAppearance,
1659 sidebarArtwork = false,
1660): void {
16341661 if (typeof document === "undefined") return;
16351662 const root = document.documentElement;
16361663 if (!root?.style) return;
16371664
1665 setThemePreviewSidebarArtwork(sidebarArtwork);
16381666 root.dataset.themeId = THEME_PREVIEW_ID;
16391667 root.classList.toggle("dark", appearance === "dark");
16401668 for (const [role, value] of Object.entries(colors) as Array<[ThemeColorRole, string]>) {
......@@ -1649,6 +1677,7 @@ export function applyThemePalette(theme: ThemePreference, appearance?: ThemeAppe
16491677 const root = document.documentElement;
16501678 if (!root?.style) return;
16511679
1680 setThemePreviewSidebarArtwork(null);
16521681 const palette = getThemeDefinition(theme);
16531682
16541683 if (palette) {