diff --git a/apps/web/src/components/settings/ThemeEditorPanel.tsx b/apps/web/src/components/settings/ThemeEditorPanel.tsx index 0074ac89304..f015fce03d0 100644 --- a/apps/web/src/components/settings/ThemeEditorPanel.tsx +++ b/apps/web/src/components/settings/ThemeEditorPanel.tsx @@ -165,6 +165,7 @@ export function ThemeEditorPanel({ const isEditing = editingTheme !== null; const [name, setName] = useState(""); const [activeAppearance, setActiveAppearance] = useState(initialAppearance); + const [sidebarArtwork, setSidebarArtwork] = useState(false); const [isAdvanced, setIsAdvanced] = useState(false); const [colorsByAppearance, setColorsByAppearance] = useState(() => getThemeEditorColorsByAppearance(), @@ -178,6 +179,7 @@ export function ThemeEditorPanel({ const [isInspecting, setIsInspecting] = useState(false); const [selectedRole, setSelectedRole] = useState(null); const [usageCount, setUsageCount] = useState(null); + const previousMergeTargetIdRef = useRef(null); // Null parks the panel at its default corner; a value is a dragged spot, // kept clamped so the header can always be grabbed again. const [position, setPosition] = useState<{ x: number; y: number } | null>(null); @@ -259,6 +261,9 @@ export function ThemeEditorPanel({ setName(editingTheme?.label ?? seedName ?? ""); setActiveAppearance(nextAppearance); + // Artwork is opt-in for new themes, including duplicates. Editing keeps + // the theme's existing choice. + setSidebarArtwork(editingTheme?.sidebarArtwork === true); // Themes saved by the guided editor carry the managed flag; anything // else (imports, hand-edited files, older saves) opens in advanced mode // so guided regeneration cannot silently discard hand-tuned colors. A @@ -315,6 +320,18 @@ export function ThemeEditorPanel({ // an explanation instead. const mergeTargetId = mergeTarget?.id ?? null; const takenAppearancesKey = takenAppearances.join(","); + useEffect(() => { + if (previousMergeTargetIdRef.current === mergeTargetId) return; + previousMergeTargetIdRef.current = mergeTargetId; + // A matching name makes that existing theme the surviving merge target. + // Seed theme-level options from it so adding a palette or renaming onto it + // does not silently reset them. Leaving the merge restores the edited + // theme's option (or the off-by-default choice for a new theme). + setSidebarArtwork( + mergeTarget ? mergeTarget.sidebarArtwork === true : editingTheme?.sidebarArtwork === true, + ); + }, [editingTheme, mergeTarget, mergeTargetId]); + useEffect(() => { if (isEditing || mergeTargetId === null) return; const taken = takenAppearancesKey.split(",").filter(Boolean) as ThemeAppearance[]; @@ -330,8 +347,8 @@ export function ThemeEditorPanel({ // comes back when the editor closes, including on cancel. useEffect(() => { if (!open || !isDraftSeeded) return; - applyThemeColorPreview(colorsByAppearance[activeAppearance], activeAppearance); - }, [activeAppearance, colorsByAppearance, isDraftSeeded, open]); + applyThemeColorPreview(colorsByAppearance[activeAppearance], activeAppearance, sidebarArtwork); + }, [activeAppearance, colorsByAppearance, isDraftSeeded, open, sidebarArtwork]); useEffect(() => { if (!open) return; @@ -657,6 +674,7 @@ export function ThemeEditorPanel({ ...mergeTarget.variants, ...Object.fromEntries(editedModes.map((mode) => [mode, colorsForSave[mode]])), }, + ...(sidebarArtwork ? { sidebarArtwork: true } : {}), ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}), }), ); @@ -687,6 +705,7 @@ export function ThemeEditorPanel({ ...(getThemeModes(editingTheme).length > 1 ? { variants: { [variantAppearance]: colorsForSave[variantAppearance] } } : {}), + ...(sidebarArtwork ? { sidebarArtwork: true } : {}), ...(isAdvanced ? {} : { managed: true }), }), ); @@ -713,6 +732,7 @@ export function ThemeEditorPanel({ ...mergeTarget.variants, [activeAppearance]: colorsForSave[activeAppearance], }, + ...(sidebarArtwork ? { sidebarArtwork: true } : {}), ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}), }), ); @@ -723,6 +743,7 @@ export function ThemeEditorPanel({ name, appearance: activeAppearance, colors: colorsForSave[activeAppearance], + ...(sidebarArtwork ? { sidebarArtwork: true } : {}), ...(isAdvanced ? {} : { managed: true }), }), ); @@ -773,6 +794,7 @@ export function ThemeEditorPanel({ name, onOpenChange, onSaved, + sidebarArtwork, simpleColorsDirtyByAppearance, takenAppearances, ]); @@ -832,6 +854,20 @@ export function ThemeEditorPanel({ ); + const renderSidebarArtworkToggle = () => ( + + ); + const renderColorsHeader = () => (
@@ -1088,6 +1124,7 @@ export function ThemeEditorPanel({

) : null} {renderAppearanceButtons()} + {renderSidebarArtworkToggle()}
{renderColorsHeader()} {renderColorFields()} diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index 741579661e7..b332fe13c2f 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -17,6 +17,37 @@ describe("resolveEnvironmentIdentificationMode", () => { "pill", ); }); + + it("uses a pill instead of artwork with a palette theme", () => { + expect( + resolveEnvironmentIdentificationMode({ + mode: "artwork", + settingsHydrated: true, + paletteThemeActive: true, + }), + ).toBe("pill"); + }); + + it("respects none with a palette theme", () => { + expect( + resolveEnvironmentIdentificationMode({ + mode: "none", + settingsHydrated: true, + paletteThemeActive: true, + }), + ).toBe("none"); + }); + + it("keeps artwork when the palette theme opts into it", () => { + expect( + resolveEnvironmentIdentificationMode({ + mode: "artwork", + settingsHydrated: true, + paletteThemeActive: true, + paletteThemeAllowsArtwork: true, + }), + ).toBe("artwork"); + }); }); describe("mergeEnvironmentSettings", () => { diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index e58876b19f7..f4797bb775d 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -28,10 +28,17 @@ import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { APP_STAGE_LABEL } from "~/branding"; import { resolveSidebarV2Enabled } from "~/branding.logic"; import { ensureLocalApi } from "~/localApi"; +import { + getThemeDefinition, + getThemePreviewSidebarArtwork, + resolveThemeHalf, + subscribeToThemePreview, +} from "~/themePalette"; import * as Struct from "effect/Struct"; import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; import { usePrimaryEnvironment } from "~/state/environments"; import { useAtomCommand } from "~/state/use-atom-command"; +import { useTheme } from "./useTheme"; const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; @@ -226,15 +233,36 @@ export function useClientSettings( export function resolveEnvironmentIdentificationMode(input: { mode: EnvironmentIdentificationMode; settingsHydrated: boolean; + paletteThemeActive?: boolean; + paletteThemeAllowsArtwork?: boolean; }): EnvironmentIdentificationMode { // Avoid briefly rendering the default artwork before a persisted pill/none choice loads. - return input.settingsHydrated ? input.mode : "none"; + if (!input.settingsHydrated) return "none"; + // Stage artwork has fixed colors that can clash with palette themes. Keep an + // explicit "none", but use the theme-aware pill in place of artwork. + return input.paletteThemeActive && !input.paletteThemeAllowsArtwork && input.mode === "artwork" + ? "pill" + : input.mode; } export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMode { const settingsHydrated = useClientSettingsHydrated(); const mode = useClientSettingsValue().environmentIdentificationMode; - return resolveEnvironmentIdentificationMode({ mode, settingsHydrated }); + const { resolvedTheme, theme, themeHalves } = useTheme(); + const previewSidebarArtwork = useSyncExternalStore( + subscribeToThemePreview, + getThemePreviewSidebarArtwork, + () => null, + ); + const activeTheme = resolveThemeHalf(theme, themeHalves, resolvedTheme); + const activeThemeDefinition = getThemeDefinition(activeTheme); + return resolveEnvironmentIdentificationMode({ + mode, + settingsHydrated, + paletteThemeActive: previewSidebarArtwork !== null || activeThemeDefinition !== null, + paletteThemeAllowsArtwork: + previewSidebarArtwork ?? activeThemeDefinition?.sidebarArtwork === true, + }); } /** diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index 2ed4ff3891d..671b5dbb76d 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { + applyThemeColorPreview, + applyThemePalette, getThemeColorsForMode, getThemeDefinition, getThemeModes, + getThemePreviewSidebarArtwork, getThemePreferenceMode, isKnownThemePreference, getCustomThemes, @@ -15,6 +18,7 @@ import { resolveDesktopTheme, resolveThemeAppearance, serializeThemeFile, + subscribeToThemePreview, subscribeToCustomThemes, T3_CHAT_THEME, EMBER_THEME, @@ -200,6 +204,49 @@ describe("theme files", () => { }); }); + it("keeps sidebar artwork opt-in through theme files", () => { + const withoutArtwork = parseThemeFile({ + version: THEME_FILE_VERSION, + name: "Plain sidebar", + appearance: "light", + colors: { accent: "#5b6cff" }, + }); + const withArtwork = parseThemeFile({ + version: THEME_FILE_VERSION, + name: "Art sidebar", + appearance: "light", + colors: { accent: "#5b6cff" }, + sidebarArtwork: true, + }); + + expect(withoutArtwork.sidebarArtwork).toBeUndefined(); + expect(withArtwork.sidebarArtwork).toBe(true); + expect(JSON.parse(serializeThemeFile(withArtwork)).sidebarArtwork).toBe(true); + }); + + it("publishes sidebar artwork changes from the live theme preview", () => { + const listener = vi.fn(); + const unsubscribe = subscribeToThemePreview(listener); + vi.stubGlobal("document", { + documentElement: { + classList: { toggle: vi.fn() }, + dataset: {}, + style: { removeProperty: vi.fn(), setProperty: vi.fn() }, + }, + }); + + applyThemeColorPreview(T3_CHAT_THEME.colors, "light", true); + expect(getThemePreviewSidebarArtwork()).toBe(true); + expect(listener).toHaveBeenCalledTimes(1); + + applyThemePalette("system"); + expect(getThemePreviewSidebarArtwork()).toBeNull(); + expect(listener).toHaveBeenCalledTimes(2); + + unsubscribe(); + vi.unstubAllGlobals(); + }); + it("keeps optional light and dark palettes under one theme id", () => { const theme = parseThemeFile({ version: THEME_FILE_VERSION, @@ -387,6 +434,7 @@ describe("theme files", () => { name: "Aurora", appearance: "light", colors: { canvas: "#f8fbff", accent: "#5b6cff" }, + sidebarArtwork: true, }), ); const updatedTheme = updateCustomTheme({ @@ -395,11 +443,17 @@ describe("theme files", () => { colors: { ...createdTheme.colors, accent: "#7c3aed" }, }); - expect(updatedTheme).toMatchObject({ id: "aurora", label: "Aurora Night" }); + expect(updatedTheme).toMatchObject({ + id: "aurora", + label: "Aurora Night", + sidebarArtwork: true, + }); + invalidateCustomThemes(); expect(getCustomThemes()).toEqual([updatedTheme]); expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")[0]).toMatchObject({ id: "aurora", label: "Aurora Night", + sidebarArtwork: true, }); vi.unstubAllGlobals(); diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index dafc5dbf457..2f6fb043454 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -96,6 +96,8 @@ export type ThemeDefinition = Readonly<{ appearance: ThemeAppearance; colors: ThemeColors; variants?: ThemeVariants; + /** Allows fixed Dev/Nightly artwork to render over this theme's sidebar. */ + sidebarArtwork?: boolean; /** True when the palette was generated by the guided editor from its * canvas and accent; such themes reopen in guided mode. */ managed?: boolean; @@ -107,6 +109,7 @@ export type ThemeFile = Readonly<{ appearance: ThemeAppearance; colors: ThemeColorOverrides; variants?: ThemeVariantOverrides; + sidebarArtwork?: boolean; managed?: boolean; }>; @@ -128,6 +131,23 @@ const RESERVED_THEME_IDS = new Set([ const customThemeListeners = new Set<() => void>(); let customThemesSnapshot: ReadonlyArray | null = null; +const themePreviewListeners = new Set<() => void>(); +let themePreviewSidebarArtwork: boolean | null = null; + +export function getThemePreviewSidebarArtwork(): boolean | null { + return themePreviewSidebarArtwork; +} + +export function subscribeToThemePreview(listener: () => void): () => void { + themePreviewListeners.add(listener); + return () => themePreviewListeners.delete(listener); +} + +function setThemePreviewSidebarArtwork(next: boolean | null): void { + if (themePreviewSidebarArtwork === next) return; + themePreviewSidebarArtwork = next; + for (const listener of themePreviewListeners) listener(); +} function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -203,6 +223,7 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null { appearance: value.appearance, colors, ...(variants ? { variants } : {}), + ...(value.sidebarArtwork === true ? { sidebarArtwork: true } : {}), ...(value.managed === true ? { managed: true } : {}), }; } @@ -1541,6 +1562,7 @@ export function parseThemeFile(value: unknown): ThemeDefinition { appearance, colors: { ...fallback, ...overrides }, ...(Object.keys(variants).length > 0 ? { variants } : {}), + ...(value.sidebarArtwork === true ? { sidebarArtwork: true } : {}), ...(value.managed === true ? { managed: true } : {}), }; } @@ -1553,6 +1575,7 @@ export function serializeThemeFile(theme: ThemeDefinition): string { appearance: theme.appearance, colors: theme.colors, ...(theme.variants ? { variants: theme.variants } : {}), + ...(theme.sidebarArtwork ? { sidebarArtwork: true } : {}), ...(theme.managed ? { managed: true } : {}), }; return `${JSON.stringify(file, null, 2)}\n`; @@ -1630,11 +1653,16 @@ export const THEME_PREVIEW_ID = "__preview"; * can be judged against the real interface instead of a miniature. Callers * restore the stored theme (refreshTheme) when the draft goes away. */ -export function applyThemeColorPreview(colors: ThemeColors, appearance: ThemeAppearance): void { +export function applyThemeColorPreview( + colors: ThemeColors, + appearance: ThemeAppearance, + sidebarArtwork = false, +): void { if (typeof document === "undefined") return; const root = document.documentElement; if (!root?.style) return; + setThemePreviewSidebarArtwork(sidebarArtwork); root.dataset.themeId = THEME_PREVIEW_ID; root.classList.toggle("dark", appearance === "dark"); for (const [role, value] of Object.entries(colors) as Array<[ThemeColorRole, string]>) { @@ -1649,6 +1677,7 @@ export function applyThemePalette(theme: ThemePreference, appearance?: ThemeAppe const root = document.documentElement; if (!root?.style) return; + setThemePreviewSidebarArtwork(null); const palette = getThemeDefinition(theme); if (palette) {