| author | |
| committer | |
| log | f0fb406acf419c1ab3d37d5f42093c07921484b2 |
| tree | 03ad6198026af6472aae44c8979793dea36ebe26 |
| parent | 82406bce99c2cf46d1d8fbad26bfefc2ab3169ce |
| signature | Signed by PGP key B5690EEEBB952194 |
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({ |
| 259 | 261 | ||
| 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; anything | 267 | // Themes saved by the guided editor carry the managed flag; anything |
| 263 | // else (imports, hand-edited files, older saves) opens in advanced mode | 268 | // else (imports, hand-edited files, older saves) opens in advanced mode |
| 264 | // so guided regeneration cannot silently discard hand-tuned colors. A | 269 | // 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]); |
| 335 | 352 | ||
| 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 > 1 | 705 | ...(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 | ); |
| 834 | 856 | ||
| 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 | }); |
| 21 | 52 | ||
| 22 | describe("mergeEnvironmentSettings", () => { | 53 | describe("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"; |
| 28 | import { APP_STAGE_LABEL } from "~/branding"; | 28 | import { APP_STAGE_LABEL } from "~/branding"; |
| 29 | import { resolveSidebarV2Enabled } from "~/branding.logic"; | 29 | import { resolveSidebarV2Enabled } from "~/branding.logic"; |
| 30 | import { ensureLocalApi } from "~/localApi"; | 30 | import { ensureLocalApi } from "~/localApi"; |
| 31 | import { | ||
| 32 | getThemeDefinition, | ||
| 33 | getThemePreviewSidebarArtwork, | ||
| 34 | resolveThemeHalf, | ||
| 35 | subscribeToThemePreview, | ||
| 36 | } from "~/themePalette"; | ||
| 31 | import * as Struct from "effect/Struct"; | 37 | import * as Struct from "effect/Struct"; |
| 32 | import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; | 38 | import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; |
| 33 | import { usePrimaryEnvironment } from "~/state/environments"; | 39 | import { usePrimaryEnvironment } from "~/state/environments"; |
| 34 | import { useAtomCommand } from "~/state/use-atom-command"; | 40 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 41 | import { useTheme } from "./useTheme"; | ||
| 35 | 42 | ||
| 36 | const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; | 43 | const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; |
| 37 | 44 | ||
| ... | @@ -226,15 +233,36 @@ export function useClientSettings<T = ClientSettings>( | ... | @@ -226,15 +233,36 @@ export function useClientSettings<T = ClientSettings>( |
| 226 | export function resolveEnvironmentIdentificationMode(input: { | 233 | export 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 | } |
| 233 | 247 | ||
| 234 | export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMode { | 248 | export 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 | } |
| 239 | 267 | ||
| 240 | /** | 268 | /** |
apps/web/src/themePalette.test.ts+55-1| ... | @@ -1,9 +1,12 @@ | ... | @@ -1,9 +1,12 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | 1 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 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 | }); |
| 202 | 206 | ||
| 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 | }); |
| 397 | 445 | ||
| 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 | }); |
| 404 | 458 | ||
| 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 its | 101 | /** 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 | }>; |
| 112 | 115 | ||
| ... | @@ -128,6 +131,23 @@ const RESERVED_THEME_IDS = new Set([ | ... | @@ -128,6 +131,23 @@ const RESERVED_THEME_IDS = new Set([ |
| 128 | 131 | ||
| 129 | const customThemeListeners = new Set<() => void>(); | 132 | const customThemeListeners = new Set<() => void>(); |
| 130 | let customThemesSnapshot: ReadonlyArray<ThemeDefinition> | null = null; | 133 | let customThemesSnapshot: ReadonlyArray<ThemeDefinition> | null = null; |
| 134 | const themePreviewListeners = new Set<() => void>(); | ||
| 135 | let themePreviewSidebarArtwork: boolean | null = null; | ||
| 136 | |||
| 137 | export function getThemePreviewSidebarArtwork(): boolean | null { | ||
| 138 | return themePreviewSidebarArtwork; | ||
| 139 | } | ||
| 140 | |||
| 141 | export function subscribeToThemePreview(listener: () => void): () => void { | ||
| 142 | themePreviewListeners.add(listener); | ||
| 143 | return () => themePreviewListeners.delete(listener); | ||
| 144 | } | ||
| 145 | |||
| 146 | function setThemePreviewSidebarArtwork(next: boolean | null): void { | ||
| 147 | if (themePreviewSidebarArtwork === next) return; | ||
| 148 | themePreviewSidebarArtwork = next; | ||
| 149 | for (const listener of themePreviewListeners) listener(); | ||
| 150 | } | ||
| 131 | 151 | ||
| 132 | function isRecord(value: unknown): value is Record<string, unknown> { | 152 | function 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. Callers | 1653 | * 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 | */ |
| 1633 | export function applyThemeColorPreview(colors: ThemeColors, appearance: ThemeAppearance): void { | 1656 | export 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; |
| 1637 | 1664 | ||
| 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; |
| 1651 | 1679 | ||
| 1680 | setThemePreviewSidebarArtwork(null); | ||
| 1652 | const palette = getThemeDefinition(theme); | 1681 | const palette = getThemeDefinition(theme); |
| 1653 | 1682 | ||
| 1654 | if (palette) { | 1683 | if (palette) { |