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:
HaoyueQinandSivanCola authored and GitHub committed 2026-07-19 20:46:58 +08:00
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]);
+8 -8
View File
@@ -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);
+32 -1
View File
@@ -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,
};
}
+2 -1
View File
@@ -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",
+3 -2
View File
@@ -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": "直角",
+3 -2
View File
@@ -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": "直角",
+112 -8
View File
@@ -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;
}
+3
View File
@@ -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
View File
@@ -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
}
+68 -3
View File
@@ -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
View File
@@ -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
+4 -2
View File
@@ -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(任务场景面板不透明度) |
### 允许的令牌键
+6 -4
View File
@@ -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": {