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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand Up@@ -773,6 +794,7 @@ export function ThemeEditorPanel({
name,
onOpenChange,
onSaved,
sidebarArtwork,
simpleColorsDirtyByAppearance,
takenAppearances,
]);
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand Up@@ -773,6 +794,7 @@ export function ThemeEditorPanel({
name,
onOpenChange,
onSaved,
sidebarArtwork,
simpleColorsDirtyByAppearance,
takenAppearances,
]);
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand Up@@ -773,6 +794,7 @@ export function ThemeEditorPanel({
name,
onOpenChange,
onSaved,
sidebarArtwork,
simpleColorsDirtyByAppearance,
takenAppearances,
]);
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand Up@@ -773,6 +794,7 @@ export function ThemeEditorPanel({
name,
onOpenChange,
onSaved,
sidebarArtwork,
simpleColorsDirtyByAppearance,
takenAppearances,
]);
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand Up@@ -773,6 +794,7 @@ export function ThemeEditorPanel({
name,
onOpenChange,
onSaved,
sidebarArtwork,
simpleColorsDirtyByAppearance,
takenAppearances,
]);
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand Up@@ -773,6 +794,7 @@ export function ThemeEditorPanel({
name,
onOpenChange,
onSaved,
sidebarArtwork,
simpleColorsDirtyByAppearance,
takenAppearances,
]);
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand Up@@ -773,6 +794,7 @@ export function ThemeEditorPanel({
name,
onOpenChange,
onSaved,
sidebarArtwork,
simpleColorsDirtyByAppearance,
takenAppearances,
]);
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand Up@@ -773,6 +794,7 @@ export function ThemeEditorPanel({
name,
onOpenChange,
onSaved,
sidebarArtwork,
simpleColorsDirtyByAppearance,
takenAppearances,
]);
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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