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({...@@ -165,6 +165,7 @@ export function ThemeEditorPanel({
165 const isEditing = editingTheme !== null;165 const isEditing = editingTheme !== null;
166 const [name, setName] = useState("");166 const [name, setName] = useState("");
167 const [activeAppearance, setActiveAppearance] = useState<ThemeAppearance>(initialAppearance);167 const [activeAppearance, setActiveAppearance] = useState<ThemeAppearance>(initialAppearance);
168 const [sidebarArtwork, setSidebarArtwork] = useState(false);
168 const [isAdvanced, setIsAdvanced] = useState(false);169 const [isAdvanced, setIsAdvanced] = useState(false);
169 const [colorsByAppearance, setColorsByAppearance] = useState<ThemeEditorColorsByAppearance>(() =>170 const [colorsByAppearance, setColorsByAppearance] = useState<ThemeEditorColorsByAppearance>(() =>
170 getThemeEditorColorsByAppearance(),171 getThemeEditorColorsByAppearance(),
...@@ -178,6 +179,7 @@ export function ThemeEditorPanel({...@@ -178,6 +179,7 @@ export function ThemeEditorPanel({
178 const [isInspecting, setIsInspecting] = useState(false);179 const [isInspecting, setIsInspecting] = useState(false);
179 const [selectedRole, setSelectedRole] = useState<ThemeColorRole | null>(null);180 const [selectedRole, setSelectedRole] = useState<ThemeColorRole | null>(null);
180 const [usageCount, setUsageCount] = useState<number | null>(null);181 const [usageCount, setUsageCount] = useState<number | null>(null);
182 const previousMergeTargetIdRef = useRef<string | null>(null);
181 // Null parks the panel at its default corner; a value is a dragged spot,183 // Null parks the panel at its default corner; a value is a dragged spot,
182 // kept clamped so the header can always be grabbed again.184 // kept clamped so the header can always be grabbed again.
183 const [position, setPosition] = useState<{ x: number; y: number } | null>(null);185 const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
...@@ -259,6 +261,9 @@ export function ThemeEditorPanel({...@@ -259,6 +261,9 @@ export function ThemeEditorPanel({
259261
260 setName(editingTheme?.label ?? seedName ?? "");262 setName(editingTheme?.label ?? seedName ?? "");
261 setActiveAppearance(nextAppearance);263 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);
262 // Themes saved by the guided editor carry the managed flag; anything267 // Themes saved by the guided editor carry the managed flag; anything
263 // else (imports, hand-edited files, older saves) opens in advanced mode268 // else (imports, hand-edited files, older saves) opens in advanced mode
264 // so guided regeneration cannot silently discard hand-tuned colors. A269 // so guided regeneration cannot silently discard hand-tuned colors. A
...@@ -315,6 +320,18 @@ export function ThemeEditorPanel({...@@ -315,6 +320,18 @@ export function ThemeEditorPanel({
315 // an explanation instead.320 // an explanation instead.
316 const mergeTargetId = mergeTarget?.id ?? null;321 const mergeTargetId = mergeTarget?.id ?? null;
317 const takenAppearancesKey = takenAppearances.join(",");322 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
318 useEffect(() => {335 useEffect(() => {
319 if (isEditing || mergeTargetId === null) return;336 if (isEditing || mergeTargetId === null) return;
320 const taken = takenAppearancesKey.split(",").filter(Boolean) as ThemeAppearance[];337 const taken = takenAppearancesKey.split(",").filter(Boolean) as ThemeAppearance[];
...@@ -330,8 +347,8 @@ export function ThemeEditorPanel({...@@ -330,8 +347,8 @@ export function ThemeEditorPanel({
330 // comes back when the editor closes, including on cancel.347 // comes back when the editor closes, including on cancel.
331 useEffect(() => {348 useEffect(() => {
332 if (!open || !isDraftSeeded) return;349 if (!open || !isDraftSeeded) return;
333 applyThemeColorPreview(colorsByAppearance[activeAppearance], activeAppearance);350 applyThemeColorPreview(colorsByAppearance[activeAppearance], activeAppearance, sidebarArtwork);
334 }, [activeAppearance, colorsByAppearance, isDraftSeeded, open]);351 }, [activeAppearance, colorsByAppearance, isDraftSeeded, open, sidebarArtwork]);
335352
336 useEffect(() => {353 useEffect(() => {
337 if (!open) return;354 if (!open) return;
...@@ -657,6 +674,7 @@ export function ThemeEditorPanel({...@@ -657,6 +674,7 @@ export function ThemeEditorPanel({
657 ...mergeTarget.variants,674 ...mergeTarget.variants,
658 ...Object.fromEntries(editedModes.map((mode) => [mode, colorsForSave[mode]])),675 ...Object.fromEntries(editedModes.map((mode) => [mode, colorsForSave[mode]])),
659 },676 },
677 ...(sidebarArtwork ? { sidebarArtwork: true } : {}),
660 ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}),678 ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}),
661 }),679 }),
662 );680 );
...@@ -687,6 +705,7 @@ export function ThemeEditorPanel({...@@ -687,6 +705,7 @@ export function ThemeEditorPanel({
687 ...(getThemeModes(editingTheme).length > 1705 ...(getThemeModes(editingTheme).length > 1
688 ? { variants: { [variantAppearance]: colorsForSave[variantAppearance] } }706 ? { variants: { [variantAppearance]: colorsForSave[variantAppearance] } }
689 : {}),707 : {}),
708 ...(sidebarArtwork ? { sidebarArtwork: true } : {}),
690 ...(isAdvanced ? {} : { managed: true }),709 ...(isAdvanced ? {} : { managed: true }),
691 }),710 }),
692 );711 );
...@@ -713,6 +732,7 @@ export function ThemeEditorPanel({...@@ -713,6 +732,7 @@ export function ThemeEditorPanel({
713 ...mergeTarget.variants,732 ...mergeTarget.variants,
714 [activeAppearance]: colorsForSave[activeAppearance],733 [activeAppearance]: colorsForSave[activeAppearance],
715 },734 },
735 ...(sidebarArtwork ? { sidebarArtwork: true } : {}),
716 ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}),736 ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}),
717 }),737 }),
718 );738 );
...@@ -723,6 +743,7 @@ export function ThemeEditorPanel({...@@ -723,6 +743,7 @@ export function ThemeEditorPanel({
723 name,743 name,
724 appearance: activeAppearance,744 appearance: activeAppearance,
725 colors: colorsForSave[activeAppearance],745 colors: colorsForSave[activeAppearance],
746 ...(sidebarArtwork ? { sidebarArtwork: true } : {}),
726 ...(isAdvanced ? {} : { managed: true }),747 ...(isAdvanced ? {} : { managed: true }),
727 }),748 }),
728 );749 );
...@@ -773,6 +794,7 @@ export function ThemeEditorPanel({...@@ -773,6 +794,7 @@ export function ThemeEditorPanel({
773 name,794 name,
774 onOpenChange,795 onOpenChange,
775 onSaved,796 onSaved,
797 sidebarArtwork,
776 simpleColorsDirtyByAppearance,798 simpleColorsDirtyByAppearance,
777 takenAppearances,799 takenAppearances,
778 ]);800 ]);
...@@ -832,6 +854,20 @@ export function ThemeEditorPanel({...@@ -832,6 +854,20 @@ export function ThemeEditorPanel({
832 </div>854 </div>
833 );855 );
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
835 const renderColorsHeader = () => (871 const renderColorsHeader = () => (
836 <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] items-start gap-3">872 <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] items-start gap-3">
837 <div>873 <div>
...@@ -1088,6 +1124,7 @@ export function ThemeEditorPanel({...@@ -1088,6 +1124,7 @@ export function ThemeEditorPanel({
1088 </p>1124 </p>
1089 ) : null}1125 ) : null}
1090 {renderAppearanceButtons()}1126 {renderAppearanceButtons()}
1127 {renderSidebarArtworkToggle()}
1091 <div className="space-y-3">1128 <div className="space-y-3">
1092 {renderColorsHeader()}1129 {renderColorsHeader()}
1093 {renderColorFields()}1130 {renderColorFields()}
apps/web/src/hooks/useSettings.test.ts+31
...@@ -17,6 +17,37 @@ describe("resolveEnvironmentIdentificationMode", () => {...@@ -17,6 +17,37 @@ describe("resolveEnvironmentIdentificationMode", () => {
17 "pill",17 "pill",
18 );18 );
19 });19 });
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 });
20});51});
2152
22describe("mergeEnvironmentSettings", () => {53describe("mergeEnvironmentSettings", () => {
apps/web/src/hooks/useSettings.ts+30-2
...@@ -28,10 +28,17 @@ import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";...@@ -28,10 +28,17 @@ import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
28import { APP_STAGE_LABEL } from "~/branding";28import { APP_STAGE_LABEL } from "~/branding";
29import { resolveSidebarV2Enabled } from "~/branding.logic";29import { resolveSidebarV2Enabled } from "~/branding.logic";
30import { ensureLocalApi } from "~/localApi";30import { ensureLocalApi } from "~/localApi";
31import {
32 getThemeDefinition,
33 getThemePreviewSidebarArtwork,
34 resolveThemeHalf,
35 subscribeToThemePreview,
36} from "~/themePalette";
31import * as Struct from "effect/Struct";37import * as Struct from "effect/Struct";
32import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server";38import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server";
33import { usePrimaryEnvironment } from "~/state/environments";39import { usePrimaryEnvironment } from "~/state/environments";
34import { useAtomCommand } from "~/state/use-atom-command";40import { useAtomCommand } from "~/state/use-atom-command";
41import { useTheme } from "./useTheme";
3542
36const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]";43const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]";
3744
...@@ -226,15 +233,36 @@ export function useClientSettings<T = ClientSettings>(...@@ -226,15 +233,36 @@ export function useClientSettings<T = ClientSettings>(
226export function resolveEnvironmentIdentificationMode(input: {233export function resolveEnvironmentIdentificationMode(input: {
227 mode: EnvironmentIdentificationMode;234 mode: EnvironmentIdentificationMode;
228 settingsHydrated: boolean;235 settingsHydrated: boolean;
236 paletteThemeActive?: boolean;
237 paletteThemeAllowsArtwork?: boolean;
229}): EnvironmentIdentificationMode {238}): EnvironmentIdentificationMode {
230 // Avoid briefly rendering the default artwork before a persisted pill/none choice loads.239 // 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;
232}246}
233247
234export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMode {248export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMode {
235 const settingsHydrated = useClientSettingsHydrated();249 const settingsHydrated = useClientSettingsHydrated();
236 const mode = useClientSettingsValue().environmentIdentificationMode;250 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 });
238}266}
239267
240/**268/**
apps/web/src/themePalette.test.ts+55-1
...@@ -1,9 +1,12 @@...@@ -1,9 +1,12 @@
1import { describe, expect, it, vi } from "vite-plus/test";1import { describe, expect, it, vi } from "vite-plus/test";
22
3import {3import {
4 applyThemeColorPreview,
5 applyThemePalette,
4 getThemeColorsForMode,6 getThemeColorsForMode,
5 getThemeDefinition,7 getThemeDefinition,
6 getThemeModes,8 getThemeModes,
9 getThemePreviewSidebarArtwork,
7 getThemePreferenceMode,10 getThemePreferenceMode,
8 isKnownThemePreference,11 isKnownThemePreference,
9 getCustomThemes,12 getCustomThemes,
...@@ -15,6 +18,7 @@ import {...@@ -15,6 +18,7 @@ import {
15 resolveDesktopTheme,18 resolveDesktopTheme,
16 resolveThemeAppearance,19 resolveThemeAppearance,
17 serializeThemeFile,20 serializeThemeFile,
21 subscribeToThemePreview,
18 subscribeToCustomThemes,22 subscribeToCustomThemes,
19 T3_CHAT_THEME,23 T3_CHAT_THEME,
20 EMBER_THEME,24 EMBER_THEME,
...@@ -200,6 +204,49 @@ describe("theme files", () => {...@@ -200,6 +204,49 @@ describe("theme files", () => {
200 });204 });
201 });205 });
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
203 it("keeps optional light and dark palettes under one theme id", () => {250 it("keeps optional light and dark palettes under one theme id", () => {
204 const theme = parseThemeFile({251 const theme = parseThemeFile({
205 version: THEME_FILE_VERSION,252 version: THEME_FILE_VERSION,
...@@ -387,6 +434,7 @@ describe("theme files", () => {...@@ -387,6 +434,7 @@ describe("theme files", () => {
387 name: "Aurora",434 name: "Aurora",
388 appearance: "light",435 appearance: "light",
389 colors: { canvas: "#f8fbff", accent: "#5b6cff" },436 colors: { canvas: "#f8fbff", accent: "#5b6cff" },
437 sidebarArtwork: true,
390 }),438 }),
391 );439 );
392 const updatedTheme = updateCustomTheme({440 const updatedTheme = updateCustomTheme({
...@@ -395,11 +443,17 @@ describe("theme files", () => {...@@ -395,11 +443,17 @@ describe("theme files", () => {
395 colors: { ...createdTheme.colors, accent: "#7c3aed" },443 colors: { ...createdTheme.colors, accent: "#7c3aed" },
396 });444 });
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();
399 expect(getCustomThemes()).toEqual([updatedTheme]);452 expect(getCustomThemes()).toEqual([updatedTheme]);
400 expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")[0]).toMatchObject({453 expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")[0]).toMatchObject({
401 id: "aurora",454 id: "aurora",
402 label: "Aurora Night",455 label: "Aurora Night",
456 sidebarArtwork: true,
403 });457 });
404458
405 vi.unstubAllGlobals();459 vi.unstubAllGlobals();
apps/web/src/themePalette.ts+30-1
...@@ -96,6 +96,8 @@ export type ThemeDefinition = Readonly<{...@@ -96,6 +96,8 @@ export type ThemeDefinition = Readonly<{
96 appearance: ThemeAppearance;96 appearance: ThemeAppearance;
97 colors: ThemeColors;97 colors: ThemeColors;
98 variants?: ThemeVariants;98 variants?: ThemeVariants;
99 /** Allows fixed Dev/Nightly artwork to render over this theme's sidebar. */
100 sidebarArtwork?: boolean;
99 /** True when the palette was generated by the guided editor from its101 /** True when the palette was generated by the guided editor from its
100 * canvas and accent; such themes reopen in guided mode. */102 * canvas and accent; such themes reopen in guided mode. */
101 managed?: boolean;103 managed?: boolean;
...@@ -107,6 +109,7 @@ export type ThemeFile = Readonly<{...@@ -107,6 +109,7 @@ export type ThemeFile = Readonly<{
107 appearance: ThemeAppearance;109 appearance: ThemeAppearance;
108 colors: ThemeColorOverrides;110 colors: ThemeColorOverrides;
109 variants?: ThemeVariantOverrides;111 variants?: ThemeVariantOverrides;
112 sidebarArtwork?: boolean;
110 managed?: boolean;113 managed?: boolean;
111}>;114}>;
112115
...@@ -128,6 +131,23 @@ const RESERVED_THEME_IDS = new Set([...@@ -128,6 +131,23 @@ const RESERVED_THEME_IDS = new Set([
128131
129const customThemeListeners = new Set<() => void>();132const customThemeListeners = new Set<() => void>();
130let customThemesSnapshot: ReadonlyArray<ThemeDefinition> | null = null;133let 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
132function isRecord(value: unknown): value is Record<string, unknown> {152function isRecord(value: unknown): value is Record<string, unknown> {
133 return typeof value === "object" && value !== null && !Array.isArray(value);153 return typeof value === "object" && value !== null && !Array.isArray(value);
...@@ -203,6 +223,7 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null {...@@ -203,6 +223,7 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null {
203 appearance: value.appearance,223 appearance: value.appearance,
204 colors,224 colors,
205 ...(variants ? { variants } : {}),225 ...(variants ? { variants } : {}),
226 ...(value.sidebarArtwork === true ? { sidebarArtwork: true } : {}),
206 ...(value.managed === true ? { managed: true } : {}),227 ...(value.managed === true ? { managed: true } : {}),
207 };228 };
208}229}
...@@ -1541,6 +1562,7 @@ export function parseThemeFile(value: unknown): ThemeDefinition {...@@ -1541,6 +1562,7 @@ export function parseThemeFile(value: unknown): ThemeDefinition {
1541 appearance,1562 appearance,
1542 colors: { ...fallback, ...overrides },1563 colors: { ...fallback, ...overrides },
1543 ...(Object.keys(variants).length > 0 ? { variants } : {}),1564 ...(Object.keys(variants).length > 0 ? { variants } : {}),
1565 ...(value.sidebarArtwork === true ? { sidebarArtwork: true } : {}),
1544 ...(value.managed === true ? { managed: true } : {}),1566 ...(value.managed === true ? { managed: true } : {}),
1545 };1567 };
1546}1568}
...@@ -1553,6 +1575,7 @@ export function serializeThemeFile(theme: ThemeDefinition): string {...@@ -1553,6 +1575,7 @@ export function serializeThemeFile(theme: ThemeDefinition): string {
1553 appearance: theme.appearance,1575 appearance: theme.appearance,
1554 colors: theme.colors,1576 colors: theme.colors,
1555 ...(theme.variants ? { variants: theme.variants } : {}),1577 ...(theme.variants ? { variants: theme.variants } : {}),
1578 ...(theme.sidebarArtwork ? { sidebarArtwork: true } : {}),
1556 ...(theme.managed ? { managed: true } : {}),1579 ...(theme.managed ? { managed: true } : {}),
1557 };1580 };
1558 return `${JSON.stringify(file, null, 2)}\n`;1581 return `${JSON.stringify(file, null, 2)}\n`;
...@@ -1630,11 +1653,16 @@ export const THEME_PREVIEW_ID = "__preview";...@@ -1630,11 +1653,16 @@ export const THEME_PREVIEW_ID = "__preview";
1630 * can be judged against the real interface instead of a miniature. Callers1653 * can be judged against the real interface instead of a miniature. Callers
1631 * restore the stored theme (refreshTheme) when the draft goes away.1654 * restore the stored theme (refreshTheme) when the draft goes away.
1632 */1655 */
1633export function applyThemeColorPreview(colors: ThemeColors, appearance: ThemeAppearance): void {1656export function applyThemeColorPreview(
1657 colors: ThemeColors,
1658 appearance: ThemeAppearance,
1659 sidebarArtwork = false,
1660): void {
1634 if (typeof document === "undefined") return;1661 if (typeof document === "undefined") return;
1635 const root = document.documentElement;1662 const root = document.documentElement;
1636 if (!root?.style) return;1663 if (!root?.style) return;
16371664
1665 setThemePreviewSidebarArtwork(sidebarArtwork);
1638 root.dataset.themeId = THEME_PREVIEW_ID;1666 root.dataset.themeId = THEME_PREVIEW_ID;
1639 root.classList.toggle("dark", appearance === "dark");1667 root.classList.toggle("dark", appearance === "dark");
1640 for (const [role, value] of Object.entries(colors) as Array<[ThemeColorRole, string]>) {1668 for (const [role, value] of Object.entries(colors) as Array<[ThemeColorRole, string]>) {
...@@ -1649,6 +1677,7 @@ export function applyThemePalette(theme: ThemePreference, appearance?: ThemeAppe...@@ -1649,6 +1677,7 @@ export function applyThemePalette(theme: ThemePreference, appearance?: ThemeAppe
1649 const root = document.documentElement;1677 const root = document.documentElement;
1650 if (!root?.style) return;1678 if (!root?.style) return;
16511679
1680 setThemePreviewSidebarArtwork(null);
1652 const palette = getThemeDefinition(theme);1681 const palette = getThemeDefinition(theme);
16531682
1654 if (palette) {1683 if (palette) {