mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(desktop): add pane opacity control and fix base style revert on custom theme save / 新增面板透明度控制,修复自定义主题保存后基础风格回退 (#6645)
* feat(desktop): add pane opacity control and fix base style revert on custom theme save New feature — per-scene pane opacity (面板透明度): - ThemePackBackground / ThemePackSceneBackground gain a paneOpacity field (0-1) that controls how transparent the shell panels render when a theme pack is active. Home defaults to 0.50, task defaults to 0.68. - Independent sliders in ThemeGallery editor for both scenes. - applyBackgroundCSSVars pre-computes CSS variables (--theme-pane-alpha, --theme-pane-shell-pct, --theme-pane-task-*) for color-mix rules. - CSS rules under :root[data-theme-pack] apply pane opacity to sidebar, topicbar, dock, statusbar, chat-pane, composer-card, and transcript with cascading opacity tiers (shell < card < inner). - ThemePreviewSurface preview cards now reflect pane opacity. - Task background opacity caps relaxed from 0.45 to 1.0 per user demand. - All 8 official mock themes include paneOpacity: 0.50. - Schema and docs (EN/ZH) updated with paneOpacity field. Bug fix — base style revert on Save & apply: When ThemeGallery.saveEditor(activate=true) called activateThemePack(), the downstream onExperienceChange callback passed exp.baseStyle (the config-file appearance, e.g. graphite) to the parent's onThemeStyle. The parent then called applyTheme(theme, OLD_style) which overwrote the pack's baseStyle (e.g. refined) that applyThemePack had just set on the DOM. Fixes: - AppearanceOverview.onExperienceChange now syncs exp.effectiveStyle instead of exp.baseStyle, and no longer calls onTheme (which reads stale themeStyle from its closure). - SettingsPanel.onThemeStyle now reads the current theme from the DOM via getTheme() instead of the React closure theme state. - ThemeGallery.saveEditor moves cancelThemePreview() to only the non-activate branch; the activate path must not restore the preview snapshot after the new pack is applied. - themeExperience.activateThemePack calls clearPreviewSnapshotOnly() first so endPreviewIfAny() cannot overwrite the newly activated pack. - Layout transparency CSS selectors gain .app:not(.app--creation) to exclude the creation-mode surface. Test contract: - TestTaskOpacityCapped renamed to TestOpacityClamped, now validates the new 0-1 clamping and PaneOpacity defaults for both scenes. - 'layout background transparent' contract test updated to a single flexible regex that tolerates intermediate selectors. - i18n: taskBackgroundHint adjusted (removed stale 45% cap reference); new paneOpacity label in EN/ZH/ZH-TW. * fix(desktop): preserve theme pane transparency state Problem: - Explicit 0% pane opacity was replaced by legacy defaults. - Token-only theme packs received background transparency rules. - Failed theme activation discarded the reversible preview snapshot. Root cause: - A scalar JSON field could not distinguish omission from an explicit zero. - Extended pane selectors were scoped to any theme pack instead of packs with backgrounds. - The preview snapshot was cleared before persistence succeeded. Fix: - Represent pane opacity as an optional persisted value and preserve explicit zero through normalization and JSON round trips. - Guard background and pane transparency CSS with data-theme-has-bg. - Commit preview state only after successful activation and add focused regressions. Verification: - cd desktop && go test ./... - cd desktop/frontend && pnpm run test - cd desktop/frontend && pnpm run build - Browser DOM/CSS checks for token-only and background theme previews * fix(desktop): separate theme base state from pack effects Problem: - Applying a theme pack mirrored its effective style into the configured base appearance, so direct theme reset restored the pack style. - Pane opacity previews changed while live card surfaces remained fixed at 88%. Root cause: - The gallery callback reused the configured-style owner callback for a temporary effective style. - Card-tier CSS variables were computed but not consumed, and preview offsets differed from live formulas. Fix: - Synchronize configured base style only when no active pack owns the effective style. - Consume scene-specific card opacity variables in live surfaces and use the same formulas in previews. - Remove unused pane variables and add regressions for direct reset and opacity consumers. Verification: - cd desktop/frontend && pnpm run test - cd desktop/frontend && pnpm run build - cd desktop && go test ./... - Browser: Sage/Slate reset restored Graphite; live and preview card opacity matched scene variables * fix(desktop): preserve theme preview lifecycle Problem: - Save and apply closed the theme editor before activation completed, allowing unmount cleanup to restore the old preview snapshot. - Base and token-only gallery previews received background pane transparency that live surfaces do not apply. Root cause: - Editor teardown occurred before activateThemePack committed the preview. - Preview opacity variables were derived without checking whether the pack had a background. Fix: - Activate or cancel the preview before closing the editor and reloading the gallery. - Keep pane previews opaque when no theme background is present. - Add regressions for lifecycle ordering and preview opacity. Verification: - cd desktop/frontend && pnpm run test - cd desktop/frontend && pnpm run build - cd desktop && go test ./... - Browser preview check: base previews stayed opaque while background previews retained configured alpha * fix(desktop): keep theme activation and previews consistent Problem: - Save and apply persisted activation twice, so a later activation failure could split persistent state from the reversible preview. - Browser official-theme mocks used 50% pane opacity while embedded manifests fell back to legacy defaults. - Legacy task scene payloads without pane opacity did not inherit the home setting. Root cause: - ThemeGallery delegated activation to SaveThemePack and then called the experience activation controller again. - Official manifests omitted the new field, and the task fallback tested object presence instead of field presence. Fix: - Make the experience controller the sole owner of active-theme pointer updates after saving. - Declare 50% pane opacity in every official manifest and verify Wails/browser parity. - Use nullish field fallback for legacy task scenes and add regression coverage. Verification: - pnpm exec tsx src/__tests__/theme-pack.test.ts (230 passed) - pnpm run typecheck - pnpm run check:css - pnpm run test - pnpm run build - go test ./... --------- Co-authored-by: SivanCola <[email protected]>
This commit is contained in:
1 parent
a46fc6f47a
commit
a144e55e41
26 files changed
+456
-72
No files matched your search
@@ -19,6 +19,15 @@ import {
|
||||
} from "../lib/themePack";
|
||||
import { applyTheme, getThemeStyle } from "../lib/theme";
|
||||
import { BASE_STYLE_PREVIEW_PALETTES, themePreviewPalette } from "../lib/themePreviewPalette";
|
||||
import { themePreviewPaneAlpha } from "../components/ThemePreviewSurface";
|
||||
import {
|
||||
activateThemePack,
|
||||
applyExperienceToDOM,
|
||||
cancelGlobalPreview,
|
||||
configuredBaseStyleForSync,
|
||||
isPreviewActive,
|
||||
startGlobalPreview,
|
||||
} from "../lib/themeExperience";
|
||||
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const packSource = readFileSync(resolve(testDir, "../lib/themePack.ts"), "utf8");
|
||||
@@ -146,10 +155,21 @@ const draft = draftPackView({
|
||||
homeOpacity: 1,
|
||||
taskOpacity: 0.2,
|
||||
overlayStrength: 0.5,
|
||||
paneOpacity: 0.50,
|
||||
},
|
||||
backgroundUrl: "/__reasonix_theme_asset/preview-pack/deadbeef/background.png",
|
||||
});
|
||||
|
||||
const tokenOnlyPreview = draftPackView({
|
||||
id: "token-only-preview",
|
||||
name: "Token Only Preview",
|
||||
baseStyle: "graphite",
|
||||
tokens: { dark: { accent: "#ff0000" } },
|
||||
recipes: { density: "comfortable", corners: "soft" },
|
||||
});
|
||||
ok(themePreviewPaneAlpha(tokenOnlyPreview, "home") === 1, "token-only preview keeps opaque panes");
|
||||
ok(themePreviewPaneAlpha(draft, "home") === 0.5, "background preview applies configured pane opacity");
|
||||
|
||||
applyThemePack(draft);
|
||||
ok(attrs.get("data-theme-pack") === "preview-pack", "sets data-theme-pack");
|
||||
ok(attrs.get("data-theme-has-bg") === "true", "marks background present");
|
||||
@@ -159,14 +179,27 @@ ok((styleEl as { textContent: string }).textContent.includes("--r:14px"), "appli
|
||||
|
||||
const twoSceneDraft = draftPackView({
|
||||
...draft,
|
||||
taskBackground: { focusX: 0.8, focusY: 0.3, safeArea: "right", opacity: 0.35, overlayStrength: 0.7 },
|
||||
taskBackground: { focusX: 0.8, focusY: 0.3, safeArea: "right", opacity: 0.35, overlayStrength: 0.7, paneOpacity: 0.68 },
|
||||
taskBackgroundUrl: "/__reasonix_theme_asset/preview-pack/deadbeef/background-task.png",
|
||||
});
|
||||
applyThemePack(twoSceneDraft);
|
||||
ok(styleProps.get("--theme-bg-task-image")?.includes("background-task.png") === true, "sets independent task image var");
|
||||
ok(styleProps.get("--theme-bg-task-opacity") === "0.35", "sets independent task opacity");
|
||||
ok(styleProps.get("--theme-pane-card-pct") === "76%", "computes home card pane opacity");
|
||||
ok(styleProps.get("--theme-pane-task-card-pct") === "82%", "computes task card pane opacity");
|
||||
ok(attrs.get("data-theme-safe-area") === "right", "task background controls safe area");
|
||||
|
||||
// Older shells and partial mocks can expose the independent task scene without
|
||||
// the newly added paneOpacity field. It must inherit the home pane value rather
|
||||
// than falling through clamp01(undefined)'s generic midpoint.
|
||||
const legacyTaskPaneDraft = draftPackView({
|
||||
...twoSceneDraft,
|
||||
taskBackground: { ...twoSceneDraft.taskBackground! },
|
||||
});
|
||||
delete (legacyTaskPaneDraft.taskBackground as { paneOpacity?: number }).paneOpacity;
|
||||
applyThemePack(legacyTaskPaneDraft);
|
||||
ok(styleProps.get("--theme-pane-task-alpha") === "0.5", "legacy task scene inherits home pane opacity");
|
||||
|
||||
applyThemeScene("task");
|
||||
ok(attrs.get("data-theme-scene") === "task", "scene task on root");
|
||||
|
||||
@@ -180,6 +213,49 @@ ok(attrs.get("data-theme-pack") === "preview-pack", "preview applies pack");
|
||||
cancelThemePreview();
|
||||
ok(!attrs.has("data-theme-pack"), "cancel restores cleared pack");
|
||||
|
||||
// A failed persistent activation must keep the preview snapshot reversible.
|
||||
clearThemePack();
|
||||
applyTheme("dark", "graphite", { persist: false });
|
||||
startGlobalPreview(draft);
|
||||
const testWindow = window as typeof window & {
|
||||
go?: { main?: { App?: { ActivateThemePack: (id: string) => Promise<void> } } };
|
||||
};
|
||||
testWindow.go = {
|
||||
main: {
|
||||
App: {
|
||||
async ActivateThemePack() {
|
||||
throw new Error("activation failed");
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
let activationRejected = false;
|
||||
try {
|
||||
await activateThemePack(draft.id);
|
||||
} catch {
|
||||
activationRejected = true;
|
||||
}
|
||||
ok(activationRejected, "activation failure surfaces to caller");
|
||||
ok(isPreviewActive(), "activation failure keeps preview reversible");
|
||||
cancelGlobalPreview();
|
||||
ok(!attrs.has("data-theme-pack") && getThemeStyle() === "graphite", "cancel restores appearance after activation failure");
|
||||
delete testWindow.go;
|
||||
|
||||
// Save-and-apply must commit the preview before editor unmount cleanup can
|
||||
// restore the old snapshot while the gallery reload is in flight.
|
||||
const saveEditorStart = gallerySource.indexOf("const saveEditor = async");
|
||||
const saveEditorEnd = gallerySource.indexOf("if (immersive && selectedPack)", saveEditorStart);
|
||||
const saveEditorSource = gallerySource.slice(saveEditorStart, saveEditorEnd);
|
||||
ok(saveEditorSource.includes("activate: false"), "save-and-apply defers persistent activation to the experience controller");
|
||||
ok(
|
||||
(saveEditorSource.match(/activateThemePack\(saved\.id\)/g) || []).length === 1,
|
||||
"save-and-apply persists activation exactly once",
|
||||
);
|
||||
ok(
|
||||
saveEditorSource.indexOf("await activateThemePack(saved.id)") < saveEditorSource.indexOf("setEditor(null)"),
|
||||
"save-and-apply activates before editor unmount",
|
||||
);
|
||||
|
||||
// Restore-default must restore config baseStyle, not leave pack baseStyle.
|
||||
setBaseAppearance("dark", "graphite");
|
||||
applyTheme("dark", "graphite", { persist: false });
|
||||
@@ -197,6 +273,25 @@ clearThemePack();
|
||||
ok(!attrs.has("data-theme-pack"), "clear removes data-theme-pack");
|
||||
ok(getThemeStyle() === "graphite", "clear restores config graphite style");
|
||||
|
||||
// React owners must not replace the configured base style with a pack's
|
||||
// effective style. Direct reset entry points depend on this restore snapshot.
|
||||
const activeAuroraExperience = {
|
||||
themeMode: "dark" as const,
|
||||
baseStyle: "graphite" as const,
|
||||
effectiveStyle: "aurora" as const,
|
||||
activeThemeId: aurora.id,
|
||||
activePack: aurora,
|
||||
safeMode: false,
|
||||
};
|
||||
applyExperienceToDOM(activeAuroraExperience);
|
||||
ok(configuredBaseStyleForSync(activeAuroraExperience) === null, "active pack effective style is not mirrored as configured base");
|
||||
clearThemePack();
|
||||
ok(getThemeStyle() === "graphite", "direct reset still restores configured base after experience sync");
|
||||
ok(
|
||||
configuredBaseStyleForSync({ ...activeAuroraExperience, activeThemeId: undefined, activePack: null, baseStyle: "slate", effectiveStyle: "slate" }) === "slate",
|
||||
"inactive experience still synchronizes a newly selected base style",
|
||||
);
|
||||
|
||||
// Density recipe must land in overlay CSS and have stylesheet consumers.
|
||||
ok((styleEl as { textContent: string }).textContent.includes("--theme-density-pad") || packSource.includes("--theme-density-pad:6px"), "compact density vars defined in pack builder");
|
||||
const compactDraft = draftPackView({
|
||||
@@ -215,10 +310,20 @@ ok(stylesSource.includes("--list-row-height: var(--theme-row-h)"), "density maps
|
||||
|
||||
// Layout must go transparent when a background is active so theme-bg is visible.
|
||||
ok(
|
||||
stylesSource.includes(':root[data-theme-has-bg="true"] .layout') &&
|
||||
/data-theme-has-bg="true"\]\s*\.layout\s*\{[^}]*background:\s*transparent/s.test(stylesSource),
|
||||
/data-theme-has-bg="true"\][^}]*\.layout\s*\{[^}]*background:\s*transparent/s.test(stylesSource),
|
||||
"layout background transparent when theme has background",
|
||||
);
|
||||
const transparencyStart = stylesSource.indexOf("Extended pane transparency");
|
||||
const transparencyEnd = stylesSource.indexOf("Density recipe consumers", transparencyStart);
|
||||
const transparencySlice = stylesSource.slice(transparencyStart, transparencyEnd);
|
||||
const unguardedTransparencySelectors = transparencySlice
|
||||
.split("\n")
|
||||
.filter((line) => line.includes(":root[data-theme-pack]") && !line.includes('[data-theme-has-bg="true"]'));
|
||||
ok(unguardedTransparencySelectors.length === 0, "token-only packs keep opaque layout surfaces");
|
||||
ok(stylesSource.includes("var(--theme-pane-card-pct, 88%)"), "home cards consume pane opacity tier");
|
||||
ok(stylesSource.includes("var(--theme-pane-task-card-pct, 88%)"), "task cards consume pane opacity tier");
|
||||
ok(stylesSource.includes("var(--tp-pane-card-pct, 88%)"), "preview cards consume the same pane opacity tier");
|
||||
ok(stylesSource.includes(':root[data-theme-has-bg="true"] .theme-bg'), "background layer only displays for packs with backgrounds");
|
||||
|
||||
// Unmount must cancel preview.
|
||||
ok(librarySource.includes("cancelThemePreview()"), "ThemeLibrary cleanup cancels preview");
|
||||
@@ -394,6 +499,7 @@ ok((bridgeSource.match(/kind: "base"/g) || []).length === 6, "mock has 6 base pa
|
||||
ok((bridgeSource.match(/kind: "official"/g) || []).length === 8, "mock has 8 official packs");
|
||||
ok((bridgeSource.match(/previewUrl: new URL\("\.\.\/\.\.\/\.\.\/themes\/official\//g) || []).length === 8, "browser mock has 8 real official previews");
|
||||
ok((bridgeSource.match(/backgroundUrl: new URL\("\.\.\/\.\.\/\.\.\/themes\/official\//g) || []).length === 8, "browser mock has 8 real official backgrounds");
|
||||
ok((bridgeSource.match(/paneOpacity:\s*0\.50/g) || []).length === 8, "browser mock gives every official theme the product pane opacity");
|
||||
ok(viteSource.includes('resolve(configDir, "../themes/official")'), "Vite dev server permits only the official theme asset directory");
|
||||
ok(stylesSource.includes("container: theme-gallery / inline-size"), "gallery establishes its own responsive container");
|
||||
ok(stylesSource.includes("@container theme-gallery (max-width: 760px)"), "gallery collapses from its content width");
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
activateBaseStyle,
|
||||
applyExperienceToDOM,
|
||||
cancelGlobalPreview,
|
||||
configuredBaseStyleForSync,
|
||||
disableThemePack,
|
||||
loadThemeExperience,
|
||||
restoreGraphiteAppearance,
|
||||
@@ -263,10 +264,11 @@ export function AppearanceOverview({
|
||||
initialCreateBaseStyle={galleryIntent === "copy-base" ? baseStyle : undefined}
|
||||
onExperienceChange={(exp) => {
|
||||
setExperience(exp);
|
||||
if (isThemeStyle(exp.baseStyle)) onThemeStyle(exp.baseStyle);
|
||||
if (exp.themeMode === "auto" || exp.themeMode === "light" || exp.themeMode === "dark") {
|
||||
onTheme(exp.themeMode);
|
||||
}
|
||||
// Keep the configured base style separate from an active pack's live
|
||||
// effective style. applyExperienceToDOM already owns the pack override;
|
||||
// mirroring it through onThemeStyle would corrupt the restore snapshot.
|
||||
const configuredStyle = configuredBaseStyleForSync(exp);
|
||||
if (configuredStyle) onThemeStyle(configuredStyle);
|
||||
}}
|
||||
onBack={() => {
|
||||
cancelGlobalPreview();
|
||||
|
||||
@@ -310,9 +310,9 @@ export function SettingsPanel({
|
||||
onThemeStyle={(style) => {
|
||||
// AppearanceOverview already persists via ActivateBaseStyle /
|
||||
// experience APIs. Parent only mirrors React + DOM state.
|
||||
applyTheme(theme, style, { persist: false });
|
||||
applyTheme(getTheme(), style, { persist: false });
|
||||
setThemeStyleState(style);
|
||||
setBaseAppearance(theme, style);
|
||||
setBaseAppearance(getTheme(), style);
|
||||
}}
|
||||
onTextSize={(size) => {
|
||||
applyTextSize(size);
|
||||
|
||||
@@ -527,19 +527,26 @@ export function ThemeGallery({
|
||||
taskBackgroundDataUrl: editor.taskBackgroundDataUrl || undefined,
|
||||
clearTaskBackground: !editor.taskBackground && !editor.taskBackgroundDataUrl && !editor.existingTaskBackgroundUrl,
|
||||
replace: editor.mode === "edit",
|
||||
activate,
|
||||
// Keep save and activation separate. The activation path below is the
|
||||
// sole owner of the active-theme pointer, so a failed activation leaves
|
||||
// the previous theme selected and the preview snapshot reversible.
|
||||
activate: false,
|
||||
};
|
||||
const saved = await app.SaveThemePack(input);
|
||||
showToast(t("settings.themeLibrary.saved", { name: saved.name }), "info");
|
||||
cancelThemePreview();
|
||||
if (activate) {
|
||||
// Commit activation before unmounting the editor. ThemeEditorInline's
|
||||
// cleanup cancels any remaining preview, so closing it earlier would
|
||||
// briefly restore the old snapshot while reload() is in flight.
|
||||
const view = await activateThemePack(saved.id);
|
||||
onExperienceChange(view);
|
||||
} else {
|
||||
cancelThemePreview();
|
||||
}
|
||||
setEditor(null);
|
||||
await reload();
|
||||
setTab("user");
|
||||
setSelected(selectionFromPack(saved));
|
||||
if (activate) {
|
||||
const view = await activateThemePack(saved.id);
|
||||
onExperienceChange(view);
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
} finally {
|
||||
@@ -1039,6 +1046,7 @@ function ThemeEditorInline({
|
||||
opacity={state.background?.homeOpacity ?? 1}
|
||||
opacityMax={1}
|
||||
overlayStrength={state.background?.overlayStrength ?? 0.62}
|
||||
paneOpacity={state.background?.paneOpacity ?? 0.72}
|
||||
busy={busy}
|
||||
onPick={() => void pickBackground("home")}
|
||||
onClear={() => onChange({ background: null, backgroundDataUrl: "", existingBackgroundUrl: "" })}
|
||||
@@ -1056,8 +1064,9 @@ function ThemeEditorInline({
|
||||
focusY={state.taskBackground?.focusY ?? state.background?.focusY ?? 0.5}
|
||||
safeArea={state.taskBackground?.safeArea || state.background?.safeArea || "center"}
|
||||
opacity={state.taskBackground?.opacity ?? state.background?.taskOpacity ?? 0.28}
|
||||
opacityMax={0.45}
|
||||
opacityMax={1}
|
||||
overlayStrength={state.taskBackground?.overlayStrength ?? state.background?.overlayStrength ?? 0.62}
|
||||
paneOpacity={state.taskBackground?.paneOpacity ?? state.background?.paneOpacity ?? 0.80}
|
||||
busy={busy}
|
||||
onPick={() => void pickBackground("task")}
|
||||
onClear={() => onChange({ taskBackground: null, taskBackgroundDataUrl: "", existingTaskBackgroundUrl: "" })}
|
||||
@@ -1107,6 +1116,7 @@ type ScenePatch = {
|
||||
safeArea?: "left" | "center" | "right";
|
||||
opacity?: number;
|
||||
overlayStrength?: number;
|
||||
paneOpacity?: number;
|
||||
};
|
||||
|
||||
function SceneImageEditor({
|
||||
@@ -1120,6 +1130,7 @@ function SceneImageEditor({
|
||||
opacity,
|
||||
opacityMax,
|
||||
overlayStrength,
|
||||
paneOpacity,
|
||||
busy,
|
||||
onPick,
|
||||
onClear,
|
||||
@@ -1135,6 +1146,7 @@ function SceneImageEditor({
|
||||
opacity: number;
|
||||
opacityMax: number;
|
||||
overlayStrength: number;
|
||||
paneOpacity: number;
|
||||
busy: boolean;
|
||||
onPick: () => void;
|
||||
onClear: () => void;
|
||||
@@ -1189,6 +1201,7 @@ function SceneImageEditor({
|
||||
</div>
|
||||
<label className="theme-editor__range"><span>{t("settings.themeEditor.opacity")} <b>{Math.round(opacity * 100)}%</b></span><input type="range" min={0} max={opacityMax} step={0.01} value={opacity} onChange={(e) => onPatch({ opacity: Number(e.target.value) })} /></label>
|
||||
<label className="theme-editor__range"><span>{t("settings.themeLibrary.overlayStrength")} <b>{Math.round(overlayStrength * 100)}%</b></span><input type="range" min={0} max={1} step={0.01} value={overlayStrength} onChange={(e) => onPatch({ overlayStrength: Number(e.target.value) })} /></label>
|
||||
<label className="theme-editor__range"><span>{t("settings.themeEditor.paneOpacity")} <b>{Math.round(paneOpacity * 100)}%</b></span><input type="range" min={0} max={1} step={0.01} value={paneOpacity} onChange={(e) => onPatch({ paneOpacity: Number(e.target.value) })} /></label>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,16 @@ import { useMemo, type CSSProperties } from "react";
|
||||
import { themePackKind, type ThemePackView } from "../lib/themePack";
|
||||
import { baseStyleForPreview, themePreviewPalette } from "../lib/themePreviewPalette";
|
||||
|
||||
export function themePreviewPaneAlpha(pack: ThemePackView | null, scene: "home" | "task"): number {
|
||||
// Live pane transparency is guarded by data-theme-has-bg. Keep token-only
|
||||
// and base previews opaque so the isolated preview matches the applied UI.
|
||||
if (pack?.hasBackground !== true) return 1;
|
||||
const paneAlpha = scene === "home"
|
||||
? pack.background?.paneOpacity ?? 0.50
|
||||
: pack.taskBackground?.paneOpacity ?? pack.background?.paneOpacity ?? 0.68;
|
||||
return Math.min(1, Math.max(0, paneAlpha));
|
||||
}
|
||||
|
||||
/** Isolated mini Reasonix surface for gallery detail — does not touch :root. */
|
||||
export function ThemePreviewSurface({
|
||||
pack,
|
||||
@@ -30,6 +40,8 @@ export function ThemePreviewSurface({
|
||||
const overlay = scene === "task"
|
||||
? pack?.taskBackground?.overlayStrength ?? pack?.background?.overlayStrength ?? 0.62
|
||||
: pack?.background?.overlayStrength ?? 0.62;
|
||||
const boundedPaneAlpha = themePreviewPaneAlpha(pack, scene);
|
||||
const paneCardOffset = scene === "home" ? 0.26 : 0.14;
|
||||
return {
|
||||
["--tp-bg" as string]: palette.bg,
|
||||
["--tp-panel" as string]: palette.panel,
|
||||
@@ -45,6 +57,8 @@ export function ThemePreviewSurface({
|
||||
["--tp-focus-y" as string]: `${focusY * 100}%`,
|
||||
["--tp-bg-opacity" as string]: String(opacity),
|
||||
["--tp-overlay" as string]: String(overlay),
|
||||
["--tp-pane-shell-pct" as string]: `${Math.min((boundedPaneAlpha + 0.08) * 100, 100)}%`,
|
||||
["--tp-pane-card-pct" as string]: `${Math.min((boundedPaneAlpha + paneCardOffset) * 100, 100)}%`,
|
||||
} as CSSProperties;
|
||||
}, [pack, mode, scene]);
|
||||
|
||||
|
||||
@@ -1036,14 +1036,14 @@ function makeMockApp(): AppBindings {
|
||||
{ id: "carbon", name: "Carbon", author: "Reasonix", baseStyle: "carbon", builtin: true, kind: "base", active: false, hasBackground: false, tokens: {}, recipes: { density: "comfortable", corners: "soft" } },
|
||||
{ id: "nocturne", name: "Nocturne", author: "Reasonix", baseStyle: "nocturne", builtin: true, kind: "base", active: false, hasBackground: false, tokens: {}, recipes: { density: "comfortable", corners: "soft" } },
|
||||
{ id: "amber", name: "Amber", author: "Reasonix", baseStyle: "amber", builtin: true, kind: "base", active: false, hasBackground: false, tokens: {}, recipes: { density: "comfortable", corners: "soft" } },
|
||||
{ ...mockOfficialThemeAssets["official-rose-dawn"], id: "official-rose-dawn", name: "Rose Dawn", author: "Reasonix Contributors", license: "MIT", baseStyle: "graphite", builtin: true, kind: "official", active: false, hasBackground: true, nameKey: "settings.themes.official.official-rose-dawn.name", descriptionKey: "settings.themes.official.official-rose-dawn.description", tokens: { light: { bg: "#FFF7F8", fg: "#3A252C", accent: "#B43F65" }, dark: { bg: "#1E1419", fg: "#FFF3F6", accent: "#E26D91" } }, recipes: { density: "comfortable", corners: "round" }, background: { focusX: 0.72, focusY: 0.43, safeArea: "left", homeOpacity: 1, taskOpacity: 0.2, overlayStrength: 0.68 } },
|
||||
{ ...mockOfficialThemeAssets["official-fortune-forge"], id: "official-fortune-forge", name: "Fortune Forge", author: "Reasonix Contributors", license: "MIT", baseStyle: "amber", builtin: true, kind: "official", active: false, hasBackground: true, nameKey: "settings.themes.official.official-fortune-forge.name", descriptionKey: "settings.themes.official.official-fortune-forge.description", tokens: { light: { bg: "#FFF8E8", fg: "#382116", accent: "#A92D22" }, dark: { bg: "#1D140D", fg: "#FFF2D1", accent: "#E8AD38" } }, recipes: { density: "comfortable", corners: "soft" }, background: { focusX: 0.74, focusY: 0.44, safeArea: "left", homeOpacity: 1, taskOpacity: 0.2, overlayStrength: 0.7 } },
|
||||
{ ...mockOfficialThemeAssets["official-crimson-horizon"], id: "official-crimson-horizon", name: "Crimson Horizon", author: "Reasonix Contributors", license: "MIT", baseStyle: "graphite", builtin: true, kind: "official", active: false, hasBackground: true, nameKey: "settings.themes.official.official-crimson-horizon.name", descriptionKey: "settings.themes.official.official-crimson-horizon.description", tokens: { light: { bg: "#FFF8F7", fg: "#301D1D", accent: "#B92B38" }, dark: { bg: "#190D11", fg: "#FFF1F2", accent: "#FF6772" } }, recipes: { density: "comfortable", corners: "soft" }, background: { focusX: 0.75, focusY: 0.45, safeArea: "left", homeOpacity: 0.98, taskOpacity: 0.22, overlayStrength: 0.66 } },
|
||||
{ ...mockOfficialThemeAssets["official-sage-breeze"], id: "official-sage-breeze", name: "Sage Breeze", author: "Reasonix Contributors", license: "MIT", baseStyle: "slate", builtin: true, kind: "official", active: false, hasBackground: true, nameKey: "settings.themes.official.official-sage-breeze.name", descriptionKey: "settings.themes.official.official-sage-breeze.description", tokens: { light: { bg: "#F7F7EF", fg: "#26332D", accent: "#47735F" }, dark: { bg: "#101814", fg: "#EEF6F0", accent: "#84CBA7" } }, recipes: { density: "comfortable", corners: "soft" }, background: { focusX: 0.73, focusY: 0.44, safeArea: "left", homeOpacity: 1, taskOpacity: 0.2, overlayStrength: 0.68 } },
|
||||
{ ...mockOfficialThemeAssets["official-spark-notebook"], id: "official-spark-notebook", name: "Spark Notebook", author: "Reasonix Contributors", license: "MIT", baseStyle: "aurora", builtin: true, kind: "official", active: false, hasBackground: true, nameKey: "settings.themes.official.official-spark-notebook.name", descriptionKey: "settings.themes.official.official-spark-notebook.description", tokens: { light: { bg: "#FFF9ED", fg: "#2B2F35", accent: "#007B78" }, dark: { bg: "#14171A", fg: "#F8F5E9", accent: "#42D1C6" } }, recipes: { density: "comfortable", corners: "round" }, background: { focusX: 0.74, focusY: 0.46, safeArea: "left", homeOpacity: 0.98, taskOpacity: 0.2, overlayStrength: 0.68 } },
|
||||
{ ...mockOfficialThemeAssets["official-violet-starlight"], id: "official-violet-starlight", name: "Violet Starlight", author: "Reasonix Contributors", license: "MIT", baseStyle: "nocturne", builtin: true, kind: "official", active: false, hasBackground: true, nameKey: "settings.themes.official.official-violet-starlight.name", descriptionKey: "settings.themes.official.official-violet-starlight.description", tokens: { light: { bg: "#F7F4FF", fg: "#251F3C", accent: "#6242C7" }, dark: { bg: "#0C1022", fg: "#F4F2FF", accent: "#9B86FF" } }, recipes: { density: "comfortable", corners: "round" }, background: { focusX: 0.73, focusY: 0.44, safeArea: "left", homeOpacity: 0.96, taskOpacity: 0.18, overlayStrength: 0.72 } },
|
||||
{ ...mockOfficialThemeAssets["official-cyan-stage"], id: "official-cyan-stage", name: "Cyan Stage", author: "Reasonix Contributors", license: "MIT", baseStyle: "carbon", builtin: true, kind: "official", active: false, hasBackground: true, nameKey: "settings.themes.official.official-cyan-stage.name", descriptionKey: "settings.themes.official.official-cyan-stage.description", tokens: { light: { bg: "#F1FCFD", fg: "#173238", accent: "#007C92" }, dark: { bg: "#07181D", fg: "#E9FCFF", accent: "#37D7E4" } }, recipes: { density: "comfortable", corners: "round" }, background: { focusX: 0.74, focusY: 0.45, safeArea: "left", homeOpacity: 0.96, taskOpacity: 0.18, overlayStrength: 0.72 } },
|
||||
{ ...mockOfficialThemeAssets["official-noir-gold"], id: "official-noir-gold", name: "Noir Gold", author: "Reasonix Contributors", license: "MIT", baseStyle: "carbon", builtin: true, kind: "official", active: false, hasBackground: true, nameKey: "settings.themes.official.official-noir-gold.name", descriptionKey: "settings.themes.official.official-noir-gold.description", tokens: { light: { bg: "#FCF8EE", fg: "#2A241B", accent: "#7A5A16" }, dark: { bg: "#0D0B09", fg: "#F8F1DF", accent: "#D9B45B" } }, recipes: { density: "comfortable", corners: "soft" }, background: { focusX: 0.73, focusY: 0.43, safeArea: "left", homeOpacity: 0.94, taskOpacity: 0.18, overlayStrength: 0.74 } },
|
||||
{ ...mockOfficialThemeAssets["official-rose-dawn"], id: "official-rose-dawn", name: "Rose Dawn", author: "Reasonix Contributors", license: "MIT", baseStyle: "graphite", builtin: true, kind: "official", active: false, hasBackground: true, nameKey: "settings.themes.official.official-rose-dawn.name", descriptionKey: "settings.themes.official.official-rose-dawn.description", tokens: { light: { bg: "#FFF7F8", fg: "#3A252C", accent: "#B43F65" }, dark: { bg: "#1E1419", fg: "#FFF3F6", accent: "#E26D91" } }, recipes: { density: "comfortable", corners: "round" }, background: { focusX: 0.72, focusY: 0.43, safeArea: "left", homeOpacity: 1, taskOpacity: 0.2, overlayStrength: 0.68, paneOpacity: 0.50 } },
|
||||
{ ...mockOfficialThemeAssets["official-fortune-forge"], id: "official-fortune-forge", name: "Fortune Forge", author: "Reasonix Contributors", license: "MIT", baseStyle: "amber", builtin: true, kind: "official", active: false, hasBackground: true, nameKey: "settings.themes.official.official-fortune-forge.name", descriptionKey: "settings.themes.official.official-fortune-forge.description", tokens: { light: { bg: "#FFF8E8", fg: "#382116", accent: "#A92D22" }, dark: { bg: "#1D140D", fg: "#FFF2D1", accent: "#E8AD38" } }, recipes: { density: "comfortable", corners: "soft" }, background: { focusX: 0.74, focusY: 0.44, safeArea: "left", homeOpacity: 1, taskOpacity: 0.2, overlayStrength: 0.7, paneOpacity: 0.50 } },
|
||||
{ ...mockOfficialThemeAssets["official-crimson-horizon"], id: "official-crimson-horizon", name: "Crimson Horizon", author: "Reasonix Contributors", license: "MIT", baseStyle: "graphite", builtin: true, kind: "official", active: false, hasBackground: true, nameKey: "settings.themes.official.official-crimson-horizon.name", descriptionKey: "settings.themes.official.official-crimson-horizon.description", tokens: { light: { bg: "#FFF8F7", fg: "#301D1D", accent: "#B92B38" }, dark: { bg: "#190D11", fg: "#FFF1F2", accent: "#FF6772" } }, recipes: { density: "comfortable", corners: "soft" }, background: { focusX: 0.75, focusY: 0.45, safeArea: "left", homeOpacity: 0.98, taskOpacity: 0.22, overlayStrength: 0.66, paneOpacity: 0.50 } },
|
||||
{ ...mockOfficialThemeAssets["official-sage-breeze"], id: "official-sage-breeze", name: "Sage Breeze", author: "Reasonix Contributors", license: "MIT", baseStyle: "slate", builtin: true, kind: "official", active: false, hasBackground: true, nameKey: "settings.themes.official.official-sage-breeze.name", descriptionKey: "settings.themes.official.official-sage-breeze.description", tokens: { light: { bg: "#F7F7EF", fg: "#26332D", accent: "#47735F" }, dark: { bg: "#101814", fg: "#EEF6F0", accent: "#84CBA7" } }, recipes: { density: "comfortable", corners: "soft" }, background: { focusX: 0.73, focusY: 0.44, safeArea: "left", homeOpacity: 1, taskOpacity: 0.2, overlayStrength: 0.68, paneOpacity: 0.50 } },
|
||||
{ ...mockOfficialThemeAssets["official-spark-notebook"], id: "official-spark-notebook", name: "Spark Notebook", author: "Reasonix Contributors", license: "MIT", baseStyle: "aurora", builtin: true, kind: "official", active: false, hasBackground: true, nameKey: "settings.themes.official.official-spark-notebook.name", descriptionKey: "settings.themes.official.official-spark-notebook.description", tokens: { light: { bg: "#FFF9ED", fg: "#2B2F35", accent: "#007B78" }, dark: { bg: "#14171A", fg: "#F8F5E9", accent: "#42D1C6" } }, recipes: { density: "comfortable", corners: "round" }, background: { focusX: 0.74, focusY: 0.46, safeArea: "left", homeOpacity: 0.98, taskOpacity: 0.2, overlayStrength: 0.68, paneOpacity: 0.50 } },
|
||||
{ ...mockOfficialThemeAssets["official-violet-starlight"], id: "official-violet-starlight", name: "Violet Starlight", author: "Reasonix Contributors", license: "MIT", baseStyle: "nocturne", builtin: true, kind: "official", active: false, hasBackground: true, nameKey: "settings.themes.official.official-violet-starlight.name", descriptionKey: "settings.themes.official.official-violet-starlight.description", tokens: { light: { bg: "#F7F4FF", fg: "#251F3C", accent: "#6242C7" }, dark: { bg: "#0C1022", fg: "#F4F2FF", accent: "#9B86FF" } }, recipes: { density: "comfortable", corners: "round" }, background: { focusX: 0.73, focusY: 0.44, safeArea: "left", homeOpacity: 0.96, taskOpacity: 0.18, overlayStrength: 0.72, paneOpacity: 0.50 } },
|
||||
{ ...mockOfficialThemeAssets["official-cyan-stage"], id: "official-cyan-stage", name: "Cyan Stage", author: "Reasonix Contributors", license: "MIT", baseStyle: "carbon", builtin: true, kind: "official", active: false, hasBackground: true, nameKey: "settings.themes.official.official-cyan-stage.name", descriptionKey: "settings.themes.official.official-cyan-stage.description", tokens: { light: { bg: "#F1FCFD", fg: "#173238", accent: "#007C92" }, dark: { bg: "#07181D", fg: "#E9FCFF", accent: "#37D7E4" } }, recipes: { density: "comfortable", corners: "round" }, background: { focusX: 0.74, focusY: 0.45, safeArea: "left", homeOpacity: 0.96, taskOpacity: 0.18, overlayStrength: 0.72, paneOpacity: 0.50 } },
|
||||
{ ...mockOfficialThemeAssets["official-noir-gold"], id: "official-noir-gold", name: "Noir Gold", author: "Reasonix Contributors", license: "MIT", baseStyle: "carbon", builtin: true, kind: "official", active: false, hasBackground: true, nameKey: "settings.themes.official.official-noir-gold.name", descriptionKey: "settings.themes.official.official-noir-gold.description", tokens: { light: { bg: "#FCF8EE", fg: "#2A241B", accent: "#7A5A16" }, dark: { bg: "#0D0B09", fg: "#F8F1DF", accent: "#D9B45B" } }, recipes: { density: "comfortable", corners: "soft" }, background: { focusX: 0.73, focusY: 0.43, safeArea: "left", homeOpacity: 0.94, taskOpacity: 0.18, overlayStrength: 0.74, paneOpacity: 0.50 } },
|
||||
];
|
||||
const day = 86_400_000;
|
||||
const t0 = Date.now();
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
applyThemePack,
|
||||
beginThemePreview,
|
||||
cancelThemePreview,
|
||||
clearPreviewSnapshotOnly,
|
||||
clearThemePack,
|
||||
commitThemePreview,
|
||||
setBaseAppearance,
|
||||
@@ -37,6 +38,16 @@ export function getCachedThemeExperience(): ThemeExperienceView | null {
|
||||
return experienceCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the configured base style that React owners should mirror.
|
||||
* An active pack's effectiveStyle is intentionally excluded: it is a live DOM
|
||||
* override, not the persisted base appearance restored when the pack is cleared.
|
||||
*/
|
||||
export function configuredBaseStyleForSync(view: ThemeExperienceView): ThemeStyle | null {
|
||||
if (!view.safeMode && view.activePack) return null;
|
||||
return (isThemeStyle(view.baseStyle) ? view.baseStyle : "graphite") as ThemeStyle;
|
||||
}
|
||||
|
||||
export async function loadThemeExperience(): Promise<ThemeExperienceView> {
|
||||
// Prefer the unified API; fall back for older shells / partial mocks.
|
||||
const api = app as typeof app & {
|
||||
@@ -150,6 +161,10 @@ export async function activateBaseStyle(style: ThemeStyle): Promise<ThemeExperie
|
||||
|
||||
export async function activateThemePack(id: string): Promise<ThemeExperienceView> {
|
||||
await app.ActivateThemePack(id);
|
||||
// Commit the preview only after persistence succeeds. If activation fails,
|
||||
// the snapshot must remain available so Back/Cancel can restore the prior
|
||||
// appearance.
|
||||
clearPreviewSnapshotOnly();
|
||||
endPreviewIfAny();
|
||||
const view = await loadThemeExperience();
|
||||
applyExperienceToDOM(view);
|
||||
|
||||
@@ -22,6 +22,7 @@ export type ThemePackBackground = {
|
||||
homeOpacity: number;
|
||||
taskOpacity: number;
|
||||
overlayStrength: number;
|
||||
paneOpacity: number;
|
||||
};
|
||||
|
||||
export type ThemePackSceneBackground = {
|
||||
@@ -31,6 +32,7 @@ export type ThemePackSceneBackground = {
|
||||
safeArea?: "left" | "right" | "center" | string;
|
||||
opacity: number;
|
||||
overlayStrength: number;
|
||||
paneOpacity: number;
|
||||
};
|
||||
|
||||
export type ThemeContrastWarning = {
|
||||
@@ -319,6 +321,15 @@ export function commitThemePreview(pack: ThemePackView | null): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the preview snapshot without restoring the original theme.
|
||||
* Use this after persistent activation succeeds and before editor cleanup so
|
||||
* cancelThemePreview() cannot overwrite the newly applied theme.
|
||||
*/
|
||||
export function clearPreviewSnapshotOnly(): void {
|
||||
previewSnapshot = null;
|
||||
}
|
||||
|
||||
function ensurePackStyleElement(): HTMLStyleElement {
|
||||
let el = document.getElementById(PACK_STYLE_ID) as HTMLStyleElement | null;
|
||||
if (!el) {
|
||||
@@ -428,6 +439,13 @@ function applyBackgroundCSSVars(root: HTMLElement, pack: ThemePackView): void {
|
||||
root.style.setProperty("--theme-bg-home-focus-x", `${clamp01(home.focusX) * 100}%`);
|
||||
root.style.setProperty("--theme-bg-home-focus-y", `${clamp01(home.focusY) * 100}%`);
|
||||
root.style.setProperty("--theme-bg-home-opacity", String(clamp01(home.homeOpacity ?? 1)));
|
||||
// Pane transparency: how much the background shows through the UI panes.
|
||||
const homePane = clamp01(home.paneOpacity ?? 0.50);
|
||||
root.style.setProperty("--theme-pane-alpha", String(homePane));
|
||||
// Pre-computed percentages for CSS (avoids calc() compat issues).
|
||||
// Clamp to 100% to prevent color-mix from receiving values > 100%.
|
||||
root.style.setProperty("--theme-pane-shell-pct", `${Math.min((homePane + 0.08) * 100, 100)}%`);
|
||||
root.style.setProperty("--theme-pane-card-pct", `${Math.min((homePane + 0.26) * 100, 100)}%`);
|
||||
// Legacy aliases keep V1 tests and third-party diagnostics stable.
|
||||
root.style.setProperty("--theme-bg-image", `url("${cssUrlEscape(homeUrl)}")`);
|
||||
root.style.setProperty("--theme-bg-focus-x", `${clamp01(home.focusX) * 100}%`);
|
||||
@@ -447,9 +465,14 @@ function applyBackgroundCSSVars(root: HTMLElement, pack: ThemePackView): void {
|
||||
}
|
||||
const taskOpacity = task ? task.opacity : home?.taskOpacity;
|
||||
const taskOverlay = task ? task.overlayStrength : home?.overlayStrength;
|
||||
root.style.setProperty("--theme-bg-task-opacity", String(Math.min(0.45, clamp01(taskOpacity ?? 0.28))));
|
||||
root.style.setProperty("--theme-bg-task-opacity", String(clamp01(taskOpacity ?? 0.28)));
|
||||
root.style.setProperty("--theme-bg-task-overlay", String(clamp01(taskOverlay ?? 0.62)));
|
||||
root.style.setProperty("--theme-bg-overlay", String(clamp01(taskOverlay ?? 0.62)));
|
||||
// Task scene pane transparency (defaults to home paneOpacity if not set on task scene).
|
||||
const taskPane = clamp01(task?.paneOpacity ?? home?.paneOpacity ?? 0.68);
|
||||
root.style.setProperty("--theme-pane-task-alpha", String(taskPane));
|
||||
root.style.setProperty("--theme-pane-task-shell-pct", `${Math.min((taskPane + 0.08) * 100, 100)}%`);
|
||||
root.style.setProperty("--theme-pane-task-card-pct", `${Math.min((taskPane + 0.14) * 100, 100)}%`);
|
||||
const safe = taskSource?.safeArea === "left" || taskSource?.safeArea === "right" ? taskSource.safeArea : "center";
|
||||
root.setAttribute("data-theme-safe-area", safe);
|
||||
root.setAttribute("data-theme-has-bg", "true");
|
||||
@@ -469,6 +492,12 @@ function clearBackgroundCSSVars(root: HTMLElement): void {
|
||||
root.style.removeProperty("--theme-bg-home-opacity");
|
||||
root.style.removeProperty("--theme-bg-task-opacity");
|
||||
root.style.removeProperty("--theme-bg-overlay");
|
||||
root.style.removeProperty("--theme-pane-alpha");
|
||||
root.style.removeProperty("--theme-pane-task-alpha");
|
||||
root.style.removeProperty("--theme-pane-shell-pct");
|
||||
root.style.removeProperty("--theme-pane-task-shell-pct");
|
||||
root.style.removeProperty("--theme-pane-card-pct");
|
||||
root.style.removeProperty("--theme-pane-task-card-pct");
|
||||
root.removeAttribute("data-theme-safe-area");
|
||||
root.removeAttribute("data-theme-has-bg");
|
||||
}
|
||||
@@ -543,6 +572,7 @@ export function defaultBackground(): ThemePackBackground {
|
||||
homeOpacity: 1,
|
||||
taskOpacity: 0.28,
|
||||
overlayStrength: 0.62,
|
||||
paneOpacity: 0.50,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -553,5 +583,6 @@ export function defaultTaskBackground(): ThemePackSceneBackground {
|
||||
safeArea: "center",
|
||||
opacity: 0.28,
|
||||
overlayStrength: 0.62,
|
||||
paneOpacity: 0.68,
|
||||
};
|
||||
}
|
||||
@@ -1795,7 +1795,7 @@ export const en = {
|
||||
"settings.themeEditor.homeBackground": "Home background",
|
||||
"settings.themeEditor.homeBackgroundHint": "Used on home and idle surfaces, where the image can be shown at full strength.",
|
||||
"settings.themeEditor.taskBackground": "Workspace background",
|
||||
"settings.themeEditor.taskBackgroundHint": "Used in tasks, chat, and code surfaces, with opacity capped at 45%.",
|
||||
"settings.themeEditor.taskBackgroundHint": "Used in tasks, chat, and code surfaces. Very low opacity may make content hard to read.",
|
||||
"settings.themeEditor.taskBackgroundFallback": "Currently reuses the home image. Upload one to tune it independently.",
|
||||
"settings.themeEditor.description": "Description",
|
||||
"settings.themeEditor.license": "License",
|
||||
@@ -1805,6 +1805,7 @@ export const en = {
|
||||
"settings.themeEditor.safeArea": "Interface content area",
|
||||
"settings.themeEditor.safeAreaHint": "Choose where foreground text and cards appear. Keep this area away from the image subject.",
|
||||
"settings.themeEditor.opacity": "Image opacity",
|
||||
"settings.themeEditor.paneOpacity": "Pane opacity",
|
||||
"settings.themeEditor.density.comfortable": "Comfortable",
|
||||
"settings.themeEditor.density.compact": "Compact",
|
||||
"settings.themeEditor.corners.square": "Square",
|
||||
|
||||
@@ -1176,7 +1176,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.themeEditor.homeBackground": "首頁背景",
|
||||
"settings.themeEditor.homeBackgroundHint": "用於首頁與閒置狀態,可完整顯示圖片。",
|
||||
"settings.themeEditor.taskBackground": "工作區背景",
|
||||
"settings.themeEditor.taskBackgroundHint": "用於任務、聊天與程式碼工作區,透明度上限為 45%。",
|
||||
"settings.themeEditor.taskBackgroundHint": "用於任務、聊天與程式碼工作區。不透明度過低可能導致內容難以辨認。",
|
||||
"settings.themeEditor.taskBackgroundFallback": "目前沿用首頁圖片;上傳後可獨立調整。",
|
||||
"settings.themeEditor.description": "主題說明",
|
||||
"settings.themeEditor.license": "授權條款",
|
||||
@@ -1185,7 +1185,8 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.themeEditor.uploadPrompt": "選擇本機 PNG、JPEG 或 WebP 圖片",
|
||||
"settings.themeEditor.safeArea": "介面內容區域",
|
||||
"settings.themeEditor.safeAreaHint": "選擇文字與卡片主要顯示的位置;建議避開圖片主體。",
|
||||
"settings.themeEditor.opacity": "圖片透明度",
|
||||
"settings.themeEditor.opacity": "圖片不透明度",
|
||||
"settings.themeEditor.paneOpacity": "面板不透明度",
|
||||
"settings.themeEditor.density.comfortable": "舒適",
|
||||
"settings.themeEditor.density.compact": "緊湊",
|
||||
"settings.themeEditor.corners.square": "直角",
|
||||
|
||||
@@ -1797,7 +1797,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.themeEditor.homeBackground": "首页背景",
|
||||
"settings.themeEditor.homeBackgroundHint": "用于首页与空闲状态,可完整展示图片。",
|
||||
"settings.themeEditor.taskBackground": "工作区背景",
|
||||
"settings.themeEditor.taskBackgroundHint": "用于任务、聊天与代码工作区,透明度上限为 45%。",
|
||||
"settings.themeEditor.taskBackgroundHint": "用于任务、聊天与代码工作区。不透明度过低可能导致内容难以辨认。",
|
||||
"settings.themeEditor.taskBackgroundFallback": "当前沿用首页图片;上传后可独立调整。",
|
||||
"settings.themeEditor.description": "主题说明",
|
||||
"settings.themeEditor.license": "许可证",
|
||||
@@ -1806,7 +1806,8 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.themeEditor.uploadPrompt": "选择本地 PNG、JPEG 或 WebP 图片",
|
||||
"settings.themeEditor.safeArea": "界面内容区域",
|
||||
"settings.themeEditor.safeAreaHint": "选择文字和卡片主要显示的位置;建议避开图片主体。",
|
||||
"settings.themeEditor.opacity": "图片透明度",
|
||||
"settings.themeEditor.opacity": "图片不透明度",
|
||||
"settings.themeEditor.paneOpacity": "面板不透明度",
|
||||
"settings.themeEditor.density.comfortable": "舒适",
|
||||
"settings.themeEditor.density.compact": "紧凑",
|
||||
"settings.themeEditor.corners.square": "直角",
|
||||
|
||||
@@ -3605,6 +3605,11 @@ a[href] {
|
||||
color: var(--fg-faint);
|
||||
overflow: visible;
|
||||
}
|
||||
/* Welcome container breathing room (classic + workbench only) */
|
||||
.app:not(.app--creation) .welcome {
|
||||
padding: 24px 28px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.welcome--brand {
|
||||
margin-top: 0;
|
||||
transform: translateY(-20px);
|
||||
@@ -32500,29 +32505,128 @@ body > .mermaid-diagram--fullscreen {
|
||||
covers .theme-bg underneath. When a pack background is active, make the layout
|
||||
shell transparent so the image can show through; panes keep their own fills.
|
||||
*/
|
||||
:root[data-theme-has-bg="true"] .layout {
|
||||
:root[data-theme-has-bg="true"] .app:not(.app--creation) .layout {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Home: semi-transparent panes so the full-strength image reads through */
|
||||
:root[data-theme-has-bg="true"][data-theme-scene="home"] .chat-pane,
|
||||
:root[data-theme-has-bg="true"][data-theme-scene="home"] .welcome {
|
||||
:root[data-theme-has-bg="true"][data-theme-scene="home"] .app:not(.app--creation) .chat-pane,
|
||||
:root[data-theme-has-bg="true"][data-theme-scene="home"] .app:not(.app--creation) .welcome {
|
||||
background-color: color-mix(in srgb, var(--chat-bg, var(--bg)) 72%, transparent);
|
||||
}
|
||||
|
||||
:root[data-theme-has-bg="true"][data-theme-scene="home"] .sidebar {
|
||||
:root[data-theme-has-bg="true"][data-theme-scene="home"] .app:not(.app--creation) .sidebar {
|
||||
background-color: color-mix(in srgb, var(--sidebar-bg) 78%, transparent);
|
||||
}
|
||||
|
||||
/* Task: more opaque panes for readability while the dimmed image remains visible at edges */
|
||||
:root[data-theme-has-bg="true"][data-theme-scene="task"] .chat-pane {
|
||||
:root[data-theme-has-bg="true"][data-theme-scene="task"] .app:not(.app--creation) .chat-pane {
|
||||
background-color: color-mix(in srgb, var(--chat-bg, var(--bg)) 88%, transparent);
|
||||
}
|
||||
|
||||
:root[data-theme-has-bg="true"][data-theme-scene="task"] .sidebar {
|
||||
:root[data-theme-has-bg="true"][data-theme-scene="task"] .app:not(.app--creation) .sidebar {
|
||||
background-color: color-mix(in srgb, var(--sidebar-bg) 90%, transparent);
|
||||
}
|
||||
|
||||
/* ── Extended pane transparency (classic + workbench only) ── */
|
||||
:root[data-theme-pack][data-theme-has-bg="true"] .app:not(.app--creation) .layout {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Home: shell elements — controlled by paneOpacity (面板透明度) */
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="home"] .app:not(.app--creation) .sidebar,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="home"] .app:not(.app--creation) .topicbar,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="home"] .app:not(.app--creation) .workbench-dock,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="home"] .app:not(.app--creation) .workbench-dock__tools,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="home"] .app:not(.app--creation) .workspace-panel,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="home"] .app:not(.app--creation) .statusbar {
|
||||
background: color-mix(in srgb, var(--bg-elev) var(--theme-pane-shell-pct, 80%), transparent) !important;
|
||||
}
|
||||
|
||||
/* Home: chat area — controlled by paneOpacity (面板透明度) */
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="home"] .app:not(.app--creation) .chat-pane {
|
||||
background: color-mix(in srgb, var(--chat-bg, var(--bg)) calc(var(--theme-pane-alpha, 0.72) * 100%), transparent) !important;
|
||||
}
|
||||
|
||||
/* Home: card elements — controlled by the paneOpacity card tier */
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="home"] .app:not(.app--creation) .composer-card,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="home"] .app:not(.app--creation) .context-panel__section,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="home"] .app:not(.app--creation) .topicbar__chrome-btn,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="home"] .app:not(.app--creation) .topicbar__action-btn,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="home"] .app:not(.app--creation) .topicbar__icon-btn {
|
||||
background: color-mix(in srgb, var(--bg-elev) var(--theme-pane-card-pct, 88%), transparent) !important;
|
||||
}
|
||||
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="home"] .app:not(.app--creation) .workspace-change-scope__head {
|
||||
background: color-mix(in srgb, var(--bg-soft) var(--theme-pane-shell-pct, 80%), transparent) !important;
|
||||
}
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="home"] .app:not(.app--creation) .transcript,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="home"] .app:not(.app--creation) .transcript-shell {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Task: shell elements — controlled by paneOpacity (面板透明度) */
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="task"] .app:not(.app--creation) .sidebar,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="task"] .app:not(.app--creation) .topicbar,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="task"] .app:not(.app--creation) .workbench-dock,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="task"] .app:not(.app--creation) .workbench-dock__tools,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="task"] .app:not(.app--creation) .workspace-panel,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="task"] .app:not(.app--creation) .statusbar {
|
||||
background: color-mix(in srgb, var(--bg-elev) var(--theme-pane-task-shell-pct, 88%), transparent) !important;
|
||||
}
|
||||
|
||||
/* Task: chat area — controlled by paneOpacity (面板透明度) */
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="task"] .app:not(.app--creation) .chat-pane {
|
||||
background: color-mix(in srgb, var(--chat-bg, var(--bg)) calc(var(--theme-pane-task-alpha, 0.80) * 100%), transparent) !important;
|
||||
}
|
||||
|
||||
/* Task: card elements — controlled by the paneOpacity card tier */
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="task"] .app:not(.app--creation) .composer-card,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="task"] .app:not(.app--creation) .context-panel__section,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="task"] .app:not(.app--creation) .topicbar__chrome-btn,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="task"] .app:not(.app--creation) .topicbar__action-btn,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="task"] .app:not(.app--creation) .topicbar__icon-btn {
|
||||
background: color-mix(in srgb, var(--bg-elev) var(--theme-pane-task-card-pct, 88%), transparent) !important;
|
||||
}
|
||||
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="task"] .app:not(.app--creation) .workspace-change-scope__head {
|
||||
background: color-mix(in srgb, var(--bg-soft) var(--theme-pane-task-shell-pct, 88%), transparent) !important;
|
||||
}
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="task"] .app:not(.app--creation) .transcript,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"][data-theme-scene="task"] .app:not(.app--creation) .transcript-shell {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Fallback: scene-independent transparency for data-theme-pack */
|
||||
:root[data-theme-pack][data-theme-has-bg="true"] .app:not(.app--creation) .sidebar,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"] .app:not(.app--creation) .topicbar,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"] .app:not(.app--creation) .workbench-dock,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"] .app:not(.app--creation) .workbench-dock__tools,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"] .app:not(.app--creation) .workspace-panel,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"] .app:not(.app--creation) .statusbar {
|
||||
background: color-mix(in srgb, var(--bg-elev) 80%, transparent) !important;
|
||||
}
|
||||
:root[data-theme-pack][data-theme-has-bg="true"] .app:not(.app--creation) .chat-pane {
|
||||
background: color-mix(in srgb, var(--chat-bg, var(--bg)) 72%, transparent) !important;
|
||||
}
|
||||
:root[data-theme-pack][data-theme-has-bg="true"] .app:not(.app--creation) .chat-pane > * {
|
||||
background: transparent !important;
|
||||
}
|
||||
:root[data-theme-pack][data-theme-has-bg="true"] .app:not(.app--creation) .composer-card,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"] .app:not(.app--creation) .context-panel__section,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"] .app:not(.app--creation) .topicbar__chrome-btn,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"] .app:not(.app--creation) .topicbar__action-btn,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"] .app:not(.app--creation) .topicbar__icon-btn {
|
||||
background: color-mix(in srgb, var(--bg-elev) 88%, transparent) !important;
|
||||
}
|
||||
:root[data-theme-pack][data-theme-has-bg="true"] .app:not(.app--creation) .workspace-change-scope__head {
|
||||
background: color-mix(in srgb, var(--bg-soft) 80%, transparent) !important;
|
||||
}
|
||||
:root[data-theme-pack][data-theme-has-bg="true"] .app:not(.app--creation) .transcript,
|
||||
:root[data-theme-pack][data-theme-has-bg="true"] .app:not(.app--creation) .transcript-shell {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Density recipe consumers — pack overlay sets --theme-density-* / --theme-row-h */
|
||||
:root {
|
||||
--theme-density-pad: 10px;
|
||||
@@ -33581,7 +33685,7 @@ body > .mermaid-diagram--fullscreen {
|
||||
min-height: 160px;
|
||||
}
|
||||
.theme-preview-surface__side {
|
||||
background: color-mix(in srgb, var(--tp-sidebar) 88%, transparent);
|
||||
background: color-mix(in srgb, var(--tp-sidebar) var(--tp-pane-shell-pct, 80%), transparent);
|
||||
border-right: 1px solid color-mix(in srgb, var(--tp-border) 70%, transparent);
|
||||
padding: 10px 8px; display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
@@ -33598,7 +33702,7 @@ body > .mermaid-diagram--fullscreen {
|
||||
.theme-preview-surface__main { padding: 14px; display: flex; align-items: center; justify-content: center; }
|
||||
.theme-preview-surface__card {
|
||||
width: min(220px, 90%);
|
||||
background: color-mix(in srgb, var(--tp-panel) 92%, transparent);
|
||||
background: color-mix(in srgb, var(--tp-panel) var(--tp-pane-card-pct, 88%), transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--tp-border) 80%, transparent);
|
||||
border-radius: var(--tp-radius, 10px); padding: 12px; display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ func TestOfficialThemesAllValid(t *testing.T) {
|
||||
if m.Background == nil || m.Background.Image != "background.webp" {
|
||||
t.Fatalf("%s: background must be background.webp", m.ID)
|
||||
}
|
||||
if m.Background.PaneOpacity == nil || *m.Background.PaneOpacity != 0.5 {
|
||||
t.Fatalf("%s: paneOpacity must be 0.5, got %v", m.ID, m.Background.PaneOpacity)
|
||||
}
|
||||
if m.Background.SafeArea != "left" {
|
||||
t.Fatalf("%s: safeArea must be left, got %q", m.ID, m.Background.SafeArea)
|
||||
}
|
||||
|
||||
+29
-15
@@ -101,24 +101,26 @@ type ThemePackRecipes struct {
|
||||
|
||||
// ThemePackBackground is an optional local background image with focus/safe area.
|
||||
type ThemePackBackground struct {
|
||||
Image string `json:"image,omitempty"`
|
||||
FocusX float64 `json:"focusX"`
|
||||
FocusY float64 `json:"focusY"`
|
||||
SafeArea string `json:"safeArea,omitempty"` // left|right|center
|
||||
HomeOpacity float64 `json:"homeOpacity"`
|
||||
TaskOpacity float64 `json:"taskOpacity"`
|
||||
OverlayStrength float64 `json:"overlayStrength"`
|
||||
Image string `json:"image,omitempty"`
|
||||
FocusX float64 `json:"focusX"`
|
||||
FocusY float64 `json:"focusY"`
|
||||
SafeArea string `json:"safeArea,omitempty"` // left|right|center
|
||||
HomeOpacity float64 `json:"homeOpacity"`
|
||||
TaskOpacity float64 `json:"taskOpacity"`
|
||||
OverlayStrength float64 `json:"overlayStrength"`
|
||||
PaneOpacity *float64 `json:"paneOpacity,omitempty"` // home scene pane transparency (0=clear, 1=opaque)
|
||||
}
|
||||
|
||||
// ThemePackSceneBackground optionally overrides the task/workspace scene.
|
||||
// V1 packs omit it and continue using Background with TaskOpacity.
|
||||
type ThemePackSceneBackground struct {
|
||||
Image string `json:"image,omitempty"`
|
||||
FocusX float64 `json:"focusX"`
|
||||
FocusY float64 `json:"focusY"`
|
||||
SafeArea string `json:"safeArea,omitempty"` // left|right|center
|
||||
Opacity float64 `json:"opacity"`
|
||||
OverlayStrength float64 `json:"overlayStrength"`
|
||||
Image string `json:"image,omitempty"`
|
||||
FocusX float64 `json:"focusX"`
|
||||
FocusY float64 `json:"focusY"`
|
||||
SafeArea string `json:"safeArea,omitempty"` // left|right|center
|
||||
Opacity float64 `json:"opacity"`
|
||||
OverlayStrength float64 `json:"overlayStrength"`
|
||||
PaneOpacity *float64 `json:"paneOpacity,omitempty"` // task scene pane transparency (0=clear, 1=opaque)
|
||||
}
|
||||
|
||||
// ThemeDesktopState is the versioned active-theme pointer (not config.toml).
|
||||
@@ -219,6 +221,10 @@ func defaultThemePackRecipes() ThemePackRecipes {
|
||||
return ThemePackRecipes{Density: "comfortable", Corners: "soft"}
|
||||
}
|
||||
|
||||
func themePackFloat64(value float64) *float64 {
|
||||
return &value
|
||||
}
|
||||
|
||||
func defaultThemePackBackground() ThemePackBackground {
|
||||
return ThemePackBackground{
|
||||
FocusX: 0.5,
|
||||
@@ -227,6 +233,7 @@ func defaultThemePackBackground() ThemePackBackground {
|
||||
HomeOpacity: 1,
|
||||
TaskOpacity: 0.28,
|
||||
OverlayStrength: 0.62,
|
||||
PaneOpacity: themePackFloat64(0.72),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +244,7 @@ func defaultThemePackTaskBackground() ThemePackSceneBackground {
|
||||
SafeArea: "center",
|
||||
Opacity: 0.28,
|
||||
OverlayStrength: 0.62,
|
||||
PaneOpacity: themePackFloat64(0.80),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,8 +420,11 @@ func normalizeThemeBackground(in *ThemePackBackground) (*ThemePackBackground, er
|
||||
}
|
||||
// Home may be full strength; task opacity is capped for readability.
|
||||
out.HomeOpacity = clampFloat(in.HomeOpacity, 0, 1, 1)
|
||||
out.TaskOpacity = clampFloat(in.TaskOpacity, 0, 0.45, 0.28)
|
||||
out.TaskOpacity = clampFloat(in.TaskOpacity, 0, 1, 0.28)
|
||||
out.OverlayStrength = clampFloat(in.OverlayStrength, 0, 1, 0.62)
|
||||
if in.PaneOpacity != nil {
|
||||
out.PaneOpacity = themePackFloat64(clampFloat(*in.PaneOpacity, 0, 1, 0.50))
|
||||
}
|
||||
// Empty image means token-only pack — drop background block.
|
||||
if out.Image == "" {
|
||||
return nil, nil
|
||||
@@ -449,8 +460,11 @@ func normalizeThemeSceneBackground(in *ThemePackSceneBackground) (*ThemePackScen
|
||||
default:
|
||||
return nil, fmt.Errorf("taskBackground.safeArea must be left|right|center")
|
||||
}
|
||||
out.Opacity = clampFloat(in.Opacity, 0, 0.45, 0.28)
|
||||
out.Opacity = clampFloat(in.Opacity, 0, 1, 0.28)
|
||||
out.OverlayStrength = clampFloat(in.OverlayStrength, 0, 1, 0.62)
|
||||
if in.PaneOpacity != nil {
|
||||
out.PaneOpacity = themePackFloat64(clampFloat(*in.PaneOpacity, 0, 1, 0.68))
|
||||
}
|
||||
if out.Image == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ func TestThemeTokenAndRecipeCSSVars(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskOpacityCapped(t *testing.T) {
|
||||
func TestOpacityClamped(t *testing.T) {
|
||||
bg, err := normalizeThemeBackground(&ThemePackBackground{
|
||||
Image: "bg.png", FocusX: 2, FocusY: -1, SafeArea: "left",
|
||||
HomeOpacity: 1.5, TaskOpacity: 0.9, OverlayStrength: 2,
|
||||
@@ -149,8 +149,11 @@ func TestTaskOpacityCapped(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bg.TaskOpacity > 0.45 {
|
||||
t.Fatalf("task opacity not capped: %v", bg.TaskOpacity)
|
||||
if bg.TaskOpacity > 1 {
|
||||
t.Fatalf("task opacity not clamped: %v", bg.TaskOpacity)
|
||||
}
|
||||
if bg.PaneOpacity == nil || *bg.PaneOpacity != 0.72 {
|
||||
t.Fatalf("pane opacity default: %v", bg.PaneOpacity)
|
||||
}
|
||||
if bg.FocusX != 1 || bg.FocusY != 0 {
|
||||
t.Fatalf("focus clamp: %v %v", bg.FocusX, bg.FocusY)
|
||||
@@ -158,6 +161,68 @@ func TestTaskOpacityCapped(t *testing.T) {
|
||||
if bg.HomeOpacity != 1 || bg.OverlayStrength != 1 {
|
||||
t.Fatalf("opacity clamp: home=%v overlay=%v", bg.HomeOpacity, bg.OverlayStrength)
|
||||
}
|
||||
// Task scene background defaults
|
||||
sbg, err := normalizeThemeSceneBackground(&ThemePackSceneBackground{
|
||||
Image: "task.png", Opacity: 1.5, OverlayStrength: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sbg.Opacity > 1 {
|
||||
t.Fatalf("scene opacity not clamped: %v", sbg.Opacity)
|
||||
}
|
||||
if sbg.PaneOpacity == nil || *sbg.PaneOpacity != 0.80 {
|
||||
t.Fatalf("scene pane opacity default: %v", sbg.PaneOpacity)
|
||||
}
|
||||
|
||||
// Explicit zero is a valid value and must not be confused with an omitted
|
||||
// field, which preserves the legacy defaults above.
|
||||
bg, err = normalizeThemeBackground(&ThemePackBackground{
|
||||
Image: "bg.png", SafeArea: "center", PaneOpacity: themePackFloat64(0),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bg.PaneOpacity == nil || *bg.PaneOpacity != 0 {
|
||||
t.Fatalf("explicit zero pane opacity changed: %v", bg.PaneOpacity)
|
||||
}
|
||||
sbg, err = normalizeThemeSceneBackground(&ThemePackSceneBackground{
|
||||
Image: "task.png", SafeArea: "center", PaneOpacity: themePackFloat64(0),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sbg.PaneOpacity == nil || *sbg.PaneOpacity != 0 {
|
||||
t.Fatalf("explicit zero scene pane opacity changed: %v", sbg.PaneOpacity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaneOpacityExplicitZeroJSONRoundTrip(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"schemaVersion": 2,
|
||||
"id": "zero-pane",
|
||||
"name": "Zero Pane",
|
||||
"baseStyle": "graphite",
|
||||
"background": {"image": "home.webp", "safeArea": "center", "paneOpacity": 0},
|
||||
"taskBackground": {"image": "task.webp", "safeArea": "center", "paneOpacity": 0}
|
||||
}`)
|
||||
m, err := parseThemePackManifest(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.Background == nil || m.Background.PaneOpacity == nil || *m.Background.PaneOpacity != 0 {
|
||||
t.Fatalf("home pane opacity did not preserve zero: %+v", m.Background)
|
||||
}
|
||||
if m.TaskBackground == nil || m.TaskBackground.PaneOpacity == nil || *m.TaskBackground.PaneOpacity != 0 {
|
||||
t.Fatalf("task pane opacity did not preserve zero: %+v", m.TaskBackground)
|
||||
}
|
||||
encoded, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := bytes.Count(encoded, []byte(`"paneOpacity":0`)); got != 2 {
|
||||
t.Fatalf("explicit zero missing after marshal: count=%d json=%s", got, encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportExportRoundTrip(t *testing.T) {
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"safeArea": "left",
|
||||
"homeOpacity": 0.98,
|
||||
"taskOpacity": 0.22,
|
||||
"overlayStrength": 0.66
|
||||
"overlayStrength": 0.66,
|
||||
"paneOpacity": 0.5
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@
|
||||
"safeArea": "left",
|
||||
"homeOpacity": 0.96,
|
||||
"taskOpacity": 0.18,
|
||||
"overlayStrength": 0.72
|
||||
"overlayStrength": 0.72,
|
||||
"paneOpacity": 0.5
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@
|
||||
"safeArea": "left",
|
||||
"homeOpacity": 1.0,
|
||||
"taskOpacity": 0.2,
|
||||
"overlayStrength": 0.7
|
||||
"overlayStrength": 0.7,
|
||||
"paneOpacity": 0.5
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@
|
||||
"safeArea": "left",
|
||||
"homeOpacity": 0.94,
|
||||
"taskOpacity": 0.18,
|
||||
"overlayStrength": 0.74
|
||||
"overlayStrength": 0.74,
|
||||
"paneOpacity": 0.5
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@
|
||||
"safeArea": "left",
|
||||
"homeOpacity": 1.0,
|
||||
"taskOpacity": 0.2,
|
||||
"overlayStrength": 0.68
|
||||
"overlayStrength": 0.68,
|
||||
"paneOpacity": 0.5
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@
|
||||
"safeArea": "left",
|
||||
"homeOpacity": 1.0,
|
||||
"taskOpacity": 0.2,
|
||||
"overlayStrength": 0.68
|
||||
"overlayStrength": 0.68,
|
||||
"paneOpacity": 0.5
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@
|
||||
"safeArea": "left",
|
||||
"homeOpacity": 0.98,
|
||||
"taskOpacity": 0.2,
|
||||
"overlayStrength": 0.68
|
||||
"overlayStrength": 0.68,
|
||||
"paneOpacity": 0.5
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@
|
||||
"safeArea": "left",
|
||||
"homeOpacity": 0.96,
|
||||
"taskOpacity": 0.18,
|
||||
"overlayStrength": 0.72
|
||||
"overlayStrength": 0.72,
|
||||
"paneOpacity": 0.5
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -152,13 +152,15 @@ JSON Schema: [theme-pack.schema.json](./theme-pack.schema.json)
|
||||
| `background.focusX/Y` | 0–1 focal point |
|
||||
| `background.safeArea` | `left` \| `right` \| `center` (task overlay direction) |
|
||||
| `background.homeOpacity` | 0–1 |
|
||||
| `background.taskOpacity` | 0–0.45 (hard cap) |
|
||||
| `background.taskOpacity` | 0–1 |
|
||||
| `background.overlayStrength` | 0–1 |
|
||||
| `background.paneOpacity` | 0–1 (home scene panel opacity) |
|
||||
| `taskBackground.image` | Optional independent task/workspace image; bare local file name only |
|
||||
| `taskBackground.focusX/Y` | 0–1 focal point |
|
||||
| `taskBackground.safeArea` | `left` \| `right` \| `center` |
|
||||
| `taskBackground.opacity` | 0–0.45 (hard cap) |
|
||||
| `taskBackground.opacity` | 0–1 |
|
||||
| `taskBackground.overlayStrength` | 0–1 |
|
||||
| `taskBackground.paneOpacity` | 0–1 (task scene panel opacity) |
|
||||
|
||||
### Allowed token keys
|
||||
|
||||
|
||||
@@ -137,13 +137,15 @@ JSON Schema: [theme-pack.schema.json](./theme-pack.schema.json)
|
||||
| `background.focusX/Y` | 0–1 焦点 |
|
||||
| `background.safeArea` | `left` \| `right` \| `center`(任务页遮罩方向) |
|
||||
| `background.homeOpacity` | 0–1 |
|
||||
| `background.taskOpacity` | 0–0.45(硬上限) |
|
||||
| `background.taskOpacity` | 0–1 |
|
||||
| `background.overlayStrength` | 0–1 |
|
||||
| `background.paneOpacity` | 0–1(首页场景面板不透明度) |
|
||||
| `taskBackground.image` | 可选的独立任务/工作区图片,仅允许本地裸文件名 |
|
||||
| `taskBackground.focusX/Y` | 0–1 焦点 |
|
||||
| `taskBackground.safeArea` | `left` \| `right` \| `center` |
|
||||
| `taskBackground.opacity` | 0–0.45(硬上限) |
|
||||
| `taskBackground.opacity` | 0–1 |
|
||||
| `taskBackground.overlayStrength` | 0–1 |
|
||||
| `taskBackground.paneOpacity` | 0–1(任务场景面板不透明度) |
|
||||
|
||||
### 允许的令牌键
|
||||
|
||||
|
||||
@@ -47,8 +47,9 @@
|
||||
"focusY": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"safeArea": { "type": "string", "enum": ["left", "right", "center"] },
|
||||
"homeOpacity": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"taskOpacity": { "type": "number", "minimum": 0, "maximum": 0.45 },
|
||||
"overlayStrength": { "type": "number", "minimum": 0, "maximum": 1 }
|
||||
"taskOpacity": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"overlayStrength": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"paneOpacity": { "type": "number", "minimum": 0, "maximum": 1 }
|
||||
}
|
||||
},
|
||||
"taskBackground": { "$ref": "#/$defs/sceneBackground" }
|
||||
@@ -75,8 +76,9 @@
|
||||
"focusX": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"focusY": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"safeArea": { "type": "string", "enum": ["left", "right", "center"] },
|
||||
"opacity": { "type": "number", "minimum": 0, "maximum": 0.45 },
|
||||
"overlayStrength": { "type": "number", "minimum": 0, "maximum": 1 }
|
||||
"opacity": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"overlayStrength": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"paneOpacity": { "type": "number", "minimum": 0, "maximum": 1 }
|
||||
}
|
||||
},
|
||||
"tokenMap": {
|
||||
|
||||
Reference in new issue
Block a user