Skip to content
Merged
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
41 changes: 39 additions & 2 deletions apps/web/src/components/settings/ThemeEditorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ export function ThemeEditorPanel({
const isEditing = editingTheme !== null;
const [name, setName] = useState("");
const [activeAppearance, setActiveAppearance] = useState<ThemeAppearance>(initialAppearance);
const [sidebarArtwork, setSidebarArtwork] = useState(false);
const [isAdvanced, setIsAdvanced] = useState(false);
const [colorsByAppearance, setColorsByAppearance] = useState<ThemeEditorColorsByAppearance>(() =>
getThemeEditorColorsByAppearance(),
Expand All @@ -178,6 +179,7 @@ export function ThemeEditorPanel({
const [isInspecting, setIsInspecting] = useState(false);
const [selectedRole, setSelectedRole] = useState<ThemeColorRole | null>(null);
const [usageCount, setUsageCount] = useState<number | null>(null);
const previousMergeTargetIdRef = useRef<string | null>(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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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[];
Expand All @@ -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;
Expand Down Expand Up @@ -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 } : {}),
}),
);
Expand Down Expand Up @@ -687,6 +705,7 @@ export function ThemeEditorPanel({
...(getThemeModes(editingTheme).length > 1
? { variants: { [variantAppearance]: colorsForSave[variantAppearance] } }
: {}),
...(sidebarArtwork ? { sidebarArtwork: true } : {}),
...(isAdvanced ? {} : { managed: true }),
}),
);
Expand All @@ -713,6 +732,7 @@ export function ThemeEditorPanel({
...mergeTarget.variants,
[activeAppearance]: colorsForSave[activeAppearance],
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
},
...(sidebarArtwork ? { sidebarArtwork: true } : {}),
...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}),
}),
);
Expand All @@ -723,6 +743,7 @@ export function ThemeEditorPanel({
name,
appearance: activeAppearance,
colors: colorsForSave[activeAppearance],
...(sidebarArtwork ? { sidebarArtwork: true } : {}),
...(isAdvanced ? {} : { managed: true }),
}),
);
Expand Down Expand Up @@ -773,6 +794,7 @@ export function ThemeEditorPanel({
name,
onOpenChange,
onSaved,
sidebarArtwork,
simpleColorsDirtyByAppearance,
takenAppearances,
]);
Expand Down Expand Up @@ -832,6 +854,20 @@ export function ThemeEditorPanel({
</div>
);

const renderSidebarArtworkToggle = () => (
<label className="grid cursor-pointer grid-cols-[minmax(0,1fr)_minmax(0,2fr)] items-center gap-3">
<span className="text-sm font-medium">Sidebar artwork</span>
<span className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>Show T3 Code environment artwork</span>
<Switch
aria-label="Allow sidebar artwork with this theme"
checked={sidebarArtwork}
onCheckedChange={(checked) => setSidebarArtwork(Boolean(checked))}
/>
</span>
</label>
);

const renderColorsHeader = () => (
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] items-start gap-3">
<div>
Expand Down Expand Up @@ -1088,6 +1124,7 @@ export function ThemeEditorPanel({
</p>
) : null}
{renderAppearanceButtons()}
{renderSidebarArtworkToggle()}
<div className="space-y-3">
{renderColorsHeader()}
{renderColorFields()}
Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/hooks/useSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
32 changes: 30 additions & 2 deletions apps/web/src/hooks/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]";

Expand Down Expand Up @@ -226,15 +233,36 @@ export function useClientSettings<T = ClientSettings>(
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,
});
}

/**
Expand Down
56 changes: 55 additions & 1 deletion apps/web/src/themePalette.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { describe, expect, it, vi } from "vite-plus/test";

import {
applyThemeColorPreview,
applyThemePalette,
getThemeColorsForMode,
getThemeDefinition,
getThemeModes,
getThemePreviewSidebarArtwork,
getThemePreferenceMode,
isKnownThemePreference,
getCustomThemes,
Expand All @@ -15,6 +18,7 @@ import {
resolveDesktopTheme,
resolveThemeAppearance,
serializeThemeFile,
subscribeToThemePreview,
subscribeToCustomThemes,
T3_CHAT_THEME,
EMBER_THEME,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -387,6 +434,7 @@ describe("theme files", () => {
name: "Aurora",
appearance: "light",
colors: { canvas: "#f8fbff", accent: "#5b6cff" },
sidebarArtwork: true,
}),
);
const updatedTheme = updateCustomTheme({
Expand All @@ -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();
Expand Down
Loading
Loading