feat(desktop): Redesign theme experience with official packs / 主题体验改造与官方主题 (#6614)
* feat(desktop): Add Theme Pack V1 engine with theme library / 新增 Theme Pack V1 引擎与主题库 Controlled, non-executable desktop skins: semantic color tokens, density and corner recipes, and an optional local background image with focus and safe area. Includes manifest validation, atomic import with replace confirmation, content-addressed asset serving, live preview with cancel restore, density consumers, transparent layout under backgrounds, contrast warnings, and safe-mode fallback to Graphite. / 受控不可执行的桌面皮肤:语义色令牌、密度 与圆角配方、可选本地背景图(焦点与安全区)。包含清单校验、原子导入与覆盖 确认、内容寻址资源服务、可取消的实时预览、密度消费者、背景下布局透明化、 对比度警告与安全模式回退 Graphite。 * feat(desktop): Redesign theme experience with official packs / 主题体验改造与官方主题 Problem: Appearance settings exposed two competing base-style pickers and applied theme packs immediately on card click, while activeThemeId could store base style ids. Official skins and a gallery/overview IA were still missing. Root cause: Theme Pack V1 layered packs on top of desktop.theme_style without a unified state model or select-then-apply UX, so base styles, official themes, and user packs competed for the same controls. Fix: - Bump desktop-theme-state to schema v2: activeThemeId is official/user only; migrate legacy base-style active ids into desktop.theme_style. - Add GetThemeExperience, ActivateBaseStyle, DisableThemePack, and RestoreGraphiteAppearance; reject base ids in ActivateThemePack. - Embed eight official themes with fixed order, asset routing, and budgets. - Ship AppearanceOverview + ThemeGallery (select ≠ apply, temp preview, immersive detail) driven by themeExperience.ts. - Document the IA and provenance; keep procedural art as placeholders. Verification: - cd desktop && go test ./... - cd desktop/frontend && pnpm typecheck && pnpm test:all && pnpm build - wails generate module (bindings match, no drift) * feat(desktop): complete the theme gallery and editor experience Problem: The theme gallery still had fragmented preview actions, placeholder flagship art, generic base-style previews, and a minimal custom-theme dialog that could not tune semantic colors or independent scenes. Root cause: Theme Pack V1 modeled one background and the gallery treated selection, preview, and persistence as separate interactions without a shared localized editor state. Fix: Upgrade the pack contract compatibly to V2 with independent home and workspace scenes, rebuild the gallery around immediate reversible preview and explicit apply, add localized semantic color and scene controls, replace the eight official artworks, and align base-style previews and actions with the settings UI. Verification: - cd desktop && go test ./... - cd desktop/frontend && pnpm test:all - cd desktop/frontend && pnpm typecheck - cd desktop/frontend && pnpm check:css - cd desktop/frontend && pnpm build - Browser-verified Chinese and English labels, live preview, scene switching, and cancel restoration * fix(desktop): satisfy the full-module formatting gate Problem: The desktop CI job stopped before tests because its repository-wide gofmt check reported main.go and theme_assets.go. Root cause: The earlier theme integration left one struct field with manual alignment and one extra trailing blank line; package tests compile these files but do not enforce gofmt byte formatting. Fix: Run gofmt on both reported files without changing runtime behavior. Verification: - cd desktop && gofmt -l . - cd desktop && go test ./... * fix(desktop): clarify theme scene content placement Problem: The scene editor exposed a vague content-safe-area label without explaining that it controls foreground text and card placement. The desktop CI lint gate also found four empty or unused Theme Pack leftovers. Root cause: The persisted safeArea field was surfaced directly as user-facing copy, while placeholder branches and helpers remained after the Theme Pack integration. Fix: Rename the control to Interface content area, add localized guidance and radio semantics, and remove or complete the four lint findings without changing theme behavior. Verification: - cd desktop && go test ./... - cd desktop && go vet ./... - cd desktop/frontend && pnpm typecheck - cd desktop/frontend && pnpm check:css - cd desktop/frontend && pnpm test:all - cd desktop/frontend && pnpm build - Theme Pack contract: 195 passed - Verified the localized control in the running browser UI * fix(desktop): unify theme confirmation dialogs Problem: Theme deletion, replacement imports, and export-rights checks used window.confirm, which surfaced platform-native dialogs that did not match the Reasonix settings UI. Root cause: The Theme Pack flows lacked a reusable in-app confirmation primitive, so both the gallery and legacy library called the browser API directly. Fix: Add an accessible portal-based Reasonix confirmation dialog with danger styling, safe focus behavior, Escape and backdrop cancellation, and focus restoration. Migrate all six theme confirmation call sites and localize their titles and actions. Verification: - pnpm typecheck - pnpm check:css - pnpm test:all - pnpm build - Browser: delete confirmation stayed in-app, Cancel received focus, Escape restored focus, and the temporary theme was deleted without a native dialog * fix(desktop): restore theme preview and custom fonts Problem: - Custom UI and monospace fonts could no longer be configured from the redesigned appearance page. - Selecting a user theme updated details without starting the advertised global preview. Root cause: - AppearanceOverview discarded both custom font name callbacks and omitted their editors. - ThemeGallery limited non-immersive previews to the catalog tab. Fix: - Restore localized custom font editors and their persistence callbacks. - Preview every non-immersive theme selection, including user packs. - Add focused source-contract coverage for both behaviors. Verification: - pnpm test:all - pnpm build - pnpm check:css - Browser verification of custom font variables, user-pack preview, and Back restoration
No files matched your search
@@ -62,3 +62,7 @@ benchmarks/context-maintenance-e2e/run/
|
||||
|
||||
# Local scratch
|
||||
temp/
|
||||
|
||||
# Official theme art intermediate outputs / caches
|
||||
scripts/official-theme-art/out/
|
||||
scripts/official-theme-art/__pycache__/
|
||||
@@ -10,7 +10,7 @@
|
||||
"check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css",
|
||||
"test:todo-visibility": "node scripts/test-todo-visibility.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/external-opener.test.tsx && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/scroll-manager.test.tsx && tsx src/__tests__/layout-style-defaults.test.ts && tsx src/__tests__/scroll-content-observer.test.ts && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/ref-token.test.tsx && tsx src/__tests__/decision-surface.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-run-strip.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/composer-context-menu-clipboard.test.tsx && tsx src/__tests__/composer-resized-input-css.test.ts && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-stream-progress.test.ts && tsx src/__tests__/use-controller-live-context.test.tsx && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/pending-prompt-stale-status.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/activate-topic-stale.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/recovery-quiet-notifications.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/sound.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/history-recovery-copies.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/turn-actions-rendering.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/message-reasoning-panel.test.tsx && tsx src/__tests__/transcript-process-fold.test.ts && tsx src/__tests__/transcript-fold-preference.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/context-window-ring.test.tsx && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/diagnostics-settings.test.tsx && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/delivery-worktree.test.ts && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
|
||||
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/external-opener.test.tsx && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/confirm-dialog.test.tsx && tsx src/__tests__/scroll-manager.test.tsx && tsx src/__tests__/layout-style-defaults.test.ts && tsx src/__tests__/scroll-content-observer.test.ts && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/theme-pack.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/ref-token.test.tsx && tsx src/__tests__/decision-surface.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-run-strip.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/composer-context-menu-clipboard.test.tsx && tsx src/__tests__/composer-resized-input-css.test.ts && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-stream-progress.test.ts && tsx src/__tests__/use-controller-live-context.test.tsx && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/pending-prompt-stale-status.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/activate-topic-stale.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/recovery-quiet-notifications.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/sound.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/history-recovery-copies.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/turn-actions-rendering.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/message-reasoning-panel.test.tsx && tsx src/__tests__/transcript-process-fold.test.ts && tsx src/__tests__/transcript-fold-preference.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/context-window-ring.test.tsx && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/diagnostics-settings.test.tsx && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/delivery-worktree.test.ts && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
|
||||
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
|
||||
"test:delivery-worktree": "tsx src/__tests__/delivery-worktree.test.ts",
|
||||
"test:all": "pnpm test:typecheck && pnpm test"
|
||||
|
||||
@@ -166,6 +166,8 @@ import {
|
||||
readLegacyThemePreference,
|
||||
type Theme,
|
||||
} from "./lib/theme";
|
||||
import { applyThemePack, applyThemeScene, clearThemePack, setBaseAppearance } from "./lib/themePack";
|
||||
import { ThemeBackground } from "./components/ThemeBackground";
|
||||
import { applyTextSize, DEFAULT_TEXT_SIZE, getTextSize, nextTextSize } from "./lib/textSize";
|
||||
import { useViewportHeightVar, useWindowStatePersistence } from "./lib/windowState";
|
||||
import { availableWorkspacePanelWidth, resolveLiveWorkspacePanelWidth, resolveWorkspacePanelWidth, workspacePanelAriaMinWidth } from "./lib/workspaceLayout";
|
||||
@@ -1315,6 +1317,8 @@ export default function App() {
|
||||
const nextTheme = normalizeThemePreference(settings.desktopTheme);
|
||||
const nextStyle = normalizeThemeStyleForTheme(settings.desktopThemeStyle, nextTheme);
|
||||
applyTheme(nextTheme, nextStyle, { persist: false });
|
||||
// Config appearance is the restore target for clearThemePack / restore-default.
|
||||
setBaseAppearance(nextTheme, nextStyle);
|
||||
const nextLayoutStyle = normalizeDesktopLayoutStyle(settings.desktopLayoutStyle);
|
||||
setDesktopLayoutStyle(nextLayoutStyle);
|
||||
applyLayoutStyleDefaults(nextLayoutStyle);
|
||||
@@ -1346,6 +1350,27 @@ export default function App() {
|
||||
hydrateDisplayMode(settings.displayMode);
|
||||
setSidebarImConnections(sidebarImConnectionsFromBot(settings.bot, t, runtimeStatus));
|
||||
setImTopicSources(sidebarImTopicSourcesFromBot(settings.bot, t));
|
||||
// Load unified theme experience after base appearance so pack tokens win.
|
||||
if (settings.safeMode === true) {
|
||||
clearThemePack();
|
||||
} else {
|
||||
try {
|
||||
const { loadThemeExperience, applyExperienceToDOM } = await import("./lib/themeExperience");
|
||||
const exp = await loadThemeExperience();
|
||||
if (cancelled) return;
|
||||
applyExperienceToDOM(exp);
|
||||
} catch (err) {
|
||||
console.warn("theme experience load failed", err);
|
||||
try {
|
||||
const active = await app.GetActiveThemePack();
|
||||
if (cancelled) return;
|
||||
if (active?.pack) applyThemePack(active.pack);
|
||||
else clearThemePack();
|
||||
} catch {
|
||||
clearThemePack();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
void syncDesktopPreferences().catch((e) => {
|
||||
console.warn("desktop preferences sync failed", e);
|
||||
@@ -1792,6 +1817,11 @@ export default function App() {
|
||||
|
||||
const sessionTitle = topicTitle(activeTab);
|
||||
const sessionHasContent = state.items.length > 0 || Boolean(state.live?.text || state.live?.reasoning);
|
||||
|
||||
// Theme pack scene: home when the session is empty, task once content exists.
|
||||
useEffect(() => {
|
||||
applyThemeScene(sessionHasContent ? "task" : "home");
|
||||
}, [sessionHasContent]);
|
||||
const getSessionMarkdown = useCallback(
|
||||
() => sessionItemsToMarkdown(sessionTitle, state.items, state.live),
|
||||
[sessionTitle, state.items, state.live],
|
||||
@@ -1962,6 +1992,16 @@ export default function App() {
|
||||
notice(t("settings.themeCurrent", { theme: cur, style: getThemeStyle(cur) }));
|
||||
return;
|
||||
}
|
||||
if (arg === "reset" || arg === "default" || arg === "clear") {
|
||||
try {
|
||||
await app.ResetThemePack();
|
||||
clearThemePack();
|
||||
notice(t("settings.themeReset"));
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isThemeMode(arg)) {
|
||||
const next = arg;
|
||||
const style = getThemeStyle(next);
|
||||
@@ -3057,6 +3097,22 @@ export default function App() {
|
||||
{ id: "cmd-trash", group: t("palette.group.commands"), title: t("palette.cmd.trash"), icon: <Trash2 size={15} />, compact: true, keywords: ["trash", "回收站"], run: () => void openTrash() },
|
||||
{ id: "cmd-settings", group: t("palette.group.commands"), title: t("palette.cmd.settings"), icon: <SettingsIcon size={15} />, compact: true, keywords: ["settings", "设置"], run: () => setSettingsTarget("general") },
|
||||
{ id: "cmd-appearance", group: t("palette.group.commands"), title: t("palette.cmd.appearance"), icon: <Palette size={15} />, compact: true, keywords: ["theme", "appearance", "外观", "主题"], run: () => setSettingsTarget("appearance") },
|
||||
{
|
||||
id: "cmd-theme-reset",
|
||||
group: t("palette.group.commands"),
|
||||
title: t("settings.themeLibrary.reset"),
|
||||
icon: <Palette size={15} />,
|
||||
compact: true,
|
||||
keywords: ["theme", "reset", "default", "恢复默认", "主题"],
|
||||
run: () => {
|
||||
void app.ResetThemePack()
|
||||
.then(() => {
|
||||
clearThemePack();
|
||||
notice(t("settings.themeReset"));
|
||||
})
|
||||
.catch((err) => showToast(err instanceof Error ? err.message : String(err), "error"));
|
||||
},
|
||||
},
|
||||
{ id: "cmd-memory", group: t("palette.group.commands"), title: t("palette.cmd.memory"), icon: <Brain size={15} />, compact: true, keywords: ["memory", "记忆"], run: () => setSettingsTarget("memory") },
|
||||
{ id: "cmd-models", group: t("palette.group.commands"), title: t("palette.cmd.models"), icon: <Cpu size={15} />, compact: true, keywords: ["model", "模型"], run: () => setSettingsTarget("models") },
|
||||
];
|
||||
@@ -3302,6 +3358,7 @@ export default function App() {
|
||||
!sidebarWorkbench && !sidebarCreation ? "app--classic" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
>
|
||||
<ThemeBackground />
|
||||
<div
|
||||
ref={layoutRef}
|
||||
className={[
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Run: tsx src/__tests__/confirm-dialog.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { useState } from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { useConfirmDialog } from "../components/ConfirmDialog";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function Harness() {
|
||||
const { confirm, dialog } = useConfirmDialog();
|
||||
const [result, setResult] = useState("pending");
|
||||
const open = async () => {
|
||||
const confirmed = await confirm({
|
||||
title: "Delete theme",
|
||||
message: "Delete Example? This cannot be undone.",
|
||||
confirmLabel: "Delete",
|
||||
cancelLabel: "Cancel",
|
||||
tone: "danger",
|
||||
});
|
||||
setResult(String(confirmed));
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<button id="open-confirm" type="button" onClick={() => void open()}>Open</button>
|
||||
<output id="confirm-result">{result}</output>
|
||||
{dialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
|
||||
console.log("\nReasonix confirmation dialog");
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) throw new Error("missing root");
|
||||
const root = createRoot(rootElement);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Harness />);
|
||||
await flush();
|
||||
});
|
||||
|
||||
const openButton = document.getElementById("open-confirm") as HTMLButtonElement;
|
||||
openButton.focus();
|
||||
await act(async () => {
|
||||
openButton.click();
|
||||
await flush();
|
||||
});
|
||||
|
||||
const dialog = document.querySelector<HTMLElement>(".reasonix-confirm-dialog");
|
||||
const cancelButton = Array.from(document.querySelectorAll<HTMLButtonElement>(".reasonix-confirm-dialog button"))
|
||||
.find((button) => button.textContent === "Cancel");
|
||||
const deleteButton = Array.from(document.querySelectorAll<HTMLButtonElement>(".reasonix-confirm-dialog button"))
|
||||
.find((button) => button.textContent === "Delete");
|
||||
ok(dialog?.getAttribute("role") === "dialog" && dialog.getAttribute("aria-modal") === "true", "renders an accessible modal dialog");
|
||||
ok(dialog?.textContent?.includes("Delete Example? This cannot be undone.") === true, "renders the confirmation message");
|
||||
ok(deleteButton?.classList.contains("btn--danger") === true, "uses the danger button recipe for deletion");
|
||||
ok(document.activeElement === cancelButton, "focuses Cancel first for destructive confirmation");
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
await flush();
|
||||
});
|
||||
ok(document.querySelector(".reasonix-confirm-dialog") === null, "Escape cancels and closes the dialog");
|
||||
ok(document.getElementById("confirm-result")?.textContent === "false", "cancellation resolves false");
|
||||
ok(document.activeElement === openButton, "restores focus to the triggering control");
|
||||
|
||||
await act(async () => {
|
||||
openButton.click();
|
||||
await flush();
|
||||
});
|
||||
const secondDeleteButton = Array.from(document.querySelectorAll<HTMLButtonElement>(".reasonix-confirm-dialog button"))
|
||||
.find((button) => button.textContent === "Delete");
|
||||
await act(async () => {
|
||||
secondDeleteButton?.click();
|
||||
await flush();
|
||||
});
|
||||
ok(document.getElementById("confirm-result")?.textContent === "true", "confirm action resolves true");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,419 @@
|
||||
// Run: tsx src/__tests__/theme-pack.test.ts
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
applyThemePack,
|
||||
applyThemeScene,
|
||||
beginThemePreview,
|
||||
cancelThemePreview,
|
||||
clearThemePack,
|
||||
draftPackView,
|
||||
isSafeBackgroundURL,
|
||||
isSafeHex,
|
||||
isThemeTokenKey,
|
||||
registerTrustedThemeBackgroundURLs,
|
||||
setBaseAppearance,
|
||||
themePackKind,
|
||||
} from "../lib/themePack";
|
||||
import { applyTheme, getThemeStyle } from "../lib/theme";
|
||||
import { BASE_STYLE_PREVIEW_PALETTES, themePreviewPalette } from "../lib/themePreviewPalette";
|
||||
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const packSource = readFileSync(resolve(testDir, "../lib/themePack.ts"), "utf8");
|
||||
const stylesSource = readFileSync(resolve(testDir, "../styles.css"), "utf8");
|
||||
const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8");
|
||||
const librarySource = readFileSync(resolve(testDir, "../components/ThemeLibrary.tsx"), "utf8");
|
||||
const gallerySource = readFileSync(resolve(testDir, "../components/ThemeGallery.tsx"), "utf8");
|
||||
const confirmDialogSource = readFileSync(resolve(testDir, "../components/ConfirmDialog.tsx"), "utf8");
|
||||
const overviewSource = readFileSync(resolve(testDir, "../components/AppearanceOverview.tsx"), "utf8");
|
||||
const settingsSource = readFileSync(resolve(testDir, "../components/SettingsPanel.tsx"), "utf8");
|
||||
const experienceSource = readFileSync(resolve(testDir, "../lib/themeExperience.ts"), "utf8");
|
||||
const bridgeSource = readFileSync(resolve(testDir, "../lib/bridge.ts"), "utf8");
|
||||
const viteSource = readFileSync(resolve(testDir, "../../vite.config.ts"), "utf8");
|
||||
const localeEn = readFileSync(resolve(testDir, "../locales/en.ts"), "utf8");
|
||||
const localeZh = readFileSync(resolve(testDir, "../locales/zh.ts"), "utf8");
|
||||
const localeZhTW = readFileSync(resolve(testDir, "../locales/zh-TW.ts"), "utf8");
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal DOM for applyThemePack
|
||||
const attrs = new Map<string, string>();
|
||||
const styleProps = new Map<string, string>();
|
||||
const styleEl: {
|
||||
id: string;
|
||||
textContent: string;
|
||||
parentElement: { removeChild: (el: unknown) => void } | null;
|
||||
remove: () => void;
|
||||
} = {
|
||||
id: "",
|
||||
textContent: "",
|
||||
parentElement: null,
|
||||
remove() {
|
||||
const idx = headChildren.indexOf(styleEl);
|
||||
if (idx >= 0) headChildren.splice(idx, 1);
|
||||
styleEl.textContent = "";
|
||||
},
|
||||
};
|
||||
const headChildren: unknown[] = [];
|
||||
|
||||
(globalThis as unknown as { document: unknown }).document = {
|
||||
documentElement: {
|
||||
setAttribute(k: string, v: string) {
|
||||
attrs.set(k, v);
|
||||
},
|
||||
removeAttribute(k: string) {
|
||||
attrs.delete(k);
|
||||
},
|
||||
style: {
|
||||
setProperty(k: string, v: string) {
|
||||
styleProps.set(k, v);
|
||||
},
|
||||
removeProperty(k: string) {
|
||||
styleProps.delete(k);
|
||||
},
|
||||
},
|
||||
},
|
||||
head: {
|
||||
appendChild(el: unknown) {
|
||||
headChildren.push(el);
|
||||
styleEl.parentElement = {
|
||||
removeChild(child: unknown) {
|
||||
const idx = headChildren.indexOf(child);
|
||||
if (idx >= 0) headChildren.splice(idx, 1);
|
||||
},
|
||||
};
|
||||
return el;
|
||||
},
|
||||
},
|
||||
getElementById(id: string) {
|
||||
if (id === "reasonix-theme-pack-overlay" && headChildren.includes(styleEl)) return styleEl;
|
||||
return null;
|
||||
},
|
||||
createElement(tag: string) {
|
||||
if (tag === "style") return styleEl;
|
||||
return {};
|
||||
},
|
||||
querySelector() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
(globalThis as unknown as { window: unknown }).window = {
|
||||
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {} }),
|
||||
location: { href: "http://127.0.0.1:5197/", origin: "http://127.0.0.1:5197" },
|
||||
runtime: undefined,
|
||||
};
|
||||
|
||||
console.log("\ntheme pack contract");
|
||||
|
||||
ok(isSafeHex("#aabbcc"), "accepts #RRGGBB");
|
||||
ok(isSafeHex("#aabbccdd"), "accepts #RRGGBBAA");
|
||||
ok(!isSafeHex("url(x)"), "rejects url()");
|
||||
ok(!isSafeHex("linear-gradient(red,blue)"), "rejects gradient");
|
||||
ok(isThemeTokenKey("accent") && !isThemeTokenKey("hack"), "token whitelist");
|
||||
|
||||
ok(isSafeBackgroundURL("/__reasonix_theme_asset/my-theme/abc/background.png"), "asset URL allowed");
|
||||
ok(isSafeBackgroundURL("data:image/png;base64,aaa"), "data URL allowed");
|
||||
ok(!isSafeBackgroundURL("https://evil.example/bg.png"), "remote URL rejected");
|
||||
const bundledOfficialBackground = "http://127.0.0.1:5197/@fs/workspace/desktop/themes/official/official-rose-dawn/background.webp";
|
||||
registerTrustedThemeBackgroundURLs([bundledOfficialBackground, "https://evil.example/assets/background-fake.webp"]);
|
||||
ok(isSafeBackgroundURL(bundledOfficialBackground), "registered same-origin official dev background allowed");
|
||||
ok(!isSafeBackgroundURL("https://evil.example/assets/background-fake.webp"), "cross-origin bundled background rejected");
|
||||
|
||||
const draft = draftPackView({
|
||||
id: "preview-pack",
|
||||
name: "Preview",
|
||||
baseStyle: "graphite",
|
||||
tokens: { dark: { accent: "#ff0000", fg: "#ffffff" }, light: { accent: "#0000ff" } },
|
||||
recipes: { density: "compact", corners: "round" },
|
||||
background: {
|
||||
focusX: 0.2,
|
||||
focusY: 0.8,
|
||||
safeArea: "left",
|
||||
homeOpacity: 1,
|
||||
taskOpacity: 0.2,
|
||||
overlayStrength: 0.5,
|
||||
},
|
||||
backgroundUrl: "/__reasonix_theme_asset/preview-pack/deadbeef/background.png",
|
||||
});
|
||||
|
||||
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");
|
||||
ok(styleProps.has("--theme-bg-image"), "sets background image var");
|
||||
ok((styleEl as { textContent: string }).textContent.includes("--accent:#ff0000"), "injects dark accent override");
|
||||
ok((styleEl as { textContent: string }).textContent.includes("--r:14px"), "applies round corners recipe");
|
||||
|
||||
const twoSceneDraft = draftPackView({
|
||||
...draft,
|
||||
taskBackground: { focusX: 0.8, focusY: 0.3, safeArea: "right", opacity: 0.35, overlayStrength: 0.7 },
|
||||
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(attrs.get("data-theme-safe-area") === "right", "task background controls safe area");
|
||||
|
||||
applyThemeScene("task");
|
||||
ok(attrs.get("data-theme-scene") === "task", "scene task on root");
|
||||
|
||||
applyThemeScene("home");
|
||||
ok(attrs.get("data-theme-scene") === "home", "scene home on root");
|
||||
|
||||
// Preview cancel restores previous (null) pack
|
||||
clearThemePack();
|
||||
beginThemePreview(draft);
|
||||
ok(attrs.get("data-theme-pack") === "preview-pack", "preview applies pack");
|
||||
cancelThemePreview();
|
||||
ok(!attrs.has("data-theme-pack"), "cancel restores cleared pack");
|
||||
|
||||
// Restore-default must restore config baseStyle, not leave pack baseStyle.
|
||||
setBaseAppearance("dark", "graphite");
|
||||
applyTheme("dark", "graphite", { persist: false });
|
||||
const aurora = draftPackView({
|
||||
id: "aurora",
|
||||
name: "Aurora",
|
||||
baseStyle: "aurora",
|
||||
tokens: {},
|
||||
recipes: { density: "comfortable", corners: "soft" },
|
||||
});
|
||||
applyThemePack(aurora);
|
||||
ok(attrs.get("data-theme-pack") === "aurora", "aurora pack active");
|
||||
ok(getThemeStyle() === "aurora", "pack switches live style to aurora");
|
||||
clearThemePack();
|
||||
ok(!attrs.has("data-theme-pack"), "clear removes data-theme-pack");
|
||||
ok(getThemeStyle() === "graphite", "clear restores config graphite 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({
|
||||
id: "dense",
|
||||
name: "Dense",
|
||||
baseStyle: "graphite",
|
||||
tokens: {},
|
||||
recipes: { density: "compact", corners: "soft" },
|
||||
});
|
||||
applyThemePack(compactDraft);
|
||||
ok((styleEl as { textContent: string }).textContent.includes("--theme-density-pad:6px"), "compact density injected");
|
||||
ok((styleEl as { textContent: string }).textContent.includes("--theme-row-h:28px"), "compact row height injected");
|
||||
ok(stylesSource.includes("padding: var(--theme-density-pad"), "density pad consumed by cards");
|
||||
ok(stylesSource.includes("gap: var(--theme-density-gap"), "density gap consumed");
|
||||
ok(stylesSource.includes("--list-row-height: var(--theme-row-h)"), "density maps to list row height");
|
||||
|
||||
// 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),
|
||||
"layout background transparent when theme has background",
|
||||
);
|
||||
|
||||
// Unmount must cancel preview.
|
||||
ok(librarySource.includes("cancelThemePreview()"), "ThemeLibrary cleanup cancels preview");
|
||||
|
||||
// Import confirm reuses staged import (replace=true empty path).
|
||||
ok(
|
||||
librarySource.includes("ImportThemePack(\"\", true)") || librarySource.includes("ImportThemePack('', true)"),
|
||||
"import confirm reuses staged path without re-picking",
|
||||
);
|
||||
ok(librarySource.includes("needsReplace"), "import handles needsReplace result");
|
||||
|
||||
// Theme confirmations stay inside the Reasonix UI instead of opening native
|
||||
// browser/system prompts.
|
||||
ok(!gallerySource.includes("window.confirm"), "ThemeGallery does not use native confirm dialogs");
|
||||
ok(!librarySource.includes("window.confirm"), "ThemeLibrary does not use native confirm dialogs");
|
||||
ok(gallerySource.includes("useConfirmDialog") && librarySource.includes("useConfirmDialog"), "theme flows share the Reasonix confirm dialog");
|
||||
ok(confirmDialogSource.includes('role="dialog"') && confirmDialogSource.includes('aria-modal="true"'), "confirm dialog exposes accessible modal semantics");
|
||||
ok(confirmDialogSource.includes('request.tone === "danger"') && confirmDialogSource.includes("btn--danger"), "destructive confirmations use danger styling");
|
||||
ok(confirmDialogSource.includes('event.key === "Escape"') && confirmDialogSource.includes("restoreFocusRef"), "confirm dialog supports Escape and focus restoration");
|
||||
ok(gallerySource.includes("moreActionsRef") && gallerySource.includes("moreActionsRef.current?.focus()"), "gallery cancellation restores focus after closing its overflow menu");
|
||||
|
||||
// Source contracts
|
||||
ok(packSource.includes("reasonix-theme-pack-overlay"), "overlay style id stable");
|
||||
ok(packSource.includes("appendChild(el)"), "overlay style appended last for priority");
|
||||
ok(packSource.includes("baseAppearance"), "tracks base appearance for restore");
|
||||
ok(stylesSource.includes(".theme-bg"), "background layer CSS present");
|
||||
ok(stylesSource.includes("data-theme-scene=\"task\""), "task scene CSS present");
|
||||
// Theme pack section must not *apply* backdrop-filter (comments may mention it).
|
||||
const themeBgIdx = stylesSource.indexOf("Theme Pack V1");
|
||||
const themeBgSlice = themeBgIdx >= 0 ? stylesSource.slice(themeBgIdx) : "";
|
||||
ok(
|
||||
!/^\s*backdrop-filter\s*:/m.test(themeBgSlice) && !/^\s*-webkit-backdrop-filter\s*:/m.test(themeBgSlice),
|
||||
"theme pack CSS does not apply backdrop-filter",
|
||||
);
|
||||
ok(themeBgSlice.includes(".theme-bg__overlay"), "overlay wash element styled");
|
||||
ok(appSource.includes("applyThemeScene"), "App wires scene from session content");
|
||||
ok(appSource.includes("ThemeBackground"), "App mounts background layer");
|
||||
ok(appSource.includes("setBaseAppearance"), "App seeds base appearance from config");
|
||||
ok(appSource.includes("ResetThemePack") || appSource.includes("theme reset") || appSource.includes('arg === "reset"'), "reset entry exists");
|
||||
|
||||
console.log("\nofficial themes (kind/grouping/i18n)");
|
||||
|
||||
// kind resolution with legacy fallback.
|
||||
ok(themePackKind({ kind: "official", builtin: true }) === "official", "kind official passthrough");
|
||||
ok(themePackKind({ kind: "base", builtin: true }) === "base", "kind base passthrough");
|
||||
ok(themePackKind({ kind: "user", builtin: false }) === "user", "kind user passthrough");
|
||||
ok(themePackKind({ builtin: true }) === "base", "legacy builtin=true falls back to base");
|
||||
ok(themePackKind({ builtin: false }) === "user", "legacy builtin=false falls back to user");
|
||||
|
||||
// Redesigned experience: overview home + independent gallery (select ≠ apply).
|
||||
ok(overviewSource.includes("appearance-overview"), "appearance overview present");
|
||||
ok(overviewSource.includes("settings.themeGallery.browse"), "overview has browse themes");
|
||||
ok(overviewSource.includes("settings.themeGallery.disable") || overviewSource.includes("handleDisable"), "overview can disable pack");
|
||||
ok(settingsSource.includes('tab !== "appearance"'), "appearance renders a single page header");
|
||||
ok(overviewSource.includes("initialCreateBaseStyle"), "base-style copy opens a prefilled theme editor");
|
||||
ok(overviewSource.includes('role="radiogroup"') && overviewSource.includes("aria-checked"), "overview segmented controls expose selection semantics");
|
||||
ok(overviewSource.includes("appearance-overview__segmented--theme"), "theme-mode control uses compact settings width");
|
||||
ok(overviewSource.includes("appearance-overview__segmented--text-size"), "text-size control uses its wider compact settings width");
|
||||
ok(stylesSource.includes("--appearance-segmented-width: 300px") && stylesSource.includes("--appearance-segmented-width: 420px"), "overview segmented controls use intentional widths");
|
||||
ok(stylesSource.includes(".appearance-overview__segmented { justify-self: stretch; width: 100%; }"), "overview segmented controls expand on narrow screens");
|
||||
ok(
|
||||
overviewSource.includes('fontFamily === "custom"') && overviewSource.includes("onCustomFontNameChange(e.target.value)"),
|
||||
"custom UI font selection exposes an editable font name",
|
||||
);
|
||||
ok(
|
||||
overviewSource.includes('monoFontFamily === "custom"') && overviewSource.includes("onCustomMonoFontNameChange(e.target.value)"),
|
||||
"custom monospace font selection exposes an editable font name",
|
||||
);
|
||||
ok(
|
||||
overviewSource.includes("fontFamilyLabel(f, t)") && overviewSource.includes("monoFontFamilyLabel(f, t)"),
|
||||
"font family selectors render localized names",
|
||||
);
|
||||
ok(overviewSource.includes("appearance-base-style-help"), "active pack explains why base style is locked");
|
||||
ok(gallerySource.includes('role="listbox"') || gallerySource.includes("role=\"listbox\""), "gallery cards are listbox options");
|
||||
ok(gallerySource.includes("settings.themeGallery.apply"), "apply lives in gallery detail");
|
||||
ok(gallerySource.includes("setSelected") || gallerySource.includes("onSelectPack"), "card click selects without applying");
|
||||
ok(gallerySource.includes("changeTab") && gallerySource.includes("nextPacks[0]"), "changing gallery groups synchronizes the selected detail");
|
||||
ok(gallerySource.includes("ActivateThemePack") || experienceSource.includes("activateThemePack"), "apply path uses activate API");
|
||||
ok(experienceSource.includes("ActivateBaseStyle") || experienceSource.includes("activateBaseStyle"), "base style API wired");
|
||||
ok(experienceSource.includes("selectedThemeId") || gallerySource.includes("selected"), "selection is frontend state");
|
||||
ok(gallerySource.includes("loading=\"lazy\"") || gallerySource.includes('loading="lazy"'), "gallery thumbs lazy-load");
|
||||
ok(gallerySource.includes("ThemePreviewSurface") || gallerySource.includes("theme-preview-surface"), "isolated detail preview");
|
||||
ok(gallerySource.includes('themePackKind(pack) === "base"') && gallerySource.includes('variant="thumbnail"'), "base gallery cards render semantic UI thumbnails");
|
||||
ok(gallerySource.includes('themePackKind(p) === "base"'), "immersive rail renders base-style thumbnails");
|
||||
for (const style of ["graphite", "aurora", "slate", "carbon", "nocturne", "amber"] as const) {
|
||||
const basePack = { id: style, name: style, baseStyle: style, builtin: true, kind: "base" as const, active: false, hasBackground: false, tokens: {}, recipes: {} };
|
||||
for (const mode of ["light", "dark"] as const) {
|
||||
const palette = themePreviewPalette(basePack, mode);
|
||||
ok(palette === BASE_STYLE_PREVIEW_PALETTES[style][mode], `${style} ${mode} uses its canonical preview palette`);
|
||||
}
|
||||
}
|
||||
ok(new Set(Object.values(BASE_STYLE_PREVIEW_PALETTES).map((modes) => modes.dark.accent)).size === 6, "six base previews have distinct dark accents");
|
||||
ok(
|
||||
!gallerySource.includes('tab === "catalog" && !immersive') &&
|
||||
gallerySource.includes("if (!immersive)") &&
|
||||
gallerySource.includes("previewPackGlobally(pack)"),
|
||||
"all gallery card clicks immediately start a global preview",
|
||||
);
|
||||
ok(gallerySource.includes("setPreviewingId(pack.id)"), "gallery preview state is visible in theme details");
|
||||
ok(gallerySource.includes("nextTab !== tab") && gallerySource.includes("cancelGlobalPreview();"), "leaving all themes restores the prior appearance");
|
||||
ok(gallerySource.includes("ThemePreviewControls"), "detail and immersive views share preview controls");
|
||||
ok((gallerySource.match(/role="radiogroup"/g) || []).length >= 2, "appearance and scene previews are separate radio groups");
|
||||
ok(gallerySource.includes("aria-checked={mode ===") && gallerySource.includes("aria-checked={scene ==="), "preview controls expose selected values");
|
||||
ok(gallerySource.includes("handlePreviewRadioKey") && gallerySource.includes("tabIndex={mode ==="), "preview radios support arrow keys and roving focus");
|
||||
ok(gallerySource.includes("if (!immersive || !selectedPack) return") && gallerySource.includes("previewPackGlobally(selectedPack)"), "immersive selection automatically starts a global preview");
|
||||
ok(gallerySource.includes("closeImmersivePreview") && gallerySource.includes("cancelGlobalPreview();"), "leaving immersive preview restores the prior appearance");
|
||||
ok(!gallerySource.includes("settings.themeGallery.tempPreview"), "redundant global-trial button is removed");
|
||||
ok(gallerySource.includes("theme-gallery__rail-section") && gallerySource.includes("packs: groups.official") && gallerySource.includes("packs: groups.user") && gallerySource.includes("packs: groups.base"), "immersive rail includes official, user, and base theme groups");
|
||||
ok(gallerySource.includes("filter((section) => section.packs.length > 0)"), "immersive rail hides empty groups");
|
||||
ok(!gallerySource.includes("theme-gallery__tabs--compact"), "immersive rail has no duplicate bottom tab navigation");
|
||||
ok(gallerySource.includes("theme-gallery__detail-status"), "active theme uses a status badge");
|
||||
ok(!gallerySource.includes("disabled={busy || isActive}"), "active status is not rendered as a disabled primary action");
|
||||
ok(gallerySource.includes("theme-gallery__detail-user-actions"), "user theme edit and export actions are visible outside the overflow menu");
|
||||
ok((gallerySource.match(/role="menuitem"/g) || []).length === 1 && gallerySource.includes("settings.themeLibrary.delete"), "user theme overflow menu keeps only delete");
|
||||
ok(gallerySource.includes("defaultTaskBackground") && gallerySource.includes("taskBackgroundDataUrl"), "editor supports an independent workspace image");
|
||||
ok(gallerySource.includes("themeTokenKeys()") && gallerySource.includes('type="color"'), "editor exposes semantic theme colors");
|
||||
ok(gallerySource.includes('type="range"') && gallerySource.includes("settings.themeEditor.opacity"), "editor exposes scene opacity controls");
|
||||
ok(gallerySource.includes('aria-checked={safeArea === area}') && gallerySource.includes("settings.themeEditor.safeAreaHint"), "content-area control exposes radio semantics and guidance");
|
||||
ok(gallerySource.includes("beginThemePreview(draft)"), "editor changes are previewed live");
|
||||
ok(bridgeSource.includes("GetThemeExperience"), "bridge exposes GetThemeExperience");
|
||||
ok(bridgeSource.includes("ActivateBaseStyle"), "bridge exposes ActivateBaseStyle");
|
||||
ok(bridgeSource.includes("DisableThemePack"), "bridge exposes DisableThemePack");
|
||||
|
||||
// Gallery navigation merges built-in choices while keeping their semantics.
|
||||
ok(gallerySource.includes('["catalog", t("settings.themeGallery.tabAll"), catalogPacks.length]'), "gallery combines official and base packs in all themes");
|
||||
ok(gallerySource.includes('id: "official"') && gallerySource.includes('id: "base"'), "all themes keeps flagship and base sections");
|
||||
ok(gallerySource.includes('role="group"') && gallerySource.includes("theme-gallery__section-head"), "catalog sections retain accessible grouping");
|
||||
ok(!gallerySource.includes('["base", t("settings.themeGallery.tabBase"), groups.base.length]'), "base styles are no longer a separate top-level tab");
|
||||
ok(gallerySource.includes("selectionSeeded.current") && gallerySource.includes("packs.length === 0"), "empty user tab is not overwritten by selection seeding");
|
||||
ok(!overviewSource.includes("theme-card-grid"), "overview no longer renders long style card grid");
|
||||
|
||||
// Localized official names/descriptions in all three locales.
|
||||
const OFFICIAL_IDS = [
|
||||
"official-rose-dawn",
|
||||
"official-fortune-forge",
|
||||
"official-crimson-horizon",
|
||||
"official-sage-breeze",
|
||||
"official-spark-notebook",
|
||||
"official-violet-starlight",
|
||||
"official-cyan-stage",
|
||||
"official-noir-gold",
|
||||
];
|
||||
for (const id of OFFICIAL_IDS) {
|
||||
for (const suffix of ["name", "description"]) {
|
||||
const key = `settings.themes.official.${id}.${suffix}`;
|
||||
ok(localeEn.includes(`"${key}"`), `en has ${key}`);
|
||||
ok(localeZh.includes(`"${key}"`), `zh has ${key}`);
|
||||
ok(localeZhTW.includes(`"${key}"`), `zh-TW has ${key}`);
|
||||
}
|
||||
}
|
||||
for (const key of [
|
||||
"settings.themeGallery.title",
|
||||
"settings.themeGallery.apply",
|
||||
"settings.themeGallery.browse",
|
||||
"settings.themeGallery.paletteLabel",
|
||||
"settings.themeGallery.appearancePreview",
|
||||
"settings.themeGallery.scenePreview",
|
||||
"settings.themeGallery.scenePreviewHint",
|
||||
"settings.themeGallery.tabAll",
|
||||
"settings.themeGallery.sectionFlagship",
|
||||
"settings.themeEditor.safeAreaHint",
|
||||
"settings.themeLibrary.confirmDeleteTitle",
|
||||
"settings.themeLibrary.confirmReplaceImportTitle",
|
||||
"settings.themeLibrary.replaceConfirm",
|
||||
"settings.themeLibrary.exportRightsTitle",
|
||||
"settings.themeLibrary.exportConfirm",
|
||||
]) {
|
||||
ok(localeEn.includes(`"${key}"`) && localeZh.includes(`"${key}"`) && localeZhTW.includes(`"${key}"`), `gallery key ${key} in all locales`);
|
||||
}
|
||||
|
||||
// Mock parity: 6 base + 8 official mock packs so browser dev matches the shell.
|
||||
ok((bridgeSource.match(/kind: "base"/g) || []).length === 6, "mock has 6 base packs");
|
||||
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(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");
|
||||
ok(stylesSource.includes(".theme-gallery__preview-control"), "preview dimensions have labeled layout styling");
|
||||
ok(gallerySource.includes("settings.themeGallery.scenePreviewHint") && gallerySource.includes("theme-gallery__preview-help"), "scene preview explains home and workspace behavior");
|
||||
ok(localeZh.includes('"settings.themeGallery.sceneHome": "首页展示"') && localeZh.includes('"settings.themeGallery.sceneTask": "工作区展示"'), "scene options use explicit Chinese labels");
|
||||
ok(localeZh.includes('"settings.themeGallery.subtitle": "点击主题即可全局预览,应用后才会保存"'), "gallery explains click-to-preview and apply-to-save semantics");
|
||||
ok(stylesSource.includes(".theme-gallery__detail-user-actions") && stylesSource.includes("grid-template-columns: repeat(2, minmax(0, 1fr))"), "user theme edit and export actions share a balanced row");
|
||||
ok(stylesSource.includes(".theme-gallery__rail-section-head") && stylesSource.includes(".theme-gallery__rail-section-items"), "immersive rail groups have lightweight headings and item stacks");
|
||||
ok(stylesSource.includes(".theme-gallery__detail-status"), "active status has dedicated non-button styling");
|
||||
ok(stylesSource.includes(".theme-editor__setting-hint"), "content-area guidance has dedicated responsive styling");
|
||||
ok(localeZh.includes('"settings.themeEditor.safeArea": "界面内容区域"') && localeZh.includes('"settings.themeEditor.safeAreaHint": "选择文字和卡片主要显示的位置;建议避开图片主体。"'), "Chinese content-area copy explains foreground placement");
|
||||
|
||||
// Pack overlay stays at :root — Workbench/Creation element-scoped auto-light
|
||||
// selectors must keep winning in their subtree (theme never overrides them).
|
||||
ok(!packSource.includes(".app--"), "pack overlay never targets layout-scoped selectors");
|
||||
ok(packSource.includes("prefers-color-scheme: light"), "auto mode follows system light/dark");
|
||||
|
||||
// Keep ThemeLibrary available for any residual editor helpers.
|
||||
ok(librarySource.includes("ThemeLibrary") || librarySource.includes("ThemeEditor"), "ThemeLibrary module retained for editor/helpers");
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,560 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Check, Copy, Images, LockKeyhole, Minus, Plus, RotateCcw } from "lucide-react";
|
||||
import { app } from "../lib/bridge";
|
||||
import { useT, type DictKey } from "../lib/i18n";
|
||||
import { THEME_STYLES, type Theme, type ThemeStyle, isThemeStyle } from "../lib/theme";
|
||||
import { TEXT_SIZES, type TextSize } from "../lib/textSize";
|
||||
import { type FontFamily, type MonoFontFamily } from "../lib/fontFamily";
|
||||
import { DEFAULT_ZOOM, MIN_ZOOM, MAX_ZOOM, ZOOM_STEP, zoomToPercent, type ZoomLevel } from "../lib/dpiScale";
|
||||
import { getAvailableFontFamilies, getAvailableMonoFontFamilies } from "../lib/fontAvailability";
|
||||
import {
|
||||
type ThemeExperienceView,
|
||||
activateBaseStyle,
|
||||
applyExperienceToDOM,
|
||||
cancelGlobalPreview,
|
||||
disableThemePack,
|
||||
loadThemeExperience,
|
||||
restoreGraphiteAppearance,
|
||||
setThemeMode,
|
||||
} from "../lib/themeExperience";
|
||||
import { useToast } from "../lib/toast";
|
||||
import { ThemeGallery } from "./ThemeGallery";
|
||||
|
||||
const STYLE_NAME_KEY: Record<ThemeStyle, DictKey> = {
|
||||
graphite: "settings.style.graphite.zh",
|
||||
aurora: "settings.style.aurora.zh",
|
||||
slate: "settings.style.slate.zh",
|
||||
carbon: "settings.style.carbon.zh",
|
||||
nocturne: "settings.style.nocturne.zh",
|
||||
amber: "settings.style.amber.zh",
|
||||
};
|
||||
|
||||
function textSizeLabel(size: TextSize, t: (key: never) => string): string {
|
||||
switch (size) {
|
||||
case "small":
|
||||
return t("settings.textSizeSmall" as never);
|
||||
case "default":
|
||||
return t("settings.textSizeDefault" as never);
|
||||
case "large":
|
||||
return t("settings.textSizeLarge" as never);
|
||||
case "xlarge":
|
||||
return t("settings.textSizeXLarge" as never);
|
||||
case "xxlarge":
|
||||
return t("settings.textSizeXXLarge" as never);
|
||||
default:
|
||||
return size;
|
||||
}
|
||||
}
|
||||
|
||||
function fontFamilyLabel(font: FontFamily, t: ReturnType<typeof useT>): string {
|
||||
switch (font) {
|
||||
case "system":
|
||||
return t("settings.fontFamilySystem");
|
||||
case "yahei":
|
||||
return t("settings.fontFamilyYaHei");
|
||||
case "pingfang":
|
||||
return t("settings.fontFamilyPingFang");
|
||||
case "noto":
|
||||
return t("settings.fontFamilyNoto");
|
||||
case "custom":
|
||||
return t("settings.fontFamilyCustom");
|
||||
}
|
||||
}
|
||||
|
||||
function monoFontFamilyLabel(font: MonoFontFamily, t: ReturnType<typeof useT>): string {
|
||||
switch (font) {
|
||||
case "system":
|
||||
return t("settings.monoFontFamilySystem");
|
||||
case "cascadia":
|
||||
return t("settings.monoFontFamilyCascadia");
|
||||
case "jetbrains":
|
||||
return t("settings.monoFontFamilyJetBrains");
|
||||
case "sfmono":
|
||||
return t("settings.monoFontFamilySFMono");
|
||||
case "custom":
|
||||
return t("settings.monoFontFamilyCustom");
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export field shells used by SettingsPanel (defined there as local helpers).
|
||||
// AppearanceOverview is rendered inside SettingsPageShell and uses the same CSS.
|
||||
|
||||
export function AppearanceOverview({
|
||||
theme,
|
||||
themeStyle,
|
||||
textSize,
|
||||
showDisplayZoom,
|
||||
zoomPct,
|
||||
fontFamily,
|
||||
monoFontFamily,
|
||||
customFontName,
|
||||
customMonoFontName,
|
||||
onTheme,
|
||||
onThemeStyle,
|
||||
onTextSize,
|
||||
onRestartZoom,
|
||||
onFontFamily,
|
||||
onMonoFontFamily,
|
||||
onCustomFontNameChange,
|
||||
onCustomMonoFontNameChange,
|
||||
}: {
|
||||
theme: Theme;
|
||||
themeStyle: ThemeStyle;
|
||||
textSize: TextSize;
|
||||
showDisplayZoom: boolean;
|
||||
zoomPct: number;
|
||||
fontFamily: FontFamily;
|
||||
monoFontFamily: MonoFontFamily;
|
||||
customFontName: string;
|
||||
customMonoFontName: string;
|
||||
onTheme: (t: Theme) => void;
|
||||
onThemeStyle: (style: ThemeStyle) => void;
|
||||
onTextSize: (size: TextSize) => void;
|
||||
onRestartZoom: (zoom: ZoomLevel) => Promise<void>;
|
||||
onFontFamily: (font: FontFamily) => void;
|
||||
onMonoFontFamily: (font: MonoFontFamily) => void;
|
||||
onCustomFontNameChange: (name: string) => void;
|
||||
onCustomMonoFontNameChange: (name: string) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const { showToast } = useToast();
|
||||
const [view, setView] = useState<"overview" | "gallery">("overview");
|
||||
const [galleryIntent, setGalleryIntent] = useState<"browse" | "copy-base">("browse");
|
||||
const [experience, setExperience] = useState<ThemeExperienceView | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const exp = await loadThemeExperience();
|
||||
setExperience(exp);
|
||||
applyExperienceToDOM(exp);
|
||||
} catch (err) {
|
||||
console.warn("theme experience load failed", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
return () => {
|
||||
cancelGlobalPreview();
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
// Keep experience in sync when parent theme/style props change from outside.
|
||||
useEffect(() => {
|
||||
setExperience((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
themeMode: theme,
|
||||
baseStyle: themeStyle,
|
||||
effectiveStyle: prev.activePack?.baseStyle || themeStyle,
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
}, [theme, themeStyle]);
|
||||
|
||||
const availableFontFamilies = useMemo(() => getAvailableFontFamilies(fontFamily), [fontFamily]);
|
||||
const availableMonoFontFamilies = useMemo(() => getAvailableMonoFontFamilies(monoFontFamily), [monoFontFamily]);
|
||||
const zoomMinPct = zoomToPercent(MIN_ZOOM);
|
||||
const zoomMaxPct = zoomToPercent(MAX_ZOOM);
|
||||
const zoomStepPct = Math.round(ZOOM_STEP * 100);
|
||||
const zoomProgressPct = Math.min(100, Math.max(0, ((zoomPct - zoomMinPct) / (zoomMaxPct - zoomMinPct)) * 100));
|
||||
const canDecreaseZoom = zoomPct > zoomMinPct;
|
||||
const canIncreaseZoom = zoomPct < zoomMaxPct;
|
||||
const setZoomPercent = (pct: number) => {
|
||||
void onRestartZoom(pct / 100);
|
||||
};
|
||||
|
||||
const pack = experience?.activePack ?? null;
|
||||
const baseStyle = (isThemeStyle(experience?.baseStyle) ? experience!.baseStyle : themeStyle) as ThemeStyle;
|
||||
const styleNameKey = STYLE_NAME_KEY[baseStyle] || STYLE_NAME_KEY.graphite;
|
||||
|
||||
const currentTitle = pack
|
||||
? pack.nameKey
|
||||
? t(pack.nameKey as never)
|
||||
: pack.name
|
||||
: t(styleNameKey);
|
||||
const kindLabel = pack
|
||||
? pack.kind === "user" || (!pack.builtin && pack.kind !== "official")
|
||||
? t("settings.themeGallery.kindUser")
|
||||
: pack.kind === "official" || (pack.builtin && pack.id.startsWith("official-"))
|
||||
? t("settings.themeGallery.kindOfficial")
|
||||
: t("settings.themeGallery.kindBase")
|
||||
: t("settings.themeGallery.kindBase");
|
||||
|
||||
const swatches = pack
|
||||
? [pack.tokens?.light?.bg || "#fff", pack.tokens?.light?.accent || "#ccc", pack.tokens?.dark?.accent || "#888"]
|
||||
: null;
|
||||
|
||||
const thumbUrl = pack?.previewUrl || pack?.backgroundUrl || "";
|
||||
|
||||
const handleBrowse = () => {
|
||||
setGalleryIntent("browse");
|
||||
setView("gallery");
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!pack) {
|
||||
setGalleryIntent("copy-base");
|
||||
setView("gallery");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const newId = `${pack.id}-copy`.toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 48);
|
||||
const created = await app.CopyThemePack(pack.id, newId, `${pack.name} Copy`);
|
||||
showToast(t("settings.themeLibrary.copied", { name: created.name }), "info");
|
||||
setView("gallery");
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisable = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const exp = await disableThemePack();
|
||||
setExperience(exp);
|
||||
onThemeStyle(isThemeStyle(exp.baseStyle) ? exp.baseStyle : "graphite");
|
||||
showToast(t("settings.themeGallery.disabled"), "info");
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBaseChange = async (style: ThemeStyle) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const exp = await activateBaseStyle(style);
|
||||
setExperience(exp);
|
||||
onThemeStyle(style);
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleThemeMode = async (mode: Theme) => {
|
||||
onTheme(mode);
|
||||
try {
|
||||
const exp = await setThemeMode(mode);
|
||||
setExperience(exp);
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
}
|
||||
};
|
||||
|
||||
if (view === "gallery" && experience) {
|
||||
return (
|
||||
<ThemeGallery
|
||||
experience={experience}
|
||||
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);
|
||||
}
|
||||
}}
|
||||
onBack={() => {
|
||||
cancelGlobalPreview();
|
||||
setGalleryIntent("browse");
|
||||
setView("overview");
|
||||
void refresh();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="appearance-overview">
|
||||
<header className="appearance-overview__header">
|
||||
<h2 className="appearance-overview__title">{t("settings.appearance")}</h2>
|
||||
<p className="appearance-overview__sub">{t("settings.appearanceMeta")}</p>
|
||||
</header>
|
||||
|
||||
<section className="appearance-overview__current" aria-labelledby="appearance-current-label">
|
||||
<h3 id="appearance-current-label" className="appearance-overview__section-label">
|
||||
{t("settings.themeGallery.currentAppearance")}
|
||||
</h3>
|
||||
<div className="appearance-overview__hero">
|
||||
<div className="appearance-overview__thumb">
|
||||
{thumbUrl ? (
|
||||
<img src={thumbUrl} alt="" loading="lazy" />
|
||||
) : (
|
||||
<div className="appearance-overview__thumb-base theme-card__swatches" data-theme-style-card={baseStyle}>
|
||||
<span className="theme-card__swatch theme-card__swatch--bg" />
|
||||
<span className="theme-card__swatch theme-card__swatch--surface" />
|
||||
<span className="theme-card__swatch theme-card__swatch--accent" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="appearance-overview__hero-meta">
|
||||
<div className="appearance-overview__hero-title-row">
|
||||
<h4 className="appearance-overview__hero-name">{currentTitle}</h4>
|
||||
<span className="theme-gallery__badge">{kindLabel}</span>
|
||||
</div>
|
||||
{swatches ? (
|
||||
<div className="appearance-overview__swatches" aria-hidden="true">
|
||||
{swatches.map((c, i) => (
|
||||
<span key={i} style={{ background: c }} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="appearance-overview__in-use">
|
||||
<Check size={14} /> {t("settings.themeLibrary.active")}
|
||||
</div>
|
||||
<div className="appearance-overview__hero-actions">
|
||||
<button type="button" className="btn btn--primary" disabled={busy} onClick={handleBrowse}>
|
||||
<Images size={14} /> {t("settings.themeGallery.browse")}
|
||||
</button>
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => void handleCopy()}>
|
||||
<Copy size={14} /> {t("settings.themeGallery.createCopy")}
|
||||
</button>
|
||||
{pack ? (
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => void handleDisable()}>
|
||||
{t("settings.themeGallery.disable")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="appearance-overview__rows">
|
||||
<div className="appearance-overview__row">
|
||||
<div id="appearance-theme-mode-label" className="appearance-overview__row-label">{t("settings.theme")}</div>
|
||||
<div
|
||||
className="set-seg appearance-overview__segmented appearance-overview__segmented--theme"
|
||||
role="radiogroup"
|
||||
aria-labelledby="appearance-theme-mode-label"
|
||||
>
|
||||
{(["auto", "light", "dark"] as Theme[]).map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={theme === opt}
|
||||
className={`set-seg__btn${theme === opt ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => void handleThemeMode(opt)}
|
||||
>
|
||||
{opt === "auto" ? t("settings.themeAuto") : opt === "light" ? t("settings.themeLight") : t("settings.themeDark")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="appearance-overview__row">
|
||||
<div id="appearance-base-style-label" className="appearance-overview__row-label">{t("settings.themeGallery.baseStyle")}</div>
|
||||
<div className="appearance-overview__control-stack">
|
||||
<select
|
||||
className="appearance-overview__select"
|
||||
value={baseStyle}
|
||||
disabled={busy || !!pack}
|
||||
aria-labelledby="appearance-base-style-label"
|
||||
aria-describedby={pack ? "appearance-base-style-help" : undefined}
|
||||
onChange={(e) => void handleBaseChange(e.target.value as ThemeStyle)}
|
||||
>
|
||||
{THEME_STYLES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(STYLE_NAME_KEY[s])}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{pack ? (
|
||||
<span id="appearance-base-style-help" className="appearance-overview__lock-note">
|
||||
<LockKeyhole size={12} aria-hidden="true" />
|
||||
{t("settings.themeGallery.baseLockedByPack")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="appearance-overview__row">
|
||||
<div id="appearance-text-size-label" className="appearance-overview__row-label">{t("settings.textSize")}</div>
|
||||
<div
|
||||
className="set-seg appearance-overview__segmented appearance-overview__segmented--text-size"
|
||||
role="radiogroup"
|
||||
aria-labelledby="appearance-text-size-label"
|
||||
>
|
||||
{TEXT_SIZES.map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={textSize === size}
|
||||
className={`set-seg__btn${textSize === size ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => onTextSize(size)}
|
||||
>
|
||||
{textSizeLabel(size, t)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="appearance-overview__row">
|
||||
<div id="appearance-font-family-label" className="appearance-overview__row-label">{t("settings.fontFamily")}</div>
|
||||
<select
|
||||
className="appearance-overview__select"
|
||||
value={fontFamily}
|
||||
aria-labelledby="appearance-font-family-label"
|
||||
onChange={(e) => onFontFamily(e.target.value as FontFamily)}
|
||||
>
|
||||
{availableFontFamilies.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{fontFamilyLabel(f, t)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{fontFamily === "custom" ? (
|
||||
<div className="appearance-overview__row">
|
||||
<div id="appearance-custom-font-name-label" className="appearance-overview__row-label">
|
||||
{t("settings.fontFamilyCustomName")}
|
||||
</div>
|
||||
<textarea
|
||||
className="mem-input appearance-overview__font-input"
|
||||
rows={2}
|
||||
aria-labelledby="appearance-custom-font-name-label"
|
||||
placeholder={t("settings.fontFamilyCustomPlaceholder")}
|
||||
value={customFontName}
|
||||
onChange={(e) => onCustomFontNameChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="appearance-overview__row">
|
||||
<div id="appearance-mono-font-family-label" className="appearance-overview__row-label">{t("settings.monoFontFamily")}</div>
|
||||
<select
|
||||
className="appearance-overview__select"
|
||||
value={monoFontFamily}
|
||||
aria-labelledby="appearance-mono-font-family-label"
|
||||
onChange={(e) => onMonoFontFamily(e.target.value as MonoFontFamily)}
|
||||
>
|
||||
{availableMonoFontFamilies.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{monoFontFamilyLabel(f, t)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{monoFontFamily === "custom" ? (
|
||||
<div className="appearance-overview__row">
|
||||
<div id="appearance-custom-mono-font-name-label" className="appearance-overview__row-label">
|
||||
{t("settings.monoFontFamilyCustomName")}
|
||||
</div>
|
||||
<textarea
|
||||
className="mem-input appearance-overview__font-input"
|
||||
rows={2}
|
||||
aria-labelledby="appearance-custom-mono-font-name-label"
|
||||
placeholder={t("settings.monoFontFamilyCustomPlaceholder")}
|
||||
value={customMonoFontName}
|
||||
onChange={(e) => onCustomMonoFontNameChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showDisplayZoom ? (
|
||||
<div className="appearance-overview__row">
|
||||
<div className="appearance-overview__row-label">{t("settings.displayZoom")}</div>
|
||||
<div className="zoom-slider-wrap">
|
||||
<div className="zoom-slider__head">
|
||||
<div className="zoom-slider__value">{zoomPct}%</div>
|
||||
<div className="zoom-stepper">
|
||||
<button
|
||||
type="button"
|
||||
className="zoom-stepper__btn"
|
||||
aria-label={t("settings.displayZoomDecrease")}
|
||||
title={t("settings.displayZoomDecrease")}
|
||||
disabled={!canDecreaseZoom}
|
||||
onClick={() => setZoomPercent(zoomPct - zoomStepPct)}
|
||||
>
|
||||
<Minus size={13} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="zoom-stepper__reset"
|
||||
aria-label={t("settings.displayZoomReset")}
|
||||
title={t("settings.displayZoomReset")}
|
||||
disabled={zoomPct === zoomToPercent(DEFAULT_ZOOM)}
|
||||
onClick={() => {
|
||||
void onRestartZoom(DEFAULT_ZOOM);
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={12} aria-hidden="true" />
|
||||
<span>100%</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="zoom-stepper__btn"
|
||||
aria-label={t("settings.displayZoomIncrease")}
|
||||
title={t("settings.displayZoomIncrease")}
|
||||
disabled={!canIncreaseZoom}
|
||||
onClick={() => setZoomPercent(zoomPct + zoomStepPct)}
|
||||
>
|
||||
<Plus size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="zoom-slider-row">
|
||||
<span className="zoom-slider__label">{zoomMinPct}%</span>
|
||||
<div className="slider-track">
|
||||
<div className="slider-track__bg" />
|
||||
<div className="slider-track__fill" style={{ width: `calc(${zoomProgressPct}% + 15px)` }} />
|
||||
<div className="slider-thumb" style={{ left: `${zoomProgressPct}%` }} />
|
||||
<input
|
||||
aria-label={t("settings.displayZoom")}
|
||||
type="range"
|
||||
min={zoomMinPct}
|
||||
max={zoomMaxPct}
|
||||
step={zoomStepPct}
|
||||
value={zoomPct}
|
||||
onChange={(e) => setZoomPercent(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<span className="zoom-slider__label">{zoomMaxPct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="appearance-overview__row appearance-overview__row--footer">
|
||||
<span id="appearance-restore-help" className="appearance-overview__reset-hint">
|
||||
{t("settings.themeGallery.restoreGraphiteHint")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--small"
|
||||
aria-describedby="appearance-restore-help"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const exp = await restoreGraphiteAppearance();
|
||||
setExperience(exp);
|
||||
onThemeStyle("graphite");
|
||||
showToast(t("settings.themeGallery.restoredGraphite"), "info");
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t("settings.themeGallery.restoreGraphite")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export type ConfirmDialogRequest = {
|
||||
title: string;
|
||||
message: ReactNode;
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
tone?: "default" | "danger";
|
||||
};
|
||||
|
||||
type PendingConfirmation = ConfirmDialogRequest & {
|
||||
resolve: (confirmed: boolean) => void;
|
||||
};
|
||||
|
||||
function ConfirmDialog({ request, onResolve }: { request: ConfirmDialogRequest; onResolve: (confirmed: boolean) => void }) {
|
||||
const titleId = useId();
|
||||
const messageId = useId();
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
const confirmRef = useRef<HTMLButtonElement>(null);
|
||||
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
restoreFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
cancelRef.current?.focus();
|
||||
return () => {
|
||||
if (restoreFocusRef.current?.isConnected) restoreFocusRef.current.focus();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onResolve(false);
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const first = cancelRef.current;
|
||||
const last = confirmRef.current;
|
||||
if (!first || !last) return;
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown, { capture: true });
|
||||
return () => document.removeEventListener("keydown", onKeyDown, { capture: true });
|
||||
}, [onResolve]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-backdrop reasonix-confirm-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) onResolve(false);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal reasonix-confirm-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={messageId}
|
||||
>
|
||||
<div className="modal__title reasonix-confirm-dialog__title" id={titleId}>{request.title}</div>
|
||||
<div className="reasonix-confirm-dialog__message" id={messageId}>{request.message}</div>
|
||||
<div className="modal__actions reasonix-confirm-dialog__actions">
|
||||
<button ref={cancelRef} className="btn btn--small" type="button" onClick={() => onResolve(false)}>
|
||||
{request.cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
ref={confirmRef}
|
||||
className={`btn btn--small ${request.tone === "danger" ? "btn--danger" : "btn--primary"}`}
|
||||
type="button"
|
||||
onClick={() => onResolve(true)}
|
||||
>
|
||||
{request.confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export function useConfirmDialog(): {
|
||||
confirm: (request: ConfirmDialogRequest) => Promise<boolean>;
|
||||
dialog: ReactNode;
|
||||
} {
|
||||
const [pending, setPending] = useState<PendingConfirmation | null>(null);
|
||||
const pendingRef = useRef<PendingConfirmation | null>(null);
|
||||
|
||||
const resolvePending = useCallback((confirmed: boolean) => {
|
||||
const current = pendingRef.current;
|
||||
if (!current) return;
|
||||
pendingRef.current = null;
|
||||
setPending(null);
|
||||
current.resolve(confirmed);
|
||||
}, []);
|
||||
|
||||
const confirm = useCallback((request: ConfirmDialogRequest) => new Promise<boolean>((resolve) => {
|
||||
pendingRef.current?.resolve(false);
|
||||
const next = { ...request, resolve };
|
||||
pendingRef.current = next;
|
||||
setPending(next);
|
||||
}), []);
|
||||
|
||||
useEffect(() => () => {
|
||||
pendingRef.current?.resolve(false);
|
||||
pendingRef.current = null;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
confirm,
|
||||
dialog: pending ? <ConfirmDialog request={pending} onResolve={resolvePending} /> : null,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { lazy, memo, Suspense, useCallback, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent, type ReactNode } from "react";
|
||||
import { Bot as BotIcon, Check, CheckCircle2, ChevronDown, ChevronUp, Clipboard, ExternalLink, GripVertical, KeyRound, Loader2, MessageCircle, Minus, Play, Plus, QrCode, RefreshCw, RotateCcw, Send } from "lucide-react";
|
||||
import { Bot as BotIcon, Check, CheckCircle2, ChevronDown, ChevronUp, Clipboard, ExternalLink, GripVertical, KeyRound, Loader2, MessageCircle, Play, QrCode, RefreshCw, Send } from "lucide-react";
|
||||
import { asArray } from "../lib/array";
|
||||
import { useDeferredClose } from "../lib/useMountTransition";
|
||||
import { app, openExternal } from "../lib/bridge";
|
||||
@@ -7,7 +7,6 @@ import { normalizeLangPref, useI18n, useT, type DictKey, type LangPref } from ".
|
||||
import { apiKeyEnvFromProviderName, inferredVisionModels, mergedFetchedProviderModels, providerApiKeyEnvForSave, providerDefaultModel, providerIsConfigured, providerModelCandidates, providerRequiresKey } from "../lib/providerModels";
|
||||
import { useUpdater } from "../lib/useUpdater";
|
||||
import {
|
||||
THEME_STYLES,
|
||||
applyTheme,
|
||||
getTheme,
|
||||
getThemeStyle,
|
||||
@@ -16,8 +15,8 @@ import {
|
||||
type Theme,
|
||||
type ThemeStyle,
|
||||
} from "../lib/theme";
|
||||
import { TEXT_SIZES, applyTextSize, getTextSize, type TextSize } from "../lib/textSize";
|
||||
import { DEFAULT_ZOOM, MAX_ZOOM, MIN_ZOOM, ZOOM_STEP, snapZoom, zoomToPercent, saveRestartZoom, getRestartZoom, type ZoomLevel } from "../lib/dpiScale";
|
||||
import { applyTextSize, getTextSize, type TextSize } from "../lib/textSize";
|
||||
import { snapZoom, zoomToPercent, saveRestartZoom, getRestartZoom, type ZoomLevel } from "../lib/dpiScale";
|
||||
import {
|
||||
applyFontFamily,
|
||||
applyMonoFontFamily,
|
||||
@@ -30,7 +29,6 @@ import {
|
||||
type FontFamily,
|
||||
type MonoFontFamily,
|
||||
} from "../lib/fontFamily";
|
||||
import { getAvailableFontFamilies, getAvailableMonoFontFamilies } from "../lib/fontAvailability";
|
||||
import { getDisplayMode, onDisplayModeChange, setDisplayMode as setLocalDisplayMode } from "../lib/displayMode";
|
||||
import { getProcessFoldPreference, onProcessFoldPreferenceChange, setProcessFoldPreference, type ProcessFoldPreference } from "../lib/processFoldPreference";
|
||||
import { DEFAULT_STATUS_BAR_ITEMS, normalizeStatusBarItems, type StatusBarItemId } from "../lib/statusBarItems";
|
||||
@@ -48,6 +46,8 @@ import {
|
||||
type ShortcutAction,
|
||||
} from "../lib/keyboardShortcuts";
|
||||
import type { BotAccessView, BotAllowlistView, BotConnectionDiagnostic, BotConnectionView, BotInstallStartResult, BotRouteView, BotSettingsView, HookConfigView, HooksSettingsView, NetworkView, ProviderPresetView, ProviderView, SettingsTab, SettingsView } from "../lib/types";
|
||||
import { AppearanceOverview } from "./AppearanceOverview";
|
||||
import { applyThemePack, getActiveThemePack, setBaseAppearance } from "../lib/themePack";
|
||||
import { InlineConfirmButton } from "./InlineConfirmButton";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { AnchoredPopover } from "./AnchoredPopover";
|
||||
@@ -275,7 +275,7 @@ export function SettingsPanel({
|
||||
{tab === "network" && s && <SettingsPageShell key={tab} s={s} tab={tab} busy={busy} apply={apply}><NetworkSection s={s} busy={busy} apply={apply} /></SettingsPageShell>}
|
||||
{tab === "appearance" && s && (
|
||||
<SettingsPageShell key={tab} s={s} tab={tab} busy={busy} apply={apply}>
|
||||
<AppearanceSection
|
||||
<AppearanceOverview
|
||||
theme={theme}
|
||||
themeStyle={themeStyle}
|
||||
textSize={textSize}
|
||||
@@ -288,12 +288,17 @@ export function SettingsPanel({
|
||||
onTheme={(nextTheme) => {
|
||||
applyTheme(nextTheme, themeStyle, { persist: false });
|
||||
setThemeState(nextTheme);
|
||||
setBaseAppearance(nextTheme, themeStyle);
|
||||
const pack = getActiveThemePack();
|
||||
if (pack) applyThemePack(pack);
|
||||
void apply(() => app.SetDesktopAppearance(nextTheme, themeStyle));
|
||||
}}
|
||||
onThemeStyle={(style) => {
|
||||
// AppearanceOverview already persists via ActivateBaseStyle /
|
||||
// experience APIs. Parent only mirrors React + DOM state.
|
||||
applyTheme(theme, style, { persist: false });
|
||||
setThemeStyleState(style);
|
||||
void apply(() => app.SetDesktopAppearance(theme, style));
|
||||
setBaseAppearance(theme, style);
|
||||
}}
|
||||
onTextSize={(size) => {
|
||||
applyTextSize(size);
|
||||
@@ -348,10 +353,12 @@ function SettingsPageShell({ s: _s, tab, children }: { s: SettingsView | null; t
|
||||
const desc = t(descKey as any);
|
||||
return (
|
||||
<div className={`settings-page settings-page--${settingsPageKind(tab)} settings-page--${tab}`}>
|
||||
<div className="settings-page__header">
|
||||
<h2 className="settings-page__title">{settingsTabPageTitle(tab, t)}</h2>
|
||||
{typeof desc === "string" && desc !== `settings.pageDesc.${tab}` && <p className="settings-page__desc">{desc}</p>}
|
||||
</div>
|
||||
{tab !== "appearance" ? (
|
||||
<div className="settings-page__header">
|
||||
<h2 className="settings-page__title">{settingsTabPageTitle(tab, t)}</h2>
|
||||
{typeof desc === "string" && desc !== `settings.pageDesc.${tab}` && <p className="settings-page__desc">{desc}</p>}
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -6551,303 +6558,6 @@ function SandboxSection({ s, busy, apply, windows }: SectionProps & { windows: b
|
||||
);
|
||||
}
|
||||
|
||||
// Visual-style metadata for the appearance theme cards. The two surface
|
||||
// swatches + accent are read from CSS variables at render time so they always
|
||||
// reflect the live token values for the currently-resolved light/dark mode.
|
||||
const THEME_STYLE_META: Record<ThemeStyle, { name: string; zh: DictKey; note: DictKey; desc: DictKey }> = {
|
||||
graphite: { name: "Graphite", zh: "settings.style.graphite.zh", note: "settings.style.graphite.note", desc: "settings.style.graphite.desc" },
|
||||
aurora: { name: "Aurora", zh: "settings.style.aurora.zh", note: "settings.style.aurora.note", desc: "settings.style.aurora.desc" },
|
||||
slate: { name: "Slate", zh: "settings.style.slate.zh", note: "settings.style.slate.note", desc: "settings.style.slate.desc" },
|
||||
carbon: { name: "Carbon", zh: "settings.style.carbon.zh", note: "settings.style.carbon.note", desc: "settings.style.carbon.desc" },
|
||||
nocturne: { name: "Nocturne", zh: "settings.style.nocturne.zh", note: "settings.style.nocturne.note", desc: "settings.style.nocturne.desc" },
|
||||
amber: { name: "Amber", zh: "settings.style.amber.zh", note: "settings.style.amber.note", desc: "settings.style.amber.desc" },
|
||||
};
|
||||
|
||||
function AppearanceSection({
|
||||
theme,
|
||||
themeStyle,
|
||||
textSize,
|
||||
showDisplayZoom,
|
||||
zoomPct,
|
||||
fontFamily,
|
||||
monoFontFamily,
|
||||
customFontName,
|
||||
customMonoFontName,
|
||||
onTheme,
|
||||
onThemeStyle,
|
||||
onTextSize,
|
||||
onRestartZoom,
|
||||
onFontFamily,
|
||||
onMonoFontFamily,
|
||||
onCustomFontNameChange,
|
||||
onCustomMonoFontNameChange,
|
||||
}: {
|
||||
theme: Theme;
|
||||
themeStyle: ThemeStyle;
|
||||
textSize: TextSize;
|
||||
showDisplayZoom: boolean;
|
||||
zoomPct: number;
|
||||
fontFamily: FontFamily;
|
||||
monoFontFamily: MonoFontFamily;
|
||||
customFontName: string;
|
||||
customMonoFontName: string;
|
||||
onTheme: (t: Theme) => void;
|
||||
onThemeStyle: (style: ThemeStyle) => void;
|
||||
onTextSize: (size: TextSize) => void;
|
||||
onRestartZoom: (zoom: ZoomLevel) => Promise<void>;
|
||||
onFontFamily: (font: FontFamily) => void;
|
||||
onMonoFontFamily: (font: MonoFontFamily) => void;
|
||||
onCustomFontNameChange: (name: string) => void;
|
||||
onCustomMonoFontNameChange: (name: string) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const themeOptions: Theme[] = ["auto", "light", "dark"];
|
||||
const availableFontFamilies = useMemo(() => getAvailableFontFamilies(fontFamily), [fontFamily]);
|
||||
const availableMonoFontFamilies = useMemo(() => getAvailableMonoFontFamilies(monoFontFamily), [monoFontFamily]);
|
||||
const zoomMinPct = zoomToPercent(MIN_ZOOM);
|
||||
const zoomMaxPct = zoomToPercent(MAX_ZOOM);
|
||||
const zoomStepPct = Math.round(ZOOM_STEP * 100);
|
||||
const zoomProgressPct = Math.min(100, Math.max(0, ((zoomPct - zoomMinPct) / (zoomMaxPct - zoomMinPct)) * 100));
|
||||
const canDecreaseZoom = zoomPct > zoomMinPct;
|
||||
const canIncreaseZoom = zoomPct < zoomMaxPct;
|
||||
const setZoomPercent = (pct: number) => {
|
||||
void onRestartZoom(pct / 100);
|
||||
};
|
||||
return (
|
||||
<SettingsSection title={t("settings.appearance")}>
|
||||
<SettingsField label={t("settings.theme")}>
|
||||
<div className="set-seg">
|
||||
{themeOptions.map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
className={`set-seg__btn${theme === opt ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => onTheme(opt)}
|
||||
>
|
||||
{themeName(opt, t)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingsField>
|
||||
<SettingsField label={t("settings.themeStyle")} stacked>
|
||||
<div className="theme-card-grid">
|
||||
{THEME_STYLES.map((opt) => {
|
||||
const meta = THEME_STYLE_META[opt];
|
||||
const selected = themeStyle === opt;
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
className={`theme-card${selected ? " theme-card--on" : ""}`}
|
||||
onClick={() => onThemeStyle(opt)}
|
||||
>
|
||||
<span className="theme-card__head">
|
||||
<span className="theme-card__name">
|
||||
{meta.name} <span className="theme-card__zh">{t(meta.zh)}</span>
|
||||
</span>
|
||||
<span className="theme-card__tag">{t(meta.note)}</span>
|
||||
</span>
|
||||
<span className="theme-card__swatches" data-theme-style-card={opt}>
|
||||
<span className="theme-card__swatch theme-card__swatch--bg" />
|
||||
<span className="theme-card__swatch theme-card__swatch--surface" />
|
||||
<span className="theme-card__swatch theme-card__swatch--accent" />
|
||||
</span>
|
||||
<span className="theme-card__desc">{t(meta.desc)}</span>
|
||||
<span className="theme-card__check" aria-hidden="true">
|
||||
<Check size={13} strokeWidth={3} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingsField>
|
||||
<SettingsField label={t("settings.textSize")}>
|
||||
<div className="set-seg">
|
||||
{TEXT_SIZES.map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
className={`set-seg__btn${textSize === size ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => onTextSize(size)}
|
||||
>
|
||||
{textSizeName(size, t)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingsField>
|
||||
{showDisplayZoom && (
|
||||
<SettingsField label={t("settings.displayZoom")}>
|
||||
<div className="zoom-slider-wrap">
|
||||
<div className="zoom-slider__head">
|
||||
<div className="zoom-slider__value">{zoomPct}%</div>
|
||||
<div className="zoom-stepper">
|
||||
<button
|
||||
type="button"
|
||||
className="zoom-stepper__btn"
|
||||
aria-label={t("settings.displayZoomDecrease")}
|
||||
title={t("settings.displayZoomDecrease")}
|
||||
disabled={!canDecreaseZoom}
|
||||
onClick={() => setZoomPercent(zoomPct - zoomStepPct)}
|
||||
>
|
||||
<Minus size={13} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="zoom-stepper__reset"
|
||||
aria-label={t("settings.displayZoomReset")}
|
||||
title={t("settings.displayZoomReset")}
|
||||
disabled={zoomPct === zoomToPercent(DEFAULT_ZOOM)}
|
||||
onClick={() => { void onRestartZoom(DEFAULT_ZOOM); }}
|
||||
>
|
||||
<RotateCcw size={12} aria-hidden="true" />
|
||||
<span>100%</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="zoom-stepper__btn"
|
||||
aria-label={t("settings.displayZoomIncrease")}
|
||||
title={t("settings.displayZoomIncrease")}
|
||||
disabled={!canIncreaseZoom}
|
||||
onClick={() => setZoomPercent(zoomPct + zoomStepPct)}
|
||||
>
|
||||
<Plus size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="zoom-slider-row">
|
||||
<span className="zoom-slider__label">{zoomMinPct}%</span>
|
||||
<div className="slider-track">
|
||||
<div className="slider-track__bg" />
|
||||
<div
|
||||
className="slider-track__fill"
|
||||
style={{ width: `calc(${zoomProgressPct}% + 15px)` }}
|
||||
/>
|
||||
<div className="slider-thumb" style={{ left: `${zoomProgressPct}%` }} />
|
||||
<input
|
||||
aria-label={t("settings.displayZoom")}
|
||||
type="range"
|
||||
min={zoomMinPct}
|
||||
max={zoomMaxPct}
|
||||
step={zoomStepPct}
|
||||
value={zoomPct}
|
||||
onChange={(e) => setZoomPercent(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<span className="zoom-slider__label">{zoomMaxPct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsField>
|
||||
)}
|
||||
<SettingsField label={t("settings.fontFamily")}>
|
||||
<div className="set-seg">
|
||||
{availableFontFamilies.map((font) => (
|
||||
<button
|
||||
key={font}
|
||||
className={`set-seg__btn${fontFamily === font ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => onFontFamily(font)}
|
||||
>
|
||||
{fontFamilyName(font, t)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingsField>
|
||||
{fontFamily === "custom" && (
|
||||
<SettingsField label={t("settings.fontFamilyCustomName")}>
|
||||
<textarea
|
||||
className="mem-input"
|
||||
style={{ width: "100%", resize: "vertical" }}
|
||||
rows={2}
|
||||
placeholder={t("settings.fontFamilyCustomPlaceholder")}
|
||||
value={customFontName}
|
||||
onChange={(e) => onCustomFontNameChange(e.target.value)}
|
||||
/>
|
||||
</SettingsField>
|
||||
)}
|
||||
<SettingsField label={t("settings.monoFontFamily")}>
|
||||
<div className="set-seg">
|
||||
{availableMonoFontFamilies.map((font) => (
|
||||
<button
|
||||
key={font}
|
||||
className={`set-seg__btn${monoFontFamily === font ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => onMonoFontFamily(font)}
|
||||
>
|
||||
{monoFontFamilyName(font, t)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingsField>
|
||||
{monoFontFamily === "custom" && (
|
||||
<SettingsField label={t("settings.monoFontFamilyCustomName")}>
|
||||
<textarea
|
||||
className="mem-input"
|
||||
style={{ width: "100%", resize: "vertical" }}
|
||||
rows={2}
|
||||
placeholder={t("settings.monoFontFamilyCustomPlaceholder")}
|
||||
value={customMonoFontName}
|
||||
onChange={(e) => onCustomMonoFontNameChange(e.target.value)}
|
||||
/>
|
||||
</SettingsField>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function themeName(theme: Theme, t: ReturnType<typeof useT>): string {
|
||||
switch (theme) {
|
||||
case "auto":
|
||||
return t("settings.themeAuto");
|
||||
case "light":
|
||||
return t("settings.themeLight");
|
||||
case "dark":
|
||||
return t("settings.themeDark");
|
||||
}
|
||||
}
|
||||
|
||||
function textSizeName(size: TextSize, t: ReturnType<typeof useT>): string {
|
||||
switch (size) {
|
||||
case "small":
|
||||
return t("settings.textSizeSmall");
|
||||
case "default":
|
||||
return t("settings.textSizeDefault");
|
||||
case "large":
|
||||
return t("settings.textSizeLarge");
|
||||
case "xlarge":
|
||||
return t("settings.textSizeXLarge");
|
||||
case "xxlarge":
|
||||
return t("settings.textSizeXXLarge");
|
||||
}
|
||||
}
|
||||
|
||||
function fontFamilyName(font: FontFamily, t: ReturnType<typeof useT>): string {
|
||||
switch (font) {
|
||||
case "system":
|
||||
return t("settings.fontFamilySystem");
|
||||
case "yahei":
|
||||
return t("settings.fontFamilyYaHei");
|
||||
case "pingfang":
|
||||
return t("settings.fontFamilyPingFang");
|
||||
case "noto":
|
||||
return t("settings.fontFamilyNoto");
|
||||
case "custom":
|
||||
return t("settings.fontFamilyCustom");
|
||||
}
|
||||
}
|
||||
|
||||
function monoFontFamilyName(font: MonoFontFamily, t: ReturnType<typeof useT>): string {
|
||||
switch (font) {
|
||||
case "system":
|
||||
return t("settings.monoFontFamilySystem");
|
||||
case "cascadia":
|
||||
return t("settings.monoFontFamilyCascadia");
|
||||
case "jetbrains":
|
||||
return t("settings.monoFontFamilyJetBrains");
|
||||
case "sfmono":
|
||||
return t("settings.monoFontFamilySFMono");
|
||||
case "custom":
|
||||
return t("settings.monoFontFamilyCustom");
|
||||
}
|
||||
}
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
const mb = (n: number) => (n / MB).toFixed(1);
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Non-interactive full-window background layer for theme packs.
|
||||
// Home scene uses full focus + opacity; task scene dims and applies a
|
||||
// directional safe-area overlay (no backdrop-filter).
|
||||
|
||||
export function ThemeBackground() {
|
||||
return (
|
||||
<div className="theme-bg" aria-hidden="true">
|
||||
<div className="theme-bg__image" />
|
||||
<div className="theme-bg__overlay" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,927 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Check, Copy, Download, Pencil, Plus, RotateCcw, Trash2, Upload } from "lucide-react";
|
||||
import { app } from "../lib/bridge";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { THEME_STYLES, type ThemeStyle, isThemeStyle } from "../lib/theme";
|
||||
import {
|
||||
type ThemePackBackground,
|
||||
type ThemePackRecipes,
|
||||
type ThemePackTokens,
|
||||
type ThemePackView,
|
||||
type ThemeSaveInput,
|
||||
applyThemePack,
|
||||
beginThemePreview,
|
||||
cancelThemePreview,
|
||||
clearThemePack,
|
||||
commitThemePreview,
|
||||
defaultBackground,
|
||||
draftPackView,
|
||||
emptyThemeTokens,
|
||||
isSafeHex,
|
||||
themePackKind,
|
||||
themeTokenKeys,
|
||||
} from "../lib/themePack";
|
||||
import { useToast } from "../lib/toast";
|
||||
import { useConfirmDialog } from "./ConfirmDialog";
|
||||
|
||||
type EditorState = {
|
||||
mode: "create" | "edit";
|
||||
id: string;
|
||||
name: string;
|
||||
author: string;
|
||||
description: string;
|
||||
license: string;
|
||||
baseStyle: ThemeStyle;
|
||||
tokens: ThemePackTokens;
|
||||
recipes: ThemePackRecipes;
|
||||
background: ThemePackBackground | null;
|
||||
backgroundDataUrl: string;
|
||||
existingBackgroundUrl: string;
|
||||
tokenMode: "light" | "dark";
|
||||
originalId: string;
|
||||
};
|
||||
|
||||
const TOKEN_GROUPS: { labelKey: string; keys: string[] }[] = [
|
||||
{ labelKey: "settings.themeTokens.surfaces", keys: ["bg", "bgSoft", "bgElev", "panel", "sidebar", "chat", "workspace", "workspaceFiles"] },
|
||||
{ labelKey: "settings.themeTokens.borderText", keys: ["border", "borderSoft", "fg", "fgDim", "fgFaint"] },
|
||||
{ labelKey: "settings.themeTokens.accentStatus", keys: ["accent", "accentFg", "ok", "warn", "err"] },
|
||||
];
|
||||
|
||||
function slugifyId(name: string): string {
|
||||
const s = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48);
|
||||
if (!s) return "my-theme";
|
||||
if (/^[a-z][a-z0-9-]*[a-z0-9]$|^[a-z]$/.test(s)) return s;
|
||||
return `t-${s}`.slice(0, 48);
|
||||
}
|
||||
|
||||
function packToEditor(pack: ThemePackView, mode: "create" | "edit"): EditorState {
|
||||
return {
|
||||
mode,
|
||||
id: mode === "create" ? slugifyId(`${pack.id}-copy`) : pack.id,
|
||||
name: mode === "create" ? `${pack.name} Copy` : pack.name,
|
||||
author: pack.author || "",
|
||||
description: pack.description || "",
|
||||
license: pack.license || "",
|
||||
baseStyle: isThemeStyle(pack.baseStyle) ? pack.baseStyle : "graphite",
|
||||
tokens: {
|
||||
light: { ...(pack.tokens?.light || {}) },
|
||||
dark: { ...(pack.tokens?.dark || {}) },
|
||||
},
|
||||
recipes: {
|
||||
density: pack.recipes?.density === "compact" ? "compact" : "comfortable",
|
||||
corners: pack.recipes?.corners === "square" || pack.recipes?.corners === "round" ? pack.recipes.corners : "soft",
|
||||
},
|
||||
background: pack.background ? { ...pack.background } : null,
|
||||
backgroundDataUrl: "",
|
||||
existingBackgroundUrl: pack.backgroundUrl || "",
|
||||
tokenMode: "dark",
|
||||
originalId: pack.id,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyEditor(): EditorState {
|
||||
return {
|
||||
mode: "create",
|
||||
id: "my-theme",
|
||||
name: "My Theme",
|
||||
author: "",
|
||||
description: "",
|
||||
license: "",
|
||||
baseStyle: "graphite",
|
||||
tokens: emptyThemeTokens(),
|
||||
recipes: { density: "comfortable", corners: "soft" },
|
||||
background: null,
|
||||
backgroundDataUrl: "",
|
||||
existingBackgroundUrl: "",
|
||||
tokenMode: "dark",
|
||||
originalId: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function ThemeLibrarySection() {
|
||||
const t = useT();
|
||||
const { showToast } = useToast();
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||
const [packs, setPacks] = useState<ThemePackView[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editor, setEditor] = useState<EditorState | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const previewTimer = useRef<number | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await app.ListThemePacks();
|
||||
setPacks(list || []);
|
||||
const active = await app.GetActiveThemePack();
|
||||
if (active?.pack) {
|
||||
commitThemePreview(active.pack);
|
||||
} else if (!active?.safeMode) {
|
||||
const still = (list || []).find((p) => p.active);
|
||||
if (!still) clearThemePack();
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
return () => {
|
||||
if (previewTimer.current) window.clearTimeout(previewTimer.current);
|
||||
// Closing settings / leaving the appearance tab must not leave a draft preview applied.
|
||||
cancelThemePreview();
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
const schedulePreview = useCallback((state: EditorState) => {
|
||||
if (previewTimer.current) window.clearTimeout(previewTimer.current);
|
||||
previewTimer.current = window.setTimeout(() => {
|
||||
const bgUrl = state.backgroundDataUrl || state.existingBackgroundUrl || "";
|
||||
const draft = draftPackView({
|
||||
id: state.id || "preview",
|
||||
name: state.name,
|
||||
baseStyle: state.baseStyle,
|
||||
tokens: state.tokens,
|
||||
recipes: state.recipes,
|
||||
background: state.background,
|
||||
backgroundUrl: bgUrl,
|
||||
});
|
||||
beginThemePreview(draft);
|
||||
}, 80);
|
||||
}, []);
|
||||
|
||||
const openCreate = () => {
|
||||
const state = emptyEditor();
|
||||
setEditor(state);
|
||||
schedulePreview(state);
|
||||
};
|
||||
|
||||
const openEdit = (pack: ThemePackView) => {
|
||||
if (themePackKind(pack) !== "user") {
|
||||
// Editing a base style means copying into a user theme.
|
||||
const state = packToEditor(pack, "create");
|
||||
setEditor(state);
|
||||
schedulePreview(state);
|
||||
return;
|
||||
}
|
||||
const state = packToEditor(pack, "edit");
|
||||
setEditor(state);
|
||||
schedulePreview(state);
|
||||
};
|
||||
|
||||
const openCopy = async (pack: ThemePackView) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const newId = slugifyId(`${pack.id}-copy`);
|
||||
const created = await app.CopyThemePack(pack.id, newId, `${pack.name} Copy`);
|
||||
showToast(t("settings.themeLibrary.copied", { name: created.name }), "info");
|
||||
await reload();
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const activate = async (pack: ThemePackView) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await app.ActivateThemePack(pack.id);
|
||||
const active = await app.GetActiveThemePack();
|
||||
commitThemePreview(active.pack ?? null);
|
||||
// Sync base style via appearance when activating a pack.
|
||||
if (active.pack && isThemeStyle(active.pack.baseStyle)) {
|
||||
// Appearance style stays independent in config; pack overlay supplies baseStyle live.
|
||||
}
|
||||
await reload();
|
||||
showToast(t("settings.themeLibrary.activated", { name: pack.name }), "info");
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetDefault = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await app.ResetThemePack();
|
||||
cancelThemePreview();
|
||||
applyThemePack(null);
|
||||
await reload();
|
||||
showToast(t("settings.themeLibrary.resetDone"), "info");
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (pack: ThemePackView) => {
|
||||
if (themePackKind(pack) !== "user") return;
|
||||
const ok = await confirm({
|
||||
title: t("settings.themeLibrary.confirmDeleteTitle"),
|
||||
message: t("settings.themeLibrary.confirmDelete", { name: packDisplayName(pack, t) }),
|
||||
confirmLabel: t("common.delete"),
|
||||
cancelLabel: t("common.cancel"),
|
||||
tone: "danger",
|
||||
});
|
||||
if (!ok) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await app.DeleteThemePack(pack.id);
|
||||
await reload();
|
||||
const active = await app.GetActiveThemePack();
|
||||
commitThemePreview(active.pack ?? null);
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const doImport = async (replace = false) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
// First call may open a file dialog. On ID conflict the backend stages the
|
||||
// extract and returns needsReplace — confirm then call again with replace=true
|
||||
// (empty path) so the staged import is published without re-picking a file.
|
||||
const result = await app.ImportThemePack("", replace);
|
||||
if (!result) return;
|
||||
if (result.needsReplace) {
|
||||
const ok = await confirm({
|
||||
title: t("settings.themeLibrary.confirmReplaceImportTitle"),
|
||||
message: t("settings.themeLibrary.confirmReplaceImport"),
|
||||
confirmLabel: t("settings.themeLibrary.replaceConfirm"),
|
||||
cancelLabel: t("common.cancel"),
|
||||
});
|
||||
if (!ok) return;
|
||||
const confirmed = await app.ImportThemePack("", true);
|
||||
if (!confirmed?.pack?.id) return;
|
||||
showToast(t("settings.themeLibrary.imported", { name: confirmed.pack.name }), "info");
|
||||
await reload();
|
||||
return;
|
||||
}
|
||||
if (!result.pack?.id) {
|
||||
// Cancelled
|
||||
return;
|
||||
}
|
||||
showToast(t("settings.themeLibrary.imported", { name: result.pack.name }), "info");
|
||||
await reload();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
showToast(msg, "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const doExport = async (pack: ThemePackView) => {
|
||||
if (pack.hasBackground) {
|
||||
const ok = await confirm({
|
||||
title: t("settings.themeLibrary.exportRightsTitle"),
|
||||
message: t("settings.themeLibrary.exportRights"),
|
||||
confirmLabel: t("settings.themeLibrary.exportConfirm"),
|
||||
cancelLabel: t("common.cancel"),
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const path = await app.ExportThemePack(pack.id, "");
|
||||
if (path) showToast(t("settings.themeLibrary.exported"), "info");
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEditor = () => {
|
||||
cancelThemePreview();
|
||||
setEditor(null);
|
||||
};
|
||||
|
||||
const saveEditor = async (activateAfter: boolean) => {
|
||||
if (!editor) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const input: ThemeSaveInput = {
|
||||
id: editor.id.trim(),
|
||||
name: editor.name.trim(),
|
||||
author: editor.author,
|
||||
description: editor.description,
|
||||
license: editor.license,
|
||||
baseStyle: editor.baseStyle,
|
||||
tokens: editor.tokens,
|
||||
recipes: editor.recipes,
|
||||
background: editor.background,
|
||||
backgroundDataUrl: editor.backgroundDataUrl || undefined,
|
||||
clearBackground: editor.background === null && editor.mode === "edit",
|
||||
replace: editor.mode === "edit",
|
||||
activate: activateAfter,
|
||||
};
|
||||
const saved = await app.SaveThemePack(input);
|
||||
commitThemePreview(activateAfter ? saved : (await app.GetActiveThemePack()).pack ?? null);
|
||||
setEditor(null);
|
||||
await reload();
|
||||
showToast(t("settings.themeLibrary.saved", { name: saved.name }), "info");
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateEditor = (patch: Partial<EditorState>) => {
|
||||
setEditor((prev) => {
|
||||
if (!prev) return prev;
|
||||
const next = { ...prev, ...patch };
|
||||
schedulePreview(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const activeId = useMemo(() => packs.find((p) => p.active)?.id ?? "", [packs]);
|
||||
const groups = useMemo(() => {
|
||||
const official: ThemePackView[] = [];
|
||||
const base: ThemePackView[] = [];
|
||||
const user: ThemePackView[] = [];
|
||||
for (const p of packs) {
|
||||
const kind = themePackKind(p);
|
||||
if (kind === "official") official.push(p);
|
||||
else if (kind === "base") base.push(p);
|
||||
else user.push(p);
|
||||
}
|
||||
return { official, base, user };
|
||||
}, [packs]);
|
||||
|
||||
return (
|
||||
<div className="theme-library">
|
||||
<div className="theme-library__toolbar">
|
||||
<button type="button" className="btn btn--small" disabled={busy} onClick={openCreate}>
|
||||
<Plus size={13} /> {t("settings.themeLibrary.new")}
|
||||
</button>
|
||||
<button type="button" className="btn btn--small" disabled={busy} onClick={() => void doImport(false)}>
|
||||
<Upload size={13} /> {t("settings.themeLibrary.import")}
|
||||
</button>
|
||||
<button type="button" className="btn btn--small theme-reset-btn" disabled={busy} onClick={() => void resetDefault()}>
|
||||
<RotateCcw size={13} /> {t("settings.themeLibrary.reset")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="theme-lib-card__sub">{t("settings.themeLibrary.loading")}</div>
|
||||
) : (
|
||||
<>
|
||||
{groups.official.length > 0 && (
|
||||
<section className="theme-library__group" data-group="official">
|
||||
<h4 className="theme-library__heading">{t("settings.themeLibrary.groupOfficial")}</h4>
|
||||
<div className="theme-library__grid theme-library__grid--official">
|
||||
{groups.official.map((pack) => (
|
||||
<OfficialThemeCard
|
||||
key={pack.id}
|
||||
pack={pack}
|
||||
active={pack.id === activeId}
|
||||
busy={busy}
|
||||
onActivate={() => void activate(pack)}
|
||||
onCopy={() => void openCopy(pack)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{groups.base.length > 0 && (
|
||||
<section className="theme-library__group" data-group="base">
|
||||
<h4 className="theme-library__heading">{t("settings.themeLibrary.groupBase")}</h4>
|
||||
<div className="theme-library__grid theme-library__grid--base">
|
||||
{groups.base.map((pack) => (
|
||||
<ThemeLibCard
|
||||
key={pack.id}
|
||||
pack={pack}
|
||||
active={pack.id === activeId}
|
||||
busy={busy}
|
||||
onActivate={() => void activate(pack)}
|
||||
onEdit={() => openEdit(pack)}
|
||||
onCopy={() => void openCopy(pack)}
|
||||
onExport={() => void doExport(pack)}
|
||||
onDelete={() => void remove(pack)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="theme-library__group" data-group="user">
|
||||
<h4 className="theme-library__heading">{t("settings.themeLibrary.groupUser")}</h4>
|
||||
{groups.user.length === 0 ? (
|
||||
<div className="theme-lib-card__sub">{t("settings.themeLibrary.emptyUser")}</div>
|
||||
) : (
|
||||
<div className="theme-library__grid">
|
||||
{groups.user.map((pack) => (
|
||||
<ThemeLibCard
|
||||
key={pack.id}
|
||||
pack={pack}
|
||||
active={pack.id === activeId}
|
||||
busy={busy}
|
||||
onActivate={() => void activate(pack)}
|
||||
onEdit={() => openEdit(pack)}
|
||||
onCopy={() => void openCopy(pack)}
|
||||
onExport={() => void doExport(pack)}
|
||||
onDelete={() => void remove(pack)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{editor && (
|
||||
<ThemeEditor
|
||||
state={editor}
|
||||
busy={busy}
|
||||
onChange={updateEditor}
|
||||
onCancel={cancelEditor}
|
||||
onSave={(activateAfter) => void saveEditor(activateAfter)}
|
||||
/>
|
||||
)}
|
||||
{confirmDialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function packDisplayName(pack: ThemePackView, t: (key: never, vars?: Record<string, string | number>) => string): string {
|
||||
return pack.nameKey ? t(pack.nameKey as never) : pack.name;
|
||||
}
|
||||
|
||||
function packDescription(pack: ThemePackView, t: (key: never, vars?: Record<string, string | number>) => string): string {
|
||||
if (pack.descriptionKey) return t(pack.descriptionKey as never);
|
||||
return pack.description || "";
|
||||
}
|
||||
|
||||
function OfficialThemeCard({
|
||||
pack,
|
||||
active,
|
||||
busy,
|
||||
onActivate,
|
||||
onCopy,
|
||||
}: {
|
||||
pack: ThemePackView;
|
||||
active: boolean;
|
||||
busy: boolean;
|
||||
onActivate: () => void;
|
||||
onCopy: () => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const name = packDisplayName(pack, t);
|
||||
const desc = packDescription(pack, t);
|
||||
const lightBg = pack.tokens?.light?.bg || "#f4f3ef";
|
||||
const darkBg = pack.tokens?.dark?.bg || "#0c0d10";
|
||||
const accent = pack.tokens?.dark?.accent || pack.tokens?.light?.accent || "#ff6a3d";
|
||||
|
||||
return (
|
||||
<div className={`theme-lib-card theme-lib-card--official${active ? " theme-lib-card--on" : ""}`}>
|
||||
<div className="theme-lib-card__thumb theme-lib-card__thumb--img">
|
||||
{pack.previewUrl ? (
|
||||
<img src={pack.previewUrl} alt={name} loading="lazy" decoding="async" />
|
||||
) : (
|
||||
<div className="theme-lib-card__thumb-fallback" style={{ background: `linear-gradient(120deg, ${lightBg} 0%, ${lightBg} 55%, ${accent} 140%)` }} />
|
||||
)}
|
||||
</div>
|
||||
<div className="theme-lib-card__meta">
|
||||
<div className="theme-lib-card__name">
|
||||
{name} {active ? <Check size={12} style={{ display: "inline", verticalAlign: "middle" }} /> : null}
|
||||
</div>
|
||||
{desc ? <div className="theme-lib-card__desc">{desc}</div> : null}
|
||||
<div className="theme-lib-card__sub">
|
||||
{pack.license || "MIT"} · {pack.author || "Reasonix Contributors"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="theme-lib-card__swatches" aria-hidden="true">
|
||||
<span className="theme-lib-card__swatch" style={{ background: lightBg }} />
|
||||
<span className="theme-lib-card__swatch" style={{ background: darkBg }} />
|
||||
<span className="theme-lib-card__swatch" style={{ background: accent }} />
|
||||
</div>
|
||||
<div className="theme-lib-card__actions">
|
||||
<button type="button" className="btn btn--small btn--primary" disabled={busy || active} onClick={onActivate}>
|
||||
{active ? t("settings.themeLibrary.active") : t("settings.themeLibrary.enable")}
|
||||
</button>
|
||||
<button type="button" className="btn btn--small" disabled={busy} onClick={onCopy}>
|
||||
<Copy size={12} /> {t("settings.themeLibrary.copyFrom")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeLibCard({
|
||||
pack,
|
||||
active,
|
||||
busy,
|
||||
onActivate,
|
||||
onEdit,
|
||||
onCopy,
|
||||
onExport,
|
||||
onDelete,
|
||||
}: {
|
||||
pack: ThemePackView;
|
||||
active: boolean;
|
||||
busy: boolean;
|
||||
onActivate: () => void;
|
||||
onEdit: () => void;
|
||||
onCopy: () => void;
|
||||
onExport: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const kind = themePackKind(pack);
|
||||
const lightBg = pack.tokens?.light?.bg || "#f4f3ef";
|
||||
const darkBg = pack.tokens?.dark?.bg || "#0c0d10";
|
||||
const accent = pack.tokens?.dark?.accent || pack.tokens?.light?.accent || "#ff6a3d";
|
||||
const thumbStyle: Record<string, string> = pack.backgroundUrl
|
||||
? { backgroundImage: `url("${pack.backgroundUrl}")`, backgroundSize: "cover" }
|
||||
: { ["--thumb-light"]: lightBg, ["--thumb-dark"]: darkBg };
|
||||
|
||||
return (
|
||||
<div className={`theme-lib-card${active ? " theme-lib-card--on" : ""}`}>
|
||||
<div className="theme-lib-card__thumb" style={thumbStyle} />
|
||||
<div className="theme-lib-card__meta">
|
||||
<div className="theme-lib-card__name">
|
||||
{pack.name} {active ? <Check size={12} style={{ display: "inline", verticalAlign: "middle" }} /> : null}
|
||||
</div>
|
||||
<div className="theme-lib-card__sub">
|
||||
{kind === "base" ? t("settings.themeLibrary.builtin") : pack.author || t("settings.themeLibrary.userTheme")}
|
||||
{" · "}
|
||||
{pack.baseStyle}
|
||||
</div>
|
||||
</div>
|
||||
<div className="theme-lib-card__swatches" aria-hidden="true">
|
||||
<span className="theme-lib-card__swatch" style={{ background: lightBg }} />
|
||||
<span className="theme-lib-card__swatch" style={{ background: darkBg }} />
|
||||
<span className="theme-lib-card__swatch" style={{ background: accent }} />
|
||||
</div>
|
||||
<div className="theme-lib-card__actions">
|
||||
<button type="button" className="btn btn--small btn--primary" disabled={busy || active} onClick={onActivate}>
|
||||
{active ? t("settings.themeLibrary.active") : t("settings.themeLibrary.enable")}
|
||||
</button>
|
||||
{kind === "user" && (
|
||||
<button type="button" className="btn btn--small" disabled={busy} onClick={onEdit} title={t("settings.themeLibrary.edit")}>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn--small" disabled={busy} onClick={onCopy} title={t("settings.themeLibrary.copy")}>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
{kind === "user" && (
|
||||
<>
|
||||
<button type="button" className="btn btn--small" disabled={busy} onClick={onExport} title={t("settings.themeLibrary.export")}>
|
||||
<Download size={12} />
|
||||
</button>
|
||||
<button type="button" className="btn btn--small" disabled={busy} onClick={onDelete} title={t("settings.themeLibrary.delete")}>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeEditor({
|
||||
state,
|
||||
busy,
|
||||
onChange,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: {
|
||||
state: EditorState;
|
||||
busy: boolean;
|
||||
onChange: (patch: Partial<EditorState>) => void;
|
||||
onCancel: () => void;
|
||||
onSave: (activate: boolean) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const { showToast } = useToast();
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const dragging = useRef(false);
|
||||
|
||||
const setToken = (key: string, value: string) => {
|
||||
const mode = state.tokenMode;
|
||||
const nextTokens = {
|
||||
...state.tokens,
|
||||
[mode]: { ...(state.tokens[mode] || {}), [key]: value },
|
||||
};
|
||||
// Allow empty to clear override
|
||||
if (!value) {
|
||||
const map = { ...(nextTokens[mode] || {}) };
|
||||
delete map[key];
|
||||
nextTokens[mode] = map;
|
||||
} else if (!isSafeHex(value) && value.length >= 7) {
|
||||
// Keep typing intermediate values without applying invalid hex to preview tokens fully
|
||||
}
|
||||
onChange({ tokens: nextTokens });
|
||||
};
|
||||
|
||||
const pickBackground = async () => {
|
||||
try {
|
||||
const dataUrl = await app.PickThemeBackground();
|
||||
if (!dataUrl) return;
|
||||
const bg = state.background ? { ...state.background } : defaultBackground();
|
||||
onChange({
|
||||
background: bg,
|
||||
backgroundDataUrl: dataUrl,
|
||||
existingBackgroundUrl: "",
|
||||
});
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const onFocusPointer = (clientX: number, clientY: number) => {
|
||||
const el = previewRef.current;
|
||||
if (!el || !state.background) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
|
||||
const y = Math.min(1, Math.max(0, (clientY - rect.top) / rect.height));
|
||||
onChange({ background: { ...state.background, focusX: x, focusY: y } });
|
||||
};
|
||||
|
||||
const bgUrl = state.backgroundDataUrl || state.existingBackgroundUrl;
|
||||
const warnings = useMemo(() => {
|
||||
// Client-side soft check mirroring backend pairs.
|
||||
const out: string[] = [];
|
||||
for (const mode of ["light", "dark"] as const) {
|
||||
const fg = state.tokens[mode]?.fg;
|
||||
const bg = state.tokens[mode]?.bg;
|
||||
if (fg && bg && isSafeHex(fg) && isSafeHex(bg)) {
|
||||
const ratio = contrastRatio(fg, bg);
|
||||
if (ratio < 4.5) out.push(`${mode} fg/bg ${ratio.toFixed(2)} < 4.5`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [state.tokens]);
|
||||
|
||||
return (
|
||||
<div className="theme-editor">
|
||||
<strong>{state.mode === "create" ? t("settings.themeLibrary.editorCreate") : t("settings.themeLibrary.editorEdit")}</strong>
|
||||
|
||||
<div className="theme-editor__row">
|
||||
<div className="theme-editor__label">{t("settings.themeLibrary.fieldId")}</div>
|
||||
<div className="theme-editor__fields">
|
||||
<input
|
||||
value={state.id}
|
||||
disabled={state.mode === "edit" || busy}
|
||||
onChange={(e) => onChange({ id: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
value={state.name}
|
||||
disabled={busy}
|
||||
placeholder={t("settings.themeLibrary.fieldName")}
|
||||
onChange={(e) => onChange({ name: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
value={state.author}
|
||||
disabled={busy}
|
||||
placeholder={t("settings.themeLibrary.fieldAuthor")}
|
||||
onChange={(e) => onChange({ author: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="theme-editor__row">
|
||||
<div className="theme-editor__label">{t("settings.themeLibrary.fieldBase")}</div>
|
||||
<div className="set-seg">
|
||||
{THEME_STYLES.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className={`set-seg__btn${state.baseStyle === s ? " set-seg__btn--on" : ""}`}
|
||||
disabled={busy}
|
||||
onClick={() => onChange({ baseStyle: s })}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="theme-editor__row">
|
||||
<div className="theme-editor__label">{t("settings.themeLibrary.fieldRecipes")}</div>
|
||||
<div className="theme-editor__fields">
|
||||
<div className="set-seg">
|
||||
{(["comfortable", "compact"] as const).map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
className={`set-seg__btn${state.recipes.density === d ? " set-seg__btn--on" : ""}`}
|
||||
disabled={busy}
|
||||
onClick={() => onChange({ recipes: { ...state.recipes, density: d } })}
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="set-seg">
|
||||
{(["square", "soft", "round"] as const).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={`set-seg__btn${state.recipes.corners === c ? " set-seg__btn--on" : ""}`}
|
||||
disabled={busy}
|
||||
onClick={() => onChange({ recipes: { ...state.recipes, corners: c } })}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="theme-editor__row">
|
||||
<div className="theme-editor__label">{t("settings.themeLibrary.fieldTokens")}</div>
|
||||
<div className="theme-editor__fields">
|
||||
<div className="set-seg">
|
||||
{(["dark", "light"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
className={`set-seg__btn${state.tokenMode === m ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => onChange({ tokenMode: m })}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{TOKEN_GROUPS.map((group) => (
|
||||
<div key={group.labelKey}>
|
||||
<div className="theme-lib-card__sub" style={{ marginBottom: 6 }}>{t(group.labelKey as never)}</div>
|
||||
<div className="theme-editor__color-grid">
|
||||
{group.keys.filter((k) => themeTokenKeys().includes(k)).map((key) => {
|
||||
const val = state.tokens[state.tokenMode]?.[key] || "";
|
||||
const colorVal = isSafeHex(val) ? val.slice(0, 7) : "#888888";
|
||||
return (
|
||||
<label key={key} className="theme-editor__color">
|
||||
<span>{key}</span>
|
||||
<input
|
||||
type="color"
|
||||
value={colorVal}
|
||||
disabled={busy}
|
||||
onChange={(e) => setToken(key, e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={val}
|
||||
placeholder="#RRGGBB"
|
||||
disabled={busy}
|
||||
onChange={(e) => setToken(key, e.target.value.trim())}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="theme-editor__row">
|
||||
<div className="theme-editor__label">{t("settings.themeLibrary.fieldBackground")}</div>
|
||||
<div className="theme-editor__fields">
|
||||
<div className="theme-library__toolbar">
|
||||
<button type="button" className="btn btn--small" disabled={busy} onClick={() => void pickBackground()}>
|
||||
{t("settings.themeLibrary.pickImage")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--small"
|
||||
disabled={busy || (!state.background && !bgUrl)}
|
||||
onClick={() => onChange({ background: null, backgroundDataUrl: "", existingBackgroundUrl: "" })}
|
||||
>
|
||||
{t("settings.themeLibrary.clearImage")}
|
||||
</button>
|
||||
</div>
|
||||
{state.background && (
|
||||
<>
|
||||
<div
|
||||
ref={previewRef}
|
||||
className="theme-editor__bg-preview"
|
||||
style={bgUrl ? { backgroundImage: `url("${bgUrl}")` } : undefined}
|
||||
onPointerDown={(e) => {
|
||||
dragging.current = true;
|
||||
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
|
||||
onFocusPointer(e.clientX, e.clientY);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (!dragging.current) return;
|
||||
onFocusPointer(e.clientX, e.clientY);
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
dragging.current = false;
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="theme-editor__focus"
|
||||
style={{ left: `${(state.background.focusX ?? 0.5) * 100}%`, top: `${(state.background.focusY ?? 0.5) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="set-seg">
|
||||
{(["left", "center", "right"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className={`set-seg__btn${state.background?.safeArea === s ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => onChange({ background: { ...state.background!, safeArea: s } })}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="theme-editor__color">
|
||||
{t("settings.themeLibrary.homeOpacity")}
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={state.background.homeOpacity}
|
||||
onChange={(e) => onChange({ background: { ...state.background!, homeOpacity: Number(e.target.value) } })}
|
||||
/>
|
||||
</label>
|
||||
<label className="theme-editor__color">
|
||||
{t("settings.themeLibrary.taskOpacity")}
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={0.45}
|
||||
step={0.01}
|
||||
value={state.background.taskOpacity}
|
||||
onChange={(e) => onChange({ background: { ...state.background!, taskOpacity: Number(e.target.value) } })}
|
||||
/>
|
||||
</label>
|
||||
<label className="theme-editor__color">
|
||||
{t("settings.themeLibrary.overlayStrength")}
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={state.background.overlayStrength}
|
||||
onChange={(e) => onChange({ background: { ...state.background!, overlayStrength: Number(e.target.value) } })}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<div className="theme-editor__warn">
|
||||
{t("settings.themeLibrary.contrastWarn")}
|
||||
<ul style={{ margin: "6px 0 0", paddingLeft: 18 }}>
|
||||
{warnings.map((w) => (
|
||||
<li key={w}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="theme-editor__actions">
|
||||
<button type="button" className="btn btn--small" disabled={busy} onClick={onCancel}>
|
||||
{t("settings.themeLibrary.cancel")}
|
||||
</button>
|
||||
<button type="button" className="btn btn--small" disabled={busy} onClick={() => onSave(false)}>
|
||||
{t("settings.themeLibrary.save")}
|
||||
</button>
|
||||
<button type="button" className="btn btn--small btn--primary" disabled={busy} onClick={() => onSave(true)}>
|
||||
{t("settings.themeLibrary.saveEnable")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function contrastRatio(a: string, b: string): number {
|
||||
const la = relativeLuminance(a);
|
||||
const lb = relativeLuminance(b);
|
||||
const lighter = Math.max(la, lb);
|
||||
const darker = Math.min(la, lb);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
function relativeLuminance(hex: string): number {
|
||||
const n = hex.replace("#", "");
|
||||
const r = parseInt(n.slice(0, 2), 16) / 255;
|
||||
const g = parseInt(n.slice(2, 4), 16) / 255;
|
||||
const b = parseInt(n.slice(4, 6), 16) / 255;
|
||||
const lin = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
||||
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useMemo, type CSSProperties } from "react";
|
||||
import { themePackKind, type ThemePackView } from "../lib/themePack";
|
||||
import { baseStyleForPreview, themePreviewPalette } from "../lib/themePreviewPalette";
|
||||
|
||||
/** Isolated mini Reasonix surface for gallery detail — does not touch :root. */
|
||||
export function ThemePreviewSurface({
|
||||
pack,
|
||||
mode,
|
||||
scene,
|
||||
variant = "full",
|
||||
}: {
|
||||
pack: ThemePackView | null;
|
||||
mode: "light" | "dark";
|
||||
scene: "home" | "task";
|
||||
variant?: "full" | "thumbnail";
|
||||
}) {
|
||||
const isBasePreview = Boolean(pack && themePackKind(pack) === "base");
|
||||
const baseStyle = baseStyleForPreview(pack);
|
||||
const style = useMemo(() => {
|
||||
const palette = themePreviewPalette(pack, mode);
|
||||
const tokens = mode === "light" ? pack?.tokens?.light : pack?.tokens?.dark;
|
||||
const chat = tokens?.chat || palette.bg;
|
||||
const sceneBackground = scene === "task" ? pack?.taskBackground || pack?.background : pack?.background;
|
||||
const focusX = sceneBackground?.focusX ?? 0.72;
|
||||
const focusY = sceneBackground?.focusY ?? 0.45;
|
||||
const opacity =
|
||||
scene === "home"
|
||||
? pack?.background?.homeOpacity ?? 1
|
||||
: pack?.taskBackground?.opacity ?? pack?.background?.taskOpacity ?? 0.28;
|
||||
const overlay = scene === "task"
|
||||
? pack?.taskBackground?.overlayStrength ?? pack?.background?.overlayStrength ?? 0.62
|
||||
: pack?.background?.overlayStrength ?? 0.62;
|
||||
return {
|
||||
["--tp-bg" as string]: palette.bg,
|
||||
["--tp-panel" as string]: palette.panel,
|
||||
["--tp-sidebar" as string]: palette.sidebar,
|
||||
["--tp-fg" as string]: palette.fg,
|
||||
["--tp-fg-dim" as string]: palette.fgDim,
|
||||
["--tp-accent" as string]: palette.accent,
|
||||
["--tp-accent-fg" as string]: palette.accentFg,
|
||||
["--tp-border" as string]: palette.border,
|
||||
["--tp-radius" as string]: palette.radius,
|
||||
["--tp-chat" as string]: chat,
|
||||
["--tp-focus-x" as string]: `${focusX * 100}%`,
|
||||
["--tp-focus-y" as string]: `${focusY * 100}%`,
|
||||
["--tp-bg-opacity" as string]: String(opacity),
|
||||
["--tp-overlay" as string]: String(overlay),
|
||||
} as CSSProperties;
|
||||
}, [pack, mode, scene]);
|
||||
|
||||
const bgUrl = isBasePreview
|
||||
? ""
|
||||
: scene === "task"
|
||||
? pack?.taskBackgroundUrl || pack?.previewUrl || pack?.backgroundUrl || ""
|
||||
: pack?.previewUrl || pack?.backgroundUrl || "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="theme-preview-surface"
|
||||
data-mode={mode}
|
||||
data-scene={scene}
|
||||
data-preview-kind={isBasePreview ? "base" : "pack"}
|
||||
data-base-style={baseStyle}
|
||||
data-variant={variant}
|
||||
style={style}
|
||||
>
|
||||
{bgUrl ? (
|
||||
<div
|
||||
className="theme-preview-surface__bg"
|
||||
style={{ backgroundImage: `url("${bgUrl}")` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`theme-preview-surface__bg ${isBasePreview ? "theme-preview-surface__bg--base" : "theme-preview-surface__bg--swatch"}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<div className="theme-preview-surface__overlay" aria-hidden="true" />
|
||||
<div className="theme-preview-surface__chrome">
|
||||
<aside className="theme-preview-surface__side">
|
||||
<div className="theme-preview-surface__logo">R</div>
|
||||
<div className="theme-preview-surface__nav" />
|
||||
<div className="theme-preview-surface__nav theme-preview-surface__nav--dim" />
|
||||
<div className="theme-preview-surface__nav theme-preview-surface__nav--dim" />
|
||||
</aside>
|
||||
<main className="theme-preview-surface__main">
|
||||
<div className="theme-preview-surface__card">
|
||||
<div className="theme-preview-surface__title" />
|
||||
<div className="theme-preview-surface__line" />
|
||||
<div className="theme-preview-surface__line theme-preview-surface__line--short" />
|
||||
<div className="theme-preview-surface__cta" />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { addBreadcrumb } from "./breadcrumbs";
|
||||
import { t } from "./i18n";
|
||||
import { providerRequiresKey } from "./providerModels";
|
||||
import { DEFAULT_STATUS_BAR_ITEMS, normalizeStatusBarItems } from "./statusBarItems";
|
||||
import { registerTrustedThemeBackgroundURLs } from "./themePack";
|
||||
import { modeHasAutoApproveTools, modeWithAutoApproveTools, modeWithPlan, normalizeCollaborationMode, normalizeMode, normalizeTokenMode, normalizeToolApprovalMode } from "./types";
|
||||
|
||||
import type {
|
||||
@@ -367,6 +368,20 @@ export interface AppBindings {
|
||||
SetStatusBarItems(items: string[]): Promise<void>;
|
||||
SetDesktopLanguage(lang: string): Promise<void>;
|
||||
SetDesktopAppearance(theme: string, style: string): Promise<void>;
|
||||
ListThemePacks(): Promise<import("./themePack").ThemePackView[]>;
|
||||
GetActiveThemePack(): Promise<import("./themePack").ThemeActiveView>;
|
||||
GetThemeExperience(): Promise<import("./themeExperience").ThemeExperienceView>;
|
||||
ActivateThemePack(id: string): Promise<void>;
|
||||
ActivateBaseStyle(style: string): Promise<void>;
|
||||
DisableThemePack(): Promise<void>;
|
||||
RestoreGraphiteAppearance(): Promise<void>;
|
||||
ResetThemePack(): Promise<void>;
|
||||
SaveThemePack(input: import("./themePack").ThemeSaveInput): Promise<import("./themePack").ThemePackView>;
|
||||
DeleteThemePack(id: string): Promise<void>;
|
||||
CopyThemePack(sourceID: string, newID: string, newName: string): Promise<import("./themePack").ThemePackView>;
|
||||
ImportThemePack(sourcePath: string, replace: boolean): Promise<import("./themePack").ThemeImportResult>;
|
||||
ExportThemePack(id: string, destPath: string): Promise<string>;
|
||||
PickThemeBackground(): Promise<string>;
|
||||
SetDesktopLayoutStyle(style: string): Promise<void>;
|
||||
SetDesktopZoomFactor(factor: number): Promise<void>;
|
||||
GetDesktopZoomFactor(): Promise<number>;
|
||||
@@ -970,6 +985,63 @@ function makeMockApp(): AppBindings {
|
||||
let workspaces = freshMock ? [] : ["~/projects/joyquant-db", "~/projects/joyquant-sys", "~/projects/reasonix", "~/projects/blade"];
|
||||
let mockEffort = "auto";
|
||||
let mockDesktopZoomFactor = 1.0;
|
||||
let mockActiveThemeId = "";
|
||||
let mockBaseStyle = "graphite";
|
||||
let mockThemeMode: "auto" | "light" | "dark" = "dark";
|
||||
// Vite rewrites these literal asset URLs in both dev and production builds.
|
||||
// Keeping them on the browser mock makes local visual acceptance match the
|
||||
// Wails bridge, whose ListThemePacks response carries the same two URLs.
|
||||
const mockOfficialThemeAssets = {
|
||||
"official-rose-dawn": {
|
||||
previewUrl: new URL("../../../themes/official/official-rose-dawn/preview.webp", import.meta.url).href,
|
||||
backgroundUrl: new URL("../../../themes/official/official-rose-dawn/background.webp", import.meta.url).href,
|
||||
},
|
||||
"official-fortune-forge": {
|
||||
previewUrl: new URL("../../../themes/official/official-fortune-forge/preview.webp", import.meta.url).href,
|
||||
backgroundUrl: new URL("../../../themes/official/official-fortune-forge/background.webp", import.meta.url).href,
|
||||
},
|
||||
"official-crimson-horizon": {
|
||||
previewUrl: new URL("../../../themes/official/official-crimson-horizon/preview.webp", import.meta.url).href,
|
||||
backgroundUrl: new URL("../../../themes/official/official-crimson-horizon/background.webp", import.meta.url).href,
|
||||
},
|
||||
"official-sage-breeze": {
|
||||
previewUrl: new URL("../../../themes/official/official-sage-breeze/preview.webp", import.meta.url).href,
|
||||
backgroundUrl: new URL("../../../themes/official/official-sage-breeze/background.webp", import.meta.url).href,
|
||||
},
|
||||
"official-spark-notebook": {
|
||||
previewUrl: new URL("../../../themes/official/official-spark-notebook/preview.webp", import.meta.url).href,
|
||||
backgroundUrl: new URL("../../../themes/official/official-spark-notebook/background.webp", import.meta.url).href,
|
||||
},
|
||||
"official-violet-starlight": {
|
||||
previewUrl: new URL("../../../themes/official/official-violet-starlight/preview.webp", import.meta.url).href,
|
||||
backgroundUrl: new URL("../../../themes/official/official-violet-starlight/background.webp", import.meta.url).href,
|
||||
},
|
||||
"official-cyan-stage": {
|
||||
previewUrl: new URL("../../../themes/official/official-cyan-stage/preview.webp", import.meta.url).href,
|
||||
backgroundUrl: new URL("../../../themes/official/official-cyan-stage/background.webp", import.meta.url).href,
|
||||
},
|
||||
"official-noir-gold": {
|
||||
previewUrl: new URL("../../../themes/official/official-noir-gold/preview.webp", import.meta.url).href,
|
||||
backgroundUrl: new URL("../../../themes/official/official-noir-gold/background.webp", import.meta.url).href,
|
||||
},
|
||||
} as const;
|
||||
registerTrustedThemeBackgroundURLs(Object.values(mockOfficialThemeAssets).map((asset) => asset.backgroundUrl));
|
||||
let mockThemePacks: import("./themePack").ThemePackView[] = [
|
||||
{ id: "graphite", name: "Graphite", author: "Reasonix", baseStyle: "graphite", builtin: true, kind: "base", active: false, hasBackground: false, tokens: {}, recipes: { density: "comfortable", corners: "soft" } },
|
||||
{ id: "aurora", name: "Aurora", author: "Reasonix", baseStyle: "aurora", builtin: true, kind: "base", active: false, hasBackground: false, tokens: {}, recipes: { density: "comfortable", corners: "soft" } },
|
||||
{ id: "slate", name: "Slate", author: "Reasonix", baseStyle: "slate", builtin: true, kind: "base", active: false, hasBackground: false, tokens: {}, recipes: { density: "comfortable", corners: "soft" } },
|
||||
{ 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 } },
|
||||
];
|
||||
const day = 86_400_000;
|
||||
const t0 = Date.now();
|
||||
// Mutable so MCP add/remove/retry are observable in browser dev.
|
||||
@@ -3665,6 +3737,126 @@ function makeMockApp(): AppBindings {
|
||||
async SetDesktopAppearance(theme: string, style: string) {
|
||||
settings.desktopTheme = theme === "auto" || theme === "light" ? theme : "dark";
|
||||
settings.desktopThemeStyle = style;
|
||||
mockThemeMode = settings.desktopTheme as "auto" | "light" | "dark";
|
||||
if (["graphite","aurora","slate","carbon","nocturne","amber"].includes(style)) {
|
||||
mockBaseStyle = style;
|
||||
}
|
||||
},
|
||||
async ListThemePacks() {
|
||||
const baseActive = !mockActiveThemeId;
|
||||
return mockThemePacks.map((p) => {
|
||||
const kind = p.kind || (p.builtin ? "base" : "user");
|
||||
let active = false;
|
||||
if (kind === "base") active = baseActive && p.id === mockBaseStyle;
|
||||
else active = p.id === mockActiveThemeId;
|
||||
return { ...p, active, tokens: { light: { ...(p.tokens.light || {}) }, dark: { ...(p.tokens.dark || {}) } }, recipes: { ...p.recipes } };
|
||||
});
|
||||
},
|
||||
async GetActiveThemePack() {
|
||||
// Base style ids are never active packs in the redesigned model.
|
||||
const pack = mockActiveThemeId && !["graphite","aurora","slate","carbon","nocturne","amber"].includes(mockActiveThemeId)
|
||||
? mockThemePacks.find((p) => p.id === mockActiveThemeId)
|
||||
: null;
|
||||
return { activeThemeId: pack ? mockActiveThemeId : "", pack: pack ? { ...pack, active: true } : null, safeMode: false };
|
||||
},
|
||||
async GetThemeExperience() {
|
||||
const pack = mockActiveThemeId && !["graphite","aurora","slate","carbon","nocturne","amber"].includes(mockActiveThemeId)
|
||||
? mockThemePacks.find((p) => p.id === mockActiveThemeId)
|
||||
: null;
|
||||
return {
|
||||
themeMode: mockThemeMode,
|
||||
baseStyle: mockBaseStyle,
|
||||
effectiveStyle: pack?.baseStyle || mockBaseStyle,
|
||||
activeThemeId: pack ? mockActiveThemeId : "",
|
||||
activePack: pack ? { ...pack, active: true } : null,
|
||||
safeMode: false,
|
||||
};
|
||||
},
|
||||
async ActivateThemePack(id: string) {
|
||||
const next = String(id || "").trim();
|
||||
if (["graphite","aurora","slate","carbon","nocturne","amber"].includes(next)) {
|
||||
throw new Error(`base style ${next} is not a theme pack; use ActivateBaseStyle`);
|
||||
}
|
||||
mockActiveThemeId = next;
|
||||
},
|
||||
async ActivateBaseStyle(style: string) {
|
||||
const s = String(style || "").trim().toLowerCase();
|
||||
if (!["graphite","aurora","slate","carbon","nocturne","amber"].includes(s)) {
|
||||
throw new Error(`unknown base style ${s}`);
|
||||
}
|
||||
mockBaseStyle = s;
|
||||
mockActiveThemeId = "";
|
||||
},
|
||||
async DisableThemePack() {
|
||||
mockActiveThemeId = "";
|
||||
},
|
||||
async RestoreGraphiteAppearance() {
|
||||
mockBaseStyle = "graphite";
|
||||
mockActiveThemeId = "";
|
||||
},
|
||||
async ResetThemePack() {
|
||||
mockActiveThemeId = "";
|
||||
},
|
||||
async SaveThemePack(input: import("./themePack").ThemeSaveInput) {
|
||||
const pack: import("./themePack").ThemePackView = {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
author: input.author,
|
||||
description: input.description,
|
||||
license: input.license,
|
||||
baseStyle: input.baseStyle,
|
||||
builtin: false,
|
||||
kind: "user",
|
||||
active: Boolean(input.activate),
|
||||
hasBackground: Boolean(
|
||||
(input.background && (input.backgroundDataUrl || input.background.image)) ||
|
||||
(input.taskBackground && (input.taskBackgroundDataUrl || input.taskBackground.image)),
|
||||
),
|
||||
backgroundUrl: input.backgroundDataUrl || "",
|
||||
taskBackgroundUrl: input.taskBackgroundDataUrl || "",
|
||||
tokens: input.tokens || {},
|
||||
recipes: input.recipes || { density: "comfortable", corners: "soft" },
|
||||
background: input.background ?? undefined,
|
||||
taskBackground: input.taskBackground ?? undefined,
|
||||
};
|
||||
const idx = mockThemePacks.findIndex((p) => p.id === pack.id);
|
||||
if (idx >= 0) mockThemePacks[idx] = pack;
|
||||
else mockThemePacks.push(pack);
|
||||
if (input.activate) mockActiveThemeId = pack.id;
|
||||
return pack;
|
||||
},
|
||||
async DeleteThemePack(id: string) {
|
||||
mockThemePacks = mockThemePacks.filter((p) => p.id !== id || p.builtin);
|
||||
if (mockActiveThemeId === id) mockActiveThemeId = "";
|
||||
},
|
||||
async CopyThemePack(sourceID: string, newID: string, newName: string) {
|
||||
const src = mockThemePacks.find((p) => p.id === sourceID);
|
||||
if (!src) throw new Error("source theme not found");
|
||||
const pack: import("./themePack").ThemePackView = {
|
||||
...src,
|
||||
id: newID,
|
||||
name: newName || `${src.name} Copy`,
|
||||
builtin: false,
|
||||
kind: "user",
|
||||
nameKey: undefined,
|
||||
descriptionKey: undefined,
|
||||
active: false,
|
||||
};
|
||||
mockThemePacks.push(pack);
|
||||
return pack;
|
||||
},
|
||||
async ImportThemePack(_sourcePath: string, replace: boolean) {
|
||||
if (replace) {
|
||||
return { pack: mockThemePacks[0], replaced: true };
|
||||
}
|
||||
// Simulate conflict path without re-prompting for a file on confirm.
|
||||
return { pack: mockThemePacks[0], replaced: false, needsReplace: true, pendingId: "pending-mock" };
|
||||
},
|
||||
async ExportThemePack(_id: string, _destPath: string) {
|
||||
return "";
|
||||
},
|
||||
async PickThemeBackground() {
|
||||
return "";
|
||||
},
|
||||
async SetDesktopLayoutStyle(style: string) {
|
||||
settings.desktopLayoutStyle = style === "workbench" || style === "creation" ? style : "classic";
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
// Unified theme experience controller for the redesigned appearance overview
|
||||
// and theme gallery. Backend is source of truth for persistence; selected and
|
||||
// temporary-preview ids stay in memory only.
|
||||
|
||||
import { app } from "./bridge";
|
||||
import {
|
||||
applyThemePack,
|
||||
beginThemePreview,
|
||||
cancelThemePreview,
|
||||
clearThemePack,
|
||||
commitThemePreview,
|
||||
setBaseAppearance,
|
||||
themePackKind,
|
||||
type ThemePackView,
|
||||
} from "./themePack";
|
||||
import { applyTheme, isThemeStyle, type Theme, type ThemeStyle } from "./theme";
|
||||
|
||||
export type ThemeExperienceView = {
|
||||
themeMode: Theme | string;
|
||||
baseStyle: ThemeStyle | string;
|
||||
effectiveStyle: ThemeStyle | string;
|
||||
activeThemeId?: string;
|
||||
activePack?: ThemePackView | null;
|
||||
safeMode: boolean;
|
||||
};
|
||||
|
||||
export type GalleryTab = "catalog" | "user";
|
||||
|
||||
export type ThemeSelection =
|
||||
| { kind: "base"; id: ThemeStyle; pack?: ThemePackView }
|
||||
| { kind: "official" | "user"; id: string; pack: ThemePackView };
|
||||
|
||||
let experienceCache: ThemeExperienceView | null = null;
|
||||
let previewDepth = 0;
|
||||
|
||||
export function getCachedThemeExperience(): ThemeExperienceView | null {
|
||||
return experienceCache;
|
||||
}
|
||||
|
||||
export async function loadThemeExperience(): Promise<ThemeExperienceView> {
|
||||
// Prefer the unified API; fall back for older shells / partial mocks.
|
||||
const api = app as typeof app & {
|
||||
GetThemeExperience?: () => Promise<ThemeExperienceView>;
|
||||
ActivateBaseStyle?: (style: string) => Promise<void>;
|
||||
DisableThemePack?: () => Promise<void>;
|
||||
RestoreGraphiteAppearance?: () => Promise<void>;
|
||||
};
|
||||
if (typeof api.GetThemeExperience === "function") {
|
||||
try {
|
||||
const view = await api.GetThemeExperience();
|
||||
experienceCache = normalizeExperience(view);
|
||||
return experienceCache;
|
||||
} catch {
|
||||
// Fall through to partial Settings / GetActiveThemePack path.
|
||||
}
|
||||
}
|
||||
|
||||
// Partial test mocks and older shells may only expose Settings + GetActiveThemePack.
|
||||
let themeMode: Theme = "auto";
|
||||
let baseStyle: ThemeStyle = "graphite";
|
||||
let safeMode = false;
|
||||
try {
|
||||
const settingsApi = app as typeof app & {
|
||||
DesktopStartupSettings?: () => Promise<{ desktopTheme?: string; desktopThemeStyle?: string; safeMode?: boolean }>;
|
||||
Settings?: () => Promise<{ desktopTheme?: string; desktopThemeStyle?: string; safeMode?: boolean }>;
|
||||
};
|
||||
const settings =
|
||||
typeof settingsApi.DesktopStartupSettings === "function"
|
||||
? await settingsApi.DesktopStartupSettings()
|
||||
: typeof settingsApi.Settings === "function"
|
||||
? await settingsApi.Settings()
|
||||
: null;
|
||||
if (settings) {
|
||||
themeMode = (settings.desktopTheme as Theme) || "auto";
|
||||
baseStyle = (isThemeStyle(settings.desktopThemeStyle) ? settings.desktopThemeStyle : "graphite") as ThemeStyle;
|
||||
safeMode = (settings as { safeMode?: boolean }).safeMode === true;
|
||||
}
|
||||
} catch {
|
||||
// Keep defaults.
|
||||
}
|
||||
|
||||
let activePack: ThemePackView | null = null;
|
||||
let activeThemeId: string | undefined;
|
||||
try {
|
||||
if (typeof app.GetActiveThemePack === "function") {
|
||||
const active = await app.GetActiveThemePack();
|
||||
activePack = active?.pack ?? null;
|
||||
activeThemeId = active?.activeThemeId || undefined;
|
||||
safeMode = safeMode || active?.safeMode === true;
|
||||
}
|
||||
} catch {
|
||||
// Keep pack unset.
|
||||
}
|
||||
|
||||
const view: ThemeExperienceView = {
|
||||
themeMode,
|
||||
baseStyle,
|
||||
effectiveStyle: activePack?.baseStyle || baseStyle,
|
||||
activeThemeId,
|
||||
activePack,
|
||||
safeMode,
|
||||
};
|
||||
experienceCache = normalizeExperience(view);
|
||||
return experienceCache;
|
||||
}
|
||||
|
||||
function normalizeExperience(view: ThemeExperienceView): ThemeExperienceView {
|
||||
const themeMode = view.themeMode === "light" || view.themeMode === "dark" || view.themeMode === "auto" ? view.themeMode : "auto";
|
||||
const baseStyle = isThemeStyle(view.baseStyle) ? view.baseStyle : "graphite";
|
||||
const effectiveStyle = isThemeStyle(view.effectiveStyle) ? view.effectiveStyle : baseStyle;
|
||||
return {
|
||||
themeMode,
|
||||
baseStyle,
|
||||
effectiveStyle,
|
||||
activeThemeId: view.activeThemeId || undefined,
|
||||
activePack: view.activePack ?? null,
|
||||
safeMode: view.safeMode === true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply a loaded experience to the live DOM (no network). */
|
||||
export function applyExperienceToDOM(view: ThemeExperienceView): void {
|
||||
const theme = (view.themeMode === "light" || view.themeMode === "dark" || view.themeMode === "auto" ? view.themeMode : "auto") as Theme;
|
||||
const base = (isThemeStyle(view.baseStyle) ? view.baseStyle : "graphite") as ThemeStyle;
|
||||
setBaseAppearance(theme, base);
|
||||
if (view.safeMode || !view.activePack) {
|
||||
clearThemePack();
|
||||
applyTheme(theme, base, { persist: false });
|
||||
return;
|
||||
}
|
||||
applyTheme(theme, base, { persist: false });
|
||||
applyThemePack(view.activePack);
|
||||
}
|
||||
|
||||
export async function activateBaseStyle(style: ThemeStyle): Promise<ThemeExperienceView> {
|
||||
const api = app as typeof app & { ActivateBaseStyle?: (style: string) => Promise<void> };
|
||||
if (typeof api.ActivateBaseStyle === "function") {
|
||||
await api.ActivateBaseStyle(style);
|
||||
} else {
|
||||
// Legacy fallback: clear pack then set appearance.
|
||||
await app.ResetThemePack();
|
||||
const theme = (experienceCache?.themeMode as Theme) || "auto";
|
||||
await app.SetDesktopAppearance(theme, style);
|
||||
}
|
||||
endPreviewIfAny();
|
||||
const view = await loadThemeExperience();
|
||||
applyExperienceToDOM(view);
|
||||
return view;
|
||||
}
|
||||
|
||||
export async function activateThemePack(id: string): Promise<ThemeExperienceView> {
|
||||
await app.ActivateThemePack(id);
|
||||
endPreviewIfAny();
|
||||
const view = await loadThemeExperience();
|
||||
applyExperienceToDOM(view);
|
||||
return view;
|
||||
}
|
||||
|
||||
export async function disableThemePack(): Promise<ThemeExperienceView> {
|
||||
const api = app as typeof app & { DisableThemePack?: () => Promise<void> };
|
||||
if (typeof api.DisableThemePack === "function") {
|
||||
await api.DisableThemePack();
|
||||
} else {
|
||||
await app.ResetThemePack();
|
||||
}
|
||||
endPreviewIfAny();
|
||||
const view = await loadThemeExperience();
|
||||
applyExperienceToDOM(view);
|
||||
return view;
|
||||
}
|
||||
|
||||
export async function restoreGraphiteAppearance(): Promise<ThemeExperienceView> {
|
||||
const api = app as typeof app & { RestoreGraphiteAppearance?: () => Promise<void> };
|
||||
if (typeof api.RestoreGraphiteAppearance === "function") {
|
||||
await api.RestoreGraphiteAppearance();
|
||||
} else {
|
||||
await app.ResetThemePack();
|
||||
const theme = (experienceCache?.themeMode as Theme) || "auto";
|
||||
await app.SetDesktopAppearance(theme, "graphite");
|
||||
}
|
||||
endPreviewIfAny();
|
||||
const view = await loadThemeExperience();
|
||||
applyExperienceToDOM(view);
|
||||
return view;
|
||||
}
|
||||
|
||||
export async function setThemeMode(mode: Theme): Promise<ThemeExperienceView> {
|
||||
const base = (isThemeStyle(experienceCache?.baseStyle) ? experienceCache!.baseStyle : "graphite") as ThemeStyle;
|
||||
await app.SetDesktopAppearance(mode, base);
|
||||
const view = await loadThemeExperience();
|
||||
// Theme mode is independent of pack — re-apply pack on top.
|
||||
applyExperienceToDOM(view);
|
||||
return view;
|
||||
}
|
||||
|
||||
export function startGlobalPreview(pack: ThemePackView): void {
|
||||
previewDepth += 1;
|
||||
beginThemePreview(pack);
|
||||
}
|
||||
|
||||
export function cancelGlobalPreview(): void {
|
||||
if (previewDepth <= 0) return;
|
||||
previewDepth = 0;
|
||||
cancelThemePreview();
|
||||
}
|
||||
|
||||
export function commitGlobalPreview(pack: ThemePackView | null): void {
|
||||
previewDepth = 0;
|
||||
commitThemePreview(pack);
|
||||
}
|
||||
|
||||
function endPreviewIfAny(): void {
|
||||
if (previewDepth > 0) {
|
||||
previewDepth = 0;
|
||||
cancelThemePreview();
|
||||
}
|
||||
}
|
||||
|
||||
export function isPreviewActive(): boolean {
|
||||
return previewDepth > 0;
|
||||
}
|
||||
|
||||
/** Group packs for the gallery tabs. */
|
||||
export function groupThemePacks(packs: ThemePackView[]): {
|
||||
official: ThemePackView[];
|
||||
user: ThemePackView[];
|
||||
base: ThemePackView[];
|
||||
} {
|
||||
const official: ThemePackView[] = [];
|
||||
const user: ThemePackView[] = [];
|
||||
const base: ThemePackView[] = [];
|
||||
for (const p of packs) {
|
||||
const k = themePackKind(p);
|
||||
if (k === "official") official.push(p);
|
||||
else if (k === "base") base.push(p);
|
||||
else user.push(p);
|
||||
}
|
||||
return { official, user, base };
|
||||
}
|
||||
|
||||
export function selectionFromPack(pack: ThemePackView): ThemeSelection {
|
||||
const k = themePackKind(pack);
|
||||
if (k === "base") {
|
||||
return { kind: "base", id: (isThemeStyle(pack.id) ? pack.id : "graphite") as ThemeStyle, pack };
|
||||
}
|
||||
return { kind: k, id: pack.id, pack };
|
||||
}
|
||||
|
||||
export function isSelectionActive(sel: ThemeSelection | null, exp: ThemeExperienceView | null): boolean {
|
||||
if (!sel || !exp) return false;
|
||||
if (sel.kind === "base") {
|
||||
return !exp.activeThemeId && exp.baseStyle === sel.id;
|
||||
}
|
||||
return exp.activeThemeId === sel.id;
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
// themePack.ts applies controlled Theme Pack V1/V2 overlays on top of the existing
|
||||
// auto/light/dark + baseStyle system. Packs cannot execute CSS/JS or load remote
|
||||
// resources — only semantic tokens, recipe enums, and local background images.
|
||||
|
||||
import { applyTheme, getTheme, getThemeStyle, isThemeStyle, type Theme, type ThemeStyle } from "./theme";
|
||||
|
||||
export type ThemePackTokens = {
|
||||
light?: Record<string, string>;
|
||||
dark?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type ThemePackRecipes = {
|
||||
density?: "compact" | "comfortable" | string;
|
||||
corners?: "square" | "soft" | "round" | string;
|
||||
};
|
||||
|
||||
export type ThemePackBackground = {
|
||||
image?: string;
|
||||
focusX: number;
|
||||
focusY: number;
|
||||
safeArea?: "left" | "right" | "center" | string;
|
||||
homeOpacity: number;
|
||||
taskOpacity: number;
|
||||
overlayStrength: number;
|
||||
};
|
||||
|
||||
export type ThemePackSceneBackground = {
|
||||
image?: string;
|
||||
focusX: number;
|
||||
focusY: number;
|
||||
safeArea?: "left" | "right" | "center" | string;
|
||||
opacity: number;
|
||||
overlayStrength: number;
|
||||
};
|
||||
|
||||
export type ThemeContrastWarning = {
|
||||
mode: string;
|
||||
pair: string;
|
||||
ratio: number;
|
||||
minimum: number;
|
||||
suggest?: string;
|
||||
};
|
||||
|
||||
export type ThemePackKind = "base" | "official" | "user";
|
||||
|
||||
export type ThemePackView = {
|
||||
id: string;
|
||||
name: string;
|
||||
author?: string;
|
||||
description?: string;
|
||||
license?: string;
|
||||
baseStyle: string;
|
||||
builtin: boolean;
|
||||
/** New in the official-themes release; old backends/mocks may omit it. */
|
||||
kind?: ThemePackKind;
|
||||
active: boolean;
|
||||
hasBackground: boolean;
|
||||
backgroundUrl?: string;
|
||||
taskBackgroundUrl?: string;
|
||||
previewUrl?: string;
|
||||
nameKey?: string;
|
||||
descriptionKey?: string;
|
||||
tokens: ThemePackTokens;
|
||||
recipes: ThemePackRecipes;
|
||||
background?: ThemePackBackground | null;
|
||||
taskBackground?: ThemePackSceneBackground | null;
|
||||
contrastWarnings?: ThemeContrastWarning[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the pack group. Older mocks/responses without `kind` fall back to
|
||||
* the historical builtin flag: builtin ? "base" : "user".
|
||||
*/
|
||||
export function themePackKind(pack: Pick<ThemePackView, "kind" | "builtin">): ThemePackKind {
|
||||
if (pack.kind === "base" || pack.kind === "official" || pack.kind === "user") return pack.kind;
|
||||
return pack.builtin ? "base" : "user";
|
||||
}
|
||||
|
||||
export type ThemeActiveView = {
|
||||
activeThemeId?: string;
|
||||
pack?: ThemePackView | null;
|
||||
safeMode: boolean;
|
||||
};
|
||||
|
||||
export type ThemeSaveInput = {
|
||||
id: string;
|
||||
name: string;
|
||||
author?: string;
|
||||
description?: string;
|
||||
license?: string;
|
||||
baseStyle: string;
|
||||
tokens: ThemePackTokens;
|
||||
recipes: ThemePackRecipes;
|
||||
background?: ThemePackBackground | null;
|
||||
taskBackground?: ThemePackSceneBackground | null;
|
||||
backgroundDataUrl?: string;
|
||||
taskBackgroundDataUrl?: string;
|
||||
clearBackground?: boolean;
|
||||
clearTaskBackground?: boolean;
|
||||
replace?: boolean;
|
||||
activate?: boolean;
|
||||
};
|
||||
|
||||
export type ThemeImportResult = {
|
||||
pack: ThemePackView;
|
||||
replaced: boolean;
|
||||
needsReplace?: boolean;
|
||||
pendingId?: string;
|
||||
};
|
||||
|
||||
export type ThemeScene = "home" | "task";
|
||||
|
||||
const PACK_STYLE_ID = "reasonix-theme-pack-overlay";
|
||||
const TOKEN_KEYS = [
|
||||
"bg",
|
||||
"bgSoft",
|
||||
"bgElev",
|
||||
"panel",
|
||||
"sidebar",
|
||||
"chat",
|
||||
"workspace",
|
||||
"workspaceFiles",
|
||||
"border",
|
||||
"borderSoft",
|
||||
"fg",
|
||||
"fgDim",
|
||||
"fgFaint",
|
||||
"accent",
|
||||
"accentFg",
|
||||
"ok",
|
||||
"warn",
|
||||
"err",
|
||||
] as const;
|
||||
|
||||
const TOKEN_TO_CSS: Record<string, string[]> = {
|
||||
bg: ["--bg", "--stage"],
|
||||
bgSoft: ["--bg-soft", "--surface-3"],
|
||||
bgElev: ["--bg-elev"],
|
||||
panel: ["--panel", "--bg-elev", "--surface"],
|
||||
sidebar: ["--sidebar-bg"],
|
||||
chat: ["--chat-bg"],
|
||||
workspace: ["--workspace-preview-bg"],
|
||||
workspaceFiles: ["--workspace-files-bg"],
|
||||
border: ["--border"],
|
||||
borderSoft: ["--border-soft"],
|
||||
fg: ["--fg", "--text"],
|
||||
fgDim: ["--fg-dim", "--text-2"],
|
||||
fgFaint: ["--fg-faint", "--text-3"],
|
||||
accent: ["--accent", "--accent-strong", "--control-primary-bg"],
|
||||
accentFg: ["--accent-fg", "--control-primary-fg"],
|
||||
ok: ["--ok"],
|
||||
warn: ["--warn"],
|
||||
err: ["--err"],
|
||||
};
|
||||
|
||||
let activePack: ThemePackView | null = null;
|
||||
let activeScene: ThemeScene = "home";
|
||||
/** User config appearance under the pack (restored on clear / restore-default). */
|
||||
let baseAppearance: { theme: Theme; style: ThemeStyle } | null = null;
|
||||
let previewSnapshot: {
|
||||
pack: ThemePackView | null;
|
||||
theme: Theme;
|
||||
style: ThemeStyle;
|
||||
baseAppearance: { theme: Theme; style: ThemeStyle } | null;
|
||||
} | null = null;
|
||||
|
||||
// Browser development uses Vite-bundled copies of the same official images
|
||||
// that Wails serves through /__reasonix_theme_asset/. Only exact, internally
|
||||
// registered URLs may cross the background URL safety boundary.
|
||||
const trustedBundledThemeBackgroundURLs = new Set<string>();
|
||||
|
||||
export function registerTrustedThemeBackgroundURLs(urls: readonly string[]): void {
|
||||
if (typeof window === "undefined" || !window.location) return;
|
||||
for (const raw of urls) {
|
||||
try {
|
||||
const parsed = new URL(raw, window.location.href);
|
||||
if (parsed.origin !== window.location.origin) continue;
|
||||
const path = decodeURIComponent(parsed.pathname);
|
||||
const viteDevOfficial = /\/desktop\/themes\/official\/official-[a-z0-9-]+\/background\.webp$/.test(path);
|
||||
const viteBuiltOfficial = /^\/assets\/background-[a-zA-Z0-9_-]+\.webp$/.test(path);
|
||||
if (viteDevOfficial || viteBuiltOfficial) trustedBundledThemeBackgroundURLs.add(parsed.href);
|
||||
} catch {
|
||||
// Ignore malformed candidates; they remain outside the allow-list.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getActiveThemePack(): ThemePackView | null {
|
||||
return activePack;
|
||||
}
|
||||
|
||||
export function getThemeScene(): ThemeScene {
|
||||
return activeScene;
|
||||
}
|
||||
|
||||
export function getBaseAppearance(): { theme: Theme; style: ThemeStyle } | null {
|
||||
return baseAppearance ? { ...baseAppearance } : null;
|
||||
}
|
||||
|
||||
export function isThemeTokenKey(key: string): boolean {
|
||||
return (TOKEN_KEYS as readonly string[]).includes(key);
|
||||
}
|
||||
|
||||
export function themeTokenKeys(): readonly string[] {
|
||||
return TOKEN_KEYS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember the user's config appearance before a pack overrides baseStyle.
|
||||
* Call from settings load when preferences are known, or let applyThemePack
|
||||
* snapshot automatically on first non-preview apply.
|
||||
*/
|
||||
export function setBaseAppearance(theme: Theme, style: ThemeStyle): void {
|
||||
baseAppearance = { theme, style };
|
||||
}
|
||||
|
||||
/** Apply or clear the active theme pack overlay. Pass null only clears overlay attrs — prefer clearThemePack(). */
|
||||
export function applyThemePack(pack: ThemePackView | null | undefined, options?: { preview?: boolean }): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const next = pack ?? null;
|
||||
if (!options?.preview) {
|
||||
activePack = next;
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
if (!next) {
|
||||
root.removeAttribute("data-theme-pack");
|
||||
removePackStyleElement();
|
||||
clearBackgroundCSSVars(root);
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot config appearance once before the first pack overrides baseStyle.
|
||||
if (!options?.preview && !baseAppearance) {
|
||||
baseAppearance = { theme: getTheme(), style: getThemeStyle() };
|
||||
}
|
||||
|
||||
root.setAttribute("data-theme-pack", next.id);
|
||||
|
||||
// Base style from the pack (inherits remaining tokens from the direction sheets).
|
||||
const style = isThemeStyle(next.baseStyle) ? next.baseStyle : getThemeStyle();
|
||||
applyTheme(getTheme(), style, { persist: false });
|
||||
|
||||
const css = buildPackOverlayCSS(next);
|
||||
ensurePackStyleElement().textContent = css;
|
||||
applyBackgroundCSSVars(root, next);
|
||||
applyThemeScene(activeScene);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the active pack and restore the user's base appearance (theme mode + style).
|
||||
* Fixes: enabling Aurora then "restore default" must return data-theme-style to Graphite
|
||||
* (or whatever was configured), not leave the pack's baseStyle behind.
|
||||
*/
|
||||
export function clearThemePack(): void {
|
||||
previewSnapshot = null;
|
||||
activePack = null;
|
||||
if (typeof document !== "undefined") {
|
||||
const root = document.documentElement;
|
||||
root.removeAttribute("data-theme-pack");
|
||||
removePackStyleElement();
|
||||
clearBackgroundCSSVars(root);
|
||||
}
|
||||
if (baseAppearance) {
|
||||
applyTheme(baseAppearance.theme, baseAppearance.style, { persist: false });
|
||||
}
|
||||
// Keep baseAppearance so subsequent applyThemePack can re-snapshot if needed;
|
||||
// after full clear the restored style IS the base.
|
||||
}
|
||||
|
||||
/** Scene is home (full background) vs task (dimmed + overlay). Does not touch chat state. */
|
||||
export function applyThemeScene(scene: ThemeScene): void {
|
||||
activeScene = scene === "task" ? "task" : "home";
|
||||
if (typeof document === "undefined") return;
|
||||
const app = document.querySelector(".app") ?? document.documentElement;
|
||||
app.setAttribute("data-theme-scene", activeScene);
|
||||
// Also mirror on root for CSS that targets :root.
|
||||
document.documentElement.setAttribute("data-theme-scene", activeScene);
|
||||
}
|
||||
|
||||
export function beginThemePreview(pack: ThemePackView): void {
|
||||
if (!previewSnapshot) {
|
||||
previewSnapshot = {
|
||||
pack: activePack,
|
||||
theme: getTheme(),
|
||||
style: getThemeStyle(),
|
||||
baseAppearance: baseAppearance ? { ...baseAppearance } : null,
|
||||
};
|
||||
}
|
||||
applyThemePack(pack, { preview: true });
|
||||
}
|
||||
|
||||
export function cancelThemePreview(): void {
|
||||
if (!previewSnapshot) return;
|
||||
const snap = previewSnapshot;
|
||||
previewSnapshot = null;
|
||||
baseAppearance = snap.baseAppearance;
|
||||
applyTheme(snap.theme, snap.style, { persist: false });
|
||||
if (snap.pack) {
|
||||
applyThemePack(snap.pack);
|
||||
} else {
|
||||
// No active pack under the preview — strip overlay without changing restored style again.
|
||||
activePack = null;
|
||||
if (typeof document !== "undefined") {
|
||||
const root = document.documentElement;
|
||||
root.removeAttribute("data-theme-pack");
|
||||
removePackStyleElement();
|
||||
clearBackgroundCSSVars(root);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function commitThemePreview(pack: ThemePackView | null): void {
|
||||
previewSnapshot = null;
|
||||
if (pack) {
|
||||
applyThemePack(pack);
|
||||
} else {
|
||||
clearThemePack();
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePackStyleElement(): HTMLStyleElement {
|
||||
let el = document.getElementById(PACK_STYLE_ID) as HTMLStyleElement | null;
|
||||
if (!el) {
|
||||
el = document.createElement("style");
|
||||
el.id = PACK_STYLE_ID;
|
||||
// Append last so pack overrides win over stylesheets and Creation locals.
|
||||
document.head.appendChild(el);
|
||||
} else if (el.parentElement === document.head) {
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function removePackStyleElement(): void {
|
||||
const el = document.getElementById(PACK_STYLE_ID);
|
||||
if (!el) return;
|
||||
if (typeof el.remove === "function") el.remove();
|
||||
else el.parentElement?.removeChild(el);
|
||||
}
|
||||
|
||||
function buildPackOverlayCSS(pack: ThemePackView): string {
|
||||
const light = tokensToDecls(pack.tokens?.light);
|
||||
const dark = tokensToDecls(pack.tokens?.dark);
|
||||
const recipes = recipeDecls(pack.recipes);
|
||||
const chunks: string[] = [];
|
||||
|
||||
// Recipe vars apply in both modes.
|
||||
if (recipes) {
|
||||
chunks.push(`:root[data-theme-pack="${cssEscape(pack.id)}"]{${recipes}}`);
|
||||
}
|
||||
|
||||
// Dark tokens (default / forced dark / auto-dark).
|
||||
if (dark) {
|
||||
chunks.push(`:root[data-theme-pack="${cssEscape(pack.id)}"]{${dark}}`);
|
||||
chunks.push(`:root[data-theme="dark"][data-theme-pack="${cssEscape(pack.id)}"]{${dark}}`);
|
||||
}
|
||||
// Light tokens.
|
||||
if (light) {
|
||||
chunks.push(`:root[data-theme="light"][data-theme-pack="${cssEscape(pack.id)}"]{${light}}`);
|
||||
chunks.push(`@media (prefers-color-scheme: light){:root:not([data-theme])[data-theme-pack="${cssEscape(pack.id)}"]{${light}}}`);
|
||||
}
|
||||
|
||||
// Soft accent derived when accent is set.
|
||||
const accentDark = pack.tokens?.dark?.accent;
|
||||
const accentLight = pack.tokens?.light?.accent;
|
||||
if (accentDark && isSafeHex(accentDark)) {
|
||||
chunks.push(
|
||||
`:root[data-theme-pack="${cssEscape(pack.id)}"]{--accent-soft: color-mix(in srgb, ${accentDark} 16%, transparent);}`,
|
||||
);
|
||||
}
|
||||
if (accentLight && isSafeHex(accentLight)) {
|
||||
chunks.push(
|
||||
`:root[data-theme="light"][data-theme-pack="${cssEscape(pack.id)}"]{--accent-soft: color-mix(in srgb, ${accentLight} 12%, transparent);}`,
|
||||
);
|
||||
}
|
||||
|
||||
return chunks.join("\n");
|
||||
}
|
||||
|
||||
function tokensToDecls(tokens?: Record<string, string>): string {
|
||||
if (!tokens) return "";
|
||||
const parts: string[] = [];
|
||||
for (const [key, value] of Object.entries(tokens)) {
|
||||
if (!isThemeTokenKey(key) || !isSafeHex(value)) continue;
|
||||
const cssVars = TOKEN_TO_CSS[key] ?? [];
|
||||
for (const css of cssVars) {
|
||||
parts.push(`${css}:${value}`);
|
||||
}
|
||||
}
|
||||
return parts.join(";");
|
||||
}
|
||||
|
||||
function recipeDecls(recipes?: ThemePackRecipes): string {
|
||||
if (!recipes) return "";
|
||||
const parts: string[] = [];
|
||||
const density = recipes.density === "compact" ? "compact" : "comfortable";
|
||||
const corners = recipes.corners === "square" || recipes.corners === "round" ? recipes.corners : "soft";
|
||||
if (density === "compact") {
|
||||
parts.push("--theme-density-pad:6px", "--theme-density-gap:6px", "--theme-row-h:28px");
|
||||
} else {
|
||||
parts.push("--theme-density-pad:10px", "--theme-density-gap:10px", "--theme-row-h:34px");
|
||||
}
|
||||
if (corners === "square") {
|
||||
parts.push("--r-s:0px", "--r:2px", "--r-l:4px", "--radius:2px");
|
||||
} else if (corners === "round") {
|
||||
parts.push("--r-s:8px", "--r:14px", "--r-l:18px", "--radius:14px");
|
||||
} else {
|
||||
parts.push("--r-s:5px", "--r:8px", "--r-l:11px", "--radius:8px");
|
||||
}
|
||||
return parts.join(";");
|
||||
}
|
||||
|
||||
function applyBackgroundCSSVars(root: HTMLElement, pack: ThemePackView): void {
|
||||
const home = pack.background;
|
||||
const homeUrl = pack.backgroundUrl || "";
|
||||
const task = pack.taskBackground;
|
||||
const taskUrl = pack.taskBackgroundUrl || "";
|
||||
const safeHomeUrl = Boolean(home && homeUrl && isSafeBackgroundURL(homeUrl));
|
||||
const safeTaskUrl = Boolean(task && taskUrl && isSafeBackgroundURL(taskUrl));
|
||||
if ((!safeHomeUrl && !safeTaskUrl) || !pack.hasBackground) {
|
||||
clearBackgroundCSSVars(root);
|
||||
return;
|
||||
}
|
||||
|
||||
if (safeHomeUrl && home) {
|
||||
root.style.setProperty("--theme-bg-home-image", `url("${cssUrlEscape(homeUrl)}")`);
|
||||
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)));
|
||||
// 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}%`);
|
||||
root.style.setProperty("--theme-bg-focus-y", `${clamp01(home.focusY) * 100}%`);
|
||||
} else {
|
||||
root.style.setProperty("--theme-bg-home-image", "none");
|
||||
}
|
||||
|
||||
const taskSource = safeTaskUrl && task ? task : home;
|
||||
const effectiveTaskUrl = safeTaskUrl ? taskUrl : safeHomeUrl ? homeUrl : "";
|
||||
if (taskSource && effectiveTaskUrl) {
|
||||
root.style.setProperty("--theme-bg-task-image", `url("${cssUrlEscape(effectiveTaskUrl)}")`);
|
||||
root.style.setProperty("--theme-bg-task-focus-x", `${clamp01(taskSource.focusX) * 100}%`);
|
||||
root.style.setProperty("--theme-bg-task-focus-y", `${clamp01(taskSource.focusY) * 100}%`);
|
||||
} else {
|
||||
root.style.setProperty("--theme-bg-task-image", "none");
|
||||
}
|
||||
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-overlay", String(clamp01(taskOverlay ?? 0.62)));
|
||||
root.style.setProperty("--theme-bg-overlay", String(clamp01(taskOverlay ?? 0.62)));
|
||||
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");
|
||||
}
|
||||
|
||||
function clearBackgroundCSSVars(root: HTMLElement): void {
|
||||
root.style.removeProperty("--theme-bg-home-image");
|
||||
root.style.removeProperty("--theme-bg-home-focus-x");
|
||||
root.style.removeProperty("--theme-bg-home-focus-y");
|
||||
root.style.removeProperty("--theme-bg-task-image");
|
||||
root.style.removeProperty("--theme-bg-task-focus-x");
|
||||
root.style.removeProperty("--theme-bg-task-focus-y");
|
||||
root.style.removeProperty("--theme-bg-task-overlay");
|
||||
root.style.removeProperty("--theme-bg-image");
|
||||
root.style.removeProperty("--theme-bg-focus-x");
|
||||
root.style.removeProperty("--theme-bg-focus-y");
|
||||
root.style.removeProperty("--theme-bg-home-opacity");
|
||||
root.style.removeProperty("--theme-bg-task-opacity");
|
||||
root.style.removeProperty("--theme-bg-overlay");
|
||||
root.removeAttribute("data-theme-safe-area");
|
||||
root.removeAttribute("data-theme-has-bg");
|
||||
}
|
||||
|
||||
export function isSafeHex(value: string): boolean {
|
||||
return /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(value.trim());
|
||||
}
|
||||
|
||||
export function isSafeBackgroundURL(url: string): boolean {
|
||||
const u = url.trim();
|
||||
if (!u) return false;
|
||||
if (u.startsWith("/__reasonix_theme_asset/")) return true;
|
||||
if (u.startsWith("data:image/png;base64,")) return true;
|
||||
if (u.startsWith("data:image/jpeg;base64,")) return true;
|
||||
if (u.startsWith("data:image/jpg;base64,")) return true;
|
||||
if (u.startsWith("data:image/webp;base64,")) return true;
|
||||
if (u.startsWith("blob:")) return true;
|
||||
if (trustedBundledThemeBackgroundURLs.has(u)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function clamp01(v: number): number {
|
||||
if (!Number.isFinite(v)) return 0.5;
|
||||
return Math.min(1, Math.max(0, v));
|
||||
}
|
||||
|
||||
function cssEscape(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
}
|
||||
|
||||
function cssUrlEscape(url: string): string {
|
||||
return url.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
/** Build a draft pack view for live editor preview (may use data-URL background). */
|
||||
export function draftPackView(input: {
|
||||
id: string;
|
||||
name: string;
|
||||
baseStyle: string;
|
||||
tokens: ThemePackTokens;
|
||||
recipes: ThemePackRecipes;
|
||||
background?: ThemePackBackground | null;
|
||||
backgroundUrl?: string;
|
||||
taskBackground?: ThemePackSceneBackground | null;
|
||||
taskBackgroundUrl?: string;
|
||||
}): ThemePackView {
|
||||
return {
|
||||
id: input.id || "preview",
|
||||
name: input.name || "Preview",
|
||||
baseStyle: input.baseStyle || "graphite",
|
||||
builtin: false,
|
||||
active: false,
|
||||
hasBackground: Boolean((input.backgroundUrl && input.background) || (input.taskBackgroundUrl && input.taskBackground)),
|
||||
backgroundUrl: input.backgroundUrl,
|
||||
taskBackgroundUrl: input.taskBackgroundUrl,
|
||||
tokens: input.tokens || {},
|
||||
recipes: input.recipes || { density: "comfortable", corners: "soft" },
|
||||
background: input.background ?? undefined,
|
||||
taskBackground: input.taskBackground ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function emptyThemeTokens(): ThemePackTokens {
|
||||
return { light: {}, dark: {} };
|
||||
}
|
||||
|
||||
export function defaultBackground(): ThemePackBackground {
|
||||
return {
|
||||
focusX: 0.5,
|
||||
focusY: 0.5,
|
||||
safeArea: "center",
|
||||
homeOpacity: 1,
|
||||
taskOpacity: 0.28,
|
||||
overlayStrength: 0.62,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultTaskBackground(): ThemePackSceneBackground {
|
||||
return {
|
||||
focusX: 0.5,
|
||||
focusY: 0.5,
|
||||
safeArea: "center",
|
||||
opacity: 0.28,
|
||||
overlayStrength: 0.62,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { isThemeStyle, type ThemeStyle } from "./theme";
|
||||
import { themePackKind, type ThemePackView } from "./themePack";
|
||||
|
||||
export type ThemePreviewMode = "light" | "dark";
|
||||
|
||||
export type ThemePreviewPalette = {
|
||||
bg: string;
|
||||
panel: string;
|
||||
sidebar: string;
|
||||
fg: string;
|
||||
fgDim: string;
|
||||
accent: string;
|
||||
accentFg: string;
|
||||
border: string;
|
||||
radius: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Canonical miniature palettes for the six native appearance directions.
|
||||
* These mirror the semantic variables in styles.css so every gallery surface
|
||||
* previews the selected base style instead of falling back to Graphite.
|
||||
*/
|
||||
export const BASE_STYLE_PREVIEW_PALETTES: Record<ThemeStyle, Record<ThemePreviewMode, ThemePreviewPalette>> = {
|
||||
graphite: {
|
||||
dark: { bg: "#0c0d10", panel: "#15161a", sidebar: "#15161a", fg: "#f1f1ef", fgDim: "#a7a8ad", accent: "#ff6a3d", accentFg: "#0c0d10", border: "rgba(255, 255, 255, 0.1)", radius: "8px" },
|
||||
light: { bg: "#f4f3ef", panel: "#ffffff", sidebar: "#ffffff", fg: "#16181d", fgDim: "#4a4d56", accent: "#ff5a2c", accentFg: "#ffffff", border: "rgba(20, 22, 28, 0.12)", radius: "8px" },
|
||||
},
|
||||
aurora: {
|
||||
dark: { bg: "#0e0d18", panel: "#17162a", sidebar: "#17162a", fg: "#ecebf7", fgDim: "#a9a4c6", accent: "#8b7cff", accentFg: "#0e0d18", border: "rgba(255, 255, 255, 0.07)", radius: "15px" },
|
||||
light: { bg: "#f6f3fb", panel: "#fdfcff", sidebar: "#fdfcff", fg: "#1c1830", fgDim: "#574f70", accent: "#6d5efc", accentFg: "#ffffff", border: "rgba(40, 22, 84, 0.09)", radius: "15px" },
|
||||
},
|
||||
slate: {
|
||||
dark: { bg: "#0d0f12", panel: "#15181d", sidebar: "#15181d", fg: "#e7eaf0", fgDim: "#9aa2b1", accent: "#4d8df6", accentFg: "#061129", border: "rgba(255, 255, 255, 0.08)", radius: "12px" },
|
||||
light: { bg: "#f5f6f9", panel: "#ffffff", sidebar: "#ffffff", fg: "#15181f", fgDim: "#4a5160", accent: "#2f6fe0", accentFg: "#ffffff", border: "rgba(20, 28, 46, 0.1)", radius: "12px" },
|
||||
},
|
||||
carbon: {
|
||||
dark: { bg: "#0e0d0c", panel: "#171614", sidebar: "#171614", fg: "#ede9e3", fgDim: "#a59f95", accent: "#2dd4bf", accentFg: "#001a16", border: "rgba(255, 250, 240, 0.08)", radius: "10px" },
|
||||
light: { bg: "#f6f4f0", panel: "#ffffff", sidebar: "#ffffff", fg: "#1a1714", fgDim: "#544e46", accent: "#0d9488", accentFg: "#ffffff", border: "rgba(30, 26, 20, 0.1)", radius: "10px" },
|
||||
},
|
||||
nocturne: {
|
||||
dark: { bg: "#101019", panel: "#191a27", sidebar: "#191a27", fg: "#eceaf3", fgDim: "#a6a2bd", accent: "#818cf8", accentFg: "#0d1024", border: "rgba(255, 255, 255, 0.08)", radius: "16px" },
|
||||
light: { bg: "#f6f5fb", panel: "#fdfcff", sidebar: "#fdfcff", fg: "#1b1830", fgDim: "#544f6e", accent: "#6366f1", accentFg: "#ffffff", border: "rgba(40, 30, 80, 0.09)", radius: "16px" },
|
||||
},
|
||||
amber: {
|
||||
dark: { bg: "#090a0c", panel: "#191b22", sidebar: "#0c0e12", fg: "#f4f5f7", fgDim: "#c0c4cc", accent: "#d4632f", accentFg: "#1a0a04", border: "#343945", radius: "8px" },
|
||||
light: { bg: "#f7f8fb", panel: "#ffffff", sidebar: "#f3f6fa", fg: "#111827", fgDim: "#4b5563", accent: "#dd5b28", accentFg: "#ffffff", border: "#d8dee8", radius: "8px" },
|
||||
},
|
||||
};
|
||||
|
||||
export function baseStyleForPreview(pack: ThemePackView | null): ThemeStyle {
|
||||
if (isThemeStyle(pack?.baseStyle)) return pack.baseStyle;
|
||||
if (isThemeStyle(pack?.id)) return pack.id;
|
||||
return "graphite";
|
||||
}
|
||||
|
||||
export function themePreviewPalette(pack: ThemePackView | null, mode: ThemePreviewMode): ThemePreviewPalette {
|
||||
const base = baseStyleForPreview(pack);
|
||||
const basePalette = BASE_STYLE_PREVIEW_PALETTES[base][mode];
|
||||
if (!pack || themePackKind(pack) === "base") return basePalette;
|
||||
|
||||
const tokens = mode === "light" ? pack.tokens?.light : pack.tokens?.dark;
|
||||
return {
|
||||
bg: tokens?.bg || basePalette.bg,
|
||||
panel: tokens?.panel || tokens?.bgElev || basePalette.panel,
|
||||
sidebar: tokens?.sidebar || basePalette.sidebar,
|
||||
fg: tokens?.fg || basePalette.fg,
|
||||
fgDim: tokens?.fgDim || basePalette.fgDim,
|
||||
accent: tokens?.accent || basePalette.accent,
|
||||
accentFg: tokens?.accentFg || basePalette.accentFg,
|
||||
border: tokens?.border || basePalette.border,
|
||||
radius: basePalette.radius,
|
||||
};
|
||||
}
|
||||
@@ -1245,7 +1245,7 @@ export const en = {
|
||||
"settings.botNoConnections": "No connections",
|
||||
"settings.botConnectionCount": "{n} connections",
|
||||
"settings.botDisabledWithConnections": "Bot off · {n} connections",
|
||||
"settings.appearanceMeta": "theme · accent · text · font",
|
||||
"settings.appearanceMeta": "theme packs · color mode · text · fonts",
|
||||
"settings.updatesMeta": "version · config",
|
||||
"settings.shortcutsTitle": "Keyboard shortcuts",
|
||||
"settings.shortcutsHint": "Customize desktop shortcuts from one shared source. The help sheet and active handlers use these same bindings.",
|
||||
@@ -1713,7 +1713,7 @@ export const en = {
|
||||
"settings.effectiveWriteRootsHint": "Expanded for the active workspace. Settings changes apply automatically; use the button above after manually editing config files.",
|
||||
"settings.noEffectiveWriteRoots": "No active write roots",
|
||||
"settings.appearance": "Appearance",
|
||||
"settings.theme": "Theme",
|
||||
"settings.theme": "Color mode",
|
||||
"settings.themeStyle": "Visual style",
|
||||
"settings.style.graphite.zh": "Graphite",
|
||||
"settings.style.graphite.note": "Crisp",
|
||||
@@ -1739,6 +1739,158 @@ export const en = {
|
||||
"settings.themeCurrent": "Theme: {theme} / {style}",
|
||||
"settings.themeChanged": "Theme changed to {theme} / {style}",
|
||||
"settings.themeUnknown": "Unknown theme: {name}",
|
||||
"settings.themeReset": "Theme pack cleared; using default Graphite path",
|
||||
"settings.themeLibrary.title": "Theme library",
|
||||
"settings.themeLibrary.new": "New theme",
|
||||
"settings.themeLibrary.import": "Import",
|
||||
"settings.themeLibrary.reset": "Restore default",
|
||||
"settings.themeLibrary.loading": "Loading themes…",
|
||||
"settings.themeLibrary.builtin": "Built-in",
|
||||
"settings.themeLibrary.userTheme": "User theme",
|
||||
"settings.themeLibrary.enable": "Enable",
|
||||
"settings.themeLibrary.active": "Active",
|
||||
"settings.themeLibrary.edit": "Edit",
|
||||
"settings.themeLibrary.copy": "Duplicate",
|
||||
"settings.themeLibrary.export": "Export",
|
||||
"settings.themeLibrary.delete": "Delete",
|
||||
"settings.themeLibrary.copied": "Copied “{name}”",
|
||||
"settings.themeLibrary.activated": "Enabled “{name}”",
|
||||
"settings.themeLibrary.resetDone": "Restored default appearance",
|
||||
"settings.themeLibrary.confirmDelete": "Delete theme “{name}”? This cannot be undone.",
|
||||
"settings.themeLibrary.confirmDeleteTitle": "Delete theme",
|
||||
"settings.themeLibrary.confirmReplaceImport": "A theme with this id already exists. Replace it?",
|
||||
"settings.themeLibrary.confirmReplaceImportTitle": "Replace existing theme",
|
||||
"settings.themeLibrary.replaceConfirm": "Replace",
|
||||
"settings.themeLibrary.imported": "Imported “{name}”",
|
||||
"settings.themeLibrary.exported": "Theme exported",
|
||||
"settings.themeLibrary.exportRights": "You are about to export a theme that may include a background image. Confirm you have the right to redistribute any photos, portraits, or third-party artwork.",
|
||||
"settings.themeLibrary.exportRightsTitle": "Confirm export rights",
|
||||
"settings.themeLibrary.exportConfirm": "Export",
|
||||
"settings.themeLibrary.saved": "Saved “{name}”",
|
||||
"settings.themeLibrary.editorCreate": "Create theme",
|
||||
"settings.themeLibrary.editorEdit": "Edit theme",
|
||||
"settings.themeEditor.subtitle": "Customize semantic colors, interface density, and per-scene background images",
|
||||
"settings.themeEditor.metadata": "Theme details",
|
||||
"settings.themeEditor.layout": "Base appearance",
|
||||
"settings.themeEditor.colors": "Theme colors",
|
||||
"settings.themeEditor.scenes": "Scene images",
|
||||
"settings.themeEditor.scenesHint": "Home and workspace can use different images. Workspace falls back to the home background when unset.",
|
||||
"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.taskBackgroundFallback": "Currently reuses the home image. Upload one to tune it independently.",
|
||||
"settings.themeEditor.description": "Description",
|
||||
"settings.themeEditor.license": "License",
|
||||
"settings.themeEditor.livePreview": "Live preview",
|
||||
"settings.themeEditor.livePreviewHint": "Changes preview immediately, but are written to the library only after Save or Save & apply.",
|
||||
"settings.themeEditor.uploadPrompt": "Choose a local PNG, JPEG, or WebP image",
|
||||
"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.density.comfortable": "Comfortable",
|
||||
"settings.themeEditor.density.compact": "Compact",
|
||||
"settings.themeEditor.corners.square": "Square",
|
||||
"settings.themeEditor.corners.soft": "Soft",
|
||||
"settings.themeEditor.corners.round": "Round",
|
||||
"settings.themeEditor.safeArea.left": "Left",
|
||||
"settings.themeEditor.safeArea.center": "Center",
|
||||
"settings.themeEditor.safeArea.right": "Right",
|
||||
"settings.themeTokens.key.bg": "Main background",
|
||||
"settings.themeTokens.key.bgSoft": "Soft background",
|
||||
"settings.themeTokens.key.bgElev": "Elevated background",
|
||||
"settings.themeTokens.key.panel": "Panel",
|
||||
"settings.themeTokens.key.sidebar": "Sidebar",
|
||||
"settings.themeTokens.key.chat": "Chat area",
|
||||
"settings.themeTokens.key.workspace": "Workspace",
|
||||
"settings.themeTokens.key.workspaceFiles": "Files area",
|
||||
"settings.themeTokens.key.border": "Primary border",
|
||||
"settings.themeTokens.key.borderSoft": "Soft border",
|
||||
"settings.themeTokens.key.fg": "Primary text",
|
||||
"settings.themeTokens.key.fgDim": "Secondary text",
|
||||
"settings.themeTokens.key.fgFaint": "Muted text",
|
||||
"settings.themeTokens.key.accent": "Accent",
|
||||
"settings.themeTokens.key.accentFg": "Accent text",
|
||||
"settings.themeTokens.key.ok": "Success",
|
||||
"settings.themeTokens.key.warn": "Warning",
|
||||
"settings.themeTokens.key.err": "Error",
|
||||
"settings.themeLibrary.fieldId": "Id / name",
|
||||
"settings.themeLibrary.fieldName": "Display name",
|
||||
"settings.themeLibrary.fieldAuthor": "Author",
|
||||
"settings.themeLibrary.fieldBase": "Base style",
|
||||
"settings.themeLibrary.fieldRecipes": "Density / corners",
|
||||
"settings.themeLibrary.fieldTokens": "Semantic colors",
|
||||
"settings.themeLibrary.fieldBackground": "Background",
|
||||
"settings.themeLibrary.pickImage": "Choose image",
|
||||
"settings.themeLibrary.clearImage": "Remove image",
|
||||
"settings.themeLibrary.homeOpacity": "Home opacity",
|
||||
"settings.themeLibrary.taskOpacity": "Task opacity",
|
||||
"settings.themeLibrary.overlayStrength": "Overlay strength",
|
||||
"settings.themeLibrary.contrastWarn": "Contrast may be below WCAG AA for some pairs. You can still save.",
|
||||
"settings.themeLibrary.cancel": "Cancel",
|
||||
"settings.themeLibrary.save": "Save",
|
||||
"settings.themeLibrary.saveEnable": "Save & enable",
|
||||
"settings.themeTokens.surfaces": "Surfaces",
|
||||
"settings.themeTokens.borderText": "Border & text",
|
||||
"settings.themeTokens.accentStatus": "Accent & status",
|
||||
"settings.themeLibrary.groupOfficial": "Reasonix official themes",
|
||||
"settings.themeLibrary.groupBase": "Base styles",
|
||||
"settings.themeLibrary.groupUser": "My themes",
|
||||
"settings.themeLibrary.emptyUser": "No custom themes yet — create one or import a pack.",
|
||||
"settings.themeLibrary.copyFrom": "Customize a copy",
|
||||
"settings.themes.official.official-rose-dawn.name": "Rose Dawn",
|
||||
"settings.themes.official.official-rose-dawn.description": "Ivory dawn light and soft roses around an original illustrated muse.",
|
||||
"settings.themes.official.official-fortune-forge.name": "Fortune Forge",
|
||||
"settings.themes.official.official-fortune-forge.description": "Warm ivory, vermilion, gold and jade around an original lucky programmer.",
|
||||
"settings.themes.official.official-crimson-horizon.name": "Crimson Horizon",
|
||||
"settings.themes.official.official-crimson-horizon.description": "A coral-red future city under a pearl-white sky.",
|
||||
"settings.themes.official.official-sage-breeze.name": "Sage Breeze",
|
||||
"settings.themes.official.official-sage-breeze.description": "Cream paper and sage sprigs with an original illustrated reader.",
|
||||
"settings.themes.official.official-spark-notebook.name": "Spark Notebook",
|
||||
"settings.themes.official.official-spark-notebook.description": "A notebook grid with teal, yellow and coral stationery and an original anime adult.",
|
||||
"settings.themes.official.official-violet-starlight.name": "Violet Starlight",
|
||||
"settings.themes.official.official-violet-starlight.description": "A blue-violet starfield with glowing butterflies and an original celestial muse.",
|
||||
"settings.themes.official.official-cyan-stage.name": "Cyan Stage",
|
||||
"settings.themes.official.official-cyan-stage.description": "An airy cyan stage with light rings and an original digital performer.",
|
||||
"settings.themes.official.official-noir-gold.name": "Noir Gold",
|
||||
"settings.themes.official.official-noir-gold.description": "A black velvet stage and gold spotlights around an original creative gentleman.",
|
||||
"settings.themeGallery.title": "Theme gallery",
|
||||
"settings.themeGallery.subtitle": "Click a theme to preview it across the app; apply to save it",
|
||||
"settings.themeGallery.browse": "Browse themes",
|
||||
"settings.themeGallery.createCopy": "Create copy",
|
||||
"settings.themeGallery.disable": "Disable theme",
|
||||
"settings.themeGallery.disabled": "Theme disabled; restored base style",
|
||||
"settings.themeGallery.apply": "Apply theme",
|
||||
"settings.themeGallery.applied": "Applied “{name}”",
|
||||
"settings.themeGallery.appliedBase": "Applied base style {name}",
|
||||
"settings.themeGallery.openPreview": "Immersive preview",
|
||||
"settings.themeGallery.previewing": "Previewing",
|
||||
"settings.themeGallery.moreActions": "More actions",
|
||||
"settings.themeGallery.previewTitle": "Preview theme",
|
||||
"settings.themeGallery.current": "In use",
|
||||
"settings.themeGallery.paletteLabel": "Theme colors",
|
||||
"settings.themeGallery.appearancePreview": "Appearance",
|
||||
"settings.themeGallery.scenePreview": "Scene",
|
||||
"settings.themeGallery.scenePreviewHint": "Preview how the theme background appears on the home screen or in the workspace. This does not apply the theme.",
|
||||
"settings.themeGallery.currentAppearance": "Current appearance",
|
||||
"settings.themeGallery.baseStyle": "Base style",
|
||||
"settings.themeGallery.baseLockedByPack": "Managed by the active theme. Disable it to change the base style.",
|
||||
"settings.themeGallery.tabBase": "Base styles",
|
||||
"settings.themeGallery.tabAll": "All themes",
|
||||
"settings.themeGallery.sectionFlagship": "Flagship themes",
|
||||
"settings.themeGallery.kindOfficial": "Official",
|
||||
"settings.themeGallery.kindUser": "My theme",
|
||||
"settings.themeGallery.kindBase": "Base style",
|
||||
"settings.themeGallery.selectHint": "Select a theme to see details",
|
||||
"settings.themeGallery.empty": "No themes in this group",
|
||||
"settings.themeGallery.back": "Back",
|
||||
"settings.themeGallery.sceneHome": "Home display",
|
||||
"settings.themeGallery.sceneTask": "Workspace display",
|
||||
"settings.themeGallery.saveAndApply": "Save & apply",
|
||||
"settings.themeGallery.restoreGraphite": "Restore default appearance",
|
||||
"settings.themeGallery.restoreGraphiteHint": "Disables the theme pack and restores Graphite. Color mode, text size, and fonts are preserved.",
|
||||
"settings.themeGallery.restoredGraphite": "Restored Graphite base appearance",
|
||||
"settings.themeGallery.deleted": "Deleted “{name}”",
|
||||
"palette.placeholder": "Search sessions, commands, files…",
|
||||
"palette.empty": "No matches",
|
||||
"palette.group.commands": "Commands",
|
||||
@@ -1830,7 +1982,7 @@ export const en = {
|
||||
"settings.pageDesc.network": "Proxy and network configuration.",
|
||||
"settings.pageDesc.permissions": "Writer mode and fine-grained tool permission rules.",
|
||||
"settings.pageDesc.sandbox": "Bash sandbox, network egress, and workspace root.",
|
||||
"settings.pageDesc.appearance": "Theme, accent color, and text size.",
|
||||
"settings.pageDesc.appearance": "Theme packs, color mode, base style, text size, and fonts.",
|
||||
"settings.pageDesc.updates": "Check for software updates and view version info.",
|
||||
"settings.modelUsage": "Model usage",
|
||||
"settings.modelUnavailable": "{ref} is not available. Pick a configured model or add provider access.",
|
||||
|
||||
@@ -941,7 +941,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.tabSub.appearance": "主題與字號",
|
||||
"settings.tabSub.updates": "版本與設定",
|
||||
"settings.providerCount": "{n} 個服務",
|
||||
"settings.appearanceMeta": "主題 · 強調色 · 字號 · 字型",
|
||||
"settings.appearanceMeta": "主題包 · 明暗模式 · 字號 · 字型",
|
||||
"settings.updatesMeta": "版本 · 設定",
|
||||
"settings.shortcutsTitle": "鍵盤快捷鍵",
|
||||
"settings.shortcutsHint": "從同一個來源自訂桌面快捷鍵。幫助表和實際監聽都會使用這些綁定。",
|
||||
@@ -1112,7 +1112,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.effectiveWriteRootsHint": "按目前工作區展開。設定頁內修改會自動生效;手動編輯設定檔後可用上方按鈕刷新目前會話。",
|
||||
"settings.noEffectiveWriteRoots": "暫無可寫根",
|
||||
"settings.appearance": "外觀",
|
||||
"settings.theme": "主題",
|
||||
"settings.theme": "明暗模式",
|
||||
"settings.themeStyle": "強調色",
|
||||
"settings.themeAuto": "自動",
|
||||
"settings.themeLight": "淺色",
|
||||
@@ -1120,6 +1120,122 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.themeCurrent": "當前主題:{theme} / {style}",
|
||||
"settings.themeChanged": "已切換主題為 {theme} / {style}",
|
||||
"settings.themeUnknown": "未知主題:{name}",
|
||||
"settings.themeReset": "已清除主題包,恢復預設 Graphite 路徑",
|
||||
"settings.themeLibrary.title": "主題庫",
|
||||
"settings.themeLibrary.new": "新建主題",
|
||||
"settings.themeLibrary.import": "匯入",
|
||||
"settings.themeLibrary.reset": "恢復預設",
|
||||
"settings.themeLibrary.loading": "正在載入主題…",
|
||||
"settings.themeLibrary.builtin": "內建",
|
||||
"settings.themeLibrary.userTheme": "使用者主題",
|
||||
"settings.themeLibrary.enable": "啟用",
|
||||
"settings.themeLibrary.active": "使用中",
|
||||
"settings.themeLibrary.edit": "編輯",
|
||||
"settings.themeLibrary.copy": "複製",
|
||||
"settings.themeLibrary.export": "匯出",
|
||||
"settings.themeLibrary.delete": "刪除",
|
||||
"settings.themeLibrary.copied": "已複製「{name}」",
|
||||
"settings.themeLibrary.activated": "已啟用「{name}」",
|
||||
"settings.themeLibrary.resetDone": "已恢復預設外觀",
|
||||
"settings.themeLibrary.confirmDelete": "刪除主題「{name}」?此操作無法復原。",
|
||||
"settings.themeLibrary.confirmDeleteTitle": "刪除主題",
|
||||
"settings.themeLibrary.confirmReplaceImport": "同 ID 主題已存在,是否取代?",
|
||||
"settings.themeLibrary.confirmReplaceImportTitle": "取代現有主題",
|
||||
"settings.themeLibrary.replaceConfirm": "取代",
|
||||
"settings.themeLibrary.imported": "已匯入「{name}」",
|
||||
"settings.themeLibrary.exported": "主題已匯出",
|
||||
"settings.themeLibrary.exportRights": "即將匯出可能包含背景圖的主題。請確認你擁有照片、人物肖像或第三方素材的散布權利。",
|
||||
"settings.themeLibrary.exportRightsTitle": "確認匯出授權",
|
||||
"settings.themeLibrary.exportConfirm": "匯出",
|
||||
"settings.themeLibrary.saved": "已儲存「{name}」",
|
||||
"settings.themeLibrary.editorCreate": "新建主題",
|
||||
"settings.themeLibrary.editorEdit": "編輯主題",
|
||||
"settings.themeEditor.subtitle": "自訂語意色彩、介面密度與不同場景的背景圖片",
|
||||
"settings.themeEditor.metadata": "主題資訊",
|
||||
"settings.themeEditor.layout": "基礎外觀",
|
||||
"settings.themeEditor.colors": "主題色彩",
|
||||
"settings.themeEditor.scenes": "場景圖片",
|
||||
"settings.themeEditor.scenesHint": "首頁與工作區可使用不同圖片;未設定工作區圖片時自動沿用首頁背景。",
|
||||
"settings.themeEditor.homeBackground": "首頁背景",
|
||||
"settings.themeEditor.homeBackgroundHint": "用於首頁與閒置狀態,可完整顯示圖片。",
|
||||
"settings.themeEditor.taskBackground": "工作區背景",
|
||||
"settings.themeEditor.taskBackgroundHint": "用於任務、聊天與程式碼工作區,透明度上限為 45%。",
|
||||
"settings.themeEditor.taskBackgroundFallback": "目前沿用首頁圖片;上傳後可獨立調整。",
|
||||
"settings.themeEditor.description": "主題說明",
|
||||
"settings.themeEditor.license": "授權條款",
|
||||
"settings.themeEditor.livePreview": "即時預覽",
|
||||
"settings.themeEditor.livePreviewHint": "修改會立即預覽,但只有儲存或儲存並套用後才會寫入主題庫。",
|
||||
"settings.themeEditor.uploadPrompt": "選擇本機 PNG、JPEG 或 WebP 圖片",
|
||||
"settings.themeEditor.safeArea": "介面內容區域",
|
||||
"settings.themeEditor.safeAreaHint": "選擇文字與卡片主要顯示的位置;建議避開圖片主體。",
|
||||
"settings.themeEditor.opacity": "圖片透明度",
|
||||
"settings.themeEditor.density.comfortable": "舒適",
|
||||
"settings.themeEditor.density.compact": "緊湊",
|
||||
"settings.themeEditor.corners.square": "直角",
|
||||
"settings.themeEditor.corners.soft": "柔和圓角",
|
||||
"settings.themeEditor.corners.round": "大圓角",
|
||||
"settings.themeEditor.safeArea.left": "左側",
|
||||
"settings.themeEditor.safeArea.center": "置中",
|
||||
"settings.themeEditor.safeArea.right": "右側",
|
||||
"settings.themeTokens.key.bg": "主背景",
|
||||
"settings.themeTokens.key.bgSoft": "柔和背景",
|
||||
"settings.themeTokens.key.bgElev": "浮層背景",
|
||||
"settings.themeTokens.key.panel": "面板",
|
||||
"settings.themeTokens.key.sidebar": "側邊欄",
|
||||
"settings.themeTokens.key.chat": "對話區",
|
||||
"settings.themeTokens.key.workspace": "工作區",
|
||||
"settings.themeTokens.key.workspaceFiles": "檔案區",
|
||||
"settings.themeTokens.key.border": "主邊框",
|
||||
"settings.themeTokens.key.borderSoft": "柔和邊框",
|
||||
"settings.themeTokens.key.fg": "主文字",
|
||||
"settings.themeTokens.key.fgDim": "次要文字",
|
||||
"settings.themeTokens.key.fgFaint": "弱化文字",
|
||||
"settings.themeTokens.key.accent": "強調色",
|
||||
"settings.themeTokens.key.accentFg": "強調色文字",
|
||||
"settings.themeTokens.key.ok": "成功狀態",
|
||||
"settings.themeTokens.key.warn": "警告狀態",
|
||||
"settings.themeTokens.key.err": "錯誤狀態",
|
||||
"settings.themeLibrary.fieldId": "ID / 名稱",
|
||||
"settings.themeLibrary.fieldName": "顯示名稱",
|
||||
"settings.themeLibrary.fieldAuthor": "作者",
|
||||
"settings.themeLibrary.fieldBase": "基礎風格",
|
||||
"settings.themeLibrary.fieldRecipes": "密度 / 圓角",
|
||||
"settings.themeLibrary.fieldTokens": "語意顏色",
|
||||
"settings.themeLibrary.fieldBackground": "背景",
|
||||
"settings.themeLibrary.pickImage": "選擇圖片",
|
||||
"settings.themeLibrary.clearImage": "移除圖片",
|
||||
"settings.themeLibrary.homeOpacity": "首頁透明度",
|
||||
"settings.themeLibrary.taskOpacity": "任務頁透明度",
|
||||
"settings.themeLibrary.overlayStrength": "遮罩強度",
|
||||
"settings.themeLibrary.contrastWarn": "部分文字與背景對比度可能低於 WCAG AA。你仍可繼續儲存。",
|
||||
"settings.themeLibrary.cancel": "取消",
|
||||
"settings.themeLibrary.save": "儲存",
|
||||
"settings.themeLibrary.saveEnable": "儲存並啟用",
|
||||
"settings.themeTokens.surfaces": "表面",
|
||||
"settings.themeTokens.borderText": "邊框與文字",
|
||||
"settings.themeTokens.accentStatus": "強調與狀態色",
|
||||
"settings.themeLibrary.groupOfficial": "Reasonix 官方主題",
|
||||
"settings.themeLibrary.groupBase": "基礎風格",
|
||||
"settings.themeLibrary.groupUser": "我的主題",
|
||||
"settings.themeLibrary.emptyUser": "還沒有自訂主題,可以新建或匯入。",
|
||||
"settings.themeLibrary.copyFrom": "建立自訂副本",
|
||||
"settings.themes.official.official-rose-dawn.name": "玫瑰晨光",
|
||||
"settings.themes.official.official-rose-dawn.description": "象牙白晨光與柔粉玫瑰,原創插畫女性人物。",
|
||||
"settings.themes.official.official-fortune-forge.name": "鴻運工坊",
|
||||
"settings.themes.official.official-fortune-forge.description": "暖白、朱紅、金色與玉綠之間的原創吉祥工程師。",
|
||||
"settings.themes.official.official-crimson-horizon.name": "赤曜新城",
|
||||
"settings.themes.official.official-crimson-horizon.description": "珍珠白天空下的珊瑚紅未來城市。",
|
||||
"settings.themes.official.official-sage-breeze.name": "鼠尾草清風",
|
||||
"settings.themes.official.official-sage-breeze.description": "奶油紙張與鼠尾草植物之間的原創讀者。",
|
||||
"settings.themes.official.official-spark-notebook.name": "靈感手帳",
|
||||
"settings.themes.official.official-spark-notebook.description": "手帳網格紙與青綠、明黃、珊瑚色文具,原創動漫人物。",
|
||||
"settings.themes.official.official-violet-starlight.name": "紫曜星夜",
|
||||
"settings.themes.official.official-violet-starlight.description": "藍紫星空、輝光蝴蝶與原創星夜繆斯。",
|
||||
"settings.themes.official.official-cyan-stage.name": "青嵐舞台",
|
||||
"settings.themes.official.official-cyan-stage.description": "青藍未來舞台與原創數位表演者。",
|
||||
"settings.themes.official.official-noir-gold.name": "黑金序曲",
|
||||
"settings.themes.official.official-noir-gold.description": "黑色絲絨舞台與金色聚光燈下的原創創意紳士。",
|
||||
|
||||
"settings.textSize": "介面字號",
|
||||
"settings.textSizeSmall": "小",
|
||||
"settings.textSizeDefault": "預設",
|
||||
@@ -1153,7 +1269,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.pageDesc.network": "代理與網路設定。",
|
||||
"settings.pageDesc.permissions": "寫操作模式與細粒度工具權限規則。",
|
||||
"settings.pageDesc.sandbox": "Bash 沙箱、網路出口與工作區根目錄。",
|
||||
"settings.pageDesc.appearance": "主題、強調色與字號。",
|
||||
"settings.pageDesc.appearance": "主題包、明暗模式、基礎配色、字號與字型。",
|
||||
"settings.pageDesc.updates": "檢查軟體更新並檢視版本資訊。",
|
||||
"settings.modelUsage": "使用模型",
|
||||
"settings.modelUnavailable": "{ref} 不可用。請選擇已設定模型,或先新增模型服務接入。",
|
||||
@@ -2255,6 +2371,43 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.style.amber.zh": "琥珀",
|
||||
"settings.style.amber.note": "暖陽",
|
||||
"settings.style.amber.desc": "暖橙強調色,明亮親和(含深色變體)。",
|
||||
"settings.themeGallery.title": "主題畫廊",
|
||||
"settings.themeGallery.subtitle": "點擊主題即可全域預覽,套用後才會儲存",
|
||||
"settings.themeGallery.browse": "瀏覽主題",
|
||||
"settings.themeGallery.createCopy": "建立副本",
|
||||
"settings.themeGallery.disable": "停用主題",
|
||||
"settings.themeGallery.disabled": "已停用主題,恢復基礎配色",
|
||||
"settings.themeGallery.apply": "套用主題",
|
||||
"settings.themeGallery.applied": "已套用「{name}」",
|
||||
"settings.themeGallery.appliedBase": "已套用基礎配色 {name}",
|
||||
"settings.themeGallery.openPreview": "沉浸預覽",
|
||||
"settings.themeGallery.previewing": "正在預覽",
|
||||
"settings.themeGallery.moreActions": "更多操作",
|
||||
"settings.themeGallery.previewTitle": "預覽主題",
|
||||
"settings.themeGallery.current": "目前使用",
|
||||
"settings.themeGallery.paletteLabel": "主題色",
|
||||
"settings.themeGallery.appearancePreview": "外觀預覽",
|
||||
"settings.themeGallery.scenePreview": "場景預覽",
|
||||
"settings.themeGallery.scenePreviewHint": "預覽主題背景在首頁或工作區中的顯示強度;不會立即套用主題。",
|
||||
"settings.themeGallery.currentAppearance": "目前外觀",
|
||||
"settings.themeGallery.baseStyle": "基礎配色",
|
||||
"settings.themeGallery.baseLockedByPack": "基礎配色由目前主題管理,停用主題後可修改。",
|
||||
"settings.themeGallery.tabBase": "基礎配色",
|
||||
"settings.themeGallery.tabAll": "全部主題",
|
||||
"settings.themeGallery.sectionFlagship": "旗艦主題",
|
||||
"settings.themeGallery.kindOfficial": "官方主題",
|
||||
"settings.themeGallery.kindUser": "我的主題",
|
||||
"settings.themeGallery.kindBase": "基礎配色",
|
||||
"settings.themeGallery.selectHint": "選擇一個主題查看詳情",
|
||||
"settings.themeGallery.empty": "此分組暫無主題",
|
||||
"settings.themeGallery.back": "返回",
|
||||
"settings.themeGallery.sceneHome": "首頁展示",
|
||||
"settings.themeGallery.sceneTask": "工作區展示",
|
||||
"settings.themeGallery.saveAndApply": "儲存並套用",
|
||||
"settings.themeGallery.restoreGraphite": "恢復預設外觀",
|
||||
"settings.themeGallery.restoreGraphiteHint": "停用主題包並恢復 Graphite;保留明暗模式、字號和字型設定。",
|
||||
"settings.themeGallery.restoredGraphite": "已恢復 Graphite 基礎外觀",
|
||||
"settings.themeGallery.deleted": "已刪除「{name}」",
|
||||
"palette.placeholder": "搜尋會話、命令、檔案…",
|
||||
"palette.empty": "沒有匹配項",
|
||||
"palette.group.commands": "命令",
|
||||
|
||||
@@ -1247,7 +1247,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.botNoConnections": "未连接",
|
||||
"settings.botConnectionCount": "{n} 个连接",
|
||||
"settings.botDisabledWithConnections": "机器人关闭 · {n} 个连接",
|
||||
"settings.appearanceMeta": "主题 · 强调色 · 字号 · 字体",
|
||||
"settings.appearanceMeta": "主题包 · 明暗模式 · 字号 · 字体",
|
||||
"settings.updatesMeta": "版本 · 配置",
|
||||
"settings.shortcutsTitle": "键盘快捷键",
|
||||
"settings.shortcutsHint": "从同一个来源自定义桌面快捷键。帮助表和实际监听都会使用这些绑定。",
|
||||
@@ -1715,7 +1715,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.effectiveWriteRootsHint": "按当前工作区展开。设置页内修改会自动生效;手动编辑配置文件后可用上方按钮刷新当前会话。",
|
||||
"settings.noEffectiveWriteRoots": "暂无可写根",
|
||||
"settings.appearance": "外观",
|
||||
"settings.theme": "主题",
|
||||
"settings.theme": "明暗模式",
|
||||
"settings.themeStyle": "视觉风格",
|
||||
"settings.style.graphite.zh": "石墨",
|
||||
"settings.style.graphite.note": "利落",
|
||||
@@ -1741,6 +1741,159 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.themeCurrent": "当前主题:{theme} / {style}",
|
||||
"settings.themeChanged": "已切换主题为 {theme} / {style}",
|
||||
"settings.themeUnknown": "未知主题:{name}",
|
||||
"settings.themeReset": "已清除主题包,恢复默认 Graphite 路径",
|
||||
"settings.themeLibrary.title": "主题库",
|
||||
"settings.themeLibrary.new": "新建主题",
|
||||
"settings.themeLibrary.import": "导入",
|
||||
"settings.themeLibrary.reset": "恢复默认",
|
||||
"settings.themeLibrary.loading": "正在加载主题…",
|
||||
"settings.themeLibrary.builtin": "内置",
|
||||
"settings.themeLibrary.userTheme": "用户主题",
|
||||
"settings.themeLibrary.enable": "启用",
|
||||
"settings.themeLibrary.active": "使用中",
|
||||
"settings.themeLibrary.edit": "编辑",
|
||||
"settings.themeLibrary.copy": "复制",
|
||||
"settings.themeLibrary.export": "导出",
|
||||
"settings.themeLibrary.delete": "删除",
|
||||
"settings.themeLibrary.copied": "已复制「{name}」",
|
||||
"settings.themeLibrary.activated": "已启用「{name}」",
|
||||
"settings.themeLibrary.resetDone": "已恢复默认外观",
|
||||
"settings.themeLibrary.confirmDelete": "删除主题「{name}」?此操作不可撤销。",
|
||||
"settings.themeLibrary.confirmDeleteTitle": "删除主题",
|
||||
"settings.themeLibrary.confirmReplaceImport": "同 ID 主题已存在,是否替换?",
|
||||
"settings.themeLibrary.confirmReplaceImportTitle": "替换现有主题",
|
||||
"settings.themeLibrary.replaceConfirm": "替换",
|
||||
"settings.themeLibrary.imported": "已导入「{name}」",
|
||||
"settings.themeLibrary.exported": "主题已导出",
|
||||
"settings.themeLibrary.exportRights": "即将导出可能包含背景图的主题。请确认你拥有照片、人物肖像或第三方素材的分发权利。",
|
||||
"settings.themeLibrary.exportRightsTitle": "确认导出授权",
|
||||
"settings.themeLibrary.exportConfirm": "导出",
|
||||
"settings.themeLibrary.saved": "已保存「{name}」",
|
||||
"settings.themeLibrary.editorCreate": "新建主题",
|
||||
"settings.themeLibrary.editorEdit": "编辑主题",
|
||||
"settings.themeEditor.subtitle": "自定义语义颜色、界面密度与不同场景的背景图片",
|
||||
"settings.themeEditor.metadata": "主题信息",
|
||||
"settings.themeEditor.layout": "基础外观",
|
||||
"settings.themeEditor.colors": "主题颜色",
|
||||
"settings.themeEditor.scenes": "场景图片",
|
||||
"settings.themeEditor.scenesHint": "首页与工作区可使用不同图片;未设置工作区图片时自动沿用首页背景。",
|
||||
"settings.themeEditor.homeBackground": "首页背景",
|
||||
"settings.themeEditor.homeBackgroundHint": "用于首页与空闲状态,可完整展示图片。",
|
||||
"settings.themeEditor.taskBackground": "工作区背景",
|
||||
"settings.themeEditor.taskBackgroundHint": "用于任务、聊天与代码工作区,透明度上限为 45%。",
|
||||
"settings.themeEditor.taskBackgroundFallback": "当前沿用首页图片;上传后可独立调整。",
|
||||
"settings.themeEditor.description": "主题说明",
|
||||
"settings.themeEditor.license": "许可证",
|
||||
"settings.themeEditor.livePreview": "实时预览",
|
||||
"settings.themeEditor.livePreviewHint": "修改会立即预览,但只有保存或保存并应用后才会写入主题库。",
|
||||
"settings.themeEditor.uploadPrompt": "选择本地 PNG、JPEG 或 WebP 图片",
|
||||
"settings.themeEditor.safeArea": "界面内容区域",
|
||||
"settings.themeEditor.safeAreaHint": "选择文字和卡片主要显示的位置;建议避开图片主体。",
|
||||
"settings.themeEditor.opacity": "图片透明度",
|
||||
"settings.themeEditor.density.comfortable": "舒适",
|
||||
"settings.themeEditor.density.compact": "紧凑",
|
||||
"settings.themeEditor.corners.square": "直角",
|
||||
"settings.themeEditor.corners.soft": "柔和圆角",
|
||||
"settings.themeEditor.corners.round": "大圆角",
|
||||
"settings.themeEditor.safeArea.left": "左侧",
|
||||
"settings.themeEditor.safeArea.center": "居中",
|
||||
"settings.themeEditor.safeArea.right": "右侧",
|
||||
"settings.themeTokens.key.bg": "主背景",
|
||||
"settings.themeTokens.key.bgSoft": "柔和背景",
|
||||
"settings.themeTokens.key.bgElev": "浮层背景",
|
||||
"settings.themeTokens.key.panel": "面板",
|
||||
"settings.themeTokens.key.sidebar": "侧边栏",
|
||||
"settings.themeTokens.key.chat": "对话区",
|
||||
"settings.themeTokens.key.workspace": "工作区",
|
||||
"settings.themeTokens.key.workspaceFiles": "文件区",
|
||||
"settings.themeTokens.key.border": "主边框",
|
||||
"settings.themeTokens.key.borderSoft": "柔和边框",
|
||||
"settings.themeTokens.key.fg": "主文字",
|
||||
"settings.themeTokens.key.fgDim": "次要文字",
|
||||
"settings.themeTokens.key.fgFaint": "弱化文字",
|
||||
"settings.themeTokens.key.accent": "强调色",
|
||||
"settings.themeTokens.key.accentFg": "强调色文字",
|
||||
"settings.themeTokens.key.ok": "成功状态",
|
||||
"settings.themeTokens.key.warn": "警告状态",
|
||||
"settings.themeTokens.key.err": "错误状态",
|
||||
"settings.themeLibrary.fieldId": "ID / 名称",
|
||||
"settings.themeLibrary.fieldName": "显示名称",
|
||||
"settings.themeLibrary.fieldAuthor": "作者",
|
||||
"settings.themeLibrary.fieldBase": "基础风格",
|
||||
"settings.themeLibrary.fieldRecipes": "密度 / 圆角",
|
||||
"settings.themeLibrary.fieldTokens": "语义颜色",
|
||||
"settings.themeLibrary.fieldBackground": "背景",
|
||||
"settings.themeLibrary.pickImage": "选择图片",
|
||||
"settings.themeLibrary.clearImage": "移除图片",
|
||||
"settings.themeLibrary.homeOpacity": "首页透明度",
|
||||
"settings.themeLibrary.taskOpacity": "任务页透明度",
|
||||
"settings.themeLibrary.overlayStrength": "遮罩强度",
|
||||
"settings.themeLibrary.contrastWarn": "部分文字与背景对比度可能低于 WCAG AA。你仍可继续保存。",
|
||||
"settings.themeLibrary.cancel": "取消",
|
||||
"settings.themeLibrary.save": "保存",
|
||||
"settings.themeLibrary.saveEnable": "保存并启用",
|
||||
"settings.themeTokens.surfaces": "表面",
|
||||
"settings.themeTokens.borderText": "边框与文字",
|
||||
"settings.themeTokens.accentStatus": "强调与状态色",
|
||||
"settings.themeLibrary.groupOfficial": "Reasonix 官方主题",
|
||||
"settings.themeLibrary.groupBase": "基础风格",
|
||||
"settings.themeLibrary.groupUser": "我的主题",
|
||||
"settings.themeLibrary.emptyUser": "还没有自定义主题,可以新建或导入。",
|
||||
"settings.themeLibrary.copyFrom": "创建自定义副本",
|
||||
"settings.themes.official.official-rose-dawn.name": "玫瑰晨光",
|
||||
"settings.themes.official.official-rose-dawn.description": "象牙白晨光与柔粉玫瑰,原创插画女性人物。",
|
||||
"settings.themes.official.official-fortune-forge.name": "鸿运工坊",
|
||||
"settings.themes.official.official-fortune-forge.description": "暖白、朱红、金色与玉绿之间的原创吉祥程序员。",
|
||||
"settings.themes.official.official-crimson-horizon.name": "赤曜新城",
|
||||
"settings.themes.official.official-crimson-horizon.description": "珍珠白天空下的珊瑚红未来城市。",
|
||||
"settings.themes.official.official-sage-breeze.name": "鼠尾草清风",
|
||||
"settings.themes.official.official-sage-breeze.description": "奶油纸张与鼠尾草植物之间的原创读者。",
|
||||
"settings.themes.official.official-spark-notebook.name": "灵感手账",
|
||||
"settings.themes.official.official-spark-notebook.description": "手账网格纸与青绿、明黄、珊瑚色文具,原创动漫人物。",
|
||||
"settings.themes.official.official-violet-starlight.name": "紫曜星夜",
|
||||
"settings.themes.official.official-violet-starlight.description": "蓝紫星空、辉光蝴蝶与原创星夜缪斯。",
|
||||
"settings.themes.official.official-cyan-stage.name": "青岚舞台",
|
||||
"settings.themes.official.official-cyan-stage.description": "青蓝未来舞台与原创数字表演者。",
|
||||
"settings.themes.official.official-noir-gold.name": "黑金序曲",
|
||||
"settings.themes.official.official-noir-gold.description": "黑色丝绒舞台与金色聚光灯下的原创创意绅士。",
|
||||
|
||||
"settings.themeGallery.title": "主题画廊",
|
||||
"settings.themeGallery.subtitle": "点击主题即可全局预览,应用后才会保存",
|
||||
"settings.themeGallery.browse": "浏览主题",
|
||||
"settings.themeGallery.createCopy": "创建副本",
|
||||
"settings.themeGallery.disable": "停用主题",
|
||||
"settings.themeGallery.disabled": "已停用主题,恢复基础配色",
|
||||
"settings.themeGallery.apply": "应用主题",
|
||||
"settings.themeGallery.applied": "已应用「{name}」",
|
||||
"settings.themeGallery.appliedBase": "已应用基础配色 {name}",
|
||||
"settings.themeGallery.openPreview": "沉浸预览",
|
||||
"settings.themeGallery.previewing": "正在预览",
|
||||
"settings.themeGallery.moreActions": "更多操作",
|
||||
"settings.themeGallery.previewTitle": "预览主题",
|
||||
"settings.themeGallery.current": "当前使用",
|
||||
"settings.themeGallery.paletteLabel": "主题色",
|
||||
"settings.themeGallery.appearancePreview": "外观预览",
|
||||
"settings.themeGallery.scenePreview": "场景预览",
|
||||
"settings.themeGallery.scenePreviewHint": "预览主题背景在首页或工作区中的显示强度;不会立即应用主题。",
|
||||
"settings.themeGallery.currentAppearance": "当前外观",
|
||||
"settings.themeGallery.baseStyle": "基础配色",
|
||||
"settings.themeGallery.baseLockedByPack": "基础配色由当前主题管理,停用主题后可修改。",
|
||||
"settings.themeGallery.tabBase": "基础配色",
|
||||
"settings.themeGallery.tabAll": "全部主题",
|
||||
"settings.themeGallery.sectionFlagship": "旗舰主题",
|
||||
"settings.themeGallery.kindOfficial": "官方主题",
|
||||
"settings.themeGallery.kindUser": "我的主题",
|
||||
"settings.themeGallery.kindBase": "基础配色",
|
||||
"settings.themeGallery.selectHint": "选择一个主题查看详情",
|
||||
"settings.themeGallery.empty": "此分组暂无主题",
|
||||
"settings.themeGallery.back": "返回",
|
||||
"settings.themeGallery.sceneHome": "首页展示",
|
||||
"settings.themeGallery.sceneTask": "工作区展示",
|
||||
"settings.themeGallery.saveAndApply": "保存并应用",
|
||||
"settings.themeGallery.restoreGraphite": "恢复默认外观",
|
||||
"settings.themeGallery.restoreGraphiteHint": "停用主题包并恢复 Graphite;保留明暗模式、字号和字体设置。",
|
||||
"settings.themeGallery.restoredGraphite": "已恢复 Graphite 基础外观",
|
||||
"settings.themeGallery.deleted": "已删除「{name}」",
|
||||
"palette.placeholder": "搜索会话、命令、文件…",
|
||||
"palette.empty": "没有匹配项",
|
||||
"palette.group.commands": "命令",
|
||||
@@ -1832,7 +1985,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.pageDesc.network": "代理与网络配置。",
|
||||
"settings.pageDesc.permissions": "写操作模式与细粒度工具权限规则。",
|
||||
"settings.pageDesc.sandbox": "Bash 沙箱、网络出口与工作区根目录。",
|
||||
"settings.pageDesc.appearance": "主题、强调色与字号。",
|
||||
"settings.pageDesc.appearance": "主题包、明暗模式、基础配色、字号与字体。",
|
||||
"settings.pageDesc.updates": "检查软件更新并查看版本信息。",
|
||||
"settings.modelUsage": "使用模型",
|
||||
"settings.modelUnavailable": "{ref} 不可用。请选择已配置模型,或先添加模型服务接入。",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
import { defineConfig, searchForWorkspaceRoot, type Plugin } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdir, readdir, rename, writeFile } from "node:fs/promises";
|
||||
@@ -150,5 +150,10 @@ export default defineConfig({
|
||||
host: "127.0.0.1",
|
||||
port: devPort,
|
||||
strictPort: true,
|
||||
fs: {
|
||||
// Browser-dev theme mocks use the same embedded source assets as Wails.
|
||||
// Keep the allow-list narrow while retaining Vite's workspace root.
|
||||
allow: [searchForWorkspaceRoot(configDir), resolve(configDir, "../themes/official")],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -18,6 +18,7 @@ require (
|
||||
github.com/minio/selfupdate v0.6.0
|
||||
github.com/tc-hib/winres v0.3.1
|
||||
github.com/wailsapp/wails/v2 v2.12.0
|
||||
golang.org/x/image v0.43.0
|
||||
golang.org/x/mod v0.37.0
|
||||
golang.org/x/sys v0.46.0
|
||||
golang.org/x/text v0.38.0
|
||||
@@ -67,7 +68,6 @@ require (
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
github.com/zalando/go-keyring v0.2.8 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/image v0.43.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
mvdan.cc/sh/v3 v3.13.1 // indirect
|
||||
|
||||
@@ -143,8 +143,12 @@ func main() {
|
||||
// white before CSS loads — particularly visible on WebKitGTK.
|
||||
BackgroundColour: &options.RGBA{R: 26, G: 26, B: 46, A: 255},
|
||||
AssetServer: &assetserver.Options{
|
||||
Assets: assets,
|
||||
Middleware: assetserver.ChainMiddleware(app.jsProfilingMiddleware(), app.workspaceMediaMiddleware()),
|
||||
Assets: assets,
|
||||
Middleware: assetserver.ChainMiddleware(
|
||||
app.jsProfilingMiddleware(),
|
||||
app.workspaceMediaMiddleware(),
|
||||
app.themeAssetMiddleware(),
|
||||
),
|
||||
},
|
||||
OnStartup: app.startup,
|
||||
OnDomReady: app.domReady,
|
||||
|
||||
@@ -0,0 +1,750 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"reasonix/internal/config"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// themeMu serializes theme library mutations (import/save/delete/activate).
|
||||
var themeMu sync.Mutex
|
||||
|
||||
// stagedThemeImport holds a ZIP extract awaiting replace confirmation.
|
||||
// Host paths stay on the Go side — the frontend only sees pendingId.
|
||||
type stagedThemeImport struct {
|
||||
id string
|
||||
staging string
|
||||
pack ThemePackView
|
||||
}
|
||||
|
||||
var (
|
||||
pendingThemeMu sync.Mutex
|
||||
pendingThemeStage *stagedThemeImport
|
||||
)
|
||||
|
||||
func clearPendingThemeImport() {
|
||||
pendingThemeMu.Lock()
|
||||
defer pendingThemeMu.Unlock()
|
||||
if pendingThemeStage != nil && pendingThemeStage.staging != "" {
|
||||
_ = os.RemoveAll(pendingThemeStage.staging)
|
||||
}
|
||||
pendingThemeStage = nil
|
||||
}
|
||||
|
||||
func setPendingThemeImport(id, staging string, pack ThemePackView) string {
|
||||
pendingThemeMu.Lock()
|
||||
if pendingThemeStage != nil && pendingThemeStage.staging != "" && pendingThemeStage.staging != staging {
|
||||
_ = os.RemoveAll(pendingThemeStage.staging)
|
||||
}
|
||||
pendingID := "pending-" + id + "-" + randomThemeSuffix()
|
||||
pendingThemeStage = &stagedThemeImport{id: id, staging: staging, pack: pack}
|
||||
pendingThemeMu.Unlock()
|
||||
return pendingID
|
||||
}
|
||||
|
||||
func takePendingThemeImport() *stagedThemeImport {
|
||||
pendingThemeMu.Lock()
|
||||
defer pendingThemeMu.Unlock()
|
||||
p := pendingThemeStage
|
||||
pendingThemeStage = nil
|
||||
return p
|
||||
}
|
||||
|
||||
// ListThemePacks returns base directions, official themes and user themes.
|
||||
// Base packs are never "active" as theme packs; their "active" flag means
|
||||
// "this is the configured base style and no pack is applied".
|
||||
func (a *App) ListThemePacks() ([]ThemePackView, error) {
|
||||
themeMu.Lock()
|
||||
defer themeMu.Unlock()
|
||||
|
||||
st := a.migrateThemeDesktopStateLocked()
|
||||
activeID := resolveActiveThemeID(st)
|
||||
baseStyle := a.desktopBaseStyleLocked()
|
||||
|
||||
safe := a.themeSafeMode()
|
||||
var out []ThemePackView
|
||||
for _, m := range builtinThemePacks() {
|
||||
cp := m
|
||||
// Base "active" = no pack applied and this is the configured base style.
|
||||
baseActive := !safe && activeID == "" && baseStyle == m.ID
|
||||
out = append(out, manifestToView(&cp, themeKindBase, baseActive, "", ""))
|
||||
}
|
||||
if safe {
|
||||
return out, nil
|
||||
}
|
||||
for _, ot := range officialThemes() {
|
||||
m := ot.manifest
|
||||
bgURL := officialAssetURL(m.ID, m.Background.Image)
|
||||
pvURL := officialAssetURL(m.ID, officialPreviewName)
|
||||
out = append(out, manifestToView(&m, themeKindOfficial, activeID == m.ID, bgURL, pvURL))
|
||||
}
|
||||
ids, err := listUserThemeIDs()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
m, err := loadUserThemeManifest(id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
bgURL := ""
|
||||
if m.Background != nil && m.Background.Image != "" {
|
||||
bgURL = themeBackgroundURL(id, m.Background.Image)
|
||||
}
|
||||
taskURL := ""
|
||||
if m.TaskBackground != nil && m.TaskBackground.Image != "" {
|
||||
taskURL = themeBackgroundURL(id, m.TaskBackground.Image)
|
||||
}
|
||||
out = append(out, manifestToView(m, themeKindUser, activeID == id, bgURL, "", taskURL))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetActiveThemePack returns the currently enabled pack (nil pack when none / safe mode).
|
||||
// Safe mode suppresses pack application but does not delete the stored id.
|
||||
func (a *App) GetActiveThemePack() (ThemeActiveView, error) {
|
||||
themeMu.Lock()
|
||||
defer themeMu.Unlock()
|
||||
|
||||
view := ThemeActiveView{SafeMode: a.themeSafeMode()}
|
||||
st := a.migrateThemeDesktopStateLocked()
|
||||
if view.SafeMode {
|
||||
// Do not clear ActiveThemeID — safe mode only blocks loading.
|
||||
return view, nil
|
||||
}
|
||||
activeID := resolveActiveThemeID(st)
|
||||
if st.ActiveThemeID != "" && activeID == "" {
|
||||
// Broken or migrated-away pointer: clear so the next launch is clean.
|
||||
st.ActiveThemeID = ""
|
||||
_ = saveThemeDesktopState(st)
|
||||
return view, nil
|
||||
}
|
||||
if activeID == "" {
|
||||
return view, nil
|
||||
}
|
||||
view.ActiveThemeID = activeID
|
||||
pack, err := a.loadThemeViewLocked(activeID, true)
|
||||
if err != nil {
|
||||
st.ActiveThemeID = ""
|
||||
_ = saveThemeDesktopState(st)
|
||||
view.ActiveThemeID = ""
|
||||
return view, nil
|
||||
}
|
||||
view.Pack = &pack
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// GetThemeExperience returns the unified appearance state for overview + gallery.
|
||||
func (a *App) GetThemeExperience() (ThemeExperienceView, error) {
|
||||
themeMu.Lock()
|
||||
defer themeMu.Unlock()
|
||||
|
||||
safe := a.themeSafeMode()
|
||||
// Migrate first so a v1 base-style activeThemeId lands in desktop.theme_style
|
||||
// before we read appearance.
|
||||
st := a.migrateThemeDesktopStateLocked()
|
||||
themeMode, baseStyle := a.desktopAppearanceLocked()
|
||||
view := ThemeExperienceView{
|
||||
ThemeMode: themeMode,
|
||||
BaseStyle: baseStyle,
|
||||
EffectiveStyle: baseStyle,
|
||||
SafeMode: safe,
|
||||
}
|
||||
if safe {
|
||||
return view, nil
|
||||
}
|
||||
activeID := resolveActiveThemeID(st)
|
||||
if st.ActiveThemeID != "" && activeID == "" {
|
||||
st.ActiveThemeID = ""
|
||||
_ = saveThemeDesktopState(st)
|
||||
return view, nil
|
||||
}
|
||||
if activeID == "" {
|
||||
return view, nil
|
||||
}
|
||||
pack, err := a.loadThemeViewLocked(activeID, true)
|
||||
if err != nil {
|
||||
st.ActiveThemeID = ""
|
||||
_ = saveThemeDesktopState(st)
|
||||
return view, nil
|
||||
}
|
||||
view.ActiveThemeID = activeID
|
||||
view.ActivePack = &pack
|
||||
if pack.BaseStyle != "" {
|
||||
view.EffectiveStyle = pack.BaseStyle
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func (a *App) loadThemeViewLocked(id string, active bool) (ThemePackView, error) {
|
||||
if isBuiltinThemeID(id) {
|
||||
m := findBuiltinManifest(id)
|
||||
if m == nil {
|
||||
return ThemePackView{}, fmt.Errorf("unknown built-in theme %q", id)
|
||||
}
|
||||
return manifestToView(m, themeKindBase, active, "", ""), nil
|
||||
}
|
||||
if ot := findOfficialTheme(id); ot != nil {
|
||||
m := ot.manifest
|
||||
bgURL := officialAssetURL(m.ID, m.Background.Image)
|
||||
pvURL := officialAssetURL(m.ID, officialPreviewName)
|
||||
return manifestToView(&m, themeKindOfficial, active, bgURL, pvURL), nil
|
||||
}
|
||||
m, err := loadUserThemeManifest(id)
|
||||
if err != nil {
|
||||
return ThemePackView{}, err
|
||||
}
|
||||
bgURL := ""
|
||||
if m.Background != nil && m.Background.Image != "" {
|
||||
bgURL = themeBackgroundURL(id, m.Background.Image)
|
||||
}
|
||||
taskURL := ""
|
||||
if m.TaskBackground != nil && m.TaskBackground.Image != "" {
|
||||
taskURL = themeBackgroundURL(id, m.TaskBackground.Image)
|
||||
}
|
||||
return manifestToView(m, themeKindUser, active, bgURL, "", taskURL), nil
|
||||
}
|
||||
|
||||
// ActivateThemePack enables an official or user theme. Empty id clears the pack
|
||||
// (same as DisableThemePack). Base style ids are rejected — use ActivateBaseStyle.
|
||||
func (a *App) ActivateThemePack(id string) error {
|
||||
themeMu.Lock()
|
||||
defer themeMu.Unlock()
|
||||
|
||||
id = strings.TrimSpace(id)
|
||||
if a.themeSafeMode() && id != "" {
|
||||
return fmt.Errorf("safe mode does not load external themes")
|
||||
}
|
||||
st := a.migrateThemeDesktopStateLocked()
|
||||
if id == "" {
|
||||
st.ActiveThemeID = ""
|
||||
return saveThemeDesktopState(st)
|
||||
}
|
||||
if isBuiltinThemeID(id) {
|
||||
return fmt.Errorf("base style %q is not a theme pack; use ActivateBaseStyle", id)
|
||||
}
|
||||
if findOfficialTheme(id) != nil {
|
||||
st.ActiveThemeID = id
|
||||
return saveThemeDesktopState(st)
|
||||
}
|
||||
if _, err := loadUserThemeManifest(id); err != nil {
|
||||
return fmt.Errorf("theme %q is missing or invalid", id)
|
||||
}
|
||||
st.ActiveThemeID = id
|
||||
return saveThemeDesktopState(st)
|
||||
}
|
||||
|
||||
// ActivateBaseStyle writes the base color direction and clears any active pack.
|
||||
// Theme mode (auto/light/dark), fonts and zoom are preserved.
|
||||
func (a *App) ActivateBaseStyle(style string) error {
|
||||
themeMu.Lock()
|
||||
defer themeMu.Unlock()
|
||||
|
||||
style = strings.TrimSpace(strings.ToLower(style))
|
||||
if !isBuiltinThemeID(style) {
|
||||
return fmt.Errorf("unknown base style %q", style)
|
||||
}
|
||||
themeMode, _ := a.desktopAppearanceLocked()
|
||||
if err := a.SetDesktopAppearance(themeMode, style); err != nil {
|
||||
return err
|
||||
}
|
||||
st := a.migrateThemeDesktopStateLocked()
|
||||
st.ActiveThemeID = ""
|
||||
return saveThemeDesktopState(st)
|
||||
}
|
||||
|
||||
// DisableThemePack clears the active pack and restores the configured base style.
|
||||
// Theme mode, fonts and zoom are preserved.
|
||||
func (a *App) DisableThemePack() error {
|
||||
themeMu.Lock()
|
||||
defer themeMu.Unlock()
|
||||
st := a.migrateThemeDesktopStateLocked()
|
||||
st.ActiveThemeID = ""
|
||||
return saveThemeDesktopState(st)
|
||||
}
|
||||
|
||||
// RestoreGraphiteAppearance disables any pack and sets base style to Graphite.
|
||||
// Theme mode, fonts and zoom are preserved.
|
||||
func (a *App) RestoreGraphiteAppearance() error {
|
||||
themeMu.Lock()
|
||||
defer themeMu.Unlock()
|
||||
themeMode, _ := a.desktopAppearanceLocked()
|
||||
if err := a.SetDesktopAppearance(themeMode, "graphite"); err != nil {
|
||||
return err
|
||||
}
|
||||
st := a.migrateThemeDesktopStateLocked()
|
||||
st.ActiveThemeID = ""
|
||||
return saveThemeDesktopState(st)
|
||||
}
|
||||
|
||||
// ResetThemePack is a compatibility wrapper for older frontends.
|
||||
// Prefer DisableThemePack or RestoreGraphiteAppearance.
|
||||
func (a *App) ResetThemePack() error {
|
||||
return a.DisableThemePack()
|
||||
}
|
||||
|
||||
// migrateThemeDesktopStateLocked upgrades v1 state and clears invalid ids.
|
||||
// Caller must hold themeMu. Side effect: may write desktop.theme_style when a
|
||||
// v1 base-style activeThemeId is migrated.
|
||||
func (a *App) migrateThemeDesktopStateLocked() ThemeDesktopState {
|
||||
st := loadThemeDesktopState()
|
||||
changed := false
|
||||
id := strings.TrimSpace(st.ActiveThemeID)
|
||||
|
||||
// v1 stored base styles as activeThemeId — move them to desktop.theme_style.
|
||||
if isBuiltinThemeID(id) {
|
||||
themeMode, _ := a.desktopAppearanceLocked()
|
||||
_ = a.SetDesktopAppearance(themeMode, id)
|
||||
st.ActiveThemeID = ""
|
||||
changed = true
|
||||
} else if id != "" && resolveActiveThemeID(st) == "" {
|
||||
// Missing / corrupt official or user pack — clear pointer only.
|
||||
st.ActiveThemeID = ""
|
||||
changed = true
|
||||
}
|
||||
if st.SchemaVersion != themeStateSchemaVer {
|
||||
st.SchemaVersion = themeStateSchemaVer
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
_ = saveThemeDesktopState(st)
|
||||
st = loadThemeDesktopState()
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func (a *App) desktopAppearanceLocked() (themeMode, baseStyle string) {
|
||||
// Read-only snapshot of user desktop prefs. applyConfigOnly serializes
|
||||
// writers; a concurrent save may race, which is acceptable for UI display.
|
||||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||||
themeMode = cfg.DesktopTheme()
|
||||
if themeMode == "" {
|
||||
themeMode = "auto"
|
||||
}
|
||||
baseStyle = cfg.DesktopThemeStyle()
|
||||
// Frontend maps legacy aliases; for API consumers normalize known bases.
|
||||
if !isBuiltinThemeID(baseStyle) {
|
||||
switch baseStyle {
|
||||
case "ember":
|
||||
baseStyle = "carbon"
|
||||
case "midnight", "porcelain":
|
||||
baseStyle = "nocturne"
|
||||
case "sandstone", "linen":
|
||||
baseStyle = "amber"
|
||||
case "glacier":
|
||||
baseStyle = "slate"
|
||||
default:
|
||||
baseStyle = "graphite"
|
||||
}
|
||||
}
|
||||
return themeMode, baseStyle
|
||||
}
|
||||
|
||||
func (a *App) desktopBaseStyleLocked() string {
|
||||
_, style := a.desktopAppearanceLocked()
|
||||
return style
|
||||
}
|
||||
|
||||
// SaveThemePack creates or updates a user theme from the editor payload.
|
||||
func (a *App) SaveThemePack(input ThemeSaveInput) (ThemePackView, error) {
|
||||
themeMu.Lock()
|
||||
defer themeMu.Unlock()
|
||||
|
||||
if a.themeSafeMode() {
|
||||
return ThemePackView{}, fmt.Errorf("safe mode cannot save themes")
|
||||
}
|
||||
m := &ThemePackManifest{
|
||||
SchemaVersion: themePackSchemaVersion,
|
||||
ID: strings.TrimSpace(input.ID),
|
||||
Name: input.Name,
|
||||
Author: input.Author,
|
||||
Description: input.Description,
|
||||
License: input.License,
|
||||
BaseStyle: input.BaseStyle,
|
||||
Tokens: input.Tokens,
|
||||
Recipes: input.Recipes,
|
||||
Background: input.Background,
|
||||
TaskBackground: input.TaskBackground,
|
||||
}
|
||||
// Preserve editor tuning while validation runs before the data URL has been
|
||||
// decoded into its final file name. The placeholder is replaced below.
|
||||
if !input.ClearBackground && strings.TrimSpace(input.BackgroundDataURL) != "" && m.Background != nil && m.Background.Image == "" {
|
||||
m.Background.Image = "background.webp"
|
||||
}
|
||||
if !input.ClearTaskBackground && strings.TrimSpace(input.TaskBackgroundDataURL) != "" && m.TaskBackground != nil && m.TaskBackground.Image == "" {
|
||||
m.TaskBackground.Image = "background-task.webp"
|
||||
}
|
||||
if err := validateThemePackManifest(m); err != nil {
|
||||
return ThemePackView{}, err
|
||||
}
|
||||
if isReservedThemeID(m.ID) {
|
||||
return ThemePackView{}, fmt.Errorf("built-in theme ids are reserved")
|
||||
}
|
||||
|
||||
var imageBytes []byte
|
||||
keepExistingImage := false
|
||||
|
||||
if input.ClearBackground {
|
||||
m.Background = nil
|
||||
} else if strings.TrimSpace(input.BackgroundDataURL) != "" {
|
||||
name, data, err := decodeDataURLImage(input.BackgroundDataURL)
|
||||
if err != nil {
|
||||
return ThemePackView{}, err
|
||||
}
|
||||
imageBytes = data
|
||||
if m.Background == nil {
|
||||
bg := defaultThemePackBackground()
|
||||
m.Background = &bg
|
||||
}
|
||||
m.Background.Image = name
|
||||
// Re-validate after image assignment.
|
||||
bg, err := normalizeThemeBackground(m.Background)
|
||||
if err != nil {
|
||||
return ThemePackView{}, err
|
||||
}
|
||||
m.Background = bg
|
||||
} else if m.Background != nil && m.Background.Image != "" {
|
||||
// Keep existing image from library when editing.
|
||||
if userThemeExists(m.ID) {
|
||||
keepExistingImage = true
|
||||
} else {
|
||||
return ThemePackView{}, fmt.Errorf("background image data is required for new themes with a background")
|
||||
}
|
||||
}
|
||||
|
||||
var taskImageBytes []byte
|
||||
keepExistingTaskImage := false
|
||||
if input.ClearTaskBackground {
|
||||
m.TaskBackground = nil
|
||||
} else if strings.TrimSpace(input.TaskBackgroundDataURL) != "" {
|
||||
name, data, err := decodeDataURLImage(input.TaskBackgroundDataURL)
|
||||
if err != nil {
|
||||
return ThemePackView{}, err
|
||||
}
|
||||
taskImageBytes = data
|
||||
if m.TaskBackground == nil {
|
||||
bg := defaultThemePackTaskBackground()
|
||||
m.TaskBackground = &bg
|
||||
}
|
||||
m.TaskBackground.Image = taskBackgroundImageName(name)
|
||||
bg, err := normalizeThemeSceneBackground(m.TaskBackground)
|
||||
if err != nil {
|
||||
return ThemePackView{}, err
|
||||
}
|
||||
m.TaskBackground = bg
|
||||
} else if m.TaskBackground != nil && m.TaskBackground.Image != "" {
|
||||
if userThemeExists(m.ID) {
|
||||
keepExistingTaskImage = true
|
||||
} else {
|
||||
return ThemePackView{}, fmt.Errorf("task background image data is required for new themes with a task background")
|
||||
}
|
||||
}
|
||||
|
||||
var staging string
|
||||
var err error
|
||||
var homeSource themeStagingImage
|
||||
if keepExistingImage {
|
||||
existing, err := resolveThemeImageAbs(m.ID, m.Background.Image)
|
||||
if err != nil {
|
||||
return ThemePackView{}, err
|
||||
}
|
||||
homeSource.path = existing
|
||||
} else {
|
||||
homeSource.bytes = imageBytes
|
||||
}
|
||||
var taskSource themeStagingImage
|
||||
if keepExistingTaskImage {
|
||||
existing, err := resolveThemeImageAbs(m.ID, m.TaskBackground.Image)
|
||||
if err != nil {
|
||||
return ThemePackView{}, err
|
||||
}
|
||||
taskSource.path = existing
|
||||
} else {
|
||||
taskSource.bytes = taskImageBytes
|
||||
}
|
||||
staging, err = writeThemeStaging(m, homeSource.path, homeSource.bytes, taskSource)
|
||||
if err != nil {
|
||||
return ThemePackView{}, err
|
||||
}
|
||||
defer os.RemoveAll(staging)
|
||||
|
||||
// Honor Replace: create/import-style saves must not silently overwrite.
|
||||
// The editor passes Replace=true when editing an existing theme.
|
||||
exists := userThemeExists(m.ID)
|
||||
if exists && !input.Replace {
|
||||
return ThemePackView{}, fmt.Errorf("theme %q already exists; set replace to overwrite", m.ID)
|
||||
}
|
||||
if err := publishThemeDir(m.ID, staging, exists && input.Replace); err != nil {
|
||||
return ThemePackView{}, err
|
||||
}
|
||||
|
||||
if input.Activate {
|
||||
st := loadThemeDesktopState()
|
||||
st.ActiveThemeID = m.ID
|
||||
if err := saveThemeDesktopState(st); err != nil {
|
||||
return ThemePackView{}, err
|
||||
}
|
||||
}
|
||||
return a.loadThemeViewLocked(m.ID, input.Activate)
|
||||
}
|
||||
|
||||
// DeleteThemePack removes a user theme. Active theme falls back to none (Graphite path).
|
||||
func (a *App) DeleteThemePack(id string) error {
|
||||
themeMu.Lock()
|
||||
defer themeMu.Unlock()
|
||||
|
||||
id = strings.TrimSpace(id)
|
||||
if isReservedThemeID(id) {
|
||||
return fmt.Errorf("built-in themes cannot be deleted")
|
||||
}
|
||||
if err := deleteUserTheme(id); err != nil {
|
||||
return err
|
||||
}
|
||||
st := loadThemeDesktopState()
|
||||
if st.ActiveThemeID == id {
|
||||
st.ActiveThemeID = ""
|
||||
return saveThemeDesktopState(st)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CopyThemePack duplicates a base, official or user theme into a new user theme id.
|
||||
func (a *App) CopyThemePack(sourceID, newID, newName string) (ThemePackView, error) {
|
||||
themeMu.Lock()
|
||||
defer themeMu.Unlock()
|
||||
|
||||
if a.themeSafeMode() {
|
||||
return ThemePackView{}, fmt.Errorf("safe mode cannot copy themes")
|
||||
}
|
||||
sourceID = strings.TrimSpace(sourceID)
|
||||
newID = strings.TrimSpace(newID)
|
||||
if !themePackIDRe.MatchString(newID) || isReservedThemeID(newID) {
|
||||
return ThemePackView{}, fmt.Errorf("invalid new theme id")
|
||||
}
|
||||
if userThemeExists(newID) {
|
||||
return ThemePackView{}, fmt.Errorf("theme %q already exists", newID)
|
||||
}
|
||||
|
||||
var m *ThemePackManifest
|
||||
var imageBytes []byte
|
||||
var taskImageBytes []byte
|
||||
if isBuiltinThemeID(sourceID) {
|
||||
src := findBuiltinManifest(sourceID)
|
||||
if src == nil {
|
||||
return ThemePackView{}, fmt.Errorf("unknown source theme")
|
||||
}
|
||||
cp := *src
|
||||
m = &cp
|
||||
} else if ot := findOfficialTheme(sourceID); ot != nil {
|
||||
// Copying an official theme embeds a private copy of its background so the
|
||||
// duplicate becomes an ordinary editable user theme.
|
||||
cp := ot.manifest
|
||||
m = &cp
|
||||
data, _, err := readOfficialAsset(sourceID, cp.Background.Image)
|
||||
if err != nil {
|
||||
return ThemePackView{}, fmt.Errorf("read official background: %w", err)
|
||||
}
|
||||
imageBytes = data
|
||||
} else {
|
||||
src, err := loadUserThemeManifest(sourceID)
|
||||
if err != nil {
|
||||
return ThemePackView{}, err
|
||||
}
|
||||
m = src
|
||||
if m.Background != nil && m.Background.Image != "" {
|
||||
p, err := resolveThemeImageAbs(sourceID, m.Background.Image)
|
||||
if err != nil {
|
||||
return ThemePackView{}, err
|
||||
}
|
||||
imageBytes, err = os.ReadFile(p)
|
||||
if err != nil {
|
||||
return ThemePackView{}, err
|
||||
}
|
||||
}
|
||||
if m.TaskBackground != nil && m.TaskBackground.Image != "" {
|
||||
p, err := resolveThemeImageAbs(sourceID, m.TaskBackground.Image)
|
||||
if err != nil {
|
||||
return ThemePackView{}, err
|
||||
}
|
||||
taskImageBytes, err = os.ReadFile(p)
|
||||
if err != nil {
|
||||
return ThemePackView{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
m.ID = newID
|
||||
if strings.TrimSpace(newName) != "" {
|
||||
m.Name = strings.TrimSpace(newName)
|
||||
} else {
|
||||
m.Name = m.Name + " Copy"
|
||||
}
|
||||
if err := validateThemePackManifest(m); err != nil {
|
||||
return ThemePackView{}, err
|
||||
}
|
||||
staging, err := writeThemeStaging(m, "", imageBytes, themeStagingImage{bytes: taskImageBytes})
|
||||
if err != nil {
|
||||
return ThemePackView{}, err
|
||||
}
|
||||
defer os.RemoveAll(staging)
|
||||
if err := publishThemeDir(newID, staging, false); err != nil {
|
||||
return ThemePackView{}, err
|
||||
}
|
||||
return a.loadThemeViewLocked(newID, false)
|
||||
}
|
||||
|
||||
// ImportThemePack opens a file dialog (or uses sourcePath in tests) and imports a ZIP.
|
||||
// When replace is false and the id exists, the extract is kept as a pending import
|
||||
// (NeedsReplace=true) so a subsequent ImportThemePack("", true) publishes without
|
||||
// re-opening the file dialog. Host paths never leave the Go side.
|
||||
func (a *App) ImportThemePack(sourcePath string, replace bool) (ThemeImportResult, error) {
|
||||
themeMu.Lock()
|
||||
defer themeMu.Unlock()
|
||||
|
||||
if a.themeSafeMode() {
|
||||
return ThemeImportResult{}, fmt.Errorf("safe mode cannot import themes")
|
||||
}
|
||||
|
||||
// Confirm a previously staged conflict without re-picking a file.
|
||||
path := strings.TrimSpace(sourcePath)
|
||||
if path == "" && replace {
|
||||
if pending := takePendingThemeImport(); pending != nil {
|
||||
defer os.RemoveAll(pending.staging)
|
||||
if err := publishThemeDir(pending.id, pending.staging, true); err != nil {
|
||||
return ThemeImportResult{}, err
|
||||
}
|
||||
pack, err := a.loadThemeViewLocked(pending.id, false)
|
||||
if err != nil {
|
||||
return ThemeImportResult{}, err
|
||||
}
|
||||
return ThemeImportResult{Pack: pack, Replaced: true}, nil
|
||||
}
|
||||
// Fall through to dialog/path if nothing was pending (e.g. tests pass path).
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
if a.ctx == nil {
|
||||
return ThemeImportResult{}, fmt.Errorf("no theme package selected")
|
||||
}
|
||||
picked, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
|
||||
Title: "Import Reasonix Theme",
|
||||
Filters: []runtime.FileFilter{
|
||||
{DisplayName: "Reasonix Theme (*.reasonix-theme)", Pattern: "*.reasonix-theme"},
|
||||
{DisplayName: "ZIP (*.zip)", Pattern: "*.zip"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return ThemeImportResult{}, err
|
||||
}
|
||||
path = picked
|
||||
}
|
||||
if path == "" {
|
||||
return ThemeImportResult{}, nil
|
||||
}
|
||||
|
||||
m, staging, err := importThemePackZIP(path)
|
||||
if err != nil {
|
||||
return ThemeImportResult{}, err
|
||||
}
|
||||
|
||||
exists := userThemeExists(m.ID)
|
||||
if exists && !replace {
|
||||
// Stage for confirmation — do not delete staging; pending owns it.
|
||||
pack := manifestToView(m, themeKindUser, false, "", "")
|
||||
pendingID := setPendingThemeImport(m.ID, staging, pack)
|
||||
return ThemeImportResult{
|
||||
Pack: pack,
|
||||
NeedsReplace: true,
|
||||
PendingID: pendingID,
|
||||
}, nil
|
||||
}
|
||||
defer os.RemoveAll(staging)
|
||||
clearPendingThemeImport()
|
||||
|
||||
if err := publishThemeDir(m.ID, staging, replace || exists); err != nil {
|
||||
return ThemeImportResult{}, err
|
||||
}
|
||||
pack, err := a.loadThemeViewLocked(m.ID, false)
|
||||
if err != nil {
|
||||
return ThemeImportResult{}, err
|
||||
}
|
||||
return ThemeImportResult{Pack: pack, Replaced: exists && replace}, nil
|
||||
}
|
||||
|
||||
// ExportThemePack writes the theme to a user-selected destination.
|
||||
func (a *App) ExportThemePack(id, destPath string) (string, error) {
|
||||
themeMu.Lock()
|
||||
defer themeMu.Unlock()
|
||||
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("theme id is required")
|
||||
}
|
||||
path := strings.TrimSpace(destPath)
|
||||
if path == "" {
|
||||
if a.ctx == nil {
|
||||
return "", fmt.Errorf("no export path")
|
||||
}
|
||||
defaultName := id + themePackExt
|
||||
picked, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
||||
Title: "Export Reasonix Theme",
|
||||
DefaultFilename: defaultName,
|
||||
Filters: []runtime.FileFilter{
|
||||
{DisplayName: "Reasonix Theme (*.reasonix-theme)", Pattern: "*.reasonix-theme"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path = picked
|
||||
}
|
||||
if path == "" {
|
||||
return "", nil
|
||||
}
|
||||
if err := exportThemePackZIP(id, path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(path), themePackExt) {
|
||||
path += themePackExt
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// PickThemeBackground opens a native file dialog for a local background image.
|
||||
// Returns a data URL for the editor preview — never exposes the absolute path.
|
||||
func (a *App) PickThemeBackground() (string, error) {
|
||||
if a.ctx == nil {
|
||||
return "", fmt.Errorf("file dialog unavailable")
|
||||
}
|
||||
path, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
|
||||
Title: "Choose Theme Background",
|
||||
Filters: []runtime.FileFilter{
|
||||
{DisplayName: "Images (*.png;*.jpg;*.jpeg;*.webp)", Pattern: "*.png;*.jpg;*.jpeg;*.webp"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if path == "" {
|
||||
return "", nil
|
||||
}
|
||||
if err := validateThemeImageFile(path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if int64(len(data)) > themePackMaxImageBytes {
|
||||
return "", fmt.Errorf("background image exceeds %d bytes", themePackMaxImageBytes)
|
||||
}
|
||||
mime := themeImageMIMEFromName(filepath.Base(path))
|
||||
// Return as data URL so the frontend never needs the host path.
|
||||
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data), nil
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
const themeAssetURLPrefix = "/__reasonix_theme_asset/"
|
||||
|
||||
// themeAssetMiddleware serves theme background images through a content-addressed
|
||||
// read-only route. The frontend only receives temporary URLs — never absolute paths.
|
||||
//
|
||||
// URL shape: /__reasonix_theme_asset/{themeID}/{digest}/{filename}
|
||||
// On every request the server re-validates theme id, filename, MIME, file identity
|
||||
// (digest), and directory containment.
|
||||
func (a *App) themeAssetMiddleware() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.URL.Path, themeAssetURLPrefix) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
// Safe mode never serves external theme assets.
|
||||
if a.themeSafeMode() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
rest := strings.TrimPrefix(r.URL.Path, themeAssetURLPrefix)
|
||||
parts := strings.Split(rest, "/")
|
||||
if len(parts) != 3 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
themeID, digest, filename := parts[0], parts[1], parts[2]
|
||||
if !themePackIDRe.MatchString(themeID) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !themePackImageRe.MatchString(filename) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if len(digest) < 8 || len(digest) > 64 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
// Official themes serve embedded assets with immutable caching;
|
||||
// everything else falls through to the user library path below.
|
||||
if isOfficialThemeID(themeID) {
|
||||
serveOfficialThemeAsset(w, r, themeID, digest, filename)
|
||||
return
|
||||
}
|
||||
if isBuiltinThemeID(themeID) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
abs, err := resolveThemeImageAbs(themeID, filename)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
// Re-validate file identity against the digest embedded in the URL.
|
||||
got, err := themeFileDigest(abs)
|
||||
if err != nil || !strings.EqualFold(got, digest) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := validateThemeImageFile(abs); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
f, err := os.Open(abs)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
mimeType := themeImageMIMEFromName(filename)
|
||||
w.Header().Set("Content-Type", mimeType)
|
||||
w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": filename}))
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||
http.ServeContent(w, r, filename, info.ModTime(), f)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// serveOfficialThemeAsset serves embedded official backgrounds and previews.
|
||||
// The URL digest is re-verified against the embedded bytes on every request;
|
||||
// only the manifest-declared background and the fixed preview name resolve.
|
||||
func serveOfficialThemeAsset(w http.ResponseWriter, r *http.Request, themeID, digest, filename string) {
|
||||
ot := findOfficialTheme(themeID)
|
||||
if ot == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
want := ""
|
||||
switch filename {
|
||||
case ot.manifest.Background.Image:
|
||||
want = ot.bgDigest
|
||||
case officialPreviewName:
|
||||
want = ot.previewDigest
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !strings.EqualFold(want, digest) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
data, name, err := readOfficialAsset(themeID, filename)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
// MIME re-verification: sniff must agree with the declared extension.
|
||||
head := data
|
||||
if len(head) > 512 {
|
||||
head = head[:512]
|
||||
}
|
||||
if sniffThemeImageMIME(head, name) != themeImageMIMEFromName(name) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", themeImageMIMEFromName(name))
|
||||
w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": name}))
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(data))
|
||||
}
|
||||
|
||||
func (a *App) themeSafeMode() bool {
|
||||
// DesktopStartupSettings exposes SafeMode; keep a local helper so asset
|
||||
// middleware and list/load paths share the same gate.
|
||||
return config.SafeModeRequested()
|
||||
}
|
||||
|
||||
func themeBackgroundURL(themeID, imageName string) string {
|
||||
if imageName == "" || isBuiltinThemeID(themeID) {
|
||||
return ""
|
||||
}
|
||||
abs, err := resolveThemeImageAbs(themeID, imageName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
digest, err := themeFileDigest(abs)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return themeAssetURLPrefix + themeID + "/" + digest + "/" + filepath.Base(imageName)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// computeContrastWarnings checks common text/background pairs for WCAG AA.
|
||||
// Warnings never block save — the UI only surfaces them.
|
||||
func computeContrastWarnings(m *ThemePackManifest) []ThemeContrastWarning {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
var out []ThemeContrastWarning
|
||||
out = append(out, contrastForMode("light", m.Tokens.Light)...)
|
||||
out = append(out, contrastForMode("dark", m.Tokens.Dark)...)
|
||||
return out
|
||||
}
|
||||
|
||||
func contrastForMode(mode string, tokens map[string]string) []ThemeContrastWarning {
|
||||
if len(tokens) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Defaults used when only one side of a pair is overridden.
|
||||
defFG, defBG, defFaint, defChat, defAccent, defAccentFG := defaultContrastColors(mode)
|
||||
|
||||
fg := firstToken(tokens, "fg", defFG)
|
||||
bg := firstToken(tokens, "bg", defBG)
|
||||
faint := firstToken(tokens, "fgFaint", defFaint)
|
||||
chat := firstToken(tokens, "chat", defChat)
|
||||
accent := firstToken(tokens, "accent", defAccent)
|
||||
accentFG := firstToken(tokens, "accentFg", defAccentFG)
|
||||
|
||||
var out []ThemeContrastWarning
|
||||
// Body text on main background — AA normal text requires 4.5:1.
|
||||
if r, ok := contrastRatio(fg, bg); ok && r < 4.5 {
|
||||
out = append(out, ThemeContrastWarning{
|
||||
Mode: mode, Pair: "fg/bg", Ratio: round2(r), Minimum: 4.5,
|
||||
Suggest: suggestFGForBG(bg, mode),
|
||||
})
|
||||
}
|
||||
if r, ok := contrastRatio(fg, chat); ok && r < 4.5 {
|
||||
out = append(out, ThemeContrastWarning{
|
||||
Mode: mode, Pair: "fg/chat", Ratio: round2(r), Minimum: 4.5,
|
||||
Suggest: suggestFGForBG(chat, mode),
|
||||
})
|
||||
}
|
||||
if r, ok := contrastRatio(faint, bg); ok && r < 3.0 {
|
||||
out = append(out, ThemeContrastWarning{
|
||||
Mode: mode, Pair: "fgFaint/bg", Ratio: round2(r), Minimum: 3.0,
|
||||
Suggest: suggestFGForBG(bg, mode),
|
||||
})
|
||||
}
|
||||
if r, ok := contrastRatio(accentFG, accent); ok && r < 3.0 {
|
||||
out = append(out, ThemeContrastWarning{
|
||||
Mode: mode, Pair: "accentFg/accent", Ratio: round2(r), Minimum: 3.0,
|
||||
Suggest: suggestFGForBG(accent, mode),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func defaultContrastColors(mode string) (fg, bg, faint, chat, accent, accentFG string) {
|
||||
if mode == "light" {
|
||||
return "#111827", "#f7f8fb", "#8a94a6", "#ffffff", "#2f5fa8", "#ffffff"
|
||||
}
|
||||
return "#f1f1ef", "#0c0d10", "#6c6e74", "#0c0d10", "#ff6a3d", "#0c0d10"
|
||||
}
|
||||
|
||||
func firstToken(tokens map[string]string, key, def string) string {
|
||||
if v, ok := tokens[key]; ok && strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func contrastRatio(a, b string) (float64, bool) {
|
||||
la, ok1 := relativeLuminance(a)
|
||||
lb, ok2 := relativeLuminance(b)
|
||||
if !ok1 || !ok2 {
|
||||
return 0, false
|
||||
}
|
||||
lighter := math.Max(la, lb)
|
||||
darker := math.Min(la, lb)
|
||||
return (lighter + 0.05) / (darker + 0.05), true
|
||||
}
|
||||
|
||||
func relativeLuminance(hex string) (float64, bool) {
|
||||
r, g, b, ok := parseHexRGB(hex)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return 0.2126*srgbLin(r) + 0.7152*srgbLin(g) + 0.0722*srgbLin(b), true
|
||||
}
|
||||
|
||||
func srgbLin(c float64) float64 {
|
||||
if c <= 0.04045 {
|
||||
return c / 12.92
|
||||
}
|
||||
return math.Pow((c+0.055)/1.055, 2.4)
|
||||
}
|
||||
|
||||
func parseHexRGB(hex string) (r, g, b float64, ok bool) {
|
||||
hex = strings.TrimPrefix(strings.TrimSpace(hex), "#")
|
||||
if len(hex) != 6 && len(hex) != 8 {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
ri, err1 := strconv.ParseUint(hex[0:2], 16, 8)
|
||||
gi, err2 := strconv.ParseUint(hex[2:4], 16, 8)
|
||||
bi, err3 := strconv.ParseUint(hex[4:6], 16, 8)
|
||||
if err1 != nil || err2 != nil || err3 != nil {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
return float64(ri) / 255, float64(gi) / 255, float64(bi) / 255, true
|
||||
}
|
||||
|
||||
func suggestFGForBG(bg string, mode string) string {
|
||||
// Simple suggestion: pick near-black or near-white based on background luminance.
|
||||
if lum, ok := relativeLuminance(bg); ok {
|
||||
if lum > 0.5 {
|
||||
return "#111827"
|
||||
}
|
||||
return "#f1f1ef"
|
||||
}
|
||||
if mode == "light" {
|
||||
return "#111827"
|
||||
}
|
||||
return "#f1f1ef"
|
||||
}
|
||||
|
||||
func round2(v float64) float64 {
|
||||
return math.Round(v*100) / 100
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
func TestActivateBaseStyleClearsActivePack(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
app := NewApp()
|
||||
|
||||
// Seed a user pack and activate it.
|
||||
m := &ThemePackManifest{
|
||||
SchemaVersion: 1, ID: "user-amber-skin", Name: "User Amber", BaseStyle: "amber",
|
||||
Recipes: defaultThemePackRecipes(),
|
||||
}
|
||||
staging, err := writeThemeStaging(m, "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(staging)
|
||||
if err := publishThemeDir(m.ID, staging, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.ActivateThemePack(m.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.ActivateBaseStyle("slate"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp, err := app.GetThemeExperience()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exp.ActiveThemeID != "" || exp.ActivePack != nil {
|
||||
t.Fatalf("expected no active pack after base style, got %+v", exp)
|
||||
}
|
||||
if exp.BaseStyle != "slate" || exp.EffectiveStyle != "slate" {
|
||||
t.Fatalf("base/effective = %q/%q, want slate", exp.BaseStyle, exp.EffectiveStyle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateThemePackRejectsBaseStyle(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
app := NewApp()
|
||||
if err := app.ActivateThemePack("amber"); err == nil {
|
||||
t.Fatal("expected base style activation to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateV1BaseActiveIDToBaseStyle(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
// Write v1 state with base style as activeThemeId.
|
||||
statePath := filepath.Join(config.MemoryUserDir(), themeStateFileName)
|
||||
if err := os.MkdirAll(filepath.Dir(statePath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"schemaVersion": 1, "activeThemeId": "amber"})
|
||||
if err := os.WriteFile(statePath, raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := NewApp()
|
||||
exp, err := app.GetThemeExperience()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exp.ActiveThemeID != "" {
|
||||
t.Fatalf("activeThemeId should be cleared, got %q", exp.ActiveThemeID)
|
||||
}
|
||||
if exp.BaseStyle != "amber" {
|
||||
t.Fatalf("baseStyle = %q, want amber after migration", exp.BaseStyle)
|
||||
}
|
||||
// File rewritten as v2 without base id.
|
||||
st := loadThemeDesktopState()
|
||||
if st.SchemaVersion != themeStateSchemaVer || st.ActiveThemeID != "" {
|
||||
t.Fatalf("persisted state = %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficialThemeOrderFixed(t *testing.T) {
|
||||
resetOfficialRegistryForTest()
|
||||
themes := officialThemes()
|
||||
if len(themes) != len(officialThemeOrderFixed) {
|
||||
t.Fatalf("count %d want %d", len(themes), len(officialThemeOrderFixed))
|
||||
}
|
||||
for i, ot := range themes {
|
||||
if ot.manifest.ID != officialThemeOrderFixed[i] {
|
||||
t.Fatalf("order[%d] = %q, want %q", i, ot.manifest.ID, officialThemeOrderFixed[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisableThemePackKeepsBaseStyle(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
app := NewApp()
|
||||
if err := app.ActivateBaseStyle("nocturne"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Activate official if present.
|
||||
if len(officialThemes()) == 0 {
|
||||
t.Skip("no official themes")
|
||||
}
|
||||
id := officialThemes()[0].manifest.ID
|
||||
if err := app.ActivateThemePack(id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.DisableThemePack(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp, err := app.GetThemeExperience()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exp.ActiveThemeID != "" {
|
||||
t.Fatalf("expected disabled pack, got %q", exp.ActiveThemeID)
|
||||
}
|
||||
if exp.BaseStyle != "nocturne" {
|
||||
t.Fatalf("baseStyle should stay nocturne, got %q", exp.BaseStyle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreGraphiteAppearance(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
app := NewApp()
|
||||
_ = app.ActivateBaseStyle("carbon")
|
||||
if len(officialThemes()) > 0 {
|
||||
_ = app.ActivateThemePack(officialThemes()[0].manifest.ID)
|
||||
}
|
||||
if err := app.RestoreGraphiteAppearance(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp, err := app.GetThemeExperience()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exp.BaseStyle != "graphite" || exp.ActiveThemeID != "" {
|
||||
t.Fatalf("got base=%q active=%q", exp.BaseStyle, exp.ActiveThemeID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
func decodeBase64Payload(payload string) ([]byte, error) {
|
||||
return base64.StdEncoding.DecodeString(payload)
|
||||
}
|
||||
|
||||
func validateThemeImageFile(path string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("background image must not be a symlink")
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("background image must be a regular file")
|
||||
}
|
||||
if info.Size() > themePackMaxImageBytes {
|
||||
return fmt.Errorf("background image exceeds %d bytes", themePackMaxImageBytes)
|
||||
}
|
||||
name := filepath.Base(path)
|
||||
if !themePackImageRe.MatchString(name) {
|
||||
return fmt.Errorf("unsupported background image name %q", name)
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
head := make([]byte, 512)
|
||||
n, err := io.ReadFull(f, head)
|
||||
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
|
||||
return err
|
||||
}
|
||||
head = head[:n]
|
||||
mime := sniffThemeImageMIME(head, name)
|
||||
if mime == "" {
|
||||
return fmt.Errorf("background image must be PNG, JPEG, or WebP")
|
||||
}
|
||||
|
||||
// Re-open for full config decode (sniff consumed some bytes).
|
||||
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, format, err := decodeThemeImageConfig(f, mime)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid background image: %w", err)
|
||||
}
|
||||
switch format {
|
||||
case "png", "jpeg", "webp":
|
||||
default:
|
||||
return fmt.Errorf("unsupported image format %q", format)
|
||||
}
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 {
|
||||
return fmt.Errorf("invalid image dimensions")
|
||||
}
|
||||
if cfg.Width > themePackMaxImageEdge || cfg.Height > themePackMaxImageEdge {
|
||||
return fmt.Errorf("background image must be at most %d×%d", themePackMaxImageEdge, themePackMaxImageEdge)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeThemeImageConfig(r io.Reader, mime string) (image.Config, string, error) {
|
||||
if mime == "image/webp" {
|
||||
cfg, err := webp.DecodeConfig(r)
|
||||
return cfg, "webp", err
|
||||
}
|
||||
return image.DecodeConfig(r)
|
||||
}
|
||||
|
||||
func sniffThemeImageMIME(head []byte, name string) string {
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
if len(head) >= 8 && bytes.Equal(head[:8], []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}) {
|
||||
if ext == ".png" || ext == "" {
|
||||
return "image/png"
|
||||
}
|
||||
}
|
||||
if len(head) >= 3 && head[0] == 0xff && head[1] == 0xd8 && head[2] == 0xff {
|
||||
if ext == ".jpg" || ext == ".jpeg" || ext == "" {
|
||||
return "image/jpeg"
|
||||
}
|
||||
}
|
||||
if len(head) >= 12 && string(head[:4]) == "RIFF" && string(head[8:12]) == "WEBP" {
|
||||
if ext == ".webp" || ext == "" {
|
||||
return "image/webp"
|
||||
}
|
||||
}
|
||||
// Extension/MIME mismatch or unsupported signature.
|
||||
return ""
|
||||
}
|
||||
|
||||
func themeImageMIMEFromName(name string) string {
|
||||
switch strings.ToLower(filepath.Ext(name)) {
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
// decodeDataURLImage extracts image bytes from a data:image/...;base64,... URL.
|
||||
func decodeDataURLImage(dataURL string) (filename string, data []byte, err error) {
|
||||
dataURL = strings.TrimSpace(dataURL)
|
||||
if dataURL == "" {
|
||||
return "", nil, fmt.Errorf("empty image data")
|
||||
}
|
||||
const prefix = "data:"
|
||||
if !strings.HasPrefix(dataURL, prefix) {
|
||||
return "", nil, fmt.Errorf("image must be a data URL")
|
||||
}
|
||||
// data:[<mediatype>][;base64],<data>
|
||||
rest := dataURL[len(prefix):]
|
||||
comma := strings.IndexByte(rest, ',')
|
||||
if comma < 0 {
|
||||
return "", nil, fmt.Errorf("invalid data URL")
|
||||
}
|
||||
meta := rest[:comma]
|
||||
payload := rest[comma+1:]
|
||||
parts := strings.Split(meta, ";")
|
||||
mime := strings.TrimSpace(parts[0])
|
||||
base64Enc := false
|
||||
for _, p := range parts[1:] {
|
||||
if strings.EqualFold(strings.TrimSpace(p), "base64") {
|
||||
base64Enc = true
|
||||
}
|
||||
}
|
||||
if !base64Enc {
|
||||
return "", nil, fmt.Errorf("image data URL must be base64-encoded")
|
||||
}
|
||||
var ext string
|
||||
switch strings.ToLower(mime) {
|
||||
case "image/png":
|
||||
ext = ".png"
|
||||
case "image/jpeg", "image/jpg":
|
||||
ext = ".jpg"
|
||||
case "image/webp":
|
||||
ext = ".webp"
|
||||
default:
|
||||
return "", nil, fmt.Errorf("unsupported image MIME %q", mime)
|
||||
}
|
||||
// Prefer stdlib base64 via encoding/base64 in caller path — import here.
|
||||
raw, err := decodeBase64Payload(payload)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("decode image data: %w", err)
|
||||
}
|
||||
if int64(len(raw)) > themePackMaxImageBytes {
|
||||
return "", nil, fmt.Errorf("background image exceeds %d bytes", themePackMaxImageBytes)
|
||||
}
|
||||
return "background" + ext, raw, nil
|
||||
}
|
||||
|
||||
func taskBackgroundImageName(decodedName string) string {
|
||||
ext := strings.ToLower(filepath.Ext(filepath.Base(decodedName)))
|
||||
return "background-task" + ext
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/fileutil"
|
||||
)
|
||||
|
||||
func themeReplaceFile(tmp, dest string) error {
|
||||
return fileutil.ReplaceFile(tmp, dest)
|
||||
}
|
||||
|
||||
// importThemePackZIP validates and extracts a .reasonix-theme ZIP into a staging dir.
|
||||
// The caller must publish with publishThemeDir.
|
||||
func importThemePackZIP(zipPath string) (manifest *ThemePackManifest, staging string, err error) {
|
||||
info, err := os.Lstat(zipPath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, "", fmt.Errorf("theme package must not be a symlink")
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, "", fmt.Errorf("theme package must be a regular file")
|
||||
}
|
||||
if info.Size() > themePackMaxZipBytes {
|
||||
return nil, "", fmt.Errorf("theme package exceeds %d bytes", themePackMaxZipBytes)
|
||||
}
|
||||
|
||||
f, err := os.Open(zipPath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Re-check size after open (TOCTOU).
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if fi.Size() > themePackMaxZipBytes {
|
||||
return nil, "", fmt.Errorf("theme package exceeds %d bytes", themePackMaxZipBytes)
|
||||
}
|
||||
|
||||
zr, err := zip.NewReader(f, fi.Size())
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("invalid theme ZIP: %w", err)
|
||||
}
|
||||
return extractThemeZip(zr)
|
||||
}
|
||||
|
||||
func extractThemeZip(zr *zip.Reader) (*ThemePackManifest, string, error) {
|
||||
seen := map[string]struct{}{}
|
||||
var manifestEntry *zip.File
|
||||
imageEntries := map[string]*zip.File{}
|
||||
|
||||
for _, zf := range zr.File {
|
||||
name := sanitizeZipEntryName(zf.Name)
|
||||
if name == "" {
|
||||
return nil, "", fmt.Errorf("theme package contains an empty or unsafe path")
|
||||
}
|
||||
if strings.Contains(name, "/") || strings.Contains(name, `\`) {
|
||||
return nil, "", fmt.Errorf("theme package may only contain root-level files (got %q)", zf.Name)
|
||||
}
|
||||
if _, dup := seen[strings.ToLower(name)]; dup {
|
||||
return nil, "", fmt.Errorf("theme package contains duplicate entry %q", name)
|
||||
}
|
||||
seen[strings.ToLower(name)] = struct{}{}
|
||||
|
||||
if zf.FileInfo().IsDir() {
|
||||
return nil, "", fmt.Errorf("theme package must not contain directories")
|
||||
}
|
||||
// Detect symlink-like mode bits when present.
|
||||
if zf.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, "", fmt.Errorf("theme package must not contain symlinks")
|
||||
}
|
||||
if zf.UncompressedSize64 > themePackMaxImageBytes && !strings.EqualFold(name, themePackManifestName) {
|
||||
return nil, "", fmt.Errorf("theme package entry %q is too large", name)
|
||||
}
|
||||
if zf.UncompressedSize64 > themePackMaxManifest && strings.EqualFold(name, themePackManifestName) {
|
||||
return nil, "", fmt.Errorf("theme manifest is too large")
|
||||
}
|
||||
|
||||
if strings.EqualFold(name, themePackManifestName) {
|
||||
manifestEntry = zf
|
||||
continue
|
||||
}
|
||||
if themePackImageRe.MatchString(name) {
|
||||
if len(imageEntries) >= 2 {
|
||||
return nil, "", fmt.Errorf("theme package may contain at most two scene images")
|
||||
}
|
||||
imageEntries[strings.ToLower(name)] = zf
|
||||
continue
|
||||
}
|
||||
return nil, "", fmt.Errorf("theme package contains disallowed file %q", name)
|
||||
}
|
||||
|
||||
if manifestEntry == nil {
|
||||
return nil, "", fmt.Errorf("theme package missing %s", themePackManifestName)
|
||||
}
|
||||
|
||||
raw, err := readZipFileLimited(manifestEntry, themePackMaxManifest)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
m, err := parseThemePackManifest(raw)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if isReservedThemeID(m.ID) {
|
||||
return nil, "", fmt.Errorf("cannot import over built-in theme id %q", m.ID)
|
||||
}
|
||||
|
||||
expectedImages := map[string]string{}
|
||||
if m.Background != nil && m.Background.Image != "" {
|
||||
expectedImages[strings.ToLower(m.Background.Image)] = m.Background.Image
|
||||
}
|
||||
if m.TaskBackground != nil && m.TaskBackground.Image != "" {
|
||||
expectedImages[strings.ToLower(m.TaskBackground.Image)] = m.TaskBackground.Image
|
||||
}
|
||||
for key, manifestName := range expectedImages {
|
||||
if imageEntries[key] == nil {
|
||||
return nil, "", fmt.Errorf("manifest references scene image %q but ZIP has none", manifestName)
|
||||
}
|
||||
}
|
||||
for key := range imageEntries {
|
||||
if _, ok := expectedImages[key]; !ok {
|
||||
return nil, "", fmt.Errorf("ZIP contains scene image not referenced by manifest")
|
||||
}
|
||||
}
|
||||
|
||||
staging, err := os.MkdirTemp("", "reasonix-theme-import-*")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
cleanup := true
|
||||
defer func() {
|
||||
if cleanup {
|
||||
_ = os.RemoveAll(staging)
|
||||
}
|
||||
}()
|
||||
|
||||
// Write manifest as canonical JSON from validated struct.
|
||||
canonical, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(staging, themePackManifestName), canonical, 0o644); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
for key, manifestName := range expectedImages {
|
||||
imgData, err := readZipFileLimited(imageEntries[key], themePackMaxImageBytes)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
imgPath := filepath.Join(staging, manifestName)
|
||||
if err := os.WriteFile(imgPath, imgData, 0o644); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := validateThemeImageFile(imgPath); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
|
||||
cleanup = false
|
||||
return m, staging, nil
|
||||
}
|
||||
|
||||
func sanitizeZipEntryName(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
name = strings.ReplaceAll(name, `\`, "/")
|
||||
// Drop absolute and parent paths (ZIP slip).
|
||||
name = strings.TrimPrefix(name, "/")
|
||||
for strings.HasPrefix(name, "../") || name == ".." {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(name, "/../") || strings.HasSuffix(name, "/..") {
|
||||
return ""
|
||||
}
|
||||
// Only basename — reject nested paths elsewhere.
|
||||
if i := strings.LastIndex(name, "/"); i >= 0 {
|
||||
// Nested path — return as-is so caller rejects.
|
||||
return name
|
||||
}
|
||||
if name == "." || name == ".." {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func readZipFileLimited(zf *zip.File, max int64) ([]byte, error) {
|
||||
rc, err := zf.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(rc, max+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > max {
|
||||
return nil, fmt.Errorf("ZIP entry %q exceeds size limit", zf.Name)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// exportThemePackZIP writes a validated user theme to a ZIP path.
|
||||
// Reserved ids (base styles + official themes) are refused: an exported pack
|
||||
// could never be re-imported because the id is reserved. Duplicate first.
|
||||
func exportThemePackZIP(id, destPath string) error {
|
||||
id = strings.TrimSpace(id)
|
||||
if isReservedThemeID(id) {
|
||||
return fmt.Errorf("built-in themes cannot be exported; create a copy first")
|
||||
}
|
||||
m, err := loadUserThemeManifest(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var img []byte
|
||||
var taskImg []byte
|
||||
if m.Background != nil && m.Background.Image != "" {
|
||||
imgPath, err := resolveThemeImageAbs(id, m.Background.Image)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
img, err = os.ReadFile(imgPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if m.TaskBackground != nil && m.TaskBackground.Image != "" {
|
||||
imgPath, err := resolveThemeImageAbs(id, m.TaskBackground.Image)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
taskImg, err = os.ReadFile(imgPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return writeThemeZip(destPath, m, img, taskImg)
|
||||
}
|
||||
|
||||
func findBuiltinManifest(id string) *ThemePackManifest {
|
||||
for _, m := range builtinThemePacks() {
|
||||
if m.ID == id {
|
||||
cp := m
|
||||
return &cp
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeThemeZip(destPath string, m *ThemePackManifest, imageBytes []byte, taskImageBytes ...[]byte) error {
|
||||
if err := validateThemePackManifest(m); err != nil {
|
||||
return err
|
||||
}
|
||||
destPath = filepath.Clean(destPath)
|
||||
if !strings.HasSuffix(strings.ToLower(destPath), themePackExt) {
|
||||
destPath += themePackExt
|
||||
}
|
||||
dir := filepath.Dir(destPath)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, ".export-theme-*.zip")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
cleanup := true
|
||||
defer func() {
|
||||
_ = tmp.Close()
|
||||
if cleanup {
|
||||
_ = os.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
|
||||
zw := zip.NewWriter(tmp)
|
||||
manifestBytes, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w, err := zw.Create(themePackManifestName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(manifestBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Background != nil && m.Background.Image != "" {
|
||||
if len(imageBytes) == 0 {
|
||||
return fmt.Errorf("missing background image bytes")
|
||||
}
|
||||
iw, err := zw.Create(m.Background.Image)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := iw.Write(imageBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if m.TaskBackground != nil && m.TaskBackground.Image != "" {
|
||||
var data []byte
|
||||
if len(taskImageBytes) > 0 {
|
||||
data = taskImageBytes[0]
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return fmt.Errorf("missing task background image bytes")
|
||||
}
|
||||
iw, err := zw.Create(m.TaskBackground.Image)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := iw.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
// ReplaceFile retries Windows AV/indexer locks and falls back on EXDEV.
|
||||
if err := themeReplaceFile(tmpName, destPath); err != nil {
|
||||
return err
|
||||
}
|
||||
cleanup = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractThemeZipBytes is a test helper for in-memory ZIP fixtures.
|
||||
func extractThemeZipBytes(data []byte) (*ThemePackManifest, string, error) {
|
||||
if int64(len(data)) > themePackMaxZipBytes {
|
||||
return nil, "", fmt.Errorf("theme package exceeds %d bytes", themePackMaxZipBytes)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return extractThemeZip(zr)
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"embed"
|
||||
)
|
||||
|
||||
// Official themes are read-only, MIT-licensed Reasonix assets embedded into the
|
||||
// desktop binary. They reuse the Theme Pack V1 validator and the same
|
||||
// content-addressed asset route as user themes, but read from embed.FS instead
|
||||
// of the user library and are served with immutable caching.
|
||||
//
|
||||
// Fail-closed contract: an invalid official entry is dropped from the registry
|
||||
// (never listed, never served) without affecting startup; the build-time test
|
||||
// in theme_official_test.go fails long before that can ship.
|
||||
|
||||
//go:embed themes/official
|
||||
var officialThemesFS embed.FS
|
||||
|
||||
const (
|
||||
officialThemeDirName = "themes/official"
|
||||
officialPreviewName = "preview.webp"
|
||||
officialBackgroundWidth = 2560
|
||||
officialBackgroundHeight = 1440
|
||||
officialPreviewWidth = 480
|
||||
officialPreviewHeight = 270
|
||||
officialMaxBackground = 2359296 // 2.25 MiB per background
|
||||
officialMaxPreview = 122880 // 120 KiB per thumbnail
|
||||
officialMaxTotalBytes = 18 << 20 // 18 MiB across all backgrounds
|
||||
officialExpectedCount = 8 // release gate: all eight themes
|
||||
themeKindBase = "base"
|
||||
themeKindOfficial = "official"
|
||||
themeKindUser = "user"
|
||||
)
|
||||
|
||||
// Fixed gallery / list order (not alphabetical).
|
||||
var officialThemeOrderFixed = []string{
|
||||
"official-rose-dawn",
|
||||
"official-fortune-forge",
|
||||
"official-crimson-horizon",
|
||||
"official-sage-breeze",
|
||||
"official-spark-notebook",
|
||||
"official-violet-starlight",
|
||||
"official-cyan-stage",
|
||||
"official-noir-gold",
|
||||
}
|
||||
|
||||
type officialTheme struct {
|
||||
manifest ThemePackManifest
|
||||
bgDigest string
|
||||
previewDigest string
|
||||
bgSize int64
|
||||
}
|
||||
|
||||
var (
|
||||
officialOnce sync.Once
|
||||
officialRegistry map[string]*officialTheme
|
||||
officialOrder []string
|
||||
officialLoadErr error
|
||||
)
|
||||
|
||||
// loadOfficialRegistry parses and validates every embedded official theme once.
|
||||
// Invalid entries are skipped (fail-closed); the first error is remembered for
|
||||
// diagnostics and tests.
|
||||
func loadOfficialRegistry() {
|
||||
officialRegistry = map[string]*officialTheme{}
|
||||
officialOrder = nil
|
||||
entries, err := officialThemesFS.ReadDir(officialThemeDirName)
|
||||
if err != nil {
|
||||
officialLoadErr = fmt.Errorf("read official themes: %w", err)
|
||||
return
|
||||
}
|
||||
var total int64
|
||||
var firstErr error
|
||||
remember := func(err error) {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
id := e.Name()
|
||||
ot, err := loadOfficialTheme(id)
|
||||
if err != nil {
|
||||
remember(fmt.Errorf("official theme %q: %w", id, err))
|
||||
continue
|
||||
}
|
||||
if _, dup := officialRegistry[ot.manifest.ID]; dup {
|
||||
remember(fmt.Errorf("official theme id %q is duplicated", ot.manifest.ID))
|
||||
continue
|
||||
}
|
||||
officialRegistry[ot.manifest.ID] = ot
|
||||
officialOrder = append(officialOrder, ot.manifest.ID)
|
||||
total += ot.bgSize
|
||||
}
|
||||
if total > officialMaxTotalBytes {
|
||||
remember(fmt.Errorf("official backgrounds total %d bytes exceeds %d", total, officialMaxTotalBytes))
|
||||
}
|
||||
// Prefer the explicit product order; append any unexpected extras last.
|
||||
ordered := make([]string, 0, len(officialRegistry))
|
||||
seen := map[string]bool{}
|
||||
for _, id := range officialThemeOrderFixed {
|
||||
if _, ok := officialRegistry[id]; ok {
|
||||
ordered = append(ordered, id)
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
var extras []string
|
||||
for id := range officialRegistry {
|
||||
if !seen[id] {
|
||||
extras = append(extras, id)
|
||||
}
|
||||
}
|
||||
sort.Strings(extras)
|
||||
officialOrder = append(ordered, extras...)
|
||||
officialLoadErr = firstErr
|
||||
}
|
||||
|
||||
func loadOfficialTheme(dirID string) (*officialTheme, error) {
|
||||
if !themePackIDRe.MatchString(dirID) {
|
||||
return nil, fmt.Errorf("invalid directory name")
|
||||
}
|
||||
base := officialThemeDirName + "/" + dirID
|
||||
raw, err := officialThemesFS.ReadFile(base + "/" + themePackManifestName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read manifest: %w", err)
|
||||
}
|
||||
m, err := parseThemePackManifest(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.ID != dirID {
|
||||
return nil, fmt.Errorf("manifest id %q does not match directory %q", m.ID, dirID)
|
||||
}
|
||||
if isBuiltinThemeID(m.ID) {
|
||||
return nil, fmt.Errorf("official theme id %q collides with a base style", m.ID)
|
||||
}
|
||||
if m.Background == nil || m.Background.Image == "" {
|
||||
return nil, fmt.Errorf("official themes require a background image")
|
||||
}
|
||||
if m.Background.Image != "background.webp" {
|
||||
return nil, fmt.Errorf("official background must be background.webp, got %q", m.Background.Image)
|
||||
}
|
||||
|
||||
bg, err := officialThemesFS.ReadFile(base + "/" + m.Background.Image)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read background: %w", err)
|
||||
}
|
||||
if err := validateOfficialImage(bg, m.Background.Image, officialBackgroundWidth, officialBackgroundHeight, officialMaxBackground); err != nil {
|
||||
return nil, fmt.Errorf("background: %w", err)
|
||||
}
|
||||
preview, err := officialThemesFS.ReadFile(base + "/" + officialPreviewName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read preview: %w", err)
|
||||
}
|
||||
if err := validateOfficialImage(preview, officialPreviewName, officialPreviewWidth, officialPreviewHeight, officialMaxPreview); err != nil {
|
||||
return nil, fmt.Errorf("preview: %w", err)
|
||||
}
|
||||
return &officialTheme{
|
||||
manifest: *m,
|
||||
bgDigest: themeBytesDigest(bg),
|
||||
previewDigest: themeBytesDigest(preview),
|
||||
bgSize: int64(len(bg)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateOfficialImage(data []byte, name string, wantW, wantH int, maxBytes int64) error {
|
||||
if int64(len(data)) > maxBytes {
|
||||
return fmt.Errorf("%s exceeds %d bytes", name, maxBytes)
|
||||
}
|
||||
head := data
|
||||
if len(head) > 512 {
|
||||
head = head[:512]
|
||||
}
|
||||
mime := sniffThemeImageMIME(head, name)
|
||||
if mime != "image/webp" {
|
||||
return fmt.Errorf("%s must be WebP", name)
|
||||
}
|
||||
cfg, format, err := decodeThemeImageConfig(bytesReader(data), mime)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode %s: %w", name, err)
|
||||
}
|
||||
if format != "webp" {
|
||||
return fmt.Errorf("%s must be WebP", name)
|
||||
}
|
||||
if cfg.Width != wantW || cfg.Height != wantH {
|
||||
return fmt.Errorf("%s must be %d×%d, got %d×%d", name, wantW, wantH, cfg.Width, cfg.Height)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bytesReader(b []byte) io.Reader {
|
||||
return bytes.NewReader(b)
|
||||
}
|
||||
|
||||
func themeBytesDigest(b []byte) string {
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:])[:16]
|
||||
}
|
||||
|
||||
func officialThemes() []*officialTheme {
|
||||
officialOnce.Do(loadOfficialRegistry)
|
||||
out := make([]*officialTheme, 0, len(officialOrder))
|
||||
for _, id := range officialOrder {
|
||||
out = append(out, officialRegistry[id])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func findOfficialTheme(id string) *officialTheme {
|
||||
officialOnce.Do(loadOfficialRegistry)
|
||||
return officialRegistry[id]
|
||||
}
|
||||
|
||||
func isOfficialThemeID(id string) bool {
|
||||
return findOfficialTheme(id) != nil
|
||||
}
|
||||
|
||||
// isReservedThemeID covers both the six base styles and the eight official
|
||||
// themes: user saves, imports, copies, overwrites and deletes must refuse them.
|
||||
func isReservedThemeID(id string) bool {
|
||||
return isBuiltinThemeID(id) || isOfficialThemeID(id)
|
||||
}
|
||||
|
||||
// officialAssetURL builds the content-addressed URL for an embedded asset.
|
||||
// Only the manifest-declared background and the fixed preview name are served.
|
||||
func officialAssetURL(id, filename string) string {
|
||||
ot := findOfficialTheme(id)
|
||||
if ot == nil {
|
||||
return ""
|
||||
}
|
||||
switch filename {
|
||||
case ot.manifest.Background.Image:
|
||||
return themeAssetURLPrefix + id + "/" + ot.bgDigest + "/" + filename
|
||||
case officialPreviewName:
|
||||
return themeAssetURLPrefix + id + "/" + ot.previewDigest + "/" + filename
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// readOfficialAsset returns embedded asset bytes after digest verification.
|
||||
func readOfficialAsset(id, filename string) ([]byte, string, error) {
|
||||
ot := findOfficialTheme(id)
|
||||
if ot == nil {
|
||||
return nil, "", fmt.Errorf("unknown official theme")
|
||||
}
|
||||
var name, digest string
|
||||
switch filename {
|
||||
case ot.manifest.Background.Image:
|
||||
name, digest = ot.manifest.Background.Image, ot.bgDigest
|
||||
case officialPreviewName:
|
||||
name, digest = officialPreviewName, ot.previewDigest
|
||||
default:
|
||||
return nil, "", fmt.Errorf("asset not declared")
|
||||
}
|
||||
data, err := fs.ReadFile(officialThemesFS, officialThemeDirName+"/"+id+"/"+name)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if got := themeBytesDigest(data); got != digest {
|
||||
return nil, "", fmt.Errorf("digest mismatch")
|
||||
}
|
||||
return data, name, nil
|
||||
}
|
||||
|
||||
// validateOfficialThemes gates the build: every embedded entry must parse and
|
||||
// pass the V1 validator plus image budgets, and the release set must be complete.
|
||||
func validateOfficialThemes() error {
|
||||
officialOnce.Do(loadOfficialRegistry)
|
||||
if officialLoadErr != nil {
|
||||
return officialLoadErr
|
||||
}
|
||||
if len(officialOrder) != officialExpectedCount {
|
||||
return fmt.Errorf("expected %d official themes, found %d", officialExpectedCount, len(officialOrder))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resetOfficialRegistryForTest clears the cached registry (tests only).
|
||||
func resetOfficialRegistryForTest() {
|
||||
officialOnce = sync.Once{}
|
||||
officialRegistry = nil
|
||||
officialOrder = nil
|
||||
officialLoadErr = nil
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The release gate: all eight embedded official themes must parse through the
|
||||
// Theme Pack V1 validator with unique ids/names, valid images and budgets.
|
||||
func TestOfficialThemesAllValid(t *testing.T) {
|
||||
resetOfficialRegistryForTest()
|
||||
if err := validateOfficialThemes(); err != nil {
|
||||
t.Fatalf("official registry invalid: %v", err)
|
||||
}
|
||||
themes := officialThemes()
|
||||
if len(themes) != officialExpectedCount {
|
||||
t.Fatalf("expected %d official themes, got %d", officialExpectedCount, len(themes))
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
names := map[string]bool{}
|
||||
var total int64
|
||||
for _, ot := range themes {
|
||||
m := ot.manifest
|
||||
if ids[m.ID] {
|
||||
t.Fatalf("duplicate official id %q", m.ID)
|
||||
}
|
||||
ids[m.ID] = true
|
||||
if names[m.Name] {
|
||||
t.Fatalf("duplicate official name %q", m.Name)
|
||||
}
|
||||
names[m.Name] = true
|
||||
if !strings.HasPrefix(m.ID, "official-") {
|
||||
t.Fatalf("official id %q must carry the official- prefix", m.ID)
|
||||
}
|
||||
if isBuiltinThemeID(m.ID) {
|
||||
t.Fatalf("official id %q collides with a base style", m.ID)
|
||||
}
|
||||
if m.Background == nil || m.Background.Image != "background.webp" {
|
||||
t.Fatalf("%s: background must be background.webp", m.ID)
|
||||
}
|
||||
if m.Background.SafeArea != "left" {
|
||||
t.Fatalf("%s: safeArea must be left, got %q", m.ID, m.Background.SafeArea)
|
||||
}
|
||||
if m.Recipes.Density != "comfortable" {
|
||||
t.Fatalf("%s: density must be comfortable", m.ID)
|
||||
}
|
||||
if m.Author != "Reasonix Contributors" || m.License != "MIT" {
|
||||
t.Fatalf("%s: author/license = %q/%q", m.ID, m.Author, m.License)
|
||||
}
|
||||
if ot.bgSize > officialMaxBackground {
|
||||
t.Fatalf("%s: background %d bytes exceeds budget", m.ID, ot.bgSize)
|
||||
}
|
||||
if len(ot.bgDigest) != 16 || len(ot.previewDigest) != 16 {
|
||||
t.Fatalf("%s: digests not computed", m.ID)
|
||||
}
|
||||
total += ot.bgSize
|
||||
// Every official manifest must ship full light+dark surface coverage and
|
||||
// zero contrast warnings.
|
||||
for _, mode := range []string{"light", "dark"} {
|
||||
tk := m.Tokens.Light
|
||||
if mode == "dark" {
|
||||
tk = m.Tokens.Dark
|
||||
}
|
||||
for _, key := range []string{"bg", "bgSoft", "bgElev", "panel", "sidebar", "chat", "workspace", "workspaceFiles", "border", "borderSoft", "fg", "fgDim", "fgFaint", "accent", "accentFg"} {
|
||||
if strings.TrimSpace(tk[key]) == "" {
|
||||
t.Fatalf("%s %s: missing token %q", m.ID, mode, key)
|
||||
}
|
||||
}
|
||||
// ok/warn/err stay inherited from the base style.
|
||||
for _, key := range []string{"ok", "warn", "err"} {
|
||||
if _, set := tk[key]; set {
|
||||
t.Fatalf("%s %s: %s must inherit the base style", m.ID, mode, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
if warns := computeContrastWarnings(&m); len(warns) != 0 {
|
||||
t.Fatalf("%s: contrast warnings: %v", m.ID, warns)
|
||||
}
|
||||
}
|
||||
if total > officialMaxTotalBytes {
|
||||
t.Fatalf("official backgrounds total %d bytes exceeds %d", total, officialMaxTotalBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficialImagesDimensionsAndBudgets(t *testing.T) {
|
||||
for _, ot := range officialThemes() {
|
||||
bg, err := officialThemesFS.ReadFile(officialThemeDirName + "/" + ot.manifest.ID + "/background.webp")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateOfficialImage(bg, "background.webp", officialBackgroundWidth, officialBackgroundHeight, officialMaxBackground); err != nil {
|
||||
t.Fatalf("%s background: %v", ot.manifest.ID, err)
|
||||
}
|
||||
pv, err := officialThemesFS.ReadFile(officialThemeDirName + "/" + ot.manifest.ID + "/preview.webp")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateOfficialImage(pv, "preview.webp", officialPreviewWidth, officialPreviewHeight, officialMaxPreview); err != nil {
|
||||
t.Fatalf("%s preview: %v", ot.manifest.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fail-closed: a broken entry never makes it into the registry.
|
||||
func TestOfficialFailClosedSkipsInvalid(t *testing.T) {
|
||||
if _, err := loadOfficialTheme("does-not-exist"); err == nil {
|
||||
t.Fatal("expected error for missing official theme")
|
||||
}
|
||||
if findOfficialTheme("does-not-exist") != nil {
|
||||
t.Fatal("missing theme must not enter the registry")
|
||||
}
|
||||
if isOfficialThemeID("graphite") {
|
||||
t.Fatal("base styles are not official themes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficialReservedIDsRefused(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
app := NewApp()
|
||||
|
||||
officialID := officialThemes()[0].manifest.ID
|
||||
for _, id := range []string{"graphite", "aurora", officialID} {
|
||||
if !isReservedThemeID(id) {
|
||||
t.Fatalf("%s must be reserved", id)
|
||||
}
|
||||
if err := app.DeleteThemePack(id); err == nil {
|
||||
t.Fatalf("delete %s must fail", id)
|
||||
}
|
||||
if _, err := app.SaveThemePack(ThemeSaveInput{ID: id, Name: "Hijack", BaseStyle: "graphite"}); err == nil {
|
||||
t.Fatalf("save %s must fail", id)
|
||||
}
|
||||
if _, err := app.CopyThemePack("graphite", id, "Hijack"); err == nil {
|
||||
t.Fatalf("copy onto reserved id %s must fail", id)
|
||||
}
|
||||
if _, err := app.ExportThemePack(id, filepath.Join(t.TempDir(), "out.reasonix-theme")); err == nil {
|
||||
t.Fatalf("export %s must fail", id)
|
||||
}
|
||||
if _, _, err := importThemePackZIPBytesForID(id); err == nil {
|
||||
t.Fatalf("import over reserved id %s must fail", id)
|
||||
}
|
||||
}
|
||||
// Overwrite path must refuse reserved ids too.
|
||||
if err := publishThemeDir(officialID, t.TempDir(), true); err == nil {
|
||||
t.Fatal("publish over official id must fail")
|
||||
}
|
||||
}
|
||||
|
||||
// importThemePackZIPBytesForID builds an in-memory package for a reserved id.
|
||||
func importThemePackZIPBytesForID(id string) (*ThemePackManifest, string, error) {
|
||||
raw := fmt.Sprintf(`{"schemaVersion":1,"id":%q,"name":"Hijack","baseStyle":"graphite"}`, id)
|
||||
tmp := filepath.Join(os.TempDir(), "hijack-"+id+".reasonix-theme")
|
||||
if err := writeThemeZip(tmp, &ThemePackManifest{SchemaVersion: 1, ID: id, Name: "Hijack", BaseStyle: "graphite"}, nil); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer os.Remove(tmp)
|
||||
_ = raw
|
||||
return importThemePackZIP(tmp)
|
||||
}
|
||||
|
||||
func TestOfficialListOrderAndKinds(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
app := NewApp()
|
||||
|
||||
// Two user themes.
|
||||
for _, id := range []string{"user-a", "user-b"} {
|
||||
m := &ThemePackManifest{SchemaVersion: 1, ID: id, Name: id, BaseStyle: "slate", Recipes: defaultThemePackRecipes()}
|
||||
staging, err := writeThemeStaging(m, "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(staging)
|
||||
if err := publishThemeDir(id, staging, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
list, err := app.ListThemePacks()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base, official, user := 0, 0, 0
|
||||
seenOfficial := 0
|
||||
for i, p := range list {
|
||||
switch p.Kind {
|
||||
case themeKindBase:
|
||||
base++
|
||||
if !p.Builtin {
|
||||
t.Fatal("base must keep builtin=true")
|
||||
}
|
||||
case themeKindOfficial:
|
||||
official++
|
||||
seenOfficial = i
|
||||
if !p.Builtin || !p.HasBackground || p.BackgroundURL == "" || p.PreviewURL == "" {
|
||||
t.Fatalf("official view incomplete: %+v", p)
|
||||
}
|
||||
if p.NameKey == "" || p.DescriptionKey == "" {
|
||||
t.Fatalf("official i18n keys missing: %+v", p)
|
||||
}
|
||||
case themeKindUser:
|
||||
user++
|
||||
if p.Builtin {
|
||||
t.Fatal("user theme must not be builtin")
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unknown kind %q", p.Kind)
|
||||
}
|
||||
}
|
||||
if base != 6 || official != officialExpectedCount || user < 2 {
|
||||
t.Fatalf("list = %d base + %d official + %d user", base, official, user)
|
||||
}
|
||||
haveA, haveB := false, false
|
||||
for _, p := range list {
|
||||
if p.ID == "user-a" {
|
||||
haveA = true
|
||||
}
|
||||
if p.ID == "user-b" {
|
||||
haveB = true
|
||||
}
|
||||
}
|
||||
if !haveA || !haveB {
|
||||
t.Fatal("expected user-a and user-b in list")
|
||||
}
|
||||
// Order: base first, then official, then user.
|
||||
if list[0].Kind != themeKindBase || list[6].Kind != themeKindOfficial || list[len(list)-1].Kind != themeKindUser {
|
||||
t.Fatal("list order must be base, official, user")
|
||||
}
|
||||
_ = seenOfficial
|
||||
}
|
||||
|
||||
func TestOfficialActivateRestoreReset(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
app := NewApp()
|
||||
|
||||
id := officialThemes()[1].manifest.ID
|
||||
if err := app.ActivateThemePack(id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
active, err := app.GetActiveThemePack()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if active.ActiveThemeID != id || active.Pack == nil || active.Pack.Kind != themeKindOfficial {
|
||||
t.Fatalf("active = %+v", active)
|
||||
}
|
||||
if active.Pack.BackgroundURL == "" || active.Pack.PreviewURL == "" {
|
||||
t.Fatalf("official pack must carry background + preview URLs: %+v", active.Pack)
|
||||
}
|
||||
// Restart recovery: a fresh App reads the same state file.
|
||||
app2 := NewApp()
|
||||
active2, err := app2.GetActiveThemePack()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if active2.ActiveThemeID != id {
|
||||
t.Fatalf("restart did not restore official theme: %+v", active2)
|
||||
}
|
||||
// Reset returns to the Graphite path.
|
||||
if err := app2.ResetThemePack(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
active3, _ := app2.GetActiveThemePack()
|
||||
if active3.ActiveThemeID != "" || active3.Pack != nil {
|
||||
t.Fatalf("reset failed: %+v", active3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficialCopyBecomesEditableUserTheme(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
app := NewApp()
|
||||
|
||||
src := officialThemes()[2].manifest.ID
|
||||
created, err := app.CopyThemePack(src, "my-fortune", "My Fortune")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.Kind != themeKindUser || created.ID != "my-fortune" {
|
||||
t.Fatalf("copy view = %+v", created)
|
||||
}
|
||||
// Background bytes were embedded -> private copy on disk.
|
||||
m, err := loadUserThemeManifest("my-fortune")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.Background == nil || m.Background.Image == "" {
|
||||
t.Fatal("copy must keep the background")
|
||||
}
|
||||
img, err := resolveThemeImageAbs("my-fortune", m.Background.Image)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateThemeImageFile(img); err != nil {
|
||||
t.Fatalf("copied background invalid: %v", err)
|
||||
}
|
||||
// The copy is an ordinary user theme: editable and exportable.
|
||||
if _, err := app.SaveThemePack(ThemeSaveInput{ID: "my-fortune", Name: "Renamed", BaseStyle: m.BaseStyle, Replace: true}); err != nil {
|
||||
t.Fatalf("edit copy: %v", err)
|
||||
}
|
||||
if _, err := app.ExportThemePack("my-fortune", filepath.Join(t.TempDir(), "copy")); err != nil {
|
||||
t.Fatalf("export copy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficialAssetRoute(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
app := NewApp()
|
||||
|
||||
ot := officialThemes()[0]
|
||||
id := ot.manifest.ID
|
||||
bgURL := officialAssetURL(id, "background.webp")
|
||||
pvURL := officialAssetURL(id, officialPreviewName)
|
||||
if bgURL == "" || pvURL == "" {
|
||||
t.Fatal("official URLs empty")
|
||||
}
|
||||
if officialAssetURL(id, "theme.json") != "" || officialAssetURL(id, "../theme.json") != "" {
|
||||
t.Fatal("undeclared assets must not resolve")
|
||||
}
|
||||
|
||||
mw := app.themeAssetMiddleware()
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
}))
|
||||
|
||||
// GET background
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, bgURL, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET bg status %d", rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "image/webp" {
|
||||
t.Fatalf("bg content-type %q", ct)
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") {
|
||||
t.Fatalf("official cache-control %q", cc)
|
||||
}
|
||||
if rec.Body.Len() != int(ot.bgSize) {
|
||||
t.Fatalf("bg bytes %d != %d", rec.Body.Len(), ot.bgSize)
|
||||
}
|
||||
|
||||
// HEAD preview
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodHead, pvURL, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("HEAD preview status %d", rec.Code)
|
||||
}
|
||||
|
||||
// Wrong digest
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, themeAssetURLPrefix+id+"/deadbeefdeadbeef/background.webp", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("wrong digest status %d", rec.Code)
|
||||
}
|
||||
|
||||
// Undeclared filename
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, themeAssetURLPrefix+id+"/"+ot.bgDigest+"/theme.json", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("undeclared file status %d", rec.Code)
|
||||
}
|
||||
|
||||
// Path traversal stays rejected
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, themeAssetURLPrefix+id+"/x/../background.webp", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("traversal status %d", rec.Code)
|
||||
}
|
||||
|
||||
// Wrong method
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, bgURL, nil))
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("POST status %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Safe mode never lists or serves official assets; Graphite path only.
|
||||
func TestOfficialSafeMode(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
t.Setenv("REASONIX_SAFE_MODE", "1")
|
||||
app := NewApp()
|
||||
|
||||
list, err := app.ListThemePacks()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 6 {
|
||||
t.Fatalf("safe mode must list only base styles, got %d", len(list))
|
||||
}
|
||||
if err := app.ActivateThemePack(officialThemes()[0].manifest.ID); err == nil {
|
||||
t.Fatal("safe mode must refuse official activation")
|
||||
}
|
||||
mw := app.themeAssetMiddleware()
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) }))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, officialAssetURL(officialThemes()[0].manifest.ID, "background.webp"), nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("safe mode asset status %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Theme Pack V2 is a controlled, non-executable desktop skin. V1 manifests
|
||||
// remain readable and fall back to one shared scene image.
|
||||
// See docs/THEME_PACK.md for the public contract.
|
||||
|
||||
const (
|
||||
themePackSchemaVersion = 2
|
||||
themePackMinSchemaVersion = 1
|
||||
themePackMaxZipBytes = 36 << 20 // two bounded scene images + manifest
|
||||
themePackMaxManifest = 1 << 20 // 1 MiB
|
||||
themePackMaxImageBytes = 16 << 20 // 16 MiB
|
||||
themePackMaxImageEdge = 8192
|
||||
themePackMaxIDLen = 64
|
||||
themePackMaxNameLen = 80
|
||||
themePackMaxTextLen = 240
|
||||
themePackManifestName = "theme.json"
|
||||
themePackExt = ".reasonix-theme"
|
||||
themeStateFileName = "desktop-theme-state.json"
|
||||
// Schema v2: activeThemeId may only reference official or user packs.
|
||||
// Base style ids (graphite/…) live exclusively in desktop.theme_style.
|
||||
themeStateSchemaVer = 2
|
||||
themeStateSchemaVerV1 = 1
|
||||
themeDirName = "themes"
|
||||
)
|
||||
|
||||
// Allowed base styles match the existing desktop theme directions.
|
||||
var themePackBaseStyles = map[string]struct{}{
|
||||
"graphite": {},
|
||||
"aurora": {},
|
||||
"slate": {},
|
||||
"carbon": {},
|
||||
"nocturne": {},
|
||||
"amber": {},
|
||||
}
|
||||
|
||||
// Token keys that a pack may override. Values must be #RRGGBB or #RRGGBBAA.
|
||||
var themePackTokenKeys = map[string]string{
|
||||
"bg": "--bg",
|
||||
"bgSoft": "--bg-soft",
|
||||
"bgElev": "--bg-elev",
|
||||
"panel": "--panel",
|
||||
"sidebar": "--sidebar-bg",
|
||||
"chat": "--chat-bg",
|
||||
"workspace": "--workspace-preview-bg",
|
||||
"workspaceFiles": "--workspace-files-bg",
|
||||
"border": "--border",
|
||||
"borderSoft": "--border-soft",
|
||||
"fg": "--fg",
|
||||
"fgDim": "--fg-dim",
|
||||
"fgFaint": "--fg-faint",
|
||||
"accent": "--accent",
|
||||
"accentFg": "--accent-fg",
|
||||
"ok": "--ok",
|
||||
"warn": "--warn",
|
||||
"err": "--err",
|
||||
}
|
||||
|
||||
var (
|
||||
themePackIDRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,62}[a-z0-9]$|^[a-z]$`)
|
||||
themePackColorRe = regexp.MustCompile(`^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$`)
|
||||
themePackImageRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,120}\.(png|jpe?g|webp)$`)
|
||||
)
|
||||
|
||||
// ThemePackManifest is the on-disk theme.json contract.
|
||||
type ThemePackManifest struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
License string `json:"license,omitempty"`
|
||||
BaseStyle string `json:"baseStyle"`
|
||||
Tokens ThemePackTokens `json:"tokens"`
|
||||
Recipes ThemePackRecipes `json:"recipes"`
|
||||
Background *ThemePackBackground `json:"background,omitempty"`
|
||||
TaskBackground *ThemePackSceneBackground `json:"taskBackground,omitempty"`
|
||||
Extra map[string]interface{} `json:"-"` // rejected on parse when present as unknown top-level
|
||||
}
|
||||
|
||||
// ThemePackTokens holds optional light/dark semantic color overrides.
|
||||
type ThemePackTokens struct {
|
||||
Light map[string]string `json:"light,omitempty"`
|
||||
Dark map[string]string `json:"dark,omitempty"`
|
||||
}
|
||||
|
||||
// ThemePackRecipes maps density/corner enums to bounded component variables.
|
||||
type ThemePackRecipes struct {
|
||||
Density string `json:"density,omitempty"` // compact|comfortable
|
||||
Corners string `json:"corners,omitempty"` // square|soft|round
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// ThemeDesktopState is the versioned active-theme pointer (not config.toml).
|
||||
type ThemeDesktopState struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
ActiveThemeID string `json:"activeThemeId,omitempty"`
|
||||
}
|
||||
|
||||
// ThemePackView is the frontend-safe summary of a theme (base, official or user).
|
||||
type ThemePackView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
License string `json:"license,omitempty"`
|
||||
BaseStyle string `json:"baseStyle"`
|
||||
Builtin bool `json:"builtin"`
|
||||
Kind string `json:"kind"` // "base" | "official" | "user"
|
||||
Active bool `json:"active"`
|
||||
HasBackground bool `json:"hasBackground"`
|
||||
BackgroundURL string `json:"backgroundUrl,omitempty"`
|
||||
TaskBackgroundURL string `json:"taskBackgroundUrl,omitempty"`
|
||||
PreviewURL string `json:"previewUrl,omitempty"`
|
||||
NameKey string `json:"nameKey,omitempty"`
|
||||
DescriptionKey string `json:"descriptionKey,omitempty"`
|
||||
Tokens ThemePackTokens `json:"tokens"`
|
||||
Recipes ThemePackRecipes `json:"recipes"`
|
||||
Background *ThemePackBackground `json:"background,omitempty"`
|
||||
TaskBackground *ThemePackSceneBackground `json:"taskBackground,omitempty"`
|
||||
ContrastWarnings []ThemeContrastWarning `json:"contrastWarnings,omitempty"`
|
||||
}
|
||||
|
||||
// ThemeContrastWarning surfaces WCAG contrast issues without blocking save.
|
||||
type ThemeContrastWarning struct {
|
||||
Mode string `json:"mode"` // light|dark
|
||||
Pair string `json:"pair"` // e.g. fg/bg
|
||||
Ratio float64 `json:"ratio"`
|
||||
Minimum float64 `json:"minimum"`
|
||||
Suggest string `json:"suggest,omitempty"`
|
||||
}
|
||||
|
||||
// ThemeActiveView is what the frontend needs to apply a pack + scene styling.
|
||||
type ThemeActiveView struct {
|
||||
ActiveThemeID string `json:"activeThemeId,omitempty"`
|
||||
Pack *ThemePackView `json:"pack,omitempty"`
|
||||
SafeMode bool `json:"safeMode"`
|
||||
}
|
||||
|
||||
// ThemeExperienceView is the unified appearance state for the redesigned
|
||||
// settings overview + theme gallery. One call supplies everything the UI needs
|
||||
// without inferring which style is actually effective.
|
||||
type ThemeExperienceView struct {
|
||||
ThemeMode string `json:"themeMode"` // auto|light|dark
|
||||
BaseStyle string `json:"baseStyle"` // graphite|aurora|…
|
||||
EffectiveStyle string `json:"effectiveStyle"` // pack.baseStyle when pack active, else baseStyle
|
||||
ActiveThemeID string `json:"activeThemeId,omitempty"` // official/user only; never a base id
|
||||
ActivePack *ThemePackView `json:"activePack,omitempty"`
|
||||
SafeMode bool `json:"safeMode"`
|
||||
}
|
||||
|
||||
// ThemeSaveInput is the editor payload for creating/updating a user theme.
|
||||
type ThemeSaveInput struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
License string `json:"license,omitempty"`
|
||||
BaseStyle string `json:"baseStyle"`
|
||||
Tokens ThemePackTokens `json:"tokens"`
|
||||
Recipes ThemePackRecipes `json:"recipes"`
|
||||
Background *ThemePackBackground `json:"background,omitempty"`
|
||||
TaskBackground *ThemePackSceneBackground `json:"taskBackground,omitempty"`
|
||||
// BackgroundDataURL is an optional data:image/... payload used when the
|
||||
// editor picked a new local image. Empty keeps the existing image.
|
||||
BackgroundDataURL string `json:"backgroundDataUrl,omitempty"`
|
||||
TaskBackgroundDataURL string `json:"taskBackgroundDataUrl,omitempty"`
|
||||
// ClearBackground removes any existing background image.
|
||||
ClearBackground bool `json:"clearBackground,omitempty"`
|
||||
ClearTaskBackground bool `json:"clearTaskBackground,omitempty"`
|
||||
// Replace allows overwriting an existing user theme with the same ID.
|
||||
Replace bool `json:"replace,omitempty"`
|
||||
// Activate enables the theme after a successful save.
|
||||
Activate bool `json:"activate,omitempty"`
|
||||
}
|
||||
|
||||
// ThemeImportResult is returned after a ZIP import attempt.
|
||||
// When NeedsReplace is true, the package was staged server-side and ConfirmImportThemePack
|
||||
// (or ImportThemePack with replace=true) will publish without re-opening a file dialog.
|
||||
// Absolute host paths are never exposed to the frontend.
|
||||
type ThemeImportResult struct {
|
||||
Pack ThemePackView `json:"pack"`
|
||||
Replaced bool `json:"replaced"`
|
||||
NeedsReplace bool `json:"needsReplace,omitempty"`
|
||||
PendingID string `json:"pendingId,omitempty"`
|
||||
}
|
||||
|
||||
func defaultThemePackRecipes() ThemePackRecipes {
|
||||
return ThemePackRecipes{Density: "comfortable", Corners: "soft"}
|
||||
}
|
||||
|
||||
func defaultThemePackBackground() ThemePackBackground {
|
||||
return ThemePackBackground{
|
||||
FocusX: 0.5,
|
||||
FocusY: 0.5,
|
||||
SafeArea: "center",
|
||||
HomeOpacity: 1,
|
||||
TaskOpacity: 0.28,
|
||||
OverlayStrength: 0.62,
|
||||
}
|
||||
}
|
||||
|
||||
func defaultThemePackTaskBackground() ThemePackSceneBackground {
|
||||
return ThemePackSceneBackground{
|
||||
FocusX: 0.5,
|
||||
FocusY: 0.5,
|
||||
SafeArea: "center",
|
||||
Opacity: 0.28,
|
||||
OverlayStrength: 0.62,
|
||||
}
|
||||
}
|
||||
|
||||
func parseThemePackManifest(data []byte) (*ThemePackManifest, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("theme manifest is empty")
|
||||
}
|
||||
if len(data) > themePackMaxManifest {
|
||||
return nil, fmt.Errorf("theme manifest exceeds %d bytes", themePackMaxManifest)
|
||||
}
|
||||
// Reject top-level keys outside the versioned allow-list.
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("theme manifest JSON: %w", err)
|
||||
}
|
||||
allowed := map[string]struct{}{
|
||||
"schemaVersion": {},
|
||||
"id": {},
|
||||
"name": {},
|
||||
"author": {},
|
||||
"description": {},
|
||||
"license": {},
|
||||
"baseStyle": {},
|
||||
"tokens": {},
|
||||
"recipes": {},
|
||||
"background": {},
|
||||
"taskBackground": {},
|
||||
}
|
||||
for k := range raw {
|
||||
if _, ok := allowed[k]; !ok {
|
||||
return nil, fmt.Errorf("theme manifest unknown field %q", k)
|
||||
}
|
||||
}
|
||||
var m ThemePackManifest
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return nil, fmt.Errorf("theme manifest JSON: %w", err)
|
||||
}
|
||||
if err := validateThemePackManifest(&m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func validateThemePackManifest(m *ThemePackManifest) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("theme manifest is nil")
|
||||
}
|
||||
if m.SchemaVersion < themePackMinSchemaVersion || m.SchemaVersion > themePackSchemaVersion {
|
||||
return fmt.Errorf("unsupported theme schemaVersion %d (supported %d-%d)", m.SchemaVersion, themePackMinSchemaVersion, themePackSchemaVersion)
|
||||
}
|
||||
if m.SchemaVersion < 2 && m.TaskBackground != nil {
|
||||
return fmt.Errorf("taskBackground requires theme schemaVersion 2")
|
||||
}
|
||||
id := strings.TrimSpace(m.ID)
|
||||
if !themePackIDRe.MatchString(id) {
|
||||
return fmt.Errorf("invalid theme id %q (use lowercase letters, digits, hyphens)", m.ID)
|
||||
}
|
||||
m.ID = id
|
||||
name := strings.TrimSpace(m.Name)
|
||||
if name == "" || len(name) > themePackMaxNameLen {
|
||||
return fmt.Errorf("theme name must be 1–%d characters", themePackMaxNameLen)
|
||||
}
|
||||
if containsControl(name) {
|
||||
return fmt.Errorf("theme name contains control characters")
|
||||
}
|
||||
m.Name = name
|
||||
m.Author = clampThemeText(m.Author)
|
||||
m.Description = clampThemeText(m.Description)
|
||||
m.License = clampThemeText(m.License)
|
||||
|
||||
base := strings.ToLower(strings.TrimSpace(m.BaseStyle))
|
||||
if _, ok := themePackBaseStyles[base]; !ok {
|
||||
return fmt.Errorf("invalid baseStyle %q", m.BaseStyle)
|
||||
}
|
||||
m.BaseStyle = base
|
||||
|
||||
if err := validateThemeTokenMap(m.Tokens.Light, "tokens.light"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateThemeTokenMap(m.Tokens.Dark, "tokens.dark"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
recipes := m.Recipes
|
||||
if recipes.Density == "" {
|
||||
recipes.Density = "comfortable"
|
||||
}
|
||||
if recipes.Corners == "" {
|
||||
recipes.Corners = "soft"
|
||||
}
|
||||
switch recipes.Density {
|
||||
case "compact", "comfortable":
|
||||
default:
|
||||
return fmt.Errorf("invalid density %q (use compact|comfortable)", recipes.Density)
|
||||
}
|
||||
switch recipes.Corners {
|
||||
case "square", "soft", "round":
|
||||
default:
|
||||
return fmt.Errorf("invalid corners %q (use square|soft|round)", recipes.Corners)
|
||||
}
|
||||
m.Recipes = recipes
|
||||
|
||||
if m.Background != nil {
|
||||
bg, err := normalizeThemeBackground(m.Background)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.Background = bg
|
||||
}
|
||||
if m.TaskBackground != nil {
|
||||
bg, err := normalizeThemeSceneBackground(m.TaskBackground)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.TaskBackground = bg
|
||||
}
|
||||
if m.Background != nil && m.TaskBackground != nil && strings.EqualFold(m.Background.Image, m.TaskBackground.Image) {
|
||||
return fmt.Errorf("background and taskBackground must use different image names")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateThemeTokenMap(tokens map[string]string, path string) error {
|
||||
if tokens == nil {
|
||||
return nil
|
||||
}
|
||||
for k, v := range tokens {
|
||||
if _, ok := themePackTokenKeys[k]; !ok {
|
||||
return fmt.Errorf("%s: unknown token %q", path, k)
|
||||
}
|
||||
color := strings.TrimSpace(v)
|
||||
if !themePackColorRe.MatchString(color) {
|
||||
return fmt.Errorf("%s.%s: color must be #RRGGBB or #RRGGBBAA, got %q", path, k, v)
|
||||
}
|
||||
// Reject CSS functions / gradients / url() even if somehow encoded.
|
||||
lower := strings.ToLower(color)
|
||||
if strings.Contains(lower, "url(") || strings.Contains(lower, "gradient") || strings.Contains(lower, "expression") {
|
||||
return fmt.Errorf("%s.%s: disallowed color value", path, k)
|
||||
}
|
||||
tokens[k] = strings.ToLower(color)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeThemeBackground(in *ThemePackBackground) (*ThemePackBackground, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
out := defaultThemePackBackground()
|
||||
if in.Image != "" {
|
||||
raw := strings.TrimSpace(in.Image)
|
||||
raw = strings.ReplaceAll(raw, "\\", "/")
|
||||
// Reject any path form — only a bare file name is allowed in the manifest.
|
||||
if raw == "" || strings.Contains(raw, "/") || strings.Contains(raw, "..") || filepath.Base(raw) != raw {
|
||||
return nil, fmt.Errorf("background.image must be a plain file name")
|
||||
}
|
||||
if !themePackImageRe.MatchString(raw) {
|
||||
return nil, fmt.Errorf("background.image must be a local png/jpeg/webp file name")
|
||||
}
|
||||
out.Image = raw
|
||||
}
|
||||
out.FocusX = clamp01(in.FocusX, 0.5)
|
||||
out.FocusY = clamp01(in.FocusY, 0.5)
|
||||
safe := strings.ToLower(strings.TrimSpace(in.SafeArea))
|
||||
if safe == "" {
|
||||
safe = "center"
|
||||
}
|
||||
switch safe {
|
||||
case "left", "right", "center":
|
||||
out.SafeArea = safe
|
||||
default:
|
||||
return nil, fmt.Errorf("background.safeArea must be left|right|center")
|
||||
}
|
||||
// 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.OverlayStrength = clampFloat(in.OverlayStrength, 0, 1, 0.62)
|
||||
// Empty image means token-only pack — drop background block.
|
||||
if out.Image == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func normalizeThemeSceneBackground(in *ThemePackSceneBackground) (*ThemePackSceneBackground, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
out := defaultThemePackTaskBackground()
|
||||
if in.Image != "" {
|
||||
raw := strings.TrimSpace(in.Image)
|
||||
raw = strings.ReplaceAll(raw, "\\", "/")
|
||||
if raw == "" || strings.Contains(raw, "/") || strings.Contains(raw, "..") || filepath.Base(raw) != raw {
|
||||
return nil, fmt.Errorf("taskBackground.image must be a plain file name")
|
||||
}
|
||||
if !themePackImageRe.MatchString(raw) {
|
||||
return nil, fmt.Errorf("taskBackground.image must be a local png/jpeg/webp file name")
|
||||
}
|
||||
out.Image = raw
|
||||
}
|
||||
out.FocusX = clamp01(in.FocusX, 0.5)
|
||||
out.FocusY = clamp01(in.FocusY, 0.5)
|
||||
safe := strings.ToLower(strings.TrimSpace(in.SafeArea))
|
||||
if safe == "" {
|
||||
safe = "center"
|
||||
}
|
||||
switch safe {
|
||||
case "left", "right", "center":
|
||||
out.SafeArea = safe
|
||||
default:
|
||||
return nil, fmt.Errorf("taskBackground.safeArea must be left|right|center")
|
||||
}
|
||||
out.Opacity = clampFloat(in.Opacity, 0, 0.45, 0.28)
|
||||
out.OverlayStrength = clampFloat(in.OverlayStrength, 0, 1, 0.62)
|
||||
if out.Image == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func clampThemeText(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) > themePackMaxTextLen {
|
||||
s = s[:themePackMaxTextLen]
|
||||
}
|
||||
if containsControl(s) {
|
||||
// Strip controls rather than reject optional fields.
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if unicode.IsControl(r) && r != '\n' && r != '\t' {
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
s = strings.TrimSpace(b.String())
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func containsControl(s string) bool {
|
||||
for _, r := range s {
|
||||
if unicode.IsControl(r) && r != '\n' && r != '\t' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func clamp01(v, def float64) float64 {
|
||||
return clampFloat(v, 0, 1, def)
|
||||
}
|
||||
|
||||
func clampFloat(v, min, max, def float64) float64 {
|
||||
if v != v { // NaN
|
||||
return def
|
||||
}
|
||||
if v < min {
|
||||
return min
|
||||
}
|
||||
if v > max {
|
||||
return max
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func isBuiltinThemeID(id string) bool {
|
||||
_, ok := themePackBaseStyles[id]
|
||||
return ok
|
||||
}
|
||||
|
||||
func builtinThemePacks() []ThemePackManifest {
|
||||
// Built-in packs mirror the six style directions with empty token overrides.
|
||||
order := []string{"graphite", "aurora", "slate", "carbon", "nocturne", "amber"}
|
||||
names := map[string]string{
|
||||
"graphite": "Graphite",
|
||||
"aurora": "Aurora",
|
||||
"slate": "Slate",
|
||||
"carbon": "Carbon",
|
||||
"nocturne": "Nocturne",
|
||||
"amber": "Amber",
|
||||
}
|
||||
out := make([]ThemePackManifest, 0, len(order))
|
||||
for _, id := range order {
|
||||
out = append(out, ThemePackManifest{
|
||||
SchemaVersion: themePackSchemaVersion,
|
||||
ID: id,
|
||||
Name: names[id],
|
||||
Author: "Reasonix",
|
||||
Description: "Built-in visual direction",
|
||||
License: "Apache-2.0",
|
||||
BaseStyle: id,
|
||||
Tokens: ThemePackTokens{},
|
||||
Recipes: defaultThemePackRecipes(),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func manifestToView(m *ThemePackManifest, kind string, active bool, backgroundURL, previewURL string, taskBackgroundURLs ...string) ThemePackView {
|
||||
taskBackgroundURL := ""
|
||||
if len(taskBackgroundURLs) > 0 {
|
||||
taskBackgroundURL = taskBackgroundURLs[0]
|
||||
}
|
||||
v := ThemePackView{
|
||||
ID: m.ID,
|
||||
Name: m.Name,
|
||||
Author: m.Author,
|
||||
Description: m.Description,
|
||||
License: m.License,
|
||||
BaseStyle: m.BaseStyle,
|
||||
Builtin: kind != themeKindUser,
|
||||
Kind: kind,
|
||||
Active: active,
|
||||
HasBackground: (m.Background != nil && m.Background.Image != "") || (m.TaskBackground != nil && m.TaskBackground.Image != ""),
|
||||
BackgroundURL: backgroundURL,
|
||||
TaskBackgroundURL: taskBackgroundURL,
|
||||
PreviewURL: previewURL,
|
||||
Tokens: ThemePackTokens{
|
||||
Light: copyStringMap(m.Tokens.Light),
|
||||
Dark: copyStringMap(m.Tokens.Dark),
|
||||
},
|
||||
Recipes: m.Recipes,
|
||||
}
|
||||
if kind == themeKindOfficial {
|
||||
v.NameKey = "settings.themes.official." + m.ID + ".name"
|
||||
v.DescriptionKey = "settings.themes.official." + m.ID + ".description"
|
||||
}
|
||||
if m.Background != nil {
|
||||
bg := *m.Background
|
||||
v.Background = &bg
|
||||
}
|
||||
if m.TaskBackground != nil {
|
||||
bg := *m.TaskBackground
|
||||
v.TaskBackground = &bg
|
||||
}
|
||||
v.ContrastWarnings = computeContrastWarnings(m)
|
||||
return v
|
||||
}
|
||||
|
||||
func copyStringMap(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func themePackCSSVars(tokens map[string]string) map[string]string {
|
||||
if len(tokens) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(tokens)*2)
|
||||
for k, v := range tokens {
|
||||
css, ok := themePackTokenKeys[k]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[css] = v
|
||||
// Keep dual aliases used across stylesheets in sync.
|
||||
switch k {
|
||||
case "fg":
|
||||
out["--text"] = v
|
||||
case "fgDim":
|
||||
out["--text-2"] = v
|
||||
case "fgFaint":
|
||||
out["--text-3"] = v
|
||||
case "panel":
|
||||
out["--bg-elev"] = v
|
||||
out["--surface"] = v
|
||||
case "bg":
|
||||
out["--stage"] = v
|
||||
case "bgSoft":
|
||||
out["--bg-soft"] = v
|
||||
out["--surface-3"] = v
|
||||
case "accent":
|
||||
// Soft accent is derived client-side; keep strong close to accent.
|
||||
out["--accent-strong"] = v
|
||||
out["--control-primary-bg"] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func recipeCSSVars(r ThemePackRecipes) map[string]string {
|
||||
out := map[string]string{}
|
||||
switch r.Density {
|
||||
case "compact":
|
||||
out["--theme-density-pad"] = "6px"
|
||||
out["--theme-density-gap"] = "6px"
|
||||
out["--theme-row-h"] = "28px"
|
||||
default:
|
||||
out["--theme-density-pad"] = "10px"
|
||||
out["--theme-density-gap"] = "10px"
|
||||
out["--theme-row-h"] = "34px"
|
||||
}
|
||||
switch r.Corners {
|
||||
case "square":
|
||||
out["--r-s"] = "0px"
|
||||
out["--r"] = "2px"
|
||||
out["--r-l"] = "4px"
|
||||
out["--radius"] = "2px"
|
||||
case "round":
|
||||
out["--r-s"] = "8px"
|
||||
out["--r"] = "14px"
|
||||
out["--r-l"] = "18px"
|
||||
out["--radius"] = "14px"
|
||||
default: // soft
|
||||
out["--r-s"] = "5px"
|
||||
out["--r"] = "8px"
|
||||
out["--r-l"] = "11px"
|
||||
out["--radius"] = "8px"
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseThemePackManifestValid(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"schemaVersion": 1,
|
||||
"id": "my-theme",
|
||||
"name": "My Theme",
|
||||
"baseStyle": "graphite",
|
||||
"tokens": {
|
||||
"light": {"bg": "#f4f3ef", "fg": "#111827", "accent": "#2f5fa8"},
|
||||
"dark": {"bg": "#0c0d10", "fg": "#f1f1ef", "accent": "#ff6a3d"}
|
||||
},
|
||||
"recipes": {"density": "comfortable", "corners": "soft"},
|
||||
"background": {
|
||||
"image": "background.webp",
|
||||
"focusX": 0.72,
|
||||
"focusY": 0.45,
|
||||
"safeArea": "left",
|
||||
"homeOpacity": 1,
|
||||
"taskOpacity": 0.28,
|
||||
"overlayStrength": 0.62
|
||||
}
|
||||
}`)
|
||||
m, err := parseThemePackManifest(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if m.ID != "my-theme" || m.BaseStyle != "graphite" {
|
||||
t.Fatalf("unexpected manifest: %+v", m)
|
||||
}
|
||||
if m.Background == nil || m.Background.SafeArea != "left" {
|
||||
t.Fatalf("background: %+v", m.Background)
|
||||
}
|
||||
if m.Tokens.Light["bg"] != "#f4f3ef" {
|
||||
t.Fatalf("token normalize = %q", m.Tokens.Light["bg"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseThemePackManifestRejects(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
}{
|
||||
{"bad schema", `{"schemaVersion":3,"id":"a","name":"A","baseStyle":"graphite"}`},
|
||||
{"v1 task background", `{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite","taskBackground":{"image":"task.png"}}`},
|
||||
{"bad id", `{"schemaVersion":1,"id":"Bad_ID","name":"A","baseStyle":"graphite"}`},
|
||||
{"unknown token", `{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite","tokens":{"light":{"hack":"#ffffff"}}}`},
|
||||
{"css url color", `{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite","tokens":{"light":{"bg":"url(x)"}}}`},
|
||||
{"gradient", `{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite","tokens":{"dark":{"accent":"linear-gradient(red,blue)"}}}`},
|
||||
{"unknown field", `{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite","scripts":"alert(1)"}`},
|
||||
{"bad density", `{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite","recipes":{"density":"huge"}}`},
|
||||
{"bad corners", `{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite","recipes":{"corners":"pill"}}`},
|
||||
{"svg image", `{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite","background":{"image":"x.svg"}}`},
|
||||
{"path traversal image", `{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite","background":{"image":"../etc/passwd.png"}}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := parseThemePackManifest([]byte(tc.raw)); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestThemePackV2IndependentSceneImagesRoundTrip(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
assets := t.TempDir()
|
||||
homeImage := writeTestPNG(t, filepath.Join(assets, "home.png"), 32, 24)
|
||||
taskImage := writeTestPNG(t, filepath.Join(assets, "task.png"), 40, 30)
|
||||
m := &ThemePackManifest{
|
||||
SchemaVersion: 2,
|
||||
ID: "two-scenes",
|
||||
Name: "Two Scenes",
|
||||
BaseStyle: "nocturne",
|
||||
Recipes: defaultThemePackRecipes(),
|
||||
Background: &ThemePackBackground{
|
||||
Image: "background.png", FocusX: 0.25, FocusY: 0.4, SafeArea: "left",
|
||||
HomeOpacity: 0.9, TaskOpacity: 0.2, OverlayStrength: 0.5,
|
||||
},
|
||||
TaskBackground: &ThemePackSceneBackground{
|
||||
Image: "background-task.png", FocusX: 0.75, FocusY: 0.6, SafeArea: "right",
|
||||
Opacity: 0.35, OverlayStrength: 0.7,
|
||||
},
|
||||
}
|
||||
staging, err := writeThemeStaging(m, homeImage, nil, themeStagingImage{path: taskImage})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(staging)
|
||||
if err := publishThemeDir(m.ID, staging, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
exportPath := filepath.Join(t.TempDir(), "two-scenes.reasonix-theme")
|
||||
if err := exportThemePackZIP(m.ID, exportPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := deleteUserTheme(m.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
imported, importedStage, err := importThemePackZIP(exportPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(importedStage)
|
||||
if imported.SchemaVersion != 2 || imported.TaskBackground == nil {
|
||||
t.Fatalf("missing V2 task background: %+v", imported)
|
||||
}
|
||||
if imported.TaskBackground.SafeArea != "right" || imported.TaskBackground.Opacity != 0.35 {
|
||||
t.Fatalf("unexpected task background: %+v", imported.TaskBackground)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(importedStage, imported.TaskBackground.Image)); err != nil {
|
||||
t.Fatalf("task scene image missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThemeTokenAndRecipeCSSVars(t *testing.T) {
|
||||
vars := themePackCSSVars(map[string]string{"fg": "#ffffff", "bg": "#000000", "accent": "#ff0000"})
|
||||
if vars["--fg"] != "#ffffff" || vars["--text"] != "#ffffff" {
|
||||
t.Fatalf("fg aliases: %+v", vars)
|
||||
}
|
||||
r := recipeCSSVars(ThemePackRecipes{Density: "compact", Corners: "square"})
|
||||
if r["--r"] != "2px" || r["--theme-row-h"] != "28px" {
|
||||
t.Fatalf("recipe vars: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskOpacityCapped(t *testing.T) {
|
||||
bg, err := normalizeThemeBackground(&ThemePackBackground{
|
||||
Image: "bg.png", FocusX: 2, FocusY: -1, SafeArea: "left",
|
||||
HomeOpacity: 1.5, TaskOpacity: 0.9, OverlayStrength: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bg.TaskOpacity > 0.45 {
|
||||
t.Fatalf("task opacity not capped: %v", bg.TaskOpacity)
|
||||
}
|
||||
if bg.FocusX != 1 || bg.FocusY != 0 {
|
||||
t.Fatalf("focus clamp: %v %v", bg.FocusX, bg.FocusY)
|
||||
}
|
||||
if bg.HomeOpacity != 1 || bg.OverlayStrength != 1 {
|
||||
t.Fatalf("opacity clamp: home=%v overlay=%v", bg.HomeOpacity, bg.OverlayStrength)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportExportRoundTrip(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
|
||||
img := writeTestPNG(t, filepath.Join(t.TempDir(), "background.png"), 32, 24)
|
||||
m := &ThemePackManifest{
|
||||
SchemaVersion: 1,
|
||||
ID: "round-trip",
|
||||
Name: "Round Trip",
|
||||
BaseStyle: "aurora",
|
||||
Tokens: ThemePackTokens{
|
||||
Dark: map[string]string{"accent": "#11aabb"},
|
||||
},
|
||||
Recipes: defaultThemePackRecipes(),
|
||||
Background: &ThemePackBackground{
|
||||
Image: "background.png", FocusX: 0.3, FocusY: 0.7, SafeArea: "right",
|
||||
HomeOpacity: 1, TaskOpacity: 0.2, OverlayStrength: 0.5,
|
||||
},
|
||||
}
|
||||
staging, err := writeThemeStaging(m, img, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(staging)
|
||||
if err := publishThemeDir(m.ID, staging, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
exportPath := filepath.Join(t.TempDir(), "out.reasonix-theme")
|
||||
if err := exportThemePackZIP(m.ID, exportPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Delete and re-import.
|
||||
if err := deleteUserTheme(m.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
im, stage2, err := importThemePackZIP(exportPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(stage2)
|
||||
if im.ID != "round-trip" || im.Background == nil || im.Background.SafeArea != "right" {
|
||||
t.Fatalf("import manifest: %+v", im)
|
||||
}
|
||||
if err := publishThemeDir(im.ID, stage2, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !userThemeExists("round-trip") {
|
||||
t.Fatal("expected theme after re-import")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportRejectsZipSlipAndSymlinkAndDuplicate(t *testing.T) {
|
||||
// Nested path
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
w, _ := zw.Create("../../evil.json")
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
_ = zw.Close()
|
||||
if _, _, err := extractThemeZipBytes(buf.Bytes()); err == nil {
|
||||
t.Fatal("expected zip-slip rejection")
|
||||
}
|
||||
|
||||
// Duplicate entries
|
||||
buf.Reset()
|
||||
zw = zip.NewWriter(&buf)
|
||||
w, _ = zw.Create("theme.json")
|
||||
_, _ = w.Write([]byte(`{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite"}`))
|
||||
w2, _ := zw.Create("Theme.json")
|
||||
_, _ = w2.Write([]byte(`{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite"}`))
|
||||
_ = zw.Close()
|
||||
if _, _, err := extractThemeZipBytes(buf.Bytes()); err == nil {
|
||||
t.Fatal("expected duplicate rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportRejectsOversizedAndDisallowedFiles(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
w, _ := zw.Create("theme.json")
|
||||
_, _ = w.Write([]byte(`{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite"}`))
|
||||
w2, _ := zw.Create("hack.js")
|
||||
_, _ = w2.Write([]byte(`alert(1)`))
|
||||
_ = zw.Close()
|
||||
if _, _, err := extractThemeZipBytes(buf.Bytes()); err == nil {
|
||||
t.Fatal("expected disallowed file rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteActiveThemeFallsBack(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
app := NewApp()
|
||||
|
||||
m := &ThemePackManifest{
|
||||
SchemaVersion: 1, ID: "deleteme", Name: "Delete Me", BaseStyle: "slate",
|
||||
Recipes: defaultThemePackRecipes(),
|
||||
}
|
||||
staging, err := writeThemeStaging(m, "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(staging)
|
||||
if err := publishThemeDir(m.ID, staging, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.ActivateThemePack("deleteme"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
active, err := app.GetActiveThemePack()
|
||||
if err != nil || active.ActiveThemeID != "deleteme" {
|
||||
t.Fatalf("active = %+v err=%v", active, err)
|
||||
}
|
||||
if err := app.DeleteThemePack("deleteme"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
active, err = app.GetActiveThemePack()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if active.ActiveThemeID != "" || active.Pack != nil {
|
||||
t.Fatalf("expected fallback after delete, got %+v", active)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinCannotDeleteOrOverwrite(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
app := NewApp()
|
||||
if err := app.DeleteThemePack("graphite"); err == nil {
|
||||
t.Fatal("expected delete builtin error")
|
||||
}
|
||||
m := &ThemePackManifest{
|
||||
SchemaVersion: 1, ID: "graphite", Name: "Hijack", BaseStyle: "graphite",
|
||||
Recipes: defaultThemePackRecipes(),
|
||||
}
|
||||
staging, err := writeThemeStaging(m, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("stage builtin theme: %v", err)
|
||||
}
|
||||
if staging != "" {
|
||||
defer os.RemoveAll(staging)
|
||||
if err := publishThemeDir("graphite", staging, true); err == nil {
|
||||
t.Fatal("expected publish builtin rejection")
|
||||
}
|
||||
}
|
||||
_, err = app.SaveThemePack(ThemeSaveInput{
|
||||
ID: "graphite", Name: "Hijack", BaseStyle: "graphite",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected save builtin rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThemeAssetRouteValidatesAndRejectsEscape(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
app := NewApp()
|
||||
|
||||
img := writeTestPNG(t, filepath.Join(t.TempDir(), "background.png"), 16, 16)
|
||||
m := &ThemePackManifest{
|
||||
SchemaVersion: 1, ID: "asset-theme", Name: "Asset", BaseStyle: "carbon",
|
||||
Recipes: defaultThemePackRecipes(),
|
||||
Background: &ThemePackBackground{
|
||||
Image: "background.png", FocusX: 0.5, FocusY: 0.5, SafeArea: "center",
|
||||
HomeOpacity: 1, TaskOpacity: 0.28, OverlayStrength: 0.5,
|
||||
},
|
||||
}
|
||||
staging, err := writeThemeStaging(m, img, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(staging)
|
||||
if err := publishThemeDir(m.ID, staging, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
url := themeBackgroundURL("asset-theme", "background.png")
|
||||
if url == "" || !strings.HasPrefix(url, themeAssetURLPrefix) {
|
||||
t.Fatalf("bad url %q", url)
|
||||
}
|
||||
|
||||
mw := app.themeAssetMiddleware()
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
}))
|
||||
|
||||
// Valid GET
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET status %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "image/png") {
|
||||
t.Fatalf("content-type %q", ct)
|
||||
}
|
||||
|
||||
// Valid HEAD
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodHead, url, nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("HEAD status %d", rec.Code)
|
||||
}
|
||||
|
||||
// Wrong digest
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, themeAssetURLPrefix+"asset-theme/deadbeef/background.png", nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("wrong digest status %d", rec.Code)
|
||||
}
|
||||
|
||||
// Path escape style filename
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, themeAssetURLPrefix+"asset-theme/x/../background.png", nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("escape status %d", rec.Code)
|
||||
}
|
||||
|
||||
// Non-theme paths pass through
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/index.html", nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusTeapot {
|
||||
t.Fatalf("passthrough status %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameIDImportRequiresReplace(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
app := NewApp()
|
||||
t.Cleanup(clearPendingThemeImport)
|
||||
|
||||
m := &ThemePackManifest{
|
||||
SchemaVersion: 1, ID: "same-id", Name: "V1", BaseStyle: "amber",
|
||||
Recipes: defaultThemePackRecipes(),
|
||||
}
|
||||
staging, err := writeThemeStaging(m, "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(staging)
|
||||
if err := publishThemeDir(m.ID, staging, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Build a second zip with same id.
|
||||
exportPath := filepath.Join(t.TempDir(), "v2.reasonix-theme")
|
||||
m.Name = "V2"
|
||||
if err := writeThemeZip(exportPath, m, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// First import stages and asks for replace — no error, no second file pick needed.
|
||||
pending, err := app.ImportThemePack(exportPath, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !pending.NeedsReplace || pending.PendingID == "" {
|
||||
t.Fatalf("expected NeedsReplace staging, got %+v", pending)
|
||||
}
|
||||
if pending.Pack.Name != "V2" {
|
||||
t.Fatalf("staged name = %q", pending.Pack.Name)
|
||||
}
|
||||
// Confirm with replace=true and empty path reuses the staged extract.
|
||||
res, err := app.ImportThemePack("", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Replaced || res.Pack.Name != "V2" {
|
||||
t.Fatalf("confirm result = %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveThemePackRejectsSameIDWithoutReplace(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
app := NewApp()
|
||||
|
||||
first, err := app.SaveThemePack(ThemeSaveInput{
|
||||
ID: "my-theme", Name: "First", BaseStyle: "graphite",
|
||||
Recipes: defaultThemePackRecipes(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Name != "First" {
|
||||
t.Fatalf("first = %+v", first)
|
||||
}
|
||||
// Default create path must not silently overwrite.
|
||||
if _, err := app.SaveThemePack(ThemeSaveInput{
|
||||
ID: "my-theme", Name: "Second", BaseStyle: "aurora",
|
||||
Recipes: defaultThemePackRecipes(),
|
||||
Replace: false,
|
||||
}); err == nil {
|
||||
t.Fatal("expected already-exists error without Replace")
|
||||
}
|
||||
// Explicit replace overwrites.
|
||||
second, err := app.SaveThemePack(ThemeSaveInput{
|
||||
ID: "my-theme", Name: "Second", BaseStyle: "aurora",
|
||||
Recipes: defaultThemePackRecipes(),
|
||||
Replace: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.Name != "Second" || second.BaseStyle != "aurora" {
|
||||
t.Fatalf("second = %+v", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorruptActiveThemeFallsBack(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
app := NewApp()
|
||||
|
||||
// Point state at a missing theme.
|
||||
if err := saveThemeDesktopState(ThemeDesktopState{SchemaVersion: 1, ActiveThemeID: "missing-theme"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
active, err := app.GetActiveThemePack()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if active.Pack != nil || active.ActiveThemeID != "" {
|
||||
t.Fatalf("expected empty active after corrupt, got %+v", active)
|
||||
}
|
||||
st := loadThemeDesktopState()
|
||||
if st.ActiveThemeID != "" {
|
||||
t.Fatalf("state not cleared: %q", st.ActiveThemeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListThemePacksIncludesBuiltins(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
app := NewApp()
|
||||
list, err := app.ListThemePacks()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) < 6 {
|
||||
t.Fatalf("expected >=6 packs, got %d", len(list))
|
||||
}
|
||||
var sawGraphite bool
|
||||
for _, p := range list {
|
||||
if p.ID == "graphite" && p.Builtin {
|
||||
sawGraphite = true
|
||||
}
|
||||
}
|
||||
if !sawGraphite {
|
||||
t.Fatal("missing graphite builtin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateThemeImagePNG(t *testing.T) {
|
||||
path := writeTestPNG(t, filepath.Join(t.TempDir(), "background.png"), 8, 8)
|
||||
if err := validateThemeImageFile(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Oversized edge
|
||||
big := writeTestPNG(t, filepath.Join(t.TempDir(), "big.png"), themePackMaxImageEdge+1, 2)
|
||||
if err := validateThemeImageFile(big); err == nil {
|
||||
t.Fatal("expected dimension rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContrastWarningLowContrast(t *testing.T) {
|
||||
m := &ThemePackManifest{
|
||||
SchemaVersion: 1, ID: "c", Name: "C", BaseStyle: "graphite",
|
||||
Tokens: ThemePackTokens{
|
||||
Light: map[string]string{"fg": "#eeeeee", "bg": "#ffffff"},
|
||||
},
|
||||
Recipes: defaultThemePackRecipes(),
|
||||
}
|
||||
warns := computeContrastWarnings(m)
|
||||
if len(warns) == 0 {
|
||||
t.Fatal("expected contrast warning")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestJSONRoundTrip(t *testing.T) {
|
||||
m := ThemePackManifest{
|
||||
SchemaVersion: 1, ID: "json-rt", Name: "JSON", BaseStyle: "nocturne",
|
||||
Tokens: ThemePackTokens{Dark: map[string]string{"panel": "#121212ff"}},
|
||||
Recipes: ThemePackRecipes{Density: "compact", Corners: "round"},
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := parseThemePackManifest(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed.Recipes.Density != "compact" || parsed.Tokens.Dark["panel"] != "#121212ff" {
|
||||
t.Fatalf("round trip: %+v", parsed)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestPNG(t *testing.T, path string, w, h int) string {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 40, A: 255})
|
||||
}
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := png.Encode(f, img); err != nil {
|
||||
_ = f.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/fileutil"
|
||||
)
|
||||
|
||||
func themeStatePath() string {
|
||||
return filepath.Join(config.MemoryUserDir(), themeStateFileName)
|
||||
}
|
||||
|
||||
func themesRootDir() string {
|
||||
return filepath.Join(config.MemoryUserDir(), themeDirName)
|
||||
}
|
||||
|
||||
func themeDir(id string) string {
|
||||
return filepath.Join(themesRootDir(), id)
|
||||
}
|
||||
|
||||
func themeManifestPath(id string) string {
|
||||
return filepath.Join(themeDir(id), themePackManifestName)
|
||||
}
|
||||
|
||||
func loadThemeDesktopState() ThemeDesktopState {
|
||||
path := themeStatePath()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ThemeDesktopState{SchemaVersion: themeStateSchemaVer}
|
||||
}
|
||||
var st ThemeDesktopState
|
||||
if err := json.Unmarshal(data, &st); err != nil {
|
||||
return ThemeDesktopState{SchemaVersion: themeStateSchemaVer}
|
||||
}
|
||||
if st.SchemaVersion == 0 {
|
||||
st.SchemaVersion = themeStateSchemaVerV1
|
||||
}
|
||||
st.ActiveThemeID = strings.TrimSpace(st.ActiveThemeID)
|
||||
return st
|
||||
}
|
||||
|
||||
func saveThemeDesktopState(st ThemeDesktopState) error {
|
||||
st.SchemaVersion = themeStateSchemaVer
|
||||
st.ActiveThemeID = strings.TrimSpace(st.ActiveThemeID)
|
||||
// Hard rule for v2: never persist base style ids as the active pack.
|
||||
if isBuiltinThemeID(st.ActiveThemeID) {
|
||||
st.ActiveThemeID = ""
|
||||
}
|
||||
dir := filepath.Dir(themeStatePath())
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(st, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFileAtomic(themeStatePath(), data, 0o644)
|
||||
}
|
||||
|
||||
func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
|
||||
// Use the shared Windows-safe atomic writer (AV/indexer lock retries +
|
||||
// cross-device fallback) instead of a bare os.Rename.
|
||||
return fileutil.AtomicWriteFile(path, data, mode)
|
||||
}
|
||||
|
||||
func loadUserThemeManifest(id string) (*ThemePackManifest, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if !themePackIDRe.MatchString(id) {
|
||||
return nil, fmt.Errorf("invalid theme id")
|
||||
}
|
||||
if isReservedThemeID(id) {
|
||||
return nil, fmt.Errorf("built-in theme %q has no user directory", id)
|
||||
}
|
||||
data, err := os.ReadFile(themeManifestPath(id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseThemePackManifest(data)
|
||||
}
|
||||
|
||||
func listUserThemeIDs() ([]string, error) {
|
||||
root := themesRootDir()
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var ids []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
id := e.Name()
|
||||
if !themePackIDRe.MatchString(id) || isReservedThemeID(id) {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(themeManifestPath(id)); err != nil {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func userThemeExists(id string) bool {
|
||||
_, err := os.Stat(themeManifestPath(id))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// publishThemeDir atomically replaces themes/<id> with the prepared staging directory.
|
||||
// stagingDir must already contain a validated theme.json and optional scene images.
|
||||
func publishThemeDir(id, stagingDir string, replace bool) error {
|
||||
id = strings.TrimSpace(id)
|
||||
if !themePackIDRe.MatchString(id) {
|
||||
return fmt.Errorf("invalid theme id")
|
||||
}
|
||||
if isReservedThemeID(id) {
|
||||
return fmt.Errorf("built-in themes cannot be overwritten")
|
||||
}
|
||||
dest := themeDir(id)
|
||||
if userThemeExists(id) && !replace {
|
||||
return fmt.Errorf("theme %q already exists (set replace to overwrite)", id)
|
||||
}
|
||||
root := themesRootDir()
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
// Final rename target: themes/<id>
|
||||
// Strategy: write to themes/.staging-<id>-* then rename over destination.
|
||||
parentStaging, err := os.MkdirTemp(root, ".staging-"+id+"-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cleanupStaging := true
|
||||
defer func() {
|
||||
if cleanupStaging {
|
||||
_ = os.RemoveAll(parentStaging)
|
||||
}
|
||||
}()
|
||||
|
||||
// Copy staging contents into parentStaging (re-validate containment).
|
||||
if err := copyThemeTree(stagingDir, parentStaging); err != nil {
|
||||
return err
|
||||
}
|
||||
// Verify manifest still parses after copy.
|
||||
data, err := os.ReadFile(filepath.Join(parentStaging, themePackManifestName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m, err := parseThemePackManifest(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if m.ID != id {
|
||||
return fmt.Errorf("theme id mismatch: manifest %q vs directory %q", m.ID, id)
|
||||
}
|
||||
if m.Background != nil && m.Background.Image != "" {
|
||||
imgPath := filepath.Join(parentStaging, m.Background.Image)
|
||||
if err := validateThemeImageFile(imgPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if m.TaskBackground != nil && m.TaskBackground.Image != "" {
|
||||
imgPath := filepath.Join(parentStaging, m.TaskBackground.Image)
|
||||
if err := validateThemeImageFile(imgPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Replace destination atomically: rename old aside, rename new in, remove old.
|
||||
backup := ""
|
||||
if _, err := os.Stat(dest); err == nil {
|
||||
backup = dest + ".bak-" + randomThemeSuffix()
|
||||
if err := os.Rename(dest, backup); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := os.Rename(parentStaging, dest); err != nil {
|
||||
if backup != "" {
|
||||
_ = os.Rename(backup, dest)
|
||||
}
|
||||
return err
|
||||
}
|
||||
cleanupStaging = false
|
||||
if backup != "" {
|
||||
_ = os.RemoveAll(backup)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func randomThemeSuffix() string {
|
||||
// Short non-crypto suffix for backup dir names.
|
||||
return fmt.Sprintf("%d", os.Getpid())
|
||||
}
|
||||
|
||||
func copyThemeTree(src, dst string) error {
|
||||
srcAbs, err := filepath.Abs(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return filepath.Walk(srcAbs, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(srcAbs, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
// Only allow root-level files (theme.json + up to two scene images).
|
||||
if strings.Contains(rel, string(os.PathSeparator)) || strings.Contains(rel, "/") || strings.Contains(rel, "\\") {
|
||||
return fmt.Errorf("theme package may only contain root-level files, found %q", rel)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("theme package must not contain symlinks")
|
||||
}
|
||||
if info.IsDir() {
|
||||
return fmt.Errorf("theme package must not contain subdirectories")
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
// Ensure target stays inside dst.
|
||||
if !pathIsInside(dst, target) {
|
||||
return fmt.Errorf("theme path escapes destination")
|
||||
}
|
||||
return copyFileLimited(path, target, themePackMaxImageBytes+themePackMaxManifest)
|
||||
})
|
||||
}
|
||||
|
||||
func pathIsInside(root, target string) bool {
|
||||
rootAbs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
targetAbs, err := filepath.Abs(target)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rel, err := filepath.Rel(rootAbs, targetAbs)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
|
||||
}
|
||||
|
||||
func copyFileLimited(src, dst string, maxBytes int64) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
info, err := in.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Size() > maxBytes {
|
||||
return fmt.Errorf("file too large: %s", filepath.Base(src))
|
||||
}
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
written, err := io.Copy(out, io.LimitReader(in, maxBytes+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if written > maxBytes {
|
||||
return fmt.Errorf("file too large: %s", filepath.Base(src))
|
||||
}
|
||||
return out.Sync()
|
||||
}
|
||||
|
||||
func deleteUserTheme(id string) error {
|
||||
id = strings.TrimSpace(id)
|
||||
if !themePackIDRe.MatchString(id) {
|
||||
return fmt.Errorf("invalid theme id")
|
||||
}
|
||||
if isReservedThemeID(id) {
|
||||
return fmt.Errorf("built-in themes cannot be deleted")
|
||||
}
|
||||
dest := themeDir(id)
|
||||
if !pathIsInside(themesRootDir(), dest) {
|
||||
return fmt.Errorf("refusing to delete path outside themes root")
|
||||
}
|
||||
if _, err := os.Stat(dest); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("theme %q not found", id)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return os.RemoveAll(dest)
|
||||
}
|
||||
|
||||
// resolveActiveThemeID returns a loadable official/user theme id, or empty.
|
||||
// Base style ids are never active packs under schema v2.
|
||||
func resolveActiveThemeID(st ThemeDesktopState) string {
|
||||
id := strings.TrimSpace(st.ActiveThemeID)
|
||||
if id == "" || isBuiltinThemeID(id) {
|
||||
return ""
|
||||
}
|
||||
if isOfficialThemeID(id) {
|
||||
return id
|
||||
}
|
||||
if userThemeExists(id) {
|
||||
// Quick re-validate; corrupt themes fall back to none (caller falls to base style).
|
||||
if _, err := loadUserThemeManifest(id); err == nil {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func themeFileDigest(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, io.LimitReader(f, themePackMaxImageBytes+1)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))[:16], nil
|
||||
}
|
||||
|
||||
type themeStagingImage struct {
|
||||
path string
|
||||
bytes []byte
|
||||
}
|
||||
|
||||
func writeThemeStaging(m *ThemePackManifest, imagePath string, imageBytes []byte, taskImages ...themeStagingImage) (staging string, err error) {
|
||||
if err := validateThemePackManifest(m); err != nil {
|
||||
return "", err
|
||||
}
|
||||
staging, err = os.MkdirTemp("", "reasonix-theme-stage-*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cleanup := true
|
||||
defer func() {
|
||||
if cleanup {
|
||||
_ = os.RemoveAll(staging)
|
||||
}
|
||||
}()
|
||||
|
||||
writeImage := func(imageName, imagePath string, imageBytes []byte, label string) error {
|
||||
var data []byte
|
||||
switch {
|
||||
case len(imageBytes) > 0:
|
||||
data = imageBytes
|
||||
case imagePath != "":
|
||||
data, err = os.ReadFile(imagePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s image: %w", label, err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%s image data is required", label)
|
||||
}
|
||||
if int64(len(data)) > themePackMaxImageBytes {
|
||||
return fmt.Errorf("%s image exceeds %d bytes", label, themePackMaxImageBytes)
|
||||
}
|
||||
imgDest := filepath.Join(staging, imageName)
|
||||
if err := os.WriteFile(imgDest, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateThemeImageFile(imgDest); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Place scene images first so names and image content are re-validated.
|
||||
if m.Background != nil && m.Background.Image != "" {
|
||||
if err := writeImage(m.Background.Image, imagePath, imageBytes, "home background"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if m.TaskBackground != nil && m.TaskBackground.Image != "" {
|
||||
var task themeStagingImage
|
||||
if len(taskImages) > 0 {
|
||||
task = taskImages[0]
|
||||
}
|
||||
if err := writeImage(m.TaskBackground.Image, task.path, task.bytes, "task background"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(raw) > themePackMaxManifest {
|
||||
return "", fmt.Errorf("theme manifest exceeds %d bytes", themePackMaxManifest)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(staging, themePackManifestName), raw, 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
cleanup = false
|
||||
return staging, nil
|
||||
}
|
||||
|
||||
func resolveThemeImageAbs(id, imageName string) (string, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
imageName = filepath.Base(strings.TrimSpace(imageName))
|
||||
if !themePackIDRe.MatchString(id) || isReservedThemeID(id) {
|
||||
return "", fmt.Errorf("invalid theme id")
|
||||
}
|
||||
if !themePackImageRe.MatchString(imageName) {
|
||||
return "", fmt.Errorf("invalid image name")
|
||||
}
|
||||
root := themeDir(id)
|
||||
abs := filepath.Join(root, imageName)
|
||||
if !pathIsInside(root, abs) {
|
||||
return "", fmt.Errorf("image path escapes theme directory")
|
||||
}
|
||||
// Resolve symlinks and re-check containment (TOCTOU defense on serve).
|
||||
resolved, err := filepath.EvalSymlinks(abs)
|
||||
if err != nil {
|
||||
// If the file doesn't exist yet, still return the intended path after containment check.
|
||||
if os.IsNotExist(err) {
|
||||
return abs, nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
rootResolved, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
rootResolved = root
|
||||
}
|
||||
if !pathIsInside(rootResolved, resolved) {
|
||||
return "", fmt.Errorf("image path escapes theme directory")
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
After Width: | Height: | Size: 269 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "official-crimson-horizon",
|
||||
"name": "Crimson Horizon",
|
||||
"author": "Reasonix Contributors",
|
||||
"description": "Pearl-white sky over a coral-red future city skyline. No people, pure skyline.",
|
||||
"license": "MIT",
|
||||
"baseStyle": "graphite",
|
||||
"tokens": {
|
||||
"light": {
|
||||
"bg": "#FFF8F7",
|
||||
"bgSoft": "#F9EEEC",
|
||||
"bgElev": "#FFFFFF",
|
||||
"panel": "#FFFFFF",
|
||||
"sidebar": "#F9ECEA",
|
||||
"chat": "#FFFBFA",
|
||||
"workspace": "#FFFFFF",
|
||||
"workspaceFiles": "#FBF1EF",
|
||||
"border": "#DFB3AD",
|
||||
"borderSoft": "#F6E4E2",
|
||||
"fg": "#301D1D",
|
||||
"fgDim": "#6B4644",
|
||||
"fgFaint": "#A9807D",
|
||||
"accent": "#B92B38",
|
||||
"accentFg": "#FFF8F7"
|
||||
},
|
||||
"dark": {
|
||||
"bg": "#190D11",
|
||||
"bgSoft": "#221318",
|
||||
"bgElev": "#2B181E",
|
||||
"panel": "#25141A",
|
||||
"sidebar": "#211218",
|
||||
"chat": "#201116",
|
||||
"workspace": "#2B181E",
|
||||
"workspaceFiles": "#221319",
|
||||
"border": "#582F3C",
|
||||
"borderSoft": "#361B24",
|
||||
"fg": "#FFF1F2",
|
||||
"fgDim": "#DFB3B6",
|
||||
"fgFaint": "#AC7E84",
|
||||
"accent": "#FF6772",
|
||||
"accentFg": "#2A0E12"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
"density": "comfortable",
|
||||
"corners": "soft"
|
||||
},
|
||||
"background": {
|
||||
"image": "background.webp",
|
||||
"focusX": 0.75,
|
||||
"focusY": 0.45,
|
||||
"safeArea": "left",
|
||||
"homeOpacity": 0.98,
|
||||
"taskOpacity": 0.22,
|
||||
"overlayStrength": 0.66
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "official-cyan-stage",
|
||||
"name": "Cyan Stage",
|
||||
"author": "Reasonix Contributors",
|
||||
"description": "Airy cyan future stage with light rings and an original digital performer.",
|
||||
"license": "MIT",
|
||||
"baseStyle": "carbon",
|
||||
"tokens": {
|
||||
"light": {
|
||||
"bg": "#F1FCFD",
|
||||
"bgSoft": "#E4F5F7",
|
||||
"bgElev": "#FAFEFE",
|
||||
"panel": "#FAFEFE",
|
||||
"sidebar": "#E3F4F6",
|
||||
"chat": "#F6FDFE",
|
||||
"workspace": "#FAFEFE",
|
||||
"workspaceFiles": "#E8F6F8",
|
||||
"border": "#9CCFD7",
|
||||
"borderSoft": "#DAEDF0",
|
||||
"fg": "#173238",
|
||||
"fgDim": "#43606A",
|
||||
"fgFaint": "#71939D",
|
||||
"accent": "#007C92",
|
||||
"accentFg": "#F1FCFD"
|
||||
},
|
||||
"dark": {
|
||||
"bg": "#07181D",
|
||||
"bgSoft": "#0C2229",
|
||||
"bgElev": "#112C34",
|
||||
"panel": "#0E252D",
|
||||
"sidebar": "#0B2128",
|
||||
"chat": "#0A2027",
|
||||
"workspace": "#112C34",
|
||||
"workspaceFiles": "#0C232A",
|
||||
"border": "#1F4550",
|
||||
"borderSoft": "#16333C",
|
||||
"fg": "#E9FCFF",
|
||||
"fgDim": "#AEDBE2",
|
||||
"fgFaint": "#6FA5AF",
|
||||
"accent": "#37D7E4",
|
||||
"accentFg": "#04222a"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
"density": "comfortable",
|
||||
"corners": "round"
|
||||
},
|
||||
"background": {
|
||||
"image": "background.webp",
|
||||
"focusX": 0.74,
|
||||
"focusY": 0.45,
|
||||
"safeArea": "left",
|
||||
"homeOpacity": 0.96,
|
||||
"taskOpacity": 0.18,
|
||||
"overlayStrength": 0.72
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "official-fortune-forge",
|
||||
"name": "Fortune Forge",
|
||||
"author": "Reasonix Contributors",
|
||||
"description": "Warm ivory workshop with vermilion, gold and jade around an original lucky programmer.",
|
||||
"license": "MIT",
|
||||
"baseStyle": "amber",
|
||||
"tokens": {
|
||||
"light": {
|
||||
"bg": "#FFF8E8",
|
||||
"bgSoft": "#F9EFD5",
|
||||
"bgElev": "#FFFDF6",
|
||||
"panel": "#FFFDF6",
|
||||
"sidebar": "#F8EDD3",
|
||||
"chat": "#FFFBF1",
|
||||
"workspace": "#FFFDF6",
|
||||
"workspaceFiles": "#FAF1DC",
|
||||
"border": "#D6B87E",
|
||||
"borderSoft": "#F2E7CD",
|
||||
"fg": "#382116",
|
||||
"fgDim": "#6E4E35",
|
||||
"fgFaint": "#A98A68",
|
||||
"accent": "#A92D22",
|
||||
"accentFg": "#FFF8E8"
|
||||
},
|
||||
"dark": {
|
||||
"bg": "#1D140D",
|
||||
"bgSoft": "#271C12",
|
||||
"bgElev": "#302417",
|
||||
"panel": "#291E13",
|
||||
"sidebar": "#251B11",
|
||||
"chat": "#231A11",
|
||||
"workspace": "#302417",
|
||||
"workspaceFiles": "#271D13",
|
||||
"border": "#4D3A22",
|
||||
"borderSoft": "#3B2C1A",
|
||||
"fg": "#FFF2D1",
|
||||
"fgDim": "#DDC49C",
|
||||
"fgFaint": "#A9926B",
|
||||
"accent": "#E8AD38",
|
||||
"accentFg": "#241606"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
"density": "comfortable",
|
||||
"corners": "soft"
|
||||
},
|
||||
"background": {
|
||||
"image": "background.webp",
|
||||
"focusX": 0.74,
|
||||
"focusY": 0.44,
|
||||
"safeArea": "left",
|
||||
"homeOpacity": 1.0,
|
||||
"taskOpacity": 0.2,
|
||||
"overlayStrength": 0.7
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "official-noir-gold",
|
||||
"name": "Noir Gold",
|
||||
"author": "Reasonix Contributors",
|
||||
"description": "Black velvet stage and gold spotlights around an original creative gentleman.",
|
||||
"license": "MIT",
|
||||
"baseStyle": "carbon",
|
||||
"tokens": {
|
||||
"light": {
|
||||
"bg": "#FCF8EE",
|
||||
"bgSoft": "#F6F0DF",
|
||||
"bgElev": "#FEFBF4",
|
||||
"panel": "#FEFBF4",
|
||||
"sidebar": "#F5EFDD",
|
||||
"chat": "#FDFAF2",
|
||||
"workspace": "#FEFBF4",
|
||||
"workspaceFiles": "#F7F1E1",
|
||||
"border": "#CCBE94",
|
||||
"borderSoft": "#EFE8D2",
|
||||
"fg": "#2A241B",
|
||||
"fgDim": "#5C5340",
|
||||
"fgFaint": "#9A8F74",
|
||||
"accent": "#7A5A16",
|
||||
"accentFg": "#FCF8EE"
|
||||
},
|
||||
"dark": {
|
||||
"bg": "#0D0B09",
|
||||
"bgSoft": "#15120E",
|
||||
"bgElev": "#1D1913",
|
||||
"panel": "#171410",
|
||||
"sidebar": "#14110D",
|
||||
"chat": "#131009",
|
||||
"workspace": "#1D1913",
|
||||
"workspaceFiles": "#15120E",
|
||||
"border": "#463B27",
|
||||
"borderSoft": "#2A2418",
|
||||
"fg": "#F8F1DF",
|
||||
"fgDim": "#D6CBAE",
|
||||
"fgFaint": "#968C6E",
|
||||
"accent": "#D9B45B",
|
||||
"accentFg": "#1D1503"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
"density": "comfortable",
|
||||
"corners": "soft"
|
||||
},
|
||||
"background": {
|
||||
"image": "background.webp",
|
||||
"focusX": 0.73,
|
||||
"focusY": 0.43,
|
||||
"safeArea": "left",
|
||||
"homeOpacity": 0.94,
|
||||
"taskOpacity": 0.18,
|
||||
"overlayStrength": 0.74
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "official-rose-dawn",
|
||||
"name": "Rose Dawn",
|
||||
"author": "Reasonix Contributors",
|
||||
"description": "Ivory dawn light, soft roses and an original illustrated muse on the right.",
|
||||
"license": "MIT",
|
||||
"baseStyle": "graphite",
|
||||
"tokens": {
|
||||
"light": {
|
||||
"bg": "#FFF7F8",
|
||||
"bgSoft": "#FBEFF1",
|
||||
"bgElev": "#FFFFFF",
|
||||
"panel": "#FFFFFF",
|
||||
"sidebar": "#FBECEF",
|
||||
"chat": "#FFFCFC",
|
||||
"workspace": "#FFFFFF",
|
||||
"workspaceFiles": "#FDF3F4",
|
||||
"border": "#E2BCC6",
|
||||
"borderSoft": "#F5E3E7",
|
||||
"fg": "#3A252C",
|
||||
"fgDim": "#6D4A55",
|
||||
"fgFaint": "#A97B87",
|
||||
"accent": "#B43F65",
|
||||
"accentFg": "#FFF7F8"
|
||||
},
|
||||
"dark": {
|
||||
"bg": "#1E1419",
|
||||
"bgSoft": "#281B21",
|
||||
"bgElev": "#312329",
|
||||
"panel": "#2A1D24",
|
||||
"sidebar": "#251922",
|
||||
"chat": "#231820",
|
||||
"workspace": "#312329",
|
||||
"workspaceFiles": "#281C22",
|
||||
"border": "#4C3440",
|
||||
"borderSoft": "#3A2832",
|
||||
"fg": "#FFF3F6",
|
||||
"fgDim": "#D9B3C0",
|
||||
"fgFaint": "#A87C8C",
|
||||
"accent": "#E26D91",
|
||||
"accentFg": "#2A121D"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
"density": "comfortable",
|
||||
"corners": "round"
|
||||
},
|
||||
"background": {
|
||||
"image": "background.webp",
|
||||
"focusX": 0.72,
|
||||
"focusY": 0.43,
|
||||
"safeArea": "left",
|
||||
"homeOpacity": 1.0,
|
||||
"taskOpacity": 0.2,
|
||||
"overlayStrength": 0.68
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "official-sage-breeze",
|
||||
"name": "Sage Breeze",
|
||||
"author": "Reasonix Contributors",
|
||||
"description": "Cream paper, sage sprigs and an original illustrated reader on the right.",
|
||||
"license": "MIT",
|
||||
"baseStyle": "slate",
|
||||
"tokens": {
|
||||
"light": {
|
||||
"bg": "#F7F7EF",
|
||||
"bgSoft": "#EFEFE2",
|
||||
"bgElev": "#FCFCF6",
|
||||
"panel": "#FCFCF6",
|
||||
"sidebar": "#EEEEE0",
|
||||
"chat": "#FAFAF4",
|
||||
"workspace": "#FCFCF6",
|
||||
"workspaceFiles": "#F1F1E5",
|
||||
"border": "#BFC095",
|
||||
"borderSoft": "#E8E8D8",
|
||||
"fg": "#26332D",
|
||||
"fgDim": "#4E6157",
|
||||
"fgFaint": "#7C8F83",
|
||||
"accent": "#47735F",
|
||||
"accentFg": "#F7F7EF"
|
||||
},
|
||||
"dark": {
|
||||
"bg": "#101814",
|
||||
"bgSoft": "#17211C",
|
||||
"bgElev": "#1E2922",
|
||||
"panel": "#19231D",
|
||||
"sidebar": "#161F1A",
|
||||
"chat": "#151E19",
|
||||
"workspace": "#1E2922",
|
||||
"workspaceFiles": "#17211C",
|
||||
"border": "#32443A",
|
||||
"borderSoft": "#25322B",
|
||||
"fg": "#EEF6F0",
|
||||
"fgDim": "#B7CDBF",
|
||||
"fgFaint": "#7E968A",
|
||||
"accent": "#84CBA7",
|
||||
"accentFg": "#0E1A13"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
"density": "comfortable",
|
||||
"corners": "soft"
|
||||
},
|
||||
"background": {
|
||||
"image": "background.webp",
|
||||
"focusX": 0.73,
|
||||
"focusY": 0.44,
|
||||
"safeArea": "left",
|
||||
"homeOpacity": 1.0,
|
||||
"taskOpacity": 0.2,
|
||||
"overlayStrength": 0.68
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "official-spark-notebook",
|
||||
"name": "Spark Notebook",
|
||||
"author": "Reasonix Contributors",
|
||||
"description": "Off-white notebook grid with teal, yellow and coral stationery around an original anime adult.",
|
||||
"license": "MIT",
|
||||
"baseStyle": "aurora",
|
||||
"tokens": {
|
||||
"light": {
|
||||
"bg": "#FFF9ED",
|
||||
"bgSoft": "#F9F1DE",
|
||||
"bgElev": "#FFFDF7",
|
||||
"panel": "#FFFDF7",
|
||||
"sidebar": "#F8F0DC",
|
||||
"chat": "#FFFCF4",
|
||||
"workspace": "#FFFDF7",
|
||||
"workspaceFiles": "#FAF3E2",
|
||||
"border": "#D2BC8E",
|
||||
"borderSoft": "#F2E9D4",
|
||||
"fg": "#2B2F35",
|
||||
"fgDim": "#565D66",
|
||||
"fgFaint": "#848D98",
|
||||
"accent": "#007B78",
|
||||
"accentFg": "#F3FBFA"
|
||||
},
|
||||
"dark": {
|
||||
"bg": "#14171A",
|
||||
"bgSoft": "#1B1F23",
|
||||
"bgElev": "#23282D",
|
||||
"panel": "#1D2226",
|
||||
"sidebar": "#1A1E22",
|
||||
"chat": "#191D21",
|
||||
"workspace": "#23282D",
|
||||
"workspaceFiles": "#1B2024",
|
||||
"border": "#38404A",
|
||||
"borderSoft": "#2A3138",
|
||||
"fg": "#F8F5E9",
|
||||
"fgDim": "#C9CCBF",
|
||||
"fgFaint": "#8B918A",
|
||||
"accent": "#42D1C6",
|
||||
"accentFg": "#08201E"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
"density": "comfortable",
|
||||
"corners": "round"
|
||||
},
|
||||
"background": {
|
||||
"image": "background.webp",
|
||||
"focusX": 0.74,
|
||||
"focusY": 0.46,
|
||||
"safeArea": "left",
|
||||
"homeOpacity": 0.98,
|
||||
"taskOpacity": 0.2,
|
||||
"overlayStrength": 0.68
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "official-violet-starlight",
|
||||
"name": "Violet Starlight",
|
||||
"author": "Reasonix Contributors",
|
||||
"description": "Blue-violet starfield with glowing butterflies and an original celestial muse.",
|
||||
"license": "MIT",
|
||||
"baseStyle": "nocturne",
|
||||
"tokens": {
|
||||
"light": {
|
||||
"bg": "#F7F4FF",
|
||||
"bgSoft": "#EFEBFB",
|
||||
"bgElev": "#FCFAFF",
|
||||
"panel": "#FCFAFF",
|
||||
"sidebar": "#EEE9FA",
|
||||
"chat": "#FAF8FF",
|
||||
"workspace": "#FCFAFF",
|
||||
"workspaceFiles": "#F1EDFB",
|
||||
"border": "#CABDEC",
|
||||
"borderSoft": "#E8E3F7",
|
||||
"fg": "#251F3C",
|
||||
"fgDim": "#544B74",
|
||||
"fgFaint": "#9188B3",
|
||||
"accent": "#6242C7",
|
||||
"accentFg": "#F7F4FF"
|
||||
},
|
||||
"dark": {
|
||||
"bg": "#0C1022",
|
||||
"bgSoft": "#131834",
|
||||
"bgElev": "#1A2140",
|
||||
"panel": "#151B36",
|
||||
"sidebar": "#121732",
|
||||
"chat": "#111630",
|
||||
"workspace": "#1A2140",
|
||||
"workspaceFiles": "#131834",
|
||||
"border": "#2E3A66",
|
||||
"borderSoft": "#222B4E",
|
||||
"fg": "#F4F2FF",
|
||||
"fgDim": "#C2BDE8",
|
||||
"fgFaint": "#8580B8",
|
||||
"accent": "#9B86FF",
|
||||
"accentFg": "#14102E"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
"density": "comfortable",
|
||||
"corners": "round"
|
||||
},
|
||||
"background": {
|
||||
"image": "background.webp",
|
||||
"focusX": 0.73,
|
||||
"focusY": 0.44,
|
||||
"safeArea": "left",
|
||||
"homeOpacity": 0.96,
|
||||
"taskOpacity": 0.18,
|
||||
"overlayStrength": 0.72
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# Official Theme Asset Provenance
|
||||
|
||||
All eight official Reasonix themes ship with **original** artwork generated procedurally
|
||||
from scratch with the scripts in `scripts/official-theme-art/` (numpy + Pillow, fixed seeds,
|
||||
fully reproducible). The visual *direction* was inspired by the MIT-licensed
|
||||
[Codex-Dream-Skin](https://github.com/Fei-Away/Codex-Dream-Skin) concept gallery, but:
|
||||
|
||||
- **No pixels, layouts, UI mockery, text, logos or watermarks were copied** from the reference
|
||||
project or any third party. Every background is re-authored code output.
|
||||
- All depicted people are **original fictional adults** drawn by the generator: an illustrated
|
||||
muse (Rose Dawn), a lucky programmer mascot (Fortune Forge), a reader (Sage Breeze), an anime
|
||||
adult (Spark Notebook), a silhouette muse (Violet Starlight), a digital performer (Cyan Stage)
|
||||
and a gentleman (Noir Gold). Crimson Horizon contains no people.
|
||||
- Backgrounds contain no windows, sidebars, cards, buttons, inputs or readable text, and are
|
||||
stripped of EXIF/author metadata.
|
||||
|
||||
Assets are released under the MIT License as part of the Reasonix repository,
|
||||
© Reasonix Contributors. Human review: Reasonix Contributors (release PR review).
|
||||
|
||||
Generation date: 2026-07-17
|
||||
|
||||
| Theme | Generator (final prompt equivalent) | background.webp SHA-256 | preview.webp SHA-256 |
|
||||
| --- | --- | --- | --- |
|
||||
| Crimson Horizon / 赤曜新城 (`official-crimson-horizon`) | `scripts/official-theme-art/scenes.py::scene_crimson_horizon (seed 20260119, 2560×1440 canvas, low-info left 0–52%, key content x 62–88% / y 16–72%)` | `34e9051e6b47c64a3d3324832325f06811aaf36ca3a7f3e55480cb978343f1f8` | `ea26252f557aefafdad5147e509e73c4b2c284c50d4d38bee33edcf64499c0dd` |
|
||||
| Cyan Stage / 青岚舞台 (`official-cyan-stage`) | `scripts/official-theme-art/scenes.py::scene_cyan_stage (seed 20260123, 2560×1440 canvas, low-info left 0–52%, key content x 62–88% / y 16–72%)` | `e03e4474e9f0cef3e9f1f48c821962e1adf65cde16ac1659ea7cfe78f524cd5e` | `1fafc6e116f57f68ad8ff040ea6979f0d31769860090779b7a4bd6e04ff2b8ca` |
|
||||
| Fortune Forge / 鸿运工坊 (`official-fortune-forge`) | `scripts/official-theme-art/scenes.py::scene_fortune_forge (seed 20260118, 2560×1440 canvas, low-info left 0–52%, key content x 62–88% / y 16–72%)` | `f8b6f1d2eaea4a2d74990c0f4fe2958016f273a579e0637ceea794789b35780e` | `04c26ed966ff1fda5acd30a802a51484c140fc9c568acdacf98e5b59310a1255` |
|
||||
| Noir Gold / 黑金序曲 (`official-noir-gold`) | `scripts/official-theme-art/scenes.py::scene_noir_gold (seed 20260124, 2560×1440 canvas, low-info left 0–52%, key content x 62–88% / y 16–72%)` | `05684900a437a462a6fdc267ff8bc28abe33e311dd5d3d7e99e1e456a3ccf914` | `10df2fe7a3d0f4c6681be4b9421137a9a83894423ae5508c056dd4f996283197` |
|
||||
| Rose Dawn / 玫瑰晨光 (`official-rose-dawn`) | `scripts/official-theme-art/scenes.py::scene_rose_dawn (seed 20260117, 2560×1440 canvas, low-info left 0–52%, key content x 62–88% / y 16–72%)` | `80e92ffa4c94e7f976f3678116bf59beb3db6f2d322b7b5e5dfbc1d447cc99bc` | `0b7e2096ea67ee2a37d0b50e04ac459b78200aca35906e5051b12382fba37f1e` |
|
||||
| Sage Breeze / 鼠尾草清风 (`official-sage-breeze`) | `scripts/official-theme-art/scenes.py::scene_sage_breeze (seed 20260120, 2560×1440 canvas, low-info left 0–52%, key content x 62–88% / y 16–72%)` | `a27c4bf63edcb5b5aed2790b2f24d3d72aceedd21c78943c314ad1b11fc05344` | `55fed2b26a7f16b4ec0e144e2f89cf07d17ce4bd53b558c84a69b712044f8aa6` |
|
||||
| Spark Notebook / 灵感手账 (`official-spark-notebook`) | `scripts/official-theme-art/scenes.py::scene_spark_notebook (seed 20260121, 2560×1440 canvas, low-info left 0–52%, key content x 62–88% / y 16–72%)` | `adc70d90e9fdf6670a2fa6b7e46a878694ed9097995bae2a6d3e59bd21810092` | `f114dc160f3be0d8ff943b0dd9bbbcb93e6fc8c306fdbd5094daecc3f7e32a26` |
|
||||
| Violet Starlight / 紫曜星夜 (`official-violet-starlight`) | `scripts/official-theme-art/scenes.py::scene_violet_starlight (seed 20260122, 2560×1440 canvas, low-info left 0–52%, key content x 62–88% / y 16–72%)` | `22999e9796f77ad10bd43e21503bf07efa1274eddd4cc0449485fde3e8a830bc` | `9dfbe62b922d140dc89b4991154148f3986f00ab463df9ebb05a997e93c4cf3f` |
|
||||
@@ -0,0 +1,27 @@
|
||||
# 官方主题素材来源与许可记录
|
||||
|
||||
八款 Reasonix 官方主题的全部图片均为**原创**,由 `scripts/official-theme-art/` 中的脚本
|
||||
从零程序化生成(numpy + Pillow,固定随机种子,可完全复现)。视觉*方向*参考了 MIT 许可的
|
||||
[Codex-Dream-Skin](https://github.com/Fei-Away/Codex-Dream-Skin) 概念图库,但是:
|
||||
|
||||
- **未复制参考项目或任何第三方素材的任何像素**、布局、界面元素、文字、标志或水印;
|
||||
所有背景均为代码重新生成的独立素材。
|
||||
- 图中人物均为生成器绘制的**原创虚构成年人**:插画女性(玫瑰晨光)、吉祥程序员(鸿运工坊)、
|
||||
读者(鼠尾草清风)、动漫人物(灵感手账)、剪影女性(紫曜星夜)、数字表演者(青岚舞台)、
|
||||
绅士(黑金序曲)。赤曜新城不含人物。
|
||||
- 背景中不含窗口、侧栏、卡片、按钮、输入框或可读文字,并已去除 EXIF 等元数据。
|
||||
|
||||
素材随 Reasonix 仓库以 MIT 许可发布,© Reasonix Contributors。人工审核:Reasonix Contributors(发布 PR 审核)。
|
||||
|
||||
生成日期:2026-07-17
|
||||
|
||||
| 主题 | 生成器(最终提示词等价物) | background.webp SHA-256 | preview.webp SHA-256 |
|
||||
| --- | --- | --- | --- |
|
||||
| Crimson Horizon / 赤曜新城(`official-crimson-horizon`) | `scripts/official-theme-art/scenes.py::scene_crimson_horizon (seed 20260119, 2560×1440 canvas, low-info left 0–52%, key content x 62–88% / y 16–72%)` | `34e9051e6b47c64a3d3324832325f06811aaf36ca3a7f3e55480cb978343f1f8` | `ea26252f557aefafdad5147e509e73c4b2c284c50d4d38bee33edcf64499c0dd` |
|
||||
| Cyan Stage / 青岚舞台(`official-cyan-stage`) | `scripts/official-theme-art/scenes.py::scene_cyan_stage (seed 20260123, 2560×1440 canvas, low-info left 0–52%, key content x 62–88% / y 16–72%)` | `e03e4474e9f0cef3e9f1f48c821962e1adf65cde16ac1659ea7cfe78f524cd5e` | `1fafc6e116f57f68ad8ff040ea6979f0d31769860090779b7a4bd6e04ff2b8ca` |
|
||||
| Fortune Forge / 鸿运工坊(`official-fortune-forge`) | `scripts/official-theme-art/scenes.py::scene_fortune_forge (seed 20260118, 2560×1440 canvas, low-info left 0–52%, key content x 62–88% / y 16–72%)` | `f8b6f1d2eaea4a2d74990c0f4fe2958016f273a579e0637ceea794789b35780e` | `04c26ed966ff1fda5acd30a802a51484c140fc9c568acdacf98e5b59310a1255` |
|
||||
| Noir Gold / 黑金序曲(`official-noir-gold`) | `scripts/official-theme-art/scenes.py::scene_noir_gold (seed 20260124, 2560×1440 canvas, low-info left 0–52%, key content x 62–88% / y 16–72%)` | `05684900a437a462a6fdc267ff8bc28abe33e311dd5d3d7e99e1e456a3ccf914` | `10df2fe7a3d0f4c6681be4b9421137a9a83894423ae5508c056dd4f996283197` |
|
||||
| Rose Dawn / 玫瑰晨光(`official-rose-dawn`) | `scripts/official-theme-art/scenes.py::scene_rose_dawn (seed 20260117, 2560×1440 canvas, low-info left 0–52%, key content x 62–88% / y 16–72%)` | `80e92ffa4c94e7f976f3678116bf59beb3db6f2d322b7b5e5dfbc1d447cc99bc` | `0b7e2096ea67ee2a37d0b50e04ac459b78200aca35906e5051b12382fba37f1e` |
|
||||
| Sage Breeze / 鼠尾草清风(`official-sage-breeze`) | `scripts/official-theme-art/scenes.py::scene_sage_breeze (seed 20260120, 2560×1440 canvas, low-info left 0–52%, key content x 62–88% / y 16–72%)` | `a27c4bf63edcb5b5aed2790b2f24d3d72aceedd21c78943c314ad1b11fc05344` | `55fed2b26a7f16b4ec0e144e2f89cf07d17ce4bd53b558c84a69b712044f8aa6` |
|
||||
| Spark Notebook / 灵感手账(`official-spark-notebook`) | `scripts/official-theme-art/scenes.py::scene_spark_notebook (seed 20260121, 2560×1440 canvas, low-info left 0–52%, key content x 62–88% / y 16–72%)` | `adc70d90e9fdf6670a2fa6b7e46a878694ed9097995bae2a6d3e59bd21810092` | `f114dc160f3be0d8ff943b0dd9bbbcb93e6fc8c306fdbd5094daecc3f7e32a26` |
|
||||
| Violet Starlight / 紫曜星夜(`official-violet-starlight`) | `scripts/official-theme-art/scenes.py::scene_violet_starlight (seed 20260122, 2560×1440 canvas, low-info left 0–52%, key content x 62–88% / y 16–72%)` | `22999e9796f77ad10bd43e21503bf07efa1274eddd4cc0449485fde3e8a830bc` | `9dfbe62b922d140dc89b4991154148f3986f00ab463df9ebb05a997e93c4cf3f` |
|
||||
@@ -0,0 +1,253 @@
|
||||
# Reasonix Theme Pack V2
|
||||
|
||||
Native theme packs for the Reasonix desktop app. Packs are controlled skins:
|
||||
semantic color tokens, density/corner recipes, and optional local images for
|
||||
the home and task/workspace scenes. They **cannot** run CSS, JavaScript, fonts,
|
||||
remote URLs, or SVG scripts. V1 packs remain valid and use the home image in
|
||||
both scenes.
|
||||
|
||||
> Chinese: [THEME_PACK.zh-CN.md](./THEME_PACK.zh-CN.md)
|
||||
|
||||
## Goals (first release)
|
||||
|
||||
- Built-in styles, user themes, backgrounds, live preview, import/export, local library
|
||||
- Full background on the home (empty) scene; reduced opacity + directional overlay on task scenes
|
||||
- Works with Classic / Workbench / Creation and `auto` / `light` / `dark`
|
||||
- **No** online marketplace, cloud sync, or script plugins
|
||||
|
||||
## Theme experience (settings IA)
|
||||
|
||||
Appearance is split into two surfaces (no third entry):
|
||||
|
||||
1. **Appearance overview** — current theme summary, light/dark mode, **one** base-style
|
||||
control, fonts and zoom. Primary action: **Browse themes**.
|
||||
2. **Theme gallery** — official / my themes / base styles tabs, select-to-inspect cards,
|
||||
detail panel with isolated preview, temporary full-app preview, and a single
|
||||
**Apply theme** action. Immersive preview is part of the gallery detail flow.
|
||||
|
||||
State model (schema v2 of `desktop-theme-state.json`):
|
||||
|
||||
| State | Meaning | Persistence |
|
||||
| --- | --- | --- |
|
||||
| `themeMode` | auto / light / dark | desktop config |
|
||||
| `baseStyle` | Graphite…Amber | desktop config (`theme_style`) |
|
||||
| `activeThemeId` | official or user pack only | `desktop-theme-state.json` |
|
||||
| `selectedThemeId` / `previewThemeId` | gallery selection / temp preview | frontend memory only |
|
||||
|
||||
- `activeThemeId` **must not** store base style ids. Choosing a base style clears the pack.
|
||||
- Applying a pack keeps `baseStyle` as the disable/fallback value.
|
||||
- Light/dark mode is independent of the pack.
|
||||
|
||||
## Theme kinds
|
||||
|
||||
The gallery has three groups:
|
||||
|
||||
| Kind | Source | Editable | Deletable | Exportable |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **Base styles** | Six visual directions (Graphite, Aurora, Slate, Carbon, Nocturne, Amber), token-less | no (duplicate first) | no | no |
|
||||
| **Official themes** | Eight read-only packs embedded in the installer (manifest + original background + thumbnail, MIT) | no (duplicate first) | no | no |
|
||||
| **User themes** | Created in the editor, duplicated, or imported as `.reasonix-theme` | yes | yes | yes |
|
||||
|
||||
- All 14 built-in ids (6 base + 8 official) are **reserved**: save, import, copy-over
|
||||
and delete all refuse collisions.
|
||||
- Activating an official theme stores only its id in `desktop-theme-state.json` —
|
||||
assets are read from the embedded copy at runtime.
|
||||
- "Duplicate" on a base/official theme creates an ordinary editable user theme
|
||||
(the official background is copied into the user library); the duplicate can
|
||||
then be edited or exported.
|
||||
- v1 states that stored a base id as `activeThemeId` are migrated to `desktop.theme_style`
|
||||
and cleared on load.
|
||||
|
||||
### The eight official themes
|
||||
|
||||
| ID | Name | Base style | Artwork |
|
||||
| --- | --- | --- | --- |
|
||||
| `official-rose-dawn` | Rose Dawn / 玫瑰晨光 | graphite | Ivory dawn, soft roses, original illustrated muse |
|
||||
| `official-fortune-forge` | Fortune Forge / 鸿运工坊 | amber | Vermilion/gold/jade workshop, original lucky programmer |
|
||||
| `official-crimson-horizon` | Crimson Horizon / 赤曜新城 | graphite | Coral-red future city skyline, no people |
|
||||
| `official-sage-breeze` | Sage Breeze / 鼠尾草清风 | slate | Cream paper, sage sprigs, original reader |
|
||||
| `official-spark-notebook` | Spark Notebook / 灵感手账 | aurora | Notebook grid with stationery, original anime adult |
|
||||
| `official-violet-starlight` | Violet Starlight / 紫曜星夜 | nocturne | Blue-violet starfield, butterflies, silhouette muse |
|
||||
| `official-cyan-stage` | Cyan Stage / 青岚舞台 | carbon | Cyan stage, light rings, original digital performer |
|
||||
| `official-noir-gold` | Noir Gold / 黑金序曲 | carbon | Black velvet, gold spotlights, original gentleman |
|
||||
|
||||
Previews are shown inside the app's theme library (Settings → Appearance) from
|
||||
real Reasonix builds. **Screenshots of the app must not be imported as theme
|
||||
backgrounds.** Asset provenance, hashes and licence ledger:
|
||||
[THEME_ASSETS.md](./THEME_ASSETS.md) · generator scripts in
|
||||
`scripts/official-theme-art/` (procedural, fixed seeds, reproducible).
|
||||
|
||||
## Package format
|
||||
|
||||
Distribute as a `.reasonix-theme` ZIP. The archive root may contain **only**:
|
||||
|
||||
| File | Required | Notes |
|
||||
| --- | --- | --- |
|
||||
| `theme.json` | yes | Manifest (≤ 1 MiB) |
|
||||
| `background.png` / `.jpg` / `.jpeg` / `.webp` | no | Home image ≤ 16 MiB, ≤ 8192×8192 |
|
||||
| `background-task.png` / `.jpg` / `.jpeg` / `.webp` | no | Independent task/workspace image ≤ 16 MiB, ≤ 8192×8192 (V2) |
|
||||
|
||||
ZIP limits: package ≤ 36 MiB; no nested directories, no symlinks, no duplicate entries, no path traversal.
|
||||
|
||||
### `theme.json` example
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"id": "my-theme",
|
||||
"name": "My Theme",
|
||||
"author": "",
|
||||
"description": "",
|
||||
"license": "",
|
||||
"baseStyle": "graphite",
|
||||
"tokens": {
|
||||
"light": {
|
||||
"bg": "#f4f3ef",
|
||||
"fg": "#111827",
|
||||
"accent": "#2f5fa8"
|
||||
},
|
||||
"dark": {
|
||||
"bg": "#0c0d10",
|
||||
"fg": "#f1f1ef",
|
||||
"accent": "#ff6a3d"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
"density": "comfortable",
|
||||
"corners": "soft"
|
||||
},
|
||||
"background": {
|
||||
"image": "background.webp",
|
||||
"focusX": 0.72,
|
||||
"focusY": 0.45,
|
||||
"safeArea": "left",
|
||||
"homeOpacity": 1,
|
||||
"taskOpacity": 0.28,
|
||||
"overlayStrength": 0.62
|
||||
},
|
||||
"taskBackground": {
|
||||
"image": "background-task.webp",
|
||||
"focusX": 0.5,
|
||||
"focusY": 0.5,
|
||||
"safeArea": "right",
|
||||
"opacity": 0.28,
|
||||
"overlayStrength": 0.62
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
JSON Schema: [theme-pack.schema.json](./theme-pack.schema.json)
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Rules |
|
||||
| --- | --- |
|
||||
| `schemaVersion` | `1` or `2`; `taskBackground` requires `2` |
|
||||
| `id` | Lowercase `[a-z][a-z0-9-]*`, reserved: `graphite`, `aurora`, `slate`, `carbon`, `nocturne`, `amber` |
|
||||
| `baseStyle` | One of the six built-in directions; uncovered tokens inherit it |
|
||||
| `tokens.light` / `tokens.dark` | Optional maps of semantic keys → `#RRGGBB` or `#RRGGBBAA` only |
|
||||
| `recipes.density` | `compact` \| `comfortable` |
|
||||
| `recipes.corners` | `square` \| `soft` \| `round` |
|
||||
| `background.image` | Bare file name only (png/jpeg/webp) |
|
||||
| `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.overlayStrength` | 0–1 |
|
||||
| `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.overlayStrength` | 0–1 |
|
||||
|
||||
### Allowed token keys
|
||||
|
||||
`bg`, `bgSoft`, `bgElev`, `panel`, `sidebar`, `chat`, `workspace`, `workspaceFiles`,
|
||||
`border`, `borderSoft`, `fg`, `fgDim`, `fgFaint`, `accent`, `accentFg`, `ok`, `warn`, `err`
|
||||
|
||||
Colors must **not** include `url()`, gradients, or arbitrary CSS.
|
||||
|
||||
## Engine behavior
|
||||
|
||||
1. Apply global `auto` / `light` / `dark` and the base visual style.
|
||||
2. Apply the pack overlay (CSS custom properties) **after** stylesheets so it wins over trailing `:root` and Creation locals.
|
||||
3. Root gets `data-theme-pack="<id>"`; the app container gets `data-theme-scene="home|task"`.
|
||||
4. Scene is derived only from whether the current session has content — it does not change chat lifecycle.
|
||||
5. Background is a fixed, non-interactive layer. Task scene dims the image and paints a directional wash (**no** `backdrop-filter`).
|
||||
|
||||
## Storage
|
||||
|
||||
| Path under Reasonix home | Purpose |
|
||||
| --- | --- |
|
||||
| `desktop-theme-state.json` | Versioned active theme pointer (not `config.toml`) |
|
||||
| `themes/<id>/` | User theme library (`theme.json` + up to two optional scene images) |
|
||||
|
||||
Legacy installs without theme state keep the previous appearance. Old app versions ignore the new directory. CLI theme, prompts, provider requests, and cache keys are unchanged.
|
||||
|
||||
## Desktop bridge (frontend)
|
||||
|
||||
List / activate / reset / save / delete / copy / import / export / pick background.
|
||||
The UI only receives temporary asset URLs (`/__reasonix_theme_asset/...`) or data URLs — never absolute host paths.
|
||||
|
||||
Import: same id is rejected until the user confirms atomic replace. Built-ins cannot be overwritten or deleted. Corrupt / missing packs fall back to the Graphite path. Safe mode does not load external themes. `/theme reset` and the command palette restore entry clear the pack.
|
||||
|
||||
## Authoring tips
|
||||
|
||||
1. Start from a built-in direction and override only the tokens you need.
|
||||
2. Prefer WCAG AA contrast (≈ 4.5:1 body text). The editor warns but does not block save.
|
||||
3. Before sharing a pack with a photo or portrait, confirm redistribution rights.
|
||||
4. Do not ship third-party or copyrighted reference assets from other products.
|
||||
|
||||
## Template
|
||||
|
||||
A minimal, royalty-free starter (no portrait photos):
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "paper-dawn",
|
||||
"name": "Paper Dawn",
|
||||
"author": "Reasonix",
|
||||
"description": "Template theme — solid tokens only, no background image.",
|
||||
"license": "CC0-1.0",
|
||||
"baseStyle": "graphite",
|
||||
"tokens": {
|
||||
"light": {
|
||||
"bg": "#f7f4ef",
|
||||
"panel": "#ffffff",
|
||||
"sidebar": "#f3efe8",
|
||||
"chat": "#fbfaf7",
|
||||
"fg": "#1c1917",
|
||||
"fgDim": "#57534e",
|
||||
"fgFaint": "#a8a29e",
|
||||
"border": "#e7e5e4",
|
||||
"accent": "#c2410c",
|
||||
"accentFg": "#fff7ed",
|
||||
"ok": "#15803d",
|
||||
"warn": "#b45309",
|
||||
"err": "#b91c1c"
|
||||
},
|
||||
"dark": {
|
||||
"bg": "#0c0b0a",
|
||||
"panel": "#171412",
|
||||
"sidebar": "#141210",
|
||||
"chat": "#0c0b0a",
|
||||
"fg": "#f5f5f4",
|
||||
"fgDim": "#a8a29e",
|
||||
"fgFaint": "#78716c",
|
||||
"border": "#292524",
|
||||
"accent": "#fb923c",
|
||||
"accentFg": "#0c0b0a",
|
||||
"ok": "#4ade80",
|
||||
"warn": "#fbbf24",
|
||||
"err": "#f87171"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
"density": "comfortable",
|
||||
"corners": "soft"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Zip as `paper-dawn.reasonix-theme` with only `theme.json` at the root.
|
||||
@@ -0,0 +1,188 @@
|
||||
# Reasonix 主题包 V2
|
||||
|
||||
Reasonix 桌面端原生主题包。主题是**受控皮肤**:语义颜色令牌、密度/圆角配方,以及首页与任务/工作区可独立配置的本地背景图。主题**不能**执行 CSS、JavaScript、加载字体、远程 URL 或 SVG 脚本。V1 主题继续兼容,并在两个场景共用首页图片。
|
||||
|
||||
> English: [THEME_PACK.md](./THEME_PACK.md)
|
||||
|
||||
## 首版目标
|
||||
|
||||
- 内置风格、自建主题、背景图、实时预览、导入/导出、本地主题库
|
||||
- 首页完整展示背景;进入任务后自动降低透明度并增加方向性遮罩
|
||||
- 同时支持 Classic / Workbench / Creation,以及 `auto` / `light` / `dark`
|
||||
- **不包含**在线主题市场、云同步或脚本插件
|
||||
|
||||
## 主题体验(设置信息架构)
|
||||
|
||||
外观拆成两个界面(不增加第三套入口):
|
||||
|
||||
1. **外观概览页** — 当前主题摘要、明暗模式、**唯一**基础配色控件、字体与缩放。主操作:**浏览主题**。
|
||||
2. **主题画廊** — 官方 / 我的主题 / 基础配色三个分类;点击卡片仅选中;详情区隔离预览;
|
||||
「临时预览」才覆盖全应用;只有「应用主题」会持久化。沉浸式预览是画廊详情的一部分。
|
||||
|
||||
状态模型(`desktop-theme-state.json` schema v2):
|
||||
|
||||
| 状态 | 含义 | 持久化 |
|
||||
| --- | --- | --- |
|
||||
| `themeMode` | 自动 / 浅色 / 深色 | 桌面配置 |
|
||||
| `baseStyle` | Graphite…Amber | 桌面配置(`theme_style`) |
|
||||
| `activeThemeId` | 仅官方或用户主题 | `desktop-theme-state.json` |
|
||||
| `selectedThemeId` / `previewThemeId` | 画廊选中 / 临时预览 | 仅前端内存 |
|
||||
|
||||
- `activeThemeId` **禁止**保存基础配色 id。选择基础配色会清空主题包。
|
||||
- 应用主题包时保留 `baseStyle` 作为停用后的回退值。
|
||||
- 明暗模式与主题包独立。
|
||||
|
||||
## 主题类型
|
||||
|
||||
画廊分三组:
|
||||
|
||||
| 类型 | 来源 | 可编辑 | 可删除 | 可导出 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **基础风格** | 六种视觉方向(Graphite、Aurora、Slate、Carbon、Nocturne、Amber),无令牌覆盖 | 否(需先复制) | 否 | 否 |
|
||||
| **官方主题** | 安装包内嵌的八款只读主题(清单 + 原创背景 + 缩略图,MIT) | 否(需先复制) | 否 | 否 |
|
||||
| **我的主题** | 编辑器新建、复制,或导入 `.reasonix-theme` | 是 | 是 | 是 |
|
||||
|
||||
- 14 个内置 id(6 基础 + 8 官方)均为**保留名**:保存、导入、覆盖复制与删除都会拒绝冲突。
|
||||
- 激活官方主题时,仅把其 id 写入 `desktop-theme-state.json`;资源在运行时从嵌入副本读取。
|
||||
- 对基础/官方主题执行「复制」会生成可编辑的用户主题(官方背景会拷入用户库),之后可编辑或导出。
|
||||
- v1 若把基础配色 id 存为 `activeThemeId`,加载时会迁移到 `desktop.theme_style` 并清空活动主题。
|
||||
|
||||
### 八款官方主题
|
||||
|
||||
| ID | 名称 | 基础风格 | 画面 |
|
||||
| --- | --- | --- | --- |
|
||||
| `official-rose-dawn` | Rose Dawn / 玫瑰晨光 | graphite | 象牙白晨光、柔粉玫瑰、原创插画女性 |
|
||||
| `official-fortune-forge` | Fortune Forge / 鸿运工坊 | amber | 朱红/金/玉绿工坊、原创吉祥程序员 |
|
||||
| `official-crimson-horizon` | Crimson Horizon / 赤曜新城 | graphite | 珊瑚红未来城市天际线,无人物 |
|
||||
| `official-sage-breeze` | Sage Breeze / 鼠尾草清风 | slate | 奶油纸张、鼠尾草、原创读者 |
|
||||
| `official-spark-notebook` | Spark Notebook / 灵感手账 | aurora | 手账网格与文具、原创动漫成年人 |
|
||||
| `official-violet-starlight` | Violet Starlight / 紫曜星夜 | nocturne | 蓝紫星空、蝴蝶、剪影女性 |
|
||||
| `official-cyan-stage` | Cyan Stage / 青岚舞台 | carbon | 青蓝舞台与光环、原创数字表演者 |
|
||||
| `official-noir-gold` | Noir Gold / 黑金序曲 | carbon | 黑丝绒、金色聚光灯、原创绅士 |
|
||||
|
||||
预览在应用内主题库(设置 → 外观)中展示,来自真实 Reasonix 构建。**请勿把应用截图当作主题背景导入。** 素材来源、哈希与许可记录见 [THEME_ASSETS.zh-CN.md](./THEME_ASSETS.zh-CN.md);生成脚本在 `scripts/official-theme-art/`(程序化、固定种子、可复现)。
|
||||
|
||||
## 包格式
|
||||
|
||||
以 `.reasonix-theme` ZIP 分发。根目录**只能**包含:
|
||||
|
||||
| 文件 | 必需 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `theme.json` | 是 | 清单(≤ 1 MiB) |
|
||||
| `background.png` / `.jpg` / `.jpeg` / `.webp` | 否 | 首页图片 ≤ 16 MiB,边长 ≤ 8192 |
|
||||
| `background-task.png` / `.jpg` / `.jpeg` / `.webp` | 否 | 独立任务/工作区图片 ≤ 16 MiB,边长 ≤ 8192(V2) |
|
||||
|
||||
ZIP 限制:包体 ≤ 36 MiB;禁止子目录、符号链接、重复条目与路径穿越。
|
||||
|
||||
### `theme.json` 示例
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"id": "my-theme",
|
||||
"name": "My Theme",
|
||||
"author": "",
|
||||
"description": "",
|
||||
"license": "",
|
||||
"baseStyle": "graphite",
|
||||
"tokens": {
|
||||
"light": {
|
||||
"bg": "#f4f3ef",
|
||||
"fg": "#111827",
|
||||
"accent": "#2f5fa8"
|
||||
},
|
||||
"dark": {
|
||||
"bg": "#0c0d10",
|
||||
"fg": "#f1f1ef",
|
||||
"accent": "#ff6a3d"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
"density": "comfortable",
|
||||
"corners": "soft"
|
||||
},
|
||||
"background": {
|
||||
"image": "background.webp",
|
||||
"focusX": 0.72,
|
||||
"focusY": 0.45,
|
||||
"safeArea": "left",
|
||||
"homeOpacity": 1,
|
||||
"taskOpacity": 0.28,
|
||||
"overlayStrength": 0.62
|
||||
},
|
||||
"taskBackground": {
|
||||
"image": "background-task.webp",
|
||||
"focusX": 0.5,
|
||||
"focusY": 0.5,
|
||||
"safeArea": "right",
|
||||
"opacity": 0.28,
|
||||
"overlayStrength": 0.62
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
JSON Schema: [theme-pack.schema.json](./theme-pack.schema.json)
|
||||
|
||||
### 字段规则
|
||||
|
||||
| 字段 | 规则 |
|
||||
| --- | --- |
|
||||
| `schemaVersion` | `1` 或 `2`;使用 `taskBackground` 时必须为 `2` |
|
||||
| `id` | 小写 `[a-z][a-z0-9-]*`;保留:`graphite`/`aurora`/`slate`/`carbon`/`nocturne`/`amber` |
|
||||
| `baseStyle` | 六套内置方向之一;未覆盖令牌继承该方向 |
|
||||
| `tokens.light` / `tokens.dark` | 可选;键 → `#RRGGBB` 或 `#RRGGBBAA` |
|
||||
| `recipes.density` | `compact` \| `comfortable` |
|
||||
| `recipes.corners` | `square` \| `soft` \| `round` |
|
||||
| `background.image` | 仅允许裸文件名(png/jpeg/webp) |
|
||||
| `background.focusX/Y` | 0–1 焦点 |
|
||||
| `background.safeArea` | `left` \| `right` \| `center`(任务页遮罩方向) |
|
||||
| `background.homeOpacity` | 0–1 |
|
||||
| `background.taskOpacity` | 0–0.45(硬上限) |
|
||||
| `background.overlayStrength` | 0–1 |
|
||||
| `taskBackground.image` | 可选的独立任务/工作区图片,仅允许本地裸文件名 |
|
||||
| `taskBackground.focusX/Y` | 0–1 焦点 |
|
||||
| `taskBackground.safeArea` | `left` \| `right` \| `center` |
|
||||
| `taskBackground.opacity` | 0–0.45(硬上限) |
|
||||
| `taskBackground.overlayStrength` | 0–1 |
|
||||
|
||||
### 允许的令牌键
|
||||
|
||||
`bg`, `bgSoft`, `bgElev`, `panel`, `sidebar`, `chat`, `workspace`, `workspaceFiles`,
|
||||
`border`, `borderSoft`, `fg`, `fgDim`, `fgFaint`, `accent`, `accentFg`, `ok`, `warn`, `err`
|
||||
|
||||
颜色**不得**包含 `url()`、渐变或任意 CSS。
|
||||
|
||||
## 引擎行为
|
||||
|
||||
1. 先应用全局 `auto`/`light`/`dark` 与基础视觉风格。
|
||||
2. 再应用主题包覆盖层(CSS 变量),挂在样式表之后,避免被后置 `:root` 与 Creation 局部变量压掉。
|
||||
3. 根节点 `data-theme-pack="<id>"`;应用容器 `data-theme-scene="home|task"`。
|
||||
4. 场景仅由当前会话是否有内容决定,不改变聊天状态或布局生命周期。
|
||||
5. 背景为独立、不可交互层。任务页限制最高透明度并叠加方向性遮罩(**不**使用 `backdrop-filter`)。
|
||||
|
||||
## 存储
|
||||
|
||||
| Reasonix 主目录路径 | 用途 |
|
||||
| --- | --- |
|
||||
| `desktop-theme-state.json` | 版本化的当前主题指针(**不**改 `config.toml`) |
|
||||
| `themes/<id>/` | 用户主题库(`theme.json` + 最多两张可选场景图片) |
|
||||
|
||||
旧配置缺少主题状态时保持原行为;旧版本忽略新目录。CLI 主题、提示词、Provider 请求与缓存键均不变。
|
||||
|
||||
## 桌面桥接
|
||||
|
||||
列出 / 启用 / 重置 / 保存 / 删除 / 复制 / 导入 / 导出 / 选择背景。
|
||||
前端只接收临时资源 URL(`/__reasonix_theme_asset/...`)或 data URL,不暴露本机绝对路径。
|
||||
|
||||
同 ID 导入默认拒绝,确认后才允许原子替换。内置主题不可覆盖或删除。损坏/丢失回退 Graphite 路径。安全模式不加载外部主题。`/theme reset` 与命令面板可恢复默认。
|
||||
|
||||
## 创作建议
|
||||
|
||||
1. 从内置方向起步,只覆盖需要的令牌。
|
||||
2. 尽量满足 WCAG AA(正文约 4.5:1);编辑器会警告但允许继续保存。
|
||||
3. 分享含背景图的主题前,确认照片/肖像/第三方素材的分发权利。
|
||||
4. 首版不复制参考仓库的人物或第三方图片资产。
|
||||
|
||||
## 模板
|
||||
|
||||
无版权素材的纯色模板(不含背景图)见英文版 [THEME_PACK.md](./THEME_PACK.md) 中的 `paper-dawn` 示例;将仅含 `theme.json` 的根目录打成 `paper-dawn.reasonix-theme` 即可导入。
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://reasonix.dev/schemas/theme-pack-v2.json",
|
||||
"title": "Reasonix Theme Pack V2",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schemaVersion", "id", "name", "baseStyle"],
|
||||
"properties": {
|
||||
"schemaVersion": { "type": "integer", "enum": [1, 2] },
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z]([a-z0-9-]{0,62}[a-z0-9])?$"
|
||||
},
|
||||
"name": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||
"author": { "type": "string", "maxLength": 240 },
|
||||
"description": { "type": "string", "maxLength": 240 },
|
||||
"license": { "type": "string", "maxLength": 240 },
|
||||
"baseStyle": {
|
||||
"type": "string",
|
||||
"enum": ["graphite", "aurora", "slate", "carbon", "nocturne", "amber"]
|
||||
},
|
||||
"tokens": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"light": { "$ref": "#/$defs/tokenMap" },
|
||||
"dark": { "$ref": "#/$defs/tokenMap" }
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"density": { "type": "string", "enum": ["compact", "comfortable"] },
|
||||
"corners": { "type": "string", "enum": ["square", "soft", "round"] }
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"image": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,120}\\.(png|jpe?g|webp)$"
|
||||
},
|
||||
"focusX": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"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 }
|
||||
}
|
||||
},
|
||||
"taskBackground": { "$ref": "#/$defs/sceneBackground" }
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": { "required": ["taskBackground"] },
|
||||
"then": { "properties": { "schemaVersion": { "const": 2 } } }
|
||||
}
|
||||
],
|
||||
"$defs": {
|
||||
"hexColor": {
|
||||
"type": "string",
|
||||
"pattern": "^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$"
|
||||
},
|
||||
"sceneBackground": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"image": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,120}\\.(png|jpe?g|webp)$"
|
||||
},
|
||||
"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 }
|
||||
}
|
||||
},
|
||||
"tokenMap": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"bg": { "$ref": "#/$defs/hexColor" },
|
||||
"bgSoft": { "$ref": "#/$defs/hexColor" },
|
||||
"bgElev": { "$ref": "#/$defs/hexColor" },
|
||||
"panel": { "$ref": "#/$defs/hexColor" },
|
||||
"sidebar": { "$ref": "#/$defs/hexColor" },
|
||||
"chat": { "$ref": "#/$defs/hexColor" },
|
||||
"workspace": { "$ref": "#/$defs/hexColor" },
|
||||
"workspaceFiles": { "$ref": "#/$defs/hexColor" },
|
||||
"border": { "$ref": "#/$defs/hexColor" },
|
||||
"borderSoft": { "$ref": "#/$defs/hexColor" },
|
||||
"fg": { "$ref": "#/$defs/hexColor" },
|
||||
"fgDim": { "$ref": "#/$defs/hexColor" },
|
||||
"fgFaint": { "$ref": "#/$defs/hexColor" },
|
||||
"accent": { "$ref": "#/$defs/hexColor" },
|
||||
"accentFg": { "$ref": "#/$defs/hexColor" },
|
||||
"ok": { "$ref": "#/$defs/hexColor" },
|
||||
"warn": { "$ref": "#/$defs/hexColor" },
|
||||
"err": { "$ref": "#/$defs/hexColor" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "paper-dawn",
|
||||
"name": "Paper Dawn",
|
||||
"author": "Reasonix",
|
||||
"description": "Template theme — solid tokens only, no background image. Royalty-free starter.",
|
||||
"license": "CC0-1.0",
|
||||
"baseStyle": "graphite",
|
||||
"tokens": {
|
||||
"light": {
|
||||
"bg": "#f7f4ef",
|
||||
"panel": "#ffffff",
|
||||
"sidebar": "#f3efe8",
|
||||
"chat": "#fbfaf7",
|
||||
"fg": "#1c1917",
|
||||
"fgDim": "#57534e",
|
||||
"fgFaint": "#a8a29e",
|
||||
"border": "#e7e5e4",
|
||||
"accent": "#c2410c",
|
||||
"accentFg": "#fff7ed",
|
||||
"ok": "#15803d",
|
||||
"warn": "#b45309",
|
||||
"err": "#b91c1c"
|
||||
},
|
||||
"dark": {
|
||||
"bg": "#0c0b0a",
|
||||
"panel": "#171412",
|
||||
"sidebar": "#141210",
|
||||
"chat": "#0c0b0a",
|
||||
"fg": "#f5f5f4",
|
||||
"fgDim": "#a8a29e",
|
||||
"fgFaint": "#78716c",
|
||||
"border": "#292524",
|
||||
"accent": "#fb923c",
|
||||
"accentFg": "#0c0b0a",
|
||||
"ok": "#4ade80",
|
||||
"warn": "#fbbf24",
|
||||
"err": "#f87171"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
"density": "comfortable",
|
||||
"corners": "soft"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
"""Shared procedural art toolkit for Reasonix official theme backgrounds.
|
||||
|
||||
All artwork is generated from scratch with numpy + PIL. No reference pixels,
|
||||
no third-party assets, no text, no UI mockery. Fixed seeds make every render
|
||||
reproducible; the SHA-256 of each output is recorded in PROVENANCE.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFilter
|
||||
|
||||
W, H = 2560, 1440
|
||||
|
||||
# Layout contract (fractions of W/H) from the theme plan:
|
||||
# low-info zone : x 0% - 52%
|
||||
# visual centre : x 68% - 76%
|
||||
# key content box: x 62% - 88%, y 16% - 72%
|
||||
KEY_X0, KEY_X1 = 0.62 * W, 0.88 * W
|
||||
KEY_Y0, KEY_Y1 = 0.16 * H, 0.72 * H
|
||||
FOCUS_X = 0.72 * W
|
||||
|
||||
|
||||
def hex2rgb(s: str) -> tuple[int, int, int]:
|
||||
s = s.lstrip("#")
|
||||
return int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)
|
||||
|
||||
|
||||
def mix(c1, c2, t: float):
|
||||
a, b = hex2rgb(c1) if isinstance(c1, str) else c1, hex2rgb(c2) if isinstance(c2, str) else c2
|
||||
return tuple(int(round(a[i] + (b[i] - a[i]) * t)) for i in range(3))
|
||||
|
||||
|
||||
def rgba(c, a: int):
|
||||
return (c[0], c[1], c[2], max(0, min(255, int(a))))
|
||||
|
||||
|
||||
def _stops_arrays(stops):
|
||||
pos = np.array([p for p, _ in stops], dtype=np.float64)
|
||||
cols = np.array([hex2rgb(c) for _, c in stops], dtype=np.float64)
|
||||
return pos, cols
|
||||
|
||||
|
||||
def _interp_channel(pos, cols, t):
|
||||
out = np.zeros((*t.shape, 3), dtype=np.float64)
|
||||
for ch in range(3):
|
||||
out[..., ch] = np.interp(t, pos, cols[:, ch])
|
||||
return out
|
||||
|
||||
|
||||
def gradient(w: int, h: int, stops, direction: str = "v") -> Image.Image:
|
||||
"""Multi-stop gradient. direction: v | h | d1 (tl->br) | d2 (bl->tr) | r (radial from stops centre)."""
|
||||
pos, cols = _stops_arrays(stops)
|
||||
if direction == "v":
|
||||
t = np.linspace(0.0, 1.0, h)[:, None] * np.ones((1, w))
|
||||
elif direction == "h":
|
||||
t = np.ones((h, 1)) * np.linspace(0.0, 1.0, w)[None, :]
|
||||
elif direction == "d1":
|
||||
t = (np.linspace(0.0, 1.0, h)[:, None] + np.linspace(0.0, 1.0, w)[None, :]) / 2.0
|
||||
elif direction == "d2":
|
||||
t = (np.linspace(1.0, 0.0, h)[:, None] + np.linspace(0.0, 1.0, w)[None, :]) / 2.0
|
||||
else:
|
||||
raise ValueError(direction)
|
||||
arr = _interp_channel(pos, cols, t).astype(np.uint8)
|
||||
return Image.fromarray(arr, "RGB").convert("RGBA")
|
||||
|
||||
|
||||
def new_layer() -> Image.Image:
|
||||
return Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
|
||||
|
||||
def comp(base: Image.Image, layer: Image.Image, blur: float = 0.0) -> Image.Image:
|
||||
if blur > 0:
|
||||
layer = layer.filter(ImageFilter.GaussianBlur(blur))
|
||||
base.alpha_composite(layer)
|
||||
return base
|
||||
|
||||
|
||||
def glow(base, cx, cy, r, color, alpha, squash=1.0):
|
||||
"""Soft radial light blob (alpha peaks at centre)."""
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
rx, ry = r, r * squash
|
||||
steps = 28
|
||||
for i in range(steps, 0, -1):
|
||||
t = i / steps
|
||||
a = alpha * (1.0 - t) ** 1.6
|
||||
d.ellipse([cx - rx * t, cy - ry * t, cx + rx * t, cy + ry * t], fill=rgba(color, a))
|
||||
base.alpha_composite(lay.filter(ImageFilter.GaussianBlur(r * 0.10)))
|
||||
|
||||
|
||||
def beam(base, apex, target, width0, width1, color, alpha, blur=24):
|
||||
"""Spotlight cone from apex towards target point."""
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
ax, ay = apex
|
||||
tx, ty = target
|
||||
dx, dy = tx - ax, ty - ay
|
||||
ln = math.hypot(dx, dy) or 1.0
|
||||
nx, ny = -dy / ln, dx / ln
|
||||
pts = [
|
||||
(ax + nx * width0 / 2, ay + ny * width0 / 2),
|
||||
(tx + nx * width1 / 2, ty + ny * width1 / 2),
|
||||
(tx - nx * width1 / 2, ty - ny * width1 / 2),
|
||||
(ax - nx * width0 / 2, ay - ny * width0 / 2),
|
||||
]
|
||||
d.polygon(pts, fill=rgba(color, alpha))
|
||||
base.alpha_composite(lay.filter(ImageFilter.GaussianBlur(blur)))
|
||||
|
||||
|
||||
def cubic(p0, p1, p2, p3, n=48):
|
||||
pts = []
|
||||
for i in range(n + 1):
|
||||
t = i / n
|
||||
mt = 1 - t
|
||||
x = mt**3 * p0[0] + 3 * mt**2 * t * p1[0] + 3 * mt * t**2 * p2[0] + t**3 * p3[0]
|
||||
y = mt**3 * p0[1] + 3 * mt**2 * t * p1[1] + 3 * mt * t**2 * p2[1] + t**3 * p3[1]
|
||||
pts.append((x, y))
|
||||
return pts
|
||||
|
||||
|
||||
def smooth_path(segments):
|
||||
"""segments: list of (p0,p1,p2,p3) cubic tuples -> concatenated point list."""
|
||||
pts = []
|
||||
for seg in segments:
|
||||
part = cubic(*seg)
|
||||
if pts:
|
||||
part = part[1:]
|
||||
pts.extend(part)
|
||||
return pts
|
||||
|
||||
|
||||
def ellipse_poly(cx, cy, rx, ry, n=72, a0=0.0, a1=2 * math.pi, rot=0.0):
|
||||
pts = []
|
||||
for i in range(n + 1):
|
||||
t = a0 + (a1 - a0) * i / n
|
||||
x, y = rx * math.cos(t), ry * math.sin(t)
|
||||
xr = x * math.cos(rot) - y * math.sin(rot)
|
||||
yr = x * math.sin(rot) + y * math.cos(rot)
|
||||
pts.append((cx + xr, cy + yr))
|
||||
return pts
|
||||
|
||||
|
||||
def superellipse_poly(cx, cy, rx, ry, power=4.0, n=96, rot=0.0):
|
||||
"""Rounded-rect-like closed curve; power 2 = ellipse, higher = boxier."""
|
||||
pts = []
|
||||
e = 2.0 / power
|
||||
for i in range(n):
|
||||
t = 2 * math.pi * i / n
|
||||
ct, st = math.cos(t), math.sin(t)
|
||||
x = rx * math.copysign(abs(ct) ** e, ct)
|
||||
y = ry * math.copysign(abs(st) ** e, st)
|
||||
xr = x * math.cos(rot) - y * math.sin(rot)
|
||||
yr = x * math.sin(rot) + y * math.cos(rot)
|
||||
pts.append((cx + xr, cy + yr))
|
||||
return pts
|
||||
|
||||
|
||||
def star4(draw, cx, cy, r, color, alpha, thin=0.18, rot=0.0):
|
||||
"""Four-point sparkle."""
|
||||
pts = []
|
||||
for i in range(8):
|
||||
ang = rot + math.pi / 4 * i
|
||||
rr = r if i % 2 == 0 else r * thin
|
||||
pts.append((cx + rr * math.cos(ang), cy + rr * math.sin(ang)))
|
||||
draw.polygon(pts, fill=rgba(color, alpha))
|
||||
|
||||
|
||||
def add_grain(img: Image.Image, amount=3.0, seed=7):
|
||||
rng = np.random.default_rng(seed)
|
||||
noise = rng.normal(0.0, amount, (H, W, 1)).repeat(3, axis=2)
|
||||
arr = np.asarray(img.convert("RGB")).astype(np.int16) + noise.astype(np.int16)
|
||||
arr = np.clip(arr, 0, 255).astype(np.uint8)
|
||||
out = Image.fromarray(arr, "RGB").convert("RGBA")
|
||||
out.putalpha(img.split()[3] if img.mode == "RGBA" else 255)
|
||||
return out
|
||||
|
||||
|
||||
def paper_texture(img, color="#000000", alpha=6, seed=3, scale=3):
|
||||
"""Fine fibrous speckle for paper-like fields."""
|
||||
rng = np.random.default_rng(seed)
|
||||
small = rng.normal(0.0, 1.0, (H // scale, W // scale))
|
||||
t = Image.fromarray(((small - small.min()) / (small.ptp() + 1e-9) * 255).astype(np.uint8))
|
||||
t = t.resize((W, H), Image.BILINEAR).filter(ImageFilter.GaussianBlur(0.6))
|
||||
lay = Image.merge("RGBA", (t, t, t, t.point(lambda v: int(v / 255 * alpha))))
|
||||
tint = Image.new("RGBA", (W, H), rgba(hex2rgb(color), 255))
|
||||
lay = Image.composite(tint, new_layer(), lay.split()[3])
|
||||
img.alpha_composite(lay)
|
||||
|
||||
|
||||
def petal_pts(cx, cy, size, angle):
|
||||
"""A single rose petal outline (teardrop with curled tip)."""
|
||||
ca, sa = math.cos(angle), math.sin(angle)
|
||||
|
||||
def tr(p):
|
||||
x, y = p
|
||||
return (cx + x * ca - y * sa, cy + x * sa + y * ca)
|
||||
|
||||
segs = [
|
||||
((0, 0), (0.55 * size, -0.42 * size), (1.05 * size, -0.28 * size), (1.18 * size, 0.10 * size)),
|
||||
((1.18 * size, 0.10 * size), (1.26 * size, 0.42 * size), (0.72 * size, 0.62 * size), (0.28 * size, 0.55 * size)),
|
||||
((0.28 * size, 0.55 * size), (-0.05 * size, 0.50 * size), (-0.10 * size, 0.18 * size), (0, 0)),
|
||||
]
|
||||
return [tr(p) for p in smooth_path(segs)]
|
||||
|
||||
|
||||
def leaf_pts(cx, cy, length, width, angle, curl=0.35):
|
||||
ca, sa = math.cos(angle), math.sin(angle)
|
||||
|
||||
def tr(p):
|
||||
x, y = p
|
||||
return (cx + x * ca - y * sa, cy + x * sa + y * ca)
|
||||
|
||||
segs = [
|
||||
((0, 0), (0.30 * length, -width), (0.75 * length, -width * 0.9), (length, -curl * width)),
|
||||
((length, -curl * width), (0.72 * length, width * 0.7), (0.32 * length, width), (0, 0)),
|
||||
]
|
||||
return [tr(p) for p in smooth_path(segs)]
|
||||
|
||||
|
||||
def butterfly_pts(cx, cy, size, angle, flap=1.0):
|
||||
"""Stylised butterfly: two upper + two lower wings + body, returns list of polys."""
|
||||
ca, sa = math.cos(angle), math.sin(angle)
|
||||
|
||||
def tr(p):
|
||||
x, y = p
|
||||
return (cx + x * ca - y * sa, cy + x * sa + y * ca)
|
||||
|
||||
polys = []
|
||||
for sgn in (-1, 1):
|
||||
upper = smooth_path([
|
||||
((0, 0), (sgn * 0.95 * size, -0.85 * size * flap), (sgn * 1.45 * size, -0.55 * size * flap), (sgn * 1.30 * size, -0.02 * size)),
|
||||
((sgn * 1.30 * size, -0.02 * size), (sgn * 1.05 * size, 0.28 * size), (sgn * 0.35 * size, 0.22 * size), (0, 0.10 * size)),
|
||||
])
|
||||
polys.append([tr(p) for p in upper])
|
||||
lower = smooth_path([
|
||||
((0, 0.08 * size), (sgn * 0.72 * size, 0.28 * size), (sgn * 0.88 * size, 0.78 * size), (sgn * 0.42 * size, 1.02 * size)),
|
||||
((sgn * 0.42 * size, 1.02 * size), (sgn * 0.10 * size, 0.95 * size), (sgn * 0.02 * size, 0.42 * size), (0, 0.22 * size)),
|
||||
])
|
||||
polys.append([tr(p) for p in lower])
|
||||
body = ellipse_poly(cx, cy, 0.09 * size, 0.42 * size, rot=angle)
|
||||
return polys, body
|
||||
|
||||
|
||||
def cloud_curl_pts(cx, cy, size, color_flip=False):
|
||||
"""Auspicious-cloud (spiral scroll) outline, flat motif."""
|
||||
pts = []
|
||||
turns = 1.65
|
||||
for i in range(90):
|
||||
t = i / 89
|
||||
ang = turns * 2 * math.pi * t + math.pi * 0.5
|
||||
r = size * (1.0 - 0.72 * t)
|
||||
pts.append((cx + r * math.cos(ang), cy + 0.62 * r * math.sin(ang)))
|
||||
# outer tail sweeping right
|
||||
tail = smooth_path([
|
||||
(pts[0], (cx + 1.9 * size, cy - 0.9 * size), (cx + 2.9 * size, cy - 0.4 * size), (cx + 3.3 * size, cy + 0.35 * size)),
|
||||
])
|
||||
return pts, tail
|
||||
|
||||
|
||||
def coin_pts(cx, cy, r, rot=0.0):
|
||||
"""Round coin with rounded-square hole (abstract lucky coin, no characters)."""
|
||||
outer = ellipse_poly(cx, cy, r, r, rot=rot)
|
||||
hole = superellipse_poly(cx, cy, r * 0.34, r * 0.34, power=4.5, rot=rot)
|
||||
return outer, hole
|
||||
|
||||
|
||||
def ring_pts(cx, cy, r, width, a0=0.0, a1=2 * math.pi, squash=1.0):
|
||||
outer = ellipse_poly(cx, cy, r, r * squash, a0=a0, a1=a1)
|
||||
inner = ellipse_poly(cx, cy, r - width, (r - width) * squash, a0=a1, a1=a0)
|
||||
return outer + inner
|
||||
|
||||
|
||||
def draw_poly(draw, pts, color, alpha=255, outline=None, outline_w=0):
|
||||
draw.polygon(pts, fill=rgba(color, alpha))
|
||||
if outline and outline_w > 0:
|
||||
draw.line(pts + [pts[0]], fill=outline, width=outline_w, joint="curve")
|
||||
|
||||
|
||||
def soft_fill(base, pts, color, alpha, blur=0.0):
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
d.polygon(pts, fill=rgba(color, alpha))
|
||||
comp(base, lay, blur)
|
||||
|
||||
|
||||
def save_webp(img: Image.Image, path: str, quality=82, target_bytes=None):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
rgb = img.convert("RGB")
|
||||
q = quality
|
||||
while True:
|
||||
rgb.save(path, "WEBP", quality=q, method=6, exact=True)
|
||||
size = os.path.getsize(path)
|
||||
if target_bytes is None or size <= target_bytes or q <= 40:
|
||||
return size
|
||||
q -= 6
|
||||
|
||||
|
||||
def make_thumb(src: Image.Image, path: str, quality=76, target_bytes=120 * 1024):
|
||||
thumb = src.convert("RGB").resize((480, 270), Image.LANCZOS)
|
||||
q = quality
|
||||
while True:
|
||||
thumb.save(path, "WEBP", quality=q, method=6, exact=True)
|
||||
size = os.path.getsize(path)
|
||||
if size <= target_bytes or q <= 30:
|
||||
return size
|
||||
q -= 8
|
||||
|
||||
|
||||
def sha256_file(path: str) -> str:
|
||||
import hashlib
|
||||
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def rng(seed: int) -> random.Random:
|
||||
return random.Random(seed)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Build official theme backgrounds + thumbnails and report budgets/hashes."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from artkit import H, W, make_thumb, save_webp, sha256_file
|
||||
from scenes import SCENES
|
||||
|
||||
OUT = os.path.join(os.path.dirname(__file__), "out")
|
||||
BG_BUDGET = int(2.25 * 1024 * 1024)
|
||||
THUMB_BUDGET = 120 * 1024
|
||||
TOTAL_BUDGET = 18 * 1024 * 1024
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("themes", nargs="*", default=None, help="theme ids to build (default: all)")
|
||||
ap.add_argument("--out", default=OUT)
|
||||
args = ap.parse_args()
|
||||
|
||||
ids = args.themes or sorted(SCENES)
|
||||
report = {}
|
||||
total = 0
|
||||
for tid in ids:
|
||||
if tid not in SCENES:
|
||||
print(f"unknown theme {tid}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
img = SCENES[tid]()
|
||||
assert img.size == (W, H), img.size
|
||||
tdir = os.path.join(args.out, tid)
|
||||
bg_path = os.path.join(tdir, "background.webp")
|
||||
th_path = os.path.join(tdir, "preview.webp")
|
||||
bg_size = save_webp(img, bg_path, quality=82, target_bytes=BG_BUDGET)
|
||||
th_size = make_thumb(img, th_path, target_bytes=THUMB_BUDGET)
|
||||
total += bg_size
|
||||
report[tid] = {
|
||||
"background_bytes": bg_size,
|
||||
"background_ok": bg_size <= BG_BUDGET,
|
||||
"background_sha256": sha256_file(bg_path),
|
||||
"preview_bytes": th_size,
|
||||
"preview_ok": th_size <= THUMB_BUDGET,
|
||||
"preview_sha256": sha256_file(th_path),
|
||||
}
|
||||
print(f"{tid}: bg={bg_size/1024:.0f} KiB ok={report[tid]['background_ok']} thumb={th_size/1024:.0f} KiB")
|
||||
print(f"total backgrounds: {total/1024/1024:.2f} MiB (budget 18 MiB)")
|
||||
with open(os.path.join(args.out, "report.json"), "w") as f:
|
||||
json.dump(report, f, indent=2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,500 @@
|
||||
"""Original stylised adult figure builders (flat-illustration look).
|
||||
|
||||
Every figure is drawn procedurally from bezier primitives — no reference
|
||||
imagery, no likeness of real persons. All characters are fictional adults.
|
||||
Coordinates use a local frame: face centre (cx, cy), face half-width fw.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from PIL import ImageDraw, ImageFilter
|
||||
|
||||
from artkit import (
|
||||
comp,
|
||||
cubic,
|
||||
draw_poly,
|
||||
ellipse_poly,
|
||||
mix,
|
||||
new_layer,
|
||||
rgba,
|
||||
smooth_path,
|
||||
soft_fill,
|
||||
superellipse_poly,
|
||||
)
|
||||
|
||||
|
||||
def _face_outline(cx, cy, fw, fh, turn=0.0):
|
||||
"""Near-frontal face with a soft jaw; turn>0 shifts chin slightly left."""
|
||||
chin = (cx - turn * 0.10 * fw, cy + fh)
|
||||
segs = [
|
||||
# left temple -> crown -> right temple
|
||||
((cx - 0.94 * fw, cy - 0.38 * fh), (cx - 1.02 * fw, cy - 1.02 * fh), (cx + 0.55 * fw, cy - 1.12 * fh), (cx + 0.92 * fw, cy - 0.42 * fh)),
|
||||
# right cheek -> right jaw
|
||||
((cx + 0.92 * fw, cy - 0.42 * fh), (cx + 0.98 * fw, cy + 0.18 * fh), (cx + 0.62 * fw, cy + 0.62 * fh), (cx + 0.30 * fw, cy + 0.86 * fh)),
|
||||
# right jaw -> chin -> left jaw
|
||||
((cx + 0.30 * fw, cy + 0.86 * fh), (cx + 0.14 * fw, cy + 1.00 * fh), (chin[0] + 0.16 * fw, chin[1] + 0.02 * fh), chin),
|
||||
(chin, (chin[0] - 0.34 * fw, chin[1] - 0.02 * fh), (cx - 0.72 * fw, cy + 0.66 * fh), (cx - 0.88 * fw, cy + 0.30 * fh)),
|
||||
# left cheek back to temple
|
||||
((cx - 0.88 * fw, cy + 0.30 * fh), (cx - 0.96 * fw, cy + 0.02 * fh), (cx - 0.97 * fw, cy - 0.16 * fh), (cx - 0.94 * fw, cy - 0.38 * fh)),
|
||||
]
|
||||
return smooth_path(segs)
|
||||
|
||||
|
||||
def _neck_pts(cx, cy, fw, fh):
|
||||
top_y = cy + 0.62 * fh
|
||||
bot_y = cy + 1.55 * fh
|
||||
return smooth_path([
|
||||
((cx - 0.34 * fw, top_y), (cx - 0.30 * fw, bot_y - 0.2 * fh), (cx - 0.38 * fw, bot_y), (cx - 0.42 * fw, bot_y)),
|
||||
((cx - 0.42 * fw, bot_y), (cx - 0.1 * fw, bot_y + 0.06 * fh), (cx + 0.2 * fw, bot_y + 0.06 * fh), (cx + 0.34 * fw, bot_y)),
|
||||
((cx + 0.34 * fw, bot_y), (cx + 0.28 * fw, bot_y - 0.2 * fh), (cx + 0.30 * fw, top_y), (cx + 0.30 * fw, top_y)),
|
||||
])
|
||||
|
||||
|
||||
def _shoulders_pts(cx, y0, width, height, slope=0.12):
|
||||
hw = width / 2
|
||||
return smooth_path([
|
||||
((cx - hw, y0 + height), (cx - hw * 1.02, y0 + height * 0.35), (cx - hw * 0.72, y0 + slope * height), (cx - hw * 0.30, y0 + 0.02 * height)),
|
||||
((cx - hw * 0.30, y0 + 0.02 * height), (cx - hw * 0.1, y0 - 0.06 * height), (cx + hw * 0.1, y0 - 0.06 * height), (cx + hw * 0.30, y0 + 0.02 * height)),
|
||||
((cx + hw * 0.30, y0 + 0.02 * height), (cx + hw * 0.72, y0 + slope * height), (cx + hw * 1.02, y0 + height * 0.35), (cx + hw, y0 + height)),
|
||||
((cx + hw, y0 + height), (cx + hw * 0.4, y0 + height * 1.04), (cx - hw * 0.4, y0 + height * 1.04), (cx - hw, y0 + height)),
|
||||
])
|
||||
|
||||
|
||||
def _closed_eye(d, ex, ey, s, color, alpha=235, lashes=0, w=0.16):
|
||||
pts = cubic((ex - 0.16 * s, ey), (ex - 0.05 * s, ey + 0.10 * s), (ex + 0.06 * s, ey + 0.10 * s), (ex + 0.17 * s, ey - 0.01 * s), n=24)
|
||||
d.line(pts, fill=rgba(color, alpha), width=max(2, int(w * s * 0.28)), joint="curve")
|
||||
for i in range(lashes):
|
||||
t = 0.55 + 0.22 * i
|
||||
bx = ex - 0.16 * s + (0.33 * s) * t
|
||||
by = ey + 0.06 * s - 0.05 * s * abs(t - 0.5)
|
||||
d.line([(bx, by), (bx + 0.05 * s, by - 0.07 * s)], fill=rgba(color, alpha), width=max(2, int(w * s * 0.2)))
|
||||
|
||||
|
||||
def _open_eye(lay, ex, ey, s, iris, alpha=255, sparkle=True):
|
||||
"""Simple bright anime-style eye."""
|
||||
d = ImageDraw.Draw(lay)
|
||||
white = (255, 255, 255)
|
||||
d.ellipse([ex - 0.13 * s, ey - 0.17 * s, ex + 0.13 * s, ey + 0.17 * s], fill=rgba(white, alpha))
|
||||
d.ellipse([ex - 0.10 * s, ey - 0.13 * s, ex + 0.10 * s, ey + 0.15 * s], fill=rgba(iris, alpha))
|
||||
d.ellipse([ex - 0.055 * s, ey - 0.06 * s, ex + 0.055 * s, ey + 0.09 * s], fill=rgba(mix(iris, "#000000", 0.55), alpha))
|
||||
if sparkle:
|
||||
d.ellipse([ex - 0.055 * s, ey - 0.10 * s, ex + 0.005 * s, ey - 0.04 * s], fill=rgba(white, alpha))
|
||||
d.ellipse([ex + 0.03 * s, ey + 0.02 * s, ex + 0.07 * s, ey + 0.06 * s], fill=rgba(white, int(alpha * 0.8)))
|
||||
|
||||
|
||||
def _brow(d, ex, ey, s, color, alpha=200, tilt=0.0):
|
||||
pts = cubic((ex - 0.15 * s, ey + tilt), (ex - 0.05 * s, ey - 0.06 * s + tilt), (ex + 0.08 * s, ey - 0.06 * s - tilt), (ex + 0.16 * s, ey - 0.02 * s - tilt), n=20)
|
||||
d.line(pts, fill=rgba(color, alpha), width=max(2, int(0.035 * s)), joint="curve")
|
||||
|
||||
|
||||
def _smile(d, cx, y, s, color, alpha=220, width=0.16):
|
||||
pts = cubic((cx - width * s, y), (cx - 0.05 * s, y + 0.07 * s), (cx + 0.05 * s, y + 0.07 * s), (cx + width * s, y - 0.01 * s), n=24)
|
||||
d.line(pts, fill=rgba(color, alpha), width=max(2, int(0.032 * s)), joint="curve")
|
||||
|
||||
|
||||
def _lips(lay, cx, y, s, color, alpha=230):
|
||||
d = ImageDraw.Draw(lay)
|
||||
pts = smooth_path([
|
||||
((cx - 0.15 * s, y), (cx - 0.05 * s, y - 0.045 * s), (cx - 0.02 * s, y - 0.03 * s), (cx, y - 0.012 * s)),
|
||||
((cx, y - 0.012 * s), (cx + 0.02 * s, y - 0.03 * s), (cx + 0.05 * s, y - 0.045 * s), (cx + 0.15 * s, y)),
|
||||
((cx + 0.15 * s, y), (cx + 0.07 * s, y + 0.075 * s), (cx - 0.07 * s, y + 0.075 * s), (cx - 0.15 * s, y)),
|
||||
])
|
||||
d.polygon(pts, fill=rgba(color, alpha))
|
||||
|
||||
|
||||
def _blush(lay, ex, ey, s, color, alpha=70):
|
||||
d = ImageDraw.Draw(lay)
|
||||
d.ellipse([ex - 0.16 * s, ey - 0.08 * s, ex + 0.16 * s, ey + 0.08 * s], fill=rgba(color, alpha))
|
||||
|
||||
|
||||
def _figure_shadow(base, cx, cy, fw, fh, scale_w=3.9, scale_h=4.1, alpha=26, blur=52, dx=22, dy=34):
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
d.ellipse(
|
||||
[cx - fw * scale_w / 2 + dx, cy - fh * 0.9 + dy, cx + fw * scale_w / 2 + dx, cy + fh * scale_h / 2 + dy],
|
||||
fill=(30, 24, 28, alpha),
|
||||
)
|
||||
comp(base, lay, blur)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bust variants
|
||||
|
||||
|
||||
def woman_serene(base, cx, cy, s, skin, hair_c, cloth_c, lip_c, blush_c, style="long", accent=None):
|
||||
"""Adult woman, gentle closed-eye expression. s = face half-width."""
|
||||
fw, fh = s, s * 1.22
|
||||
accent = accent or cloth_c
|
||||
_figure_shadow(base, cx, cy, fw, fh)
|
||||
|
||||
# back hair mass
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
if style == "long":
|
||||
back = smooth_path([
|
||||
((cx - 1.05 * fw, cy - 0.5 * fh), (cx - 1.25 * fw, cy - 1.5 * fh), (cx + 0.9 * fw, cy - 1.65 * fh), (cx + 1.15 * fw, cy - 0.45 * fh)),
|
||||
((cx + 1.15 * fw, cy - 0.45 * fh), (cx + 1.5 * fw, cy + 0.9 * fh), (cx + 1.35 * fw, cy + 2.6 * fh), (cx + 0.75 * fw, cy + 3.1 * fh)),
|
||||
((cx + 0.75 * fw, cy + 3.1 * fh), (cx + 0.45 * fw, cy + 2.2 * fh), (cx + 0.15 * fw, cy + 3.4 * fh), (cx - 0.35 * fw, cy + 3.5 * fh)),
|
||||
((cx - 0.35 * fw, cy + 3.5 * fh), (cx - 1.05 * fw, cy + 2.4 * fh), (cx - 1.35 * fw, cy + 1.0 * fh), (cx - 1.05 * fw, cy - 0.5 * fh)),
|
||||
])
|
||||
else: # bun
|
||||
back = smooth_path([
|
||||
((cx - 1.02 * fw, cy - 0.45 * fh), (cx - 1.1 * fw, cy - 1.4 * fh), (cx + 0.8 * fw, cy - 1.5 * fh), (cx + 1.05 * fw, cy - 0.4 * fh)),
|
||||
((cx + 1.05 * fw, cy - 0.4 * fh), (cx + 1.15 * fw, cy + 0.5 * fh), (cx + 0.6 * fw, cy + 1.0 * fh), (cx, cy + 1.05 * fh)),
|
||||
((cx, cy + 1.05 * fh), (cx - 0.7 * fw, cy + 1.0 * fh), (cx - 1.1 * fw, cy + 0.4 * fh), (cx - 1.02 * fw, cy - 0.45 * fh)),
|
||||
])
|
||||
d.ellipse([cx + 0.35 * fw, cy - 1.95 * fh, cx + 1.05 * fw, cy - 1.15 * fh], fill=rgba(hair_c, 255))
|
||||
d.polygon(back, fill=rgba(hair_c, 255))
|
||||
base.alpha_composite(lay)
|
||||
|
||||
# neck + shoulders
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
d.polygon(_neck_pts(cx, cy, fw, fh), fill=rgba(mix(skin, "#000000", 0.07), 255))
|
||||
sh = _shoulders_pts(cx, cy + 1.45 * fh, 3.4 * fw, 2.2 * fh)
|
||||
d.polygon(sh, fill=rgba(cloth_c, 255))
|
||||
# collar V
|
||||
d.polygon(
|
||||
[(cx - 0.30 * fw, cy + 1.50 * fh), (cx, cy + 1.95 * fh), (cx + 0.30 * fw, cy + 1.50 * fh), (cx + 0.22 * fw, cy + 1.42 * fh), (cx - 0.22 * fw, cy + 1.42 * fh)],
|
||||
fill=rgba(mix(cloth_c, "#ffffff", 0.35), 255),
|
||||
)
|
||||
base.alpha_composite(lay)
|
||||
|
||||
# face
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
d.polygon(_face_outline(cx, cy, fw, fh), fill=rgba(skin, 255))
|
||||
base.alpha_composite(lay)
|
||||
|
||||
# fringe / front hair
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
fringe = smooth_path([
|
||||
((cx - 1.0 * fw, cy - 0.18 * fh), (cx - 1.10 * fw, cy - 1.25 * fh), (cx + 0.6 * fw, cy - 1.48 * fh), (cx + 0.98 * fw, cy - 0.42 * fh)),
|
||||
((cx + 0.98 * fw, cy - 0.42 * fh), (cx + 0.72 * fw, cy - 0.58 * fh), (cx + 0.55 * fw, cy - 0.70 * fh), (cx + 0.30 * fw, cy - 0.62 * fh)),
|
||||
((cx + 0.30 * fw, cy - 0.62 * fh), (cx + 0.05 * fw, cy - 0.80 * fh), (cx - 0.35 * fw, cy - 0.78 * fh), (cx - 0.55 * fw, cy - 0.56 * fh)),
|
||||
((cx - 0.55 * fw, cy - 0.56 * fh), (cx - 0.75 * fw, cy - 0.70 * fh), (cx - 0.9 * fw, cy - 0.48 * fh), (cx - 1.0 * fw, cy - 0.18 * fh)),
|
||||
])
|
||||
d.polygon(fringe, fill=rgba(hair_c, 255))
|
||||
# side strand
|
||||
strand = smooth_path([
|
||||
((cx + 0.82 * fw, cy - 0.62 * fh), (cx + 1.10 * fw, cy - 0.05 * fh), (cx + 1.05 * fw, cy + 0.9 * fh), (cx + 0.8 * fw, cy + 1.6 * fh)),
|
||||
((cx + 0.8 * fw, cy + 1.6 * fh), (cx + 0.66 * fw, cy + 0.8 * fh), (cx + 0.64 * fw, cy - 0.05 * fh), (cx + 0.68 * fw, cy - 0.58 * fh)),
|
||||
])
|
||||
d.polygon(strand, fill=rgba(mix(hair_c, "#ffffff", 0.06), 255))
|
||||
base.alpha_composite(lay)
|
||||
|
||||
# features
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
ink = mix(skin, "#000000", 0.72)
|
||||
_closed_eye(d, cx - 0.42 * fw, cy - 0.02 * fh, fw, ink, lashes=2)
|
||||
_closed_eye(d, cx + 0.42 * fw, cy - 0.02 * fh, fw, ink, lashes=2)
|
||||
_brow(d, cx - 0.42 * fw, cy - 0.30 * fh, fw, mix(hair_c, "#000000", 0.25))
|
||||
_brow(d, cx + 0.42 * fw, cy - 0.30 * fh, fw, mix(hair_c, "#000000", 0.25))
|
||||
nose = cubic((cx - 0.02 * fw, cy + 0.16 * fh), (cx + 0.03 * fw, cy + 0.24 * fh), (cx + 0.02 * fw, cy + 0.30 * fh), (cx - 0.04 * fw, cy + 0.32 * fh), n=16)
|
||||
d.line(nose, fill=rgba(mix(skin, "#000000", 0.30), 200), width=max(2, int(0.03 * fw)), joint="curve")
|
||||
_lips(lay, cx + 0.02 * fw, cy + 0.52 * fh, fw, lip_c)
|
||||
_blush(lay, cx - 0.52 * fw, cy + 0.30 * fh, fw, blush_c)
|
||||
_blush(lay, cx + 0.56 * fw, cy + 0.30 * fh, fw, blush_c)
|
||||
base.alpha_composite(lay)
|
||||
|
||||
|
||||
def man_bust(base, cx, cy, s, skin, hair_c, cloth_c, collar_c=None, style="shirt"):
|
||||
"""Adult man, calm closed-eye expression. style: shirt | suit."""
|
||||
fw, fh = s, s * 1.20
|
||||
collar_c = collar_c or mix(cloth_c, "#ffffff", 0.5)
|
||||
_figure_shadow(base, cx, cy, fw, fh, alpha=40)
|
||||
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
d.polygon(_neck_pts(cx, cy, fw, fh), fill=rgba(mix(skin, "#000000", 0.08), 255))
|
||||
sh = _shoulders_pts(cx, cy + 1.45 * fh, 3.6 * fw, 2.25 * fh)
|
||||
d.polygon(sh, fill=rgba(cloth_c, 255))
|
||||
if style == "suit":
|
||||
# shirt V + lapels + tie
|
||||
d.polygon([(cx - 0.38 * fw, cy + 1.5 * fh), (cx, cy + 2.35 * fh), (cx + 0.38 * fw, cy + 1.5 * fh)], fill=rgba(collar_c, 255))
|
||||
d.polygon([(cx - 0.09 * fw, cy + 1.62 * fh), (cx + 0.09 * fw, cy + 1.62 * fh), (cx + 0.05 * fw, cy + 1.78 * fh), (cx - 0.05 * fw, cy + 1.78 * fh)], fill=rgba(mix(cloth_c, "#000000", 0.4), 255))
|
||||
d.polygon([(cx - 0.05 * fw, cy + 1.78 * fh), (cx + 0.05 * fw, cy + 1.78 * fh), (cx + 0.10 * fw, cy + 2.35 * fh), (cx, cy + 2.5 * fh), (cx - 0.10 * fw, cy + 2.35 * fh)], fill=rgba(mix(cloth_c, "#000000", 0.4), 255))
|
||||
lap_l = [(cx - 0.38 * fw, cy + 1.5 * fh), (cx - 0.95 * fw, cy + 1.75 * fh), (cx - 0.55 * fw, cy + 2.6 * fh), (cx - 0.12 * fw, cy + 2.1 * fh)]
|
||||
lap_r = [(cx + 0.38 * fw, cy + 1.5 * fh), (cx + 0.95 * fw, cy + 1.75 * fh), (cx + 0.55 * fw, cy + 2.6 * fh), (cx + 0.12 * fw, cy + 2.1 * fh)]
|
||||
d.polygon(lap_l, fill=rgba(mix(cloth_c, "#ffffff", 0.08), 255))
|
||||
d.polygon(lap_r, fill=rgba(mix(cloth_c, "#ffffff", 0.08), 255))
|
||||
else:
|
||||
# crew / open collar
|
||||
d.polygon([(cx - 0.40 * fw, cy + 1.48 * fh), (cx, cy + 1.95 * fh), (cx + 0.40 * fw, cy + 1.48 * fh), (cx + 0.28 * fw, cy + 1.38 * fh), (cx - 0.28 * fw, cy + 1.38 * fh)], fill=rgba(collar_c, 255))
|
||||
base.alpha_composite(lay)
|
||||
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
d.polygon(_face_outline(cx, cy, fw, fh, turn=0.4), fill=rgba(skin, 255))
|
||||
base.alpha_composite(lay)
|
||||
|
||||
# short hair
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
hair = smooth_path([
|
||||
((cx - 0.98 * fw, cy - 0.30 * fh), (cx - 1.05 * fw, cy - 1.18 * fh), (cx + 0.6 * fw, cy - 1.38 * fh), (cx + 0.97 * fw, cy - 0.42 * fh)),
|
||||
((cx + 0.97 * fw, cy - 0.42 * fh), (cx + 0.86 * fw, cy - 0.60 * fh), (cx + 0.68 * fw, cy - 0.74 * fh), (cx + 0.42 * fw, cy - 0.68 * fh)),
|
||||
((cx + 0.42 * fw, cy - 0.68 * fh), (cx + 0.08 * fw, cy - 0.88 * fh), (cx - 0.45 * fw, cy - 0.84 * fh), (cx - 0.68 * fw, cy - 0.58 * fh)),
|
||||
((cx - 0.68 * fw, cy - 0.58 * fh), (cx - 0.86 * fw, cy - 0.68 * fh), (cx - 0.96 * fw, cy - 0.50 * fh), (cx - 0.98 * fw, cy - 0.30 * fh)),
|
||||
])
|
||||
d.polygon(hair, fill=rgba(hair_c, 255))
|
||||
base.alpha_composite(lay)
|
||||
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
ink = mix(skin, "#000000", 0.75)
|
||||
_closed_eye(d, cx - 0.42 * fw, cy + 0.0 * fh, fw, ink, lashes=0)
|
||||
_closed_eye(d, cx + 0.42 * fw, cy + 0.0 * fh, fw, ink, lashes=0)
|
||||
_brow(d, cx - 0.42 * fw, cy - 0.28 * fh, fw, mix(hair_c, "#000000", 0.2), tilt=0.01 * fw)
|
||||
_brow(d, cx + 0.42 * fw, cy - 0.28 * fh, fw, mix(hair_c, "#000000", 0.2), tilt=0.01 * fw)
|
||||
nose = cubic((cx - 0.02 * fw, cy + 0.14 * fh), (cx + 0.035 * fw, cy + 0.24 * fh), (cx + 0.03 * fw, cy + 0.31 * fh), (cx - 0.05 * fw, cy + 0.33 * fh), n=16)
|
||||
d.line(nose, fill=rgba(mix(skin, "#000000", 0.32), 200), width=max(2, int(0.032 * fw)), joint="curve")
|
||||
_smile(d, cx, cy + 0.52 * fh, fw, mix(skin, "#7a3b3b", 0.55), width=0.15)
|
||||
base.alpha_composite(lay)
|
||||
|
||||
|
||||
def anime_adult(base, cx, cy, s, skin, hair_c, cloth_c, iris_c, style="bob"):
|
||||
"""Original adult anime-style character, bright open eyes."""
|
||||
fw, fh = s, s * 1.16
|
||||
_figure_shadow(base, cx, cy, fw, fh, alpha=40)
|
||||
|
||||
# back hair
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
if style == "twin":
|
||||
for sgn in (-1, 1):
|
||||
tail = smooth_path([
|
||||
((cx + sgn * 0.85 * fw, cy - 0.7 * fh), (cx + sgn * 1.9 * fw, cy - 0.2 * fh), (cx + sgn * 2.1 * fw, cy + 1.2 * fh), (cx + sgn * 1.5 * fw, cy + 2.4 * fh)),
|
||||
((cx + sgn * 1.5 * fw, cy + 2.4 * fh), (cx + sgn * 1.2 * fw, cy + 1.4 * fh), (cx + sgn * 0.9 * fw, cy + 0.6 * fh), (cx + sgn * 0.72 * fw, cy - 0.2 * fh)),
|
||||
])
|
||||
d.polygon(tail, fill=rgba(mix(hair_c, "#000000", 0.08), 255))
|
||||
back = smooth_path([
|
||||
((cx - 1.08 * fw, cy - 0.3 * fh), (cx - 1.2 * fw, cy - 1.5 * fh), (cx + 0.9 * fw, cy - 1.6 * fh), (cx + 1.12 * fw, cy - 0.3 * fh)),
|
||||
((cx + 1.12 * fw, cy - 0.3 * fh), (cx + 1.3 * fw, cy + 0.9 * fh), (cx + 0.95 * fw, cy + 2.0 * fh), (cx + 0.55 * fw, cy + 2.5 * fh)),
|
||||
((cx + 0.55 * fw, cy + 2.5 * fh), (cx + 0.3 * fw, cy + 1.6 * fh), (cx - 0.1 * fw, cy + 2.6 * fh), (cx - 0.5 * fw, cy + 2.4 * fh)),
|
||||
((cx - 0.5 * fw, cy + 2.4 * fh), (cx - 1.15 * fw, cy + 1.2 * fh), (cx - 1.25 * fw, cy + 0.4 * fh), (cx - 1.08 * fw, cy - 0.3 * fh)),
|
||||
])
|
||||
d.polygon(back, fill=rgba(hair_c, 255))
|
||||
base.alpha_composite(lay)
|
||||
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
d.polygon(_neck_pts(cx, cy, fw, fh), fill=rgba(mix(skin, "#000000", 0.06), 255))
|
||||
sh = _shoulders_pts(cx, cy + 1.42 * fh, 3.1 * fw, 2.1 * fh)
|
||||
d.polygon(sh, fill=rgba(cloth_c, 255))
|
||||
# hoodie strings + collar
|
||||
d.polygon([(cx - 0.44 * fw, cy + 1.5 * fh), (cx, cy + 2.05 * fh), (cx + 0.44 * fw, cy + 1.5 * fh), (cx + 0.30 * fw, cy + 1.38 * fh), (cx - 0.30 * fw, cy + 1.38 * fh)], fill=rgba(mix(cloth_c, "#000000", 0.12), 255))
|
||||
d.line([(cx - 0.14 * fw, cy + 1.9 * fh), (cx - 0.16 * fw, cy + 2.3 * fh)], fill=rgba(mix(cloth_c, "#ffffff", 0.6), 255), width=max(2, int(0.045 * fw)))
|
||||
d.line([(cx + 0.14 * fw, cy + 1.9 * fh), (cx + 0.16 * fw, cy + 2.3 * fh)], fill=rgba(mix(cloth_c, "#ffffff", 0.6), 255), width=max(2, int(0.045 * fw)))
|
||||
base.alpha_composite(lay)
|
||||
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
d.polygon(_face_outline(cx, cy, fw, fh), fill=rgba(skin, 255))
|
||||
base.alpha_composite(lay)
|
||||
|
||||
# fringe with anime points
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
pts = []
|
||||
x0, x1 = cx - 1.02 * fw, cx + 1.02 * fw
|
||||
top_y = cy - 1.28 * fh
|
||||
bot_y = cy - 0.42 * fh
|
||||
n = 7
|
||||
pts.append((x0, cy - 0.2 * fh))
|
||||
pts.extend(cubic((x0, cy - 0.2 * fh), (x0 - 0.05 * fw, top_y - 0.2 * fh), (cx, top_y - 0.25 * fh), (x1, cy - 0.35 * fh), n=30)[1:])
|
||||
pts.append((x1, cy - 0.1 * fh))
|
||||
for i in range(n):
|
||||
xm = x1 - (x1 - x0) * (i + 0.5) / n
|
||||
xe = x1 - (x1 - x0) * (i + 1) / n
|
||||
depth = bot_y + (0.10 * fh if i % 2 == 0 else -0.06 * fh)
|
||||
pts.append((xm, depth))
|
||||
pts.append((xe, bot_y - 0.08 * fh))
|
||||
d.polygon(pts, fill=rgba(hair_c, 255))
|
||||
base.alpha_composite(lay)
|
||||
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
_open_eye(lay, cx - 0.42 * fw, cy + 0.05 * fh, fw, iris_c)
|
||||
_open_eye(lay, cx + 0.42 * fw, cy + 0.05 * fh, fw, iris_c)
|
||||
ink = mix(hair_c, "#000000", 0.45)
|
||||
d.line(cubic((cx - 0.58 * fw, cy - 0.12 * fh), (cx - 0.45 * fw, cy - 0.22 * fh), (cx - 0.28 * fw, cy - 0.22 * fh), (cx - 0.24 * fw, cy - 0.14 * fh), n=16), fill=rgba(ink, 220), width=max(2, int(0.035 * fw)))
|
||||
d.line(cubic((cx + 0.58 * fw, cy - 0.12 * fh), (cx + 0.45 * fw, cy - 0.22 * fh), (cx + 0.28 * fw, cy - 0.22 * fh), (cx + 0.24 * fw, cy - 0.14 * fh), n=16), fill=rgba(ink, 220), width=max(2, int(0.035 * fw)))
|
||||
_smile(d, cx, cy + 0.5 * fh, fw, mix(skin, "#8a4040", 0.6), width=0.13)
|
||||
_blush(lay, cx - 0.58 * fw, cy + 0.32 * fh, fw, (242, 153, 142), alpha=80)
|
||||
_blush(lay, cx + 0.58 * fw, cy + 0.32 * fh, fw, (242, 153, 142), alpha=80)
|
||||
base.alpha_composite(lay)
|
||||
|
||||
|
||||
def silhouette_muse(base, cx, cy, s, body_c, rim_c=None):
|
||||
"""Elegant adult woman silhouette in a flowing gown (no facial features)."""
|
||||
fw, fh = s, s * 1.2
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
# flowing gown
|
||||
gown = smooth_path([
|
||||
((cx - 0.35 * fw, cy + 1.2 * fh), (cx - 1.6 * fw, cy + 2.2 * fh), (cx - 2.6 * fw, cy + 3.4 * fh), (cx - 2.9 * fw, cy + 4.6 * fh)),
|
||||
((cx - 2.9 * fw, cy + 4.6 * fh), (cx - 1.2 * fw, cy + 4.9 * fh), (cx + 1.4 * fw, cy + 4.9 * fh), (cx + 2.8 * fw, cy + 4.4 * fh)),
|
||||
((cx + 2.8 * fw, cy + 4.4 * fh), (cx + 2.2 * fw, cy + 3.0 * fh), (cx + 1.3 * fw, cy + 2.0 * fh), (cx + 0.38 * fw, cy + 1.2 * fh)),
|
||||
])
|
||||
d.polygon(gown, fill=rgba(body_c, 255))
|
||||
# torso + neck + head
|
||||
d.polygon(_neck_pts(cx, cy, fw, fh), fill=rgba(body_c, 255))
|
||||
torso = smooth_path([
|
||||
((cx - 0.9 * fw, cy + 2.2 * fh), (cx - 0.75 * fw, cy + 1.4 * fh), (cx - 0.4 * fw, cy + 1.25 * fh), (cx, cy + 1.2 * fh)),
|
||||
((cx, cy + 1.2 * fh), (cx + 0.4 * fw, cy + 1.25 * fh), (cx + 0.75 * fw, cy + 1.4 * fh), (cx + 0.9 * fw, cy + 2.2 * fh)),
|
||||
((cx + 0.9 * fw, cy + 2.2 * fh), (cx + 0.4 * fw, cy + 2.5 * fh), (cx - 0.4 * fw, cy + 2.5 * fh), (cx - 0.9 * fw, cy + 2.2 * fh)),
|
||||
])
|
||||
d.polygon(torso, fill=rgba(body_c, 255))
|
||||
d.polygon(_face_outline(cx, cy, fw * 0.92, fh * 0.92), fill=rgba(body_c, 255))
|
||||
# flowing hair
|
||||
hair = smooth_path([
|
||||
((cx - 0.95 * fw, cy - 0.35 * fh), (cx - 1.1 * fw, cy - 1.5 * fh), (cx + 0.9 * fw, cy - 1.6 * fh), (cx + 1.05 * fw, cy - 0.3 * fh)),
|
||||
((cx + 1.05 * fw, cy - 0.3 * fh), (cx + 1.9 * fw, cy + 0.6 * fh), (cx + 2.6 * fw, cy + 1.1 * fh), (cx + 3.2 * fw, cy + 0.9 * fh)),
|
||||
((cx + 3.2 * fw, cy + 0.9 * fh), (cx + 2.4 * fw, cy + 1.7 * fh), (cx + 1.6 * fw, cy + 1.7 * fh), (cx + 1.0 * fw, cy + 1.3 * fh)),
|
||||
((cx + 1.0 * fw, cy + 1.3 * fh), (cx + 0.5 * fw, cy + 0.9 * fh), (cx - 0.2 * fw, cy + 1.1 * fh), (cx - 0.6 * fw, cy + 0.7 * fh)),
|
||||
((cx - 0.6 * fw, cy + 0.7 * fh), (cx - 1.05 * fw, cy + 0.3 * fh), (cx - 1.0 * fw, cy - 0.05 * fh), (cx - 0.95 * fw, cy - 0.35 * fh)),
|
||||
])
|
||||
d.polygon(hair, fill=rgba(mix(body_c, "#000000", 0.12), 255))
|
||||
base.alpha_composite(lay)
|
||||
if rim_c:
|
||||
rim = new_layer()
|
||||
rd = ImageDraw.Draw(rim)
|
||||
rd.line(cubic((cx + 0.9 * fw, cy - 0.6 * fh), (cx + 1.2 * fw, cy + 0.4 * fh), (cx + 1.1 * fw, cy + 1.6 * fh), (cx + 0.8 * fw, cy + 2.2 * fh), n=40), fill=rgba(rim_c, 150), width=max(3, int(0.07 * fw)))
|
||||
rd.line(cubic((cx + 1.05 * fw, cy - 0.3 * fh), (cx + 2.0 * fw, cy + 0.7 * fh), (cx + 2.8 * fw, cy + 1.2 * fh), (cx + 3.1 * fw, cy + 1.0 * fh), n=40), fill=rgba(rim_c, 120), width=max(3, int(0.06 * fw)))
|
||||
comp(base, rim, 6)
|
||||
|
||||
|
||||
def mascot_programmer(base, cx, cy, s, skin, hair_c, cloth_c, accent_c, glasses_c=None):
|
||||
"""Original cheerful 'lucky programmer' mascot — fictional adult coder."""
|
||||
fw, fh = s, s * 1.14
|
||||
glasses_c = glasses_c or mix(cloth_c, "#000000", 0.5)
|
||||
_figure_shadow(base, cx, cy, fw, fh, alpha=42)
|
||||
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
d.polygon(_neck_pts(cx, cy, fw, fh), fill=rgba(mix(skin, "#000000", 0.07), 255))
|
||||
sh = _shoulders_pts(cx, cy + 1.42 * fh, 3.5 * fw, 2.3 * fh)
|
||||
d.polygon(sh, fill=rgba(cloth_c, 255))
|
||||
# hoodie collar + pocket + strings
|
||||
d.polygon([(cx - 0.5 * fw, cy + 1.5 * fh), (cx, cy + 2.0 * fh), (cx + 0.5 * fw, cy + 1.5 * fh), (cx + 0.36 * fw, cy + 1.34 * fh), (cx - 0.36 * fw, cy + 1.34 * fh)], fill=rgba(mix(cloth_c, "#000000", 0.14), 255))
|
||||
d.line([(cx - 0.16 * fw, cy + 1.85 * fh), (cx - 0.20 * fw, cy + 2.35 * fh)], fill=rgba(accent_c, 255), width=max(3, int(0.05 * fw)))
|
||||
d.line([(cx + 0.16 * fw, cy + 1.85 * fh), (cx + 0.20 * fw, cy + 2.35 * fh)], fill=rgba(accent_c, 255), width=max(3, int(0.05 * fw)))
|
||||
pocket = superellipse_poly(cx, cy + 2.9 * fh, 0.7 * fw, 0.5 * fh, power=5)
|
||||
d.polygon(pocket, fill=rgba(mix(cloth_c, "#ffffff", 0.10), 255))
|
||||
base.alpha_composite(lay)
|
||||
|
||||
# headphones around neck
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
band = cubic((cx - 0.75 * fw, cy + 1.35 * fh), (cx - 0.5 * fw, cy + 1.9 * fh), (cx + 0.5 * fw, cy + 1.9 * fh), (cx + 0.75 * fw, cy + 1.35 * fh), n=40)
|
||||
d.line(band, fill=rgba(glasses_c, 255), width=max(4, int(0.14 * fw)))
|
||||
for sgn in (-1, 1):
|
||||
d.ellipse([cx + sgn * 0.82 * fw - 0.17 * fw, cy + 1.18 * fh, cx + sgn * 0.82 * fw + 0.17 * fw, cy + 1.62 * fh], fill=rgba(glasses_c, 255))
|
||||
d.ellipse([cx + sgn * 0.82 * fw - 0.09 * fw, cy + 1.28 * fh, cx + sgn * 0.82 * fw + 0.09 * fw, cy + 1.52 * fh], fill=rgba(accent_c, 255))
|
||||
base.alpha_composite(lay)
|
||||
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
d.polygon(_face_outline(cx, cy, fw, fh), fill=rgba(skin, 255))
|
||||
base.alpha_composite(lay)
|
||||
|
||||
# short tidy hair
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
hair = smooth_path([
|
||||
((cx - 0.98 * fw, cy - 0.28 * fh), (cx - 1.07 * fw, cy - 1.22 * fh), (cx + 0.62 * fw, cy - 1.40 * fh), (cx + 0.99 * fw, cy - 0.38 * fh)),
|
||||
((cx + 0.99 * fw, cy - 0.38 * fh), (cx + 0.88 * fw, cy - 0.62 * fh), (cx + 0.58 * fw, cy - 0.80 * fh), (cx + 0.28 * fw, cy - 0.72 * fh)),
|
||||
((cx + 0.28 * fw, cy - 0.72 * fh), (cx - 0.06 * fw, cy - 0.88 * fh), (cx - 0.5 * fw, cy - 0.82 * fh), (cx - 0.70 * fw, cy - 0.54 * fh)),
|
||||
((cx - 0.70 * fw, cy - 0.54 * fh), (cx - 0.88 * fw, cy - 0.64 * fh), (cx - 0.97 * fw, cy - 0.48 * fh), (cx - 0.98 * fw, cy - 0.28 * fh)),
|
||||
])
|
||||
d.polygon(hair, fill=rgba(hair_c, 255))
|
||||
base.alpha_composite(lay)
|
||||
|
||||
# face: happy closed eyes (^^), round glasses
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
ink = mix(skin, "#000000", 0.78)
|
||||
for sgn in (-1, 1):
|
||||
ex = cx + sgn * 0.42 * fw
|
||||
d.ellipse([ex - 0.26 * fw, cy - 0.20 * fh, ex + 0.26 * fw, cy + 0.30 * fh], outline=rgba(glasses_c, 235), width=max(3, int(0.045 * fw)))
|
||||
pts = cubic((ex - 0.14 * fw, cy + 0.08 * fh), (ex - 0.05 * fw, cy - 0.03 * fh), (ex + 0.05 * fw, cy - 0.03 * fh), (ex + 0.14 * fw, cy + 0.08 * fh), n=20)
|
||||
d.line(pts, fill=rgba(ink, 240), width=max(3, int(0.045 * fw)), joint="curve")
|
||||
d.line([(cx - 0.16 * fw, cy + 0.05 * fh), (cx + 0.16 * fw, cy + 0.05 * fh)], fill=rgba(glasses_c, 235), width=max(3, int(0.04 * fw)))
|
||||
d.line([(cx - 0.68 * fw, cy + 0.02 * fh), (cx - 0.88 * fw, cy - 0.05 * fh)], fill=rgba(glasses_c, 235), width=max(3, int(0.04 * fw)))
|
||||
d.line([(cx + 0.68 * fw, cy + 0.02 * fh), (cx + 0.88 * fw, cy - 0.05 * fh)], fill=rgba(glasses_c, 235), width=max(3, int(0.04 * fw)))
|
||||
# open happy mouth
|
||||
mouth = smooth_path([
|
||||
((cx - 0.16 * fw, cy + 0.5 * fh), (cx - 0.05 * fw, cy + 0.52 * fh), (cx + 0.05 * fw, cy + 0.52 * fh), (cx + 0.16 * fw, cy + 0.5 * fh)),
|
||||
((cx + 0.16 * fw, cy + 0.5 * fh), (cx + 0.08 * fw, cy + 0.68 * fh), (cx - 0.08 * fw, cy + 0.68 * fh), (cx - 0.16 * fw, cy + 0.5 * fh)),
|
||||
])
|
||||
d.polygon(mouth, fill=rgba(mix(skin, "#7a2e2e", 0.6), 235))
|
||||
_blush(lay, cx - 0.62 * fw, cy + 0.34 * fh, fw, (238, 140, 110), alpha=85)
|
||||
_blush(lay, cx + 0.62 * fw, cy + 0.34 * fh, fw, (238, 140, 110), alpha=85)
|
||||
base.alpha_composite(lay)
|
||||
|
||||
|
||||
def performer(base, cx, cy, s, skin, hair_c, cloth_c, glow_c):
|
||||
"""Original adult digital stage performer with headset mic, high ponytail."""
|
||||
fw, fh = s, s * 1.18
|
||||
_figure_shadow(base, cx, cy, fw, fh, alpha=44)
|
||||
|
||||
# ponytail
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
tail = smooth_path([
|
||||
((cx + 0.55 * fw, cy - 1.35 * fh), (cx + 1.6 * fw, cy - 1.2 * fh), (cx + 2.1 * fw, cy + 0.2 * fh), (cx + 1.85 * fw, cy + 1.6 * fh)),
|
||||
((cx + 1.85 * fw, cy + 1.6 * fh), (cx + 1.25 * fw, cy + 0.9 * fh), (cx + 0.85 * fw, cy + 0.2 * fh), (cx + 0.7 * fw, cy - 0.6 * fh)),
|
||||
])
|
||||
d.polygon(tail, fill=rgba(mix(hair_c, "#000000", 0.10), 255))
|
||||
base.alpha_composite(lay)
|
||||
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
d.polygon(_neck_pts(cx, cy, fw, fh), fill=rgba(mix(skin, "#000000", 0.07), 255))
|
||||
sh = _shoulders_pts(cx, cy + 1.45 * fh, 3.3 * fw, 2.2 * fh)
|
||||
d.polygon(sh, fill=rgba(cloth_c, 255))
|
||||
# jacket collar wings
|
||||
d.polygon([(cx - 0.46 * fw, cy + 1.5 * fh), (cx - 0.12 * fw, cy + 2.1 * fh), (cx - 0.55 * fw, cy + 2.0 * fh), (cx - 0.75 * fw, cy + 1.65 * fh)], fill=rgba(mix(cloth_c, glow_c, 0.25), 255))
|
||||
d.polygon([(cx + 0.46 * fw, cy + 1.5 * fh), (cx + 0.12 * fw, cy + 2.1 * fh), (cx + 0.55 * fw, cy + 2.0 * fh), (cx + 0.75 * fw, cy + 1.65 * fh)], fill=rgba(mix(cloth_c, glow_c, 0.25), 255))
|
||||
base.alpha_composite(lay)
|
||||
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
d.polygon(_face_outline(cx, cy, fw, fh), fill=rgba(skin, 255))
|
||||
base.alpha_composite(lay)
|
||||
|
||||
# swept hair + fringe
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
hair = smooth_path([
|
||||
((cx - 1.0 * fw, cy - 0.3 * fh), (cx - 1.1 * fw, cy - 1.3 * fh), (cx + 0.7 * fw, cy - 1.5 * fh), (cx + 1.05 * fw, cy - 0.4 * fh)),
|
||||
((cx + 1.05 * fw, cy - 0.4 * fh), (cx + 0.8 * fw, cy - 0.7 * fh), (cx + 0.5 * fw, cy - 0.9 * fh), (cx + 0.2 * fw, cy - 0.82 * fh)),
|
||||
((cx + 0.2 * fw, cy - 0.82 * fh), (cx - 0.2 * fw, cy - 1.0 * fh), (cx - 0.6 * fw, cy - 0.9 * fh), (cx - 0.8 * fw, cy - 0.6 * fh)),
|
||||
((cx - 0.8 * fw, cy - 0.6 * fh), (cx - 0.95 * fw, cy - 0.7 * fh), (cx - 1.0 * fw, cy - 0.5 * fh), (cx - 1.0 * fw, cy - 0.3 * fh)),
|
||||
])
|
||||
d.polygon(hair, fill=rgba(hair_c, 255))
|
||||
base.alpha_composite(lay)
|
||||
|
||||
# headset: band over crown + mic boom to mouth
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
band = cubic((cx - 0.85 * fw, cy - 0.45 * fh), (cx - 0.6 * fw, cy - 1.45 * fh), (cx + 0.6 * fw, cy - 1.45 * fh), (cx + 0.85 * fw, cy - 0.45 * fh), n=40)
|
||||
d.line(band, fill=rgba(mix(cloth_c, "#000000", 0.3), 255), width=max(3, int(0.09 * fw)))
|
||||
d.ellipse([cx + 0.72 * fw, cy - 0.28 * fh, cx + 1.02 * fw, cy + 0.12 * fh], fill=rgba(mix(cloth_c, "#000000", 0.3), 255))
|
||||
boom = cubic((cx + 0.88 * fw, cy + 0.05 * fh), (cx + 0.95 * fw, cy + 0.45 * fh), (cx + 0.5 * fw, cy + 0.62 * fh), (cx + 0.28 * fw, cy + 0.58 * fh), n=30)
|
||||
d.line(boom, fill=rgba(mix(cloth_c, "#000000", 0.3), 255), width=max(2, int(0.05 * fw)))
|
||||
d.ellipse([cx + 0.18 * fw, cy + 0.5 * fh, cx + 0.34 * fw, cy + 0.66 * fh], fill=rgba(glow_c, 255))
|
||||
|
||||
ink = mix(skin, "#000000", 0.72)
|
||||
_closed_eye(d, cx - 0.42 * fw, cy + 0.0 * fh, fw, ink, lashes=2)
|
||||
_open_eye(lay, cx + 0.42 * fw, cy + 0.02 * fh, fw, mix(glow_c, "#000000", 0.25))
|
||||
_brow(d, cx - 0.42 * fw, cy - 0.28 * fh, fw, mix(hair_c, "#000000", 0.25))
|
||||
_brow(d, cx + 0.42 * fw, cy - 0.28 * fh, fw, mix(hair_c, "#000000", 0.25))
|
||||
_smile(d, cx, cy + 0.52 * fh, fw, mix(skin, "#8a4040", 0.6), width=0.15)
|
||||
_blush(lay, cx - 0.56 * fw, cy + 0.3 * fh, fw, glow_c, alpha=55)
|
||||
_blush(lay, cx + 0.56 * fw, cy + 0.3 * fh, fw, glow_c, alpha=55)
|
||||
base.alpha_composite(lay)
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Official theme palettes + WCAG contrast gate.
|
||||
|
||||
Defines the full token set for all eight official themes and checks the same
|
||||
pairs the Go backend validates (fg/bg, fg/chat >= 4.5; fgFaint/bg >= 3.0;
|
||||
accentFg/accent >= 3.0). ok/warn/err are intentionally unset so they inherit
|
||||
the base style. `write_manifests()` emits theme.json files.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(__file__)
|
||||
OUT = os.path.join(HERE, "out")
|
||||
DEST = os.path.join(HERE, "..", "..", "desktop", "themes", "official")
|
||||
|
||||
SURFACE_KEYS = ["bg", "bgSoft", "bgElev", "panel", "sidebar", "chat", "workspace", "workspaceFiles"]
|
||||
TEXT_KEYS = ["border", "borderSoft", "fg", "fgDim", "fgFaint", "accent", "accentFg"]
|
||||
|
||||
# focusX, focusY / homeOpacity / taskOpacity / overlayStrength from the plan table.
|
||||
THEMES = {
|
||||
"official-rose-dawn": {
|
||||
"name": "Rose Dawn",
|
||||
"zh": "玫瑰晨光",
|
||||
"baseStyle": "graphite",
|
||||
"corners": "round",
|
||||
"bgparams": (0.72, 0.43, 1.0, 0.20, 0.68),
|
||||
"description": "Ivory dawn light, soft roses and an original illustrated muse on the right.",
|
||||
"light": {
|
||||
"bg": "#FFF7F8", "bgSoft": "#FBEFF1", "bgElev": "#FFFFFF", "panel": "#FFFFFF",
|
||||
"sidebar": "#FBECEF", "chat": "#FFFCFC", "workspace": "#FFFFFF", "workspaceFiles": "#FDF3F4",
|
||||
"border": "#E2BCC6", "borderSoft": "#F5E3E7",
|
||||
"fg": "#3A252C", "fgDim": "#6D4A55", "fgFaint": "#A97B87",
|
||||
"accent": "#B43F65", "accentFg": "#FFF7F8",
|
||||
},
|
||||
"dark": {
|
||||
"bg": "#1E1419", "bgSoft": "#281B21", "bgElev": "#312329", "panel": "#2A1D24",
|
||||
"sidebar": "#251922", "chat": "#231820", "workspace": "#312329", "workspaceFiles": "#281C22",
|
||||
"border": "#4C3440", "borderSoft": "#3A2832",
|
||||
"fg": "#FFF3F6", "fgDim": "#D9B3C0", "fgFaint": "#A87C8C",
|
||||
"accent": "#E26D91", "accentFg": "#2A121D",
|
||||
},
|
||||
},
|
||||
"official-fortune-forge": {
|
||||
"name": "Fortune Forge",
|
||||
"zh": "鸿运工坊",
|
||||
"baseStyle": "amber",
|
||||
"corners": "soft",
|
||||
"bgparams": (0.74, 0.44, 1.0, 0.20, 0.70),
|
||||
"description": "Warm ivory workshop with vermilion, gold and jade around an original lucky programmer.",
|
||||
"light": {
|
||||
"bg": "#FFF8E8", "bgSoft": "#F9EFD5", "bgElev": "#FFFDF6", "panel": "#FFFDF6",
|
||||
"sidebar": "#F8EDD3", "chat": "#FFFBF1", "workspace": "#FFFDF6", "workspaceFiles": "#FAF1DC",
|
||||
"border": "#D6B87E", "borderSoft": "#F2E7CD",
|
||||
"fg": "#382116", "fgDim": "#6E4E35", "fgFaint": "#A98A68",
|
||||
"accent": "#A92D22", "accentFg": "#FFF8E8",
|
||||
},
|
||||
"dark": {
|
||||
"bg": "#1D140D", "bgSoft": "#271C12", "bgElev": "#302417", "panel": "#291E13",
|
||||
"sidebar": "#251B11", "chat": "#231A11", "workspace": "#302417", "workspaceFiles": "#271D13",
|
||||
"border": "#4D3A22", "borderSoft": "#3B2C1A",
|
||||
"fg": "#FFF2D1", "fgDim": "#DDC49C", "fgFaint": "#A9926B",
|
||||
"accent": "#E8AD38", "accentFg": "#241606",
|
||||
},
|
||||
},
|
||||
"official-crimson-horizon": {
|
||||
"name": "Crimson Horizon",
|
||||
"zh": "赤曜新城",
|
||||
"baseStyle": "graphite",
|
||||
"corners": "soft",
|
||||
"bgparams": (0.75, 0.45, 0.98, 0.22, 0.66),
|
||||
"description": "Pearl-white sky over a coral-red future city skyline. No people, pure skyline.",
|
||||
"light": {
|
||||
"bg": "#FFF8F7", "bgSoft": "#F9EEEC", "bgElev": "#FFFFFF", "panel": "#FFFFFF",
|
||||
"sidebar": "#F9ECEA", "chat": "#FFFBFA", "workspace": "#FFFFFF", "workspaceFiles": "#FBF1EF",
|
||||
"border": "#DFB3AD", "borderSoft": "#F6E4E2",
|
||||
"fg": "#301D1D", "fgDim": "#6B4644", "fgFaint": "#A9807D",
|
||||
"accent": "#B92B38", "accentFg": "#FFF8F7",
|
||||
},
|
||||
"dark": {
|
||||
"bg": "#190D11", "bgSoft": "#221318", "bgElev": "#2B181E", "panel": "#25141A",
|
||||
"sidebar": "#211218", "chat": "#201116", "workspace": "#2B181E", "workspaceFiles": "#221319",
|
||||
"border": "#582F3C", "borderSoft": "#361B24",
|
||||
"fg": "#FFF1F2", "fgDim": "#DFB3B6", "fgFaint": "#AC7E84",
|
||||
"accent": "#FF6772", "accentFg": "#2A0E12",
|
||||
},
|
||||
},
|
||||
"official-sage-breeze": {
|
||||
"name": "Sage Breeze",
|
||||
"zh": "鼠尾草清风",
|
||||
"baseStyle": "slate",
|
||||
"corners": "soft",
|
||||
"bgparams": (0.73, 0.44, 1.0, 0.20, 0.68),
|
||||
"description": "Cream paper, sage sprigs and an original illustrated reader on the right.",
|
||||
"light": {
|
||||
"bg": "#F7F7EF", "bgSoft": "#EFEFE2", "bgElev": "#FCFCF6", "panel": "#FCFCF6",
|
||||
"sidebar": "#EEEEE0", "chat": "#FAFAF4", "workspace": "#FCFCF6", "workspaceFiles": "#F1F1E5",
|
||||
"border": "#BFC095", "borderSoft": "#E8E8D8",
|
||||
"fg": "#26332D", "fgDim": "#4E6157", "fgFaint": "#7C8F83",
|
||||
"accent": "#47735F", "accentFg": "#F7F7EF",
|
||||
},
|
||||
"dark": {
|
||||
"bg": "#101814", "bgSoft": "#17211C", "bgElev": "#1E2922", "panel": "#19231D",
|
||||
"sidebar": "#161F1A", "chat": "#151E19", "workspace": "#1E2922", "workspaceFiles": "#17211C",
|
||||
"border": "#32443A", "borderSoft": "#25322B",
|
||||
"fg": "#EEF6F0", "fgDim": "#B7CDBF", "fgFaint": "#7E968A",
|
||||
"accent": "#84CBA7", "accentFg": "#0E1A13",
|
||||
},
|
||||
},
|
||||
"official-spark-notebook": {
|
||||
"name": "Spark Notebook",
|
||||
"zh": "灵感手账",
|
||||
"baseStyle": "aurora",
|
||||
"corners": "round",
|
||||
"bgparams": (0.74, 0.46, 0.98, 0.20, 0.68),
|
||||
"description": "Off-white notebook grid with teal, yellow and coral stationery around an original anime adult.",
|
||||
"light": {
|
||||
"bg": "#FFF9ED", "bgSoft": "#F9F1DE", "bgElev": "#FFFDF7", "panel": "#FFFDF7",
|
||||
"sidebar": "#F8F0DC", "chat": "#FFFCF4", "workspace": "#FFFDF7", "workspaceFiles": "#FAF3E2",
|
||||
"border": "#D2BC8E", "borderSoft": "#F2E9D4",
|
||||
"fg": "#2B2F35", "fgDim": "#565D66", "fgFaint": "#848D98",
|
||||
"accent": "#007B78", "accentFg": "#F3FBFA",
|
||||
},
|
||||
"dark": {
|
||||
"bg": "#14171A", "bgSoft": "#1B1F23", "bgElev": "#23282D", "panel": "#1D2226",
|
||||
"sidebar": "#1A1E22", "chat": "#191D21", "workspace": "#23282D", "workspaceFiles": "#1B2024",
|
||||
"border": "#38404A", "borderSoft": "#2A3138",
|
||||
"fg": "#F8F5E9", "fgDim": "#C9CCBF", "fgFaint": "#8B918A",
|
||||
"accent": "#42D1C6", "accentFg": "#08201E",
|
||||
},
|
||||
},
|
||||
"official-violet-starlight": {
|
||||
"name": "Violet Starlight",
|
||||
"zh": "紫曜星夜",
|
||||
"baseStyle": "nocturne",
|
||||
"corners": "round",
|
||||
"bgparams": (0.73, 0.44, 0.96, 0.18, 0.72),
|
||||
"description": "Blue-violet starfield with glowing butterflies and an original silhouette muse.",
|
||||
"light": {
|
||||
"bg": "#F7F4FF", "bgSoft": "#EFEBFB", "bgElev": "#FCFAFF", "panel": "#FCFAFF",
|
||||
"sidebar": "#EEE9FA", "chat": "#FAF8FF", "workspace": "#FCFAFF", "workspaceFiles": "#F1EDFB",
|
||||
"border": "#CABDEC", "borderSoft": "#E8E3F7",
|
||||
"fg": "#251F3C", "fgDim": "#544B74", "fgFaint": "#9188B3",
|
||||
"accent": "#6242C7", "accentFg": "#F7F4FF",
|
||||
},
|
||||
"dark": {
|
||||
"bg": "#0C1022", "bgSoft": "#131834", "bgElev": "#1A2140", "panel": "#151B36",
|
||||
"sidebar": "#121732", "chat": "#111630", "workspace": "#1A2140", "workspaceFiles": "#131834",
|
||||
"border": "#2E3A66", "borderSoft": "#222B4E",
|
||||
"fg": "#F4F2FF", "fgDim": "#C2BDE8", "fgFaint": "#8580B8",
|
||||
"accent": "#9B86FF", "accentFg": "#14102E",
|
||||
},
|
||||
},
|
||||
"official-cyan-stage": {
|
||||
"name": "Cyan Stage",
|
||||
"zh": "青岚舞台",
|
||||
"baseStyle": "carbon",
|
||||
"corners": "round",
|
||||
"bgparams": (0.74, 0.45, 0.96, 0.18, 0.72),
|
||||
"description": "Airy cyan future stage with light rings and an original digital performer.",
|
||||
"light": {
|
||||
"bg": "#F1FCFD", "bgSoft": "#E4F5F7", "bgElev": "#FAFEFE", "panel": "#FAFEFE",
|
||||
"sidebar": "#E3F4F6", "chat": "#F6FDFE", "workspace": "#FAFEFE", "workspaceFiles": "#E8F6F8",
|
||||
"border": "#9CCFD7", "borderSoft": "#DAEDF0",
|
||||
"fg": "#173238", "fgDim": "#43606A", "fgFaint": "#71939D",
|
||||
"accent": "#007C92", "accentFg": "#F1FCFD",
|
||||
},
|
||||
"dark": {
|
||||
"bg": "#07181D", "bgSoft": "#0C2229", "bgElev": "#112C34", "panel": "#0E252D",
|
||||
"sidebar": "#0B2128", "chat": "#0A2027", "workspace": "#112C34", "workspaceFiles": "#0C232A",
|
||||
"border": "#1F4550", "borderSoft": "#16333C",
|
||||
"fg": "#E9FCFF", "fgDim": "#AEDBE2", "fgFaint": "#6FA5AF",
|
||||
"accent": "#37D7E4", "accentFg": "#04222a",
|
||||
},
|
||||
},
|
||||
"official-noir-gold": {
|
||||
"name": "Noir Gold",
|
||||
"zh": "黑金序曲",
|
||||
"baseStyle": "carbon",
|
||||
"corners": "soft",
|
||||
"bgparams": (0.73, 0.43, 0.94, 0.18, 0.74),
|
||||
"description": "Champagne field, black velvet curtain and gold spotlights around an original gentleman.",
|
||||
"light": {
|
||||
"bg": "#FCF8EE", "bgSoft": "#F6F0DF", "bgElev": "#FEFBF4", "panel": "#FEFBF4",
|
||||
"sidebar": "#F5EFDD", "chat": "#FDFAF2", "workspace": "#FEFBF4", "workspaceFiles": "#F7F1E1",
|
||||
"border": "#CCBE94", "borderSoft": "#EFE8D2",
|
||||
"fg": "#2A241B", "fgDim": "#5C5340", "fgFaint": "#9A8F74",
|
||||
"accent": "#7A5A16", "accentFg": "#FCF8EE",
|
||||
},
|
||||
"dark": {
|
||||
"bg": "#0D0B09", "bgSoft": "#15120E", "bgElev": "#1D1913", "panel": "#171410",
|
||||
"sidebar": "#14110D", "chat": "#131009", "workspace": "#1D1913", "workspaceFiles": "#15120E",
|
||||
"border": "#463B27", "borderSoft": "#2A2418",
|
||||
"fg": "#F8F1DF", "fgDim": "#D6CBAE", "fgFaint": "#968C6E",
|
||||
"accent": "#D9B45B", "accentFg": "#1D1503",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _lum(hexs: str) -> float:
|
||||
s = hexs.lstrip("#")[:6]
|
||||
rgb = [int(s[i : i + 2], 16) / 255 for i in (0, 2, 4)]
|
||||
|
||||
def lin(c):
|
||||
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
||||
|
||||
return 0.2126 * lin(rgb[0]) + 0.7152 * lin(rgb[1]) + 0.0722 * lin(rgb[2])
|
||||
|
||||
|
||||
def ratio(a: str, b: str) -> float:
|
||||
la, lb = _lum(a), _lum(b)
|
||||
return (max(la, lb) + 0.05) / (min(la, lb) + 0.05)
|
||||
|
||||
|
||||
def check() -> int:
|
||||
fails = 0
|
||||
for tid, spec in THEMES.items():
|
||||
for mode in ("light", "dark"):
|
||||
tk = spec[mode]
|
||||
pairs = [
|
||||
("fg/bg", tk["fg"], tk["bg"], 4.5),
|
||||
("fg/chat", tk["fg"], tk["chat"], 4.5),
|
||||
("fg/panel", tk["fg"], tk["panel"], 4.5),
|
||||
("fgDim/bg", tk["fgDim"], tk["bg"], 3.0),
|
||||
("fgFaint/bg", tk["fgFaint"], tk["bg"], 3.0),
|
||||
("fgFaint/panel", tk["fgFaint"], tk["panel"], 3.0),
|
||||
("accentFg/accent", tk["accentFg"], tk["accent"], 3.0),
|
||||
("accent/bg", tk["accent"], tk["bg"], 3.0),
|
||||
("border/bg", tk["border"], tk["bg"], 1.6),
|
||||
]
|
||||
for name, a, b, minimum in pairs:
|
||||
r = ratio(a, b)
|
||||
if r < minimum:
|
||||
fails += 1
|
||||
print(f"FAIL {tid} {mode} {name}: {r:.2f} < {minimum} ({a} on {b})")
|
||||
print("contrast gate:", "OK — zero warnings" if fails == 0 else f"{fails} failures")
|
||||
return fails
|
||||
|
||||
|
||||
def write_manifests():
|
||||
for tid, spec in THEMES.items():
|
||||
fx, fy, home, task, overlay = spec["bgparams"]
|
||||
manifest = {
|
||||
"schemaVersion": 1,
|
||||
"id": tid,
|
||||
"name": spec["name"],
|
||||
"author": "Reasonix Contributors",
|
||||
"description": spec["description"],
|
||||
"license": "MIT",
|
||||
"baseStyle": spec["baseStyle"],
|
||||
"tokens": {"light": spec["light"], "dark": spec["dark"]},
|
||||
"recipes": {"density": "comfortable", "corners": spec["corners"]},
|
||||
"background": {
|
||||
"image": "background.webp",
|
||||
"focusX": fx,
|
||||
"focusY": fy,
|
||||
"safeArea": "left",
|
||||
"homeOpacity": home,
|
||||
"taskOpacity": task,
|
||||
"overlayStrength": overlay,
|
||||
},
|
||||
}
|
||||
tdir = os.path.join(DEST, tid)
|
||||
os.makedirs(tdir, exist_ok=True)
|
||||
with open(os.path.join(tdir, "theme.json"), "w") as f:
|
||||
json.dump(manifest, f, indent=2, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
for name in ("background.webp", "preview.webp"):
|
||||
src = os.path.join(OUT, tid, name)
|
||||
shutil.copyfile(src, os.path.join(tdir, name))
|
||||
print(f"wrote {tdir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
bad = check()
|
||||
if bad:
|
||||
sys.exit(1)
|
||||
if "--write" in sys.argv:
|
||||
write_manifests()
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Emit docs/THEME_ASSETS.md + .zh-CN.md — provenance & licence ledger for the
|
||||
official theme assets. Reads hashes from out/report.json (produced by build.py).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
|
||||
HERE = os.path.dirname(__file__)
|
||||
DOCS = os.path.join(HERE, "..", "..", "docs")
|
||||
|
||||
# scene function name + seed per theme (see scenes.py).
|
||||
SCENES = {
|
||||
"official-rose-dawn": ("scene_rose_dawn", 20260117),
|
||||
"official-fortune-forge": ("scene_fortune_forge", 20260118),
|
||||
"official-crimson-horizon": ("scene_crimson_horizon", 20260119),
|
||||
"official-sage-breeze": ("scene_sage_breeze", 20260120),
|
||||
"official-spark-notebook": ("scene_spark_notebook", 20260121),
|
||||
"official-violet-starlight": ("scene_violet_starlight", 20260122),
|
||||
"official-cyan-stage": ("scene_cyan_stage", 20260123),
|
||||
"official-noir-gold": ("scene_noir_gold", 20260124),
|
||||
}
|
||||
|
||||
NAMES = {
|
||||
"official-rose-dawn": "Rose Dawn / 玫瑰晨光",
|
||||
"official-fortune-forge": "Fortune Forge / 鸿运工坊",
|
||||
"official-crimson-horizon": "Crimson Horizon / 赤曜新城",
|
||||
"official-sage-breeze": "Sage Breeze / 鼠尾草清风",
|
||||
"official-spark-notebook": "Spark Notebook / 灵感手账",
|
||||
"official-violet-starlight": "Violet Starlight / 紫曜星夜",
|
||||
"official-cyan-stage": "Cyan Stage / 青岚舞台",
|
||||
"official-noir-gold": "Noir Gold / 黑金序曲",
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
report = json.load(open(os.path.join(HERE, "out", "report.json")))
|
||||
today = datetime.date.today().isoformat()
|
||||
|
||||
rows = []
|
||||
for tid in sorted(SCENES):
|
||||
fn, seed = SCENES[tid]
|
||||
r = report[tid]
|
||||
prompt = f"scripts/official-theme-art/scenes.py::{fn} (seed {seed}, 2560×1440 canvas, low-info left 0–52%, key content x 62–88% / y 16–72%)"
|
||||
rows.append((tid, NAMES[tid], prompt, r["background_sha256"], r["preview_sha256"]))
|
||||
|
||||
en = []
|
||||
en.append("# Official Theme Asset Provenance\n")
|
||||
en.append("All eight official Reasonix themes ship with **original** artwork generated procedurally")
|
||||
en.append("from scratch with the scripts in `scripts/official-theme-art/` (numpy + Pillow, fixed seeds,")
|
||||
en.append("fully reproducible). The visual *direction* was inspired by the MIT-licensed")
|
||||
en.append("[Codex-Dream-Skin](https://github.com/Fei-Away/Codex-Dream-Skin) concept gallery, but:\n")
|
||||
en.append("- **No pixels, layouts, UI mockery, text, logos or watermarks were copied** from the reference")
|
||||
en.append(" project or any third party. Every background is re-authored code output.")
|
||||
en.append("- All depicted people are **original fictional adults** drawn by the generator: an illustrated")
|
||||
en.append(" muse (Rose Dawn), a lucky programmer mascot (Fortune Forge), a reader (Sage Breeze), an anime")
|
||||
en.append(" adult (Spark Notebook), a silhouette muse (Violet Starlight), a digital performer (Cyan Stage)")
|
||||
en.append(" and a gentleman (Noir Gold). Crimson Horizon contains no people.")
|
||||
en.append("- Backgrounds contain no windows, sidebars, cards, buttons, inputs or readable text, and are")
|
||||
en.append(" stripped of EXIF/author metadata.\n")
|
||||
en.append("Assets are released under the MIT License as part of the Reasonix repository,")
|
||||
en.append("© Reasonix Contributors. Human review: Reasonix Contributors (release PR review).\n")
|
||||
en.append(f"Generation date: {today}\n")
|
||||
en.append("| Theme | Generator (final prompt equivalent) | background.webp SHA-256 | preview.webp SHA-256 |")
|
||||
en.append("| --- | --- | --- | --- |")
|
||||
for tid, name, prompt, bg, pv in rows:
|
||||
en.append(f"| {name} (`{tid}`) | `{prompt}` | `{bg}` | `{pv}` |")
|
||||
en.append("")
|
||||
|
||||
zh = []
|
||||
zh.append("# 官方主题素材来源与许可记录\n")
|
||||
zh.append("八款 Reasonix 官方主题的全部图片均为**原创**,由 `scripts/official-theme-art/` 中的脚本")
|
||||
zh.append("从零程序化生成(numpy + Pillow,固定随机种子,可完全复现)。视觉*方向*参考了 MIT 许可的")
|
||||
zh.append("[Codex-Dream-Skin](https://github.com/Fei-Away/Codex-Dream-Skin) 概念图库,但是:\n")
|
||||
zh.append("- **未复制参考项目或任何第三方素材的任何像素**、布局、界面元素、文字、标志或水印;")
|
||||
zh.append(" 所有背景均为代码重新生成的独立素材。")
|
||||
zh.append("- 图中人物均为生成器绘制的**原创虚构成年人**:插画女性(玫瑰晨光)、吉祥程序员(鸿运工坊)、")
|
||||
zh.append(" 读者(鼠尾草清风)、动漫人物(灵感手账)、剪影女性(紫曜星夜)、数字表演者(青岚舞台)、")
|
||||
zh.append(" 绅士(黑金序曲)。赤曜新城不含人物。")
|
||||
zh.append("- 背景中不含窗口、侧栏、卡片、按钮、输入框或可读文字,并已去除 EXIF 等元数据。\n")
|
||||
zh.append("素材随 Reasonix 仓库以 MIT 许可发布,© Reasonix Contributors。人工审核:Reasonix Contributors(发布 PR 审核)。\n")
|
||||
zh.append(f"生成日期:{today}\n")
|
||||
zh.append("| 主题 | 生成器(最终提示词等价物) | background.webp SHA-256 | preview.webp SHA-256 |")
|
||||
zh.append("| --- | --- | --- | --- |")
|
||||
for tid, name, prompt, bg, pv in rows:
|
||||
zh.append(f"| {name}(`{tid}`) | `{prompt}` | `{bg}` | `{pv}` |")
|
||||
zh.append("")
|
||||
|
||||
with open(os.path.join(DOCS, "THEME_ASSETS.md"), "w") as f:
|
||||
f.write("\n".join(en))
|
||||
with open(os.path.join(DOCS, "THEME_ASSETS.zh-CN.md"), "w") as f:
|
||||
f.write("\n".join(zh))
|
||||
print("wrote docs/THEME_ASSETS.md + .zh-CN.md")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,742 @@
|
||||
"""Per-theme scene generators. Each returns a 2560x1440 RGBA image.
|
||||
|
||||
Layout contract (from the theme plan):
|
||||
- left 0-52% of the width stays a low-information zone
|
||||
- main visual centre sits at x 68-76%
|
||||
- key content stays inside x 62-88%, y 16-72%
|
||||
- no windows/sidebars/cards/buttons/inputs, no readable text, no logos
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFilter
|
||||
|
||||
from artkit import (
|
||||
H,
|
||||
W,
|
||||
beam,
|
||||
butterfly_pts,
|
||||
cloud_curl_pts,
|
||||
coin_pts,
|
||||
comp,
|
||||
cubic,
|
||||
draw_poly,
|
||||
ellipse_poly,
|
||||
glow,
|
||||
gradient,
|
||||
hex2rgb,
|
||||
leaf_pts,
|
||||
mix,
|
||||
new_layer,
|
||||
petal_pts,
|
||||
rgba,
|
||||
ring_pts,
|
||||
rng,
|
||||
smooth_path,
|
||||
star4,
|
||||
superellipse_poly,
|
||||
)
|
||||
from figures import (
|
||||
anime_adult,
|
||||
man_bust,
|
||||
mascot_programmer,
|
||||
performer,
|
||||
silhouette_muse,
|
||||
woman_serene,
|
||||
)
|
||||
|
||||
|
||||
def _rose(d, cx, cy, r, deep, mid, light, seed=0):
|
||||
"""Layered flat rose head."""
|
||||
rr = rng(seed)
|
||||
for ring_i, (frac, col) in enumerate([(1.0, mid), (0.72, light), (0.46, mid), (0.26, deep)]):
|
||||
n = 7 if ring_i < 2 else 5
|
||||
for i in range(n):
|
||||
ang = (2 * math.pi * i / n) + ring_i * 0.45 + rr.uniform(-0.06, 0.06)
|
||||
px = cx + math.cos(ang) * r * frac * 0.34
|
||||
py = cy + math.sin(ang) * r * frac * 0.34
|
||||
d.polygon(petal_pts(px, py, r * frac * 0.72, ang + math.pi / 2), fill=rgba(col, 255))
|
||||
# centre spiral
|
||||
pts = []
|
||||
for i in range(40):
|
||||
t = i / 39
|
||||
a = 4.6 * math.pi * t
|
||||
pts.append((cx + math.cos(a) * r * 0.20 * (1 - t * 0.75), cy + math.sin(a) * r * 0.20 * (1 - t * 0.75)))
|
||||
d.line(pts, fill=rgba(deep, 220), width=max(2, int(r * 0.05)), joint="curve")
|
||||
|
||||
|
||||
def scene_rose_dawn() -> "Image":
|
||||
R = rng(20260117)
|
||||
img = gradient(W, H, [(0.0, "#FFF9F5"), (0.45, "#FDF0F1"), (0.8, "#F9E0E4"), (1.0, "#F5D3DA")], "d1")
|
||||
glow(img, 0.78 * W, 0.30 * H, 0.55 * W, hex2rgb("#FFFFFF"), 70)
|
||||
glow(img, 0.86 * W, 0.72 * H, 0.38 * W, hex2rgb("#F2B7C4"), 60)
|
||||
|
||||
# faint drifting petals on the low-info left
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
for _ in range(9):
|
||||
x = R.uniform(0.04, 0.48) * W
|
||||
y = R.uniform(0.10, 0.92) * H
|
||||
s = R.uniform(26, 52)
|
||||
d.polygon(petal_pts(x, y, s, R.uniform(0, 6.28)), fill=rgba(hex2rgb("#EFC4CD"), R.randint(28, 55)))
|
||||
comp(img, lay, 2)
|
||||
|
||||
# woman
|
||||
woman_serene(
|
||||
img, 0.722 * W, 0.40 * H, 152,
|
||||
skin=hex2rgb("#F6D8C6"), hair_c=hex2rgb("#5E3A36"),
|
||||
cloth_c=hex2rgb("#FCEFEA"), lip_c=hex2rgb("#C45B73"), blush_c=hex2rgb("#EBA9B5"),
|
||||
style="long",
|
||||
)
|
||||
|
||||
# rose cluster bottom-right + one at shoulder height
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
roses = [
|
||||
(0.865 * W, 0.82 * H, 150, 1),
|
||||
(0.940 * W, 0.62 * H, 108, 2),
|
||||
(0.780 * W, 0.93 * H, 124, 3),
|
||||
(0.925 * W, 0.95 * H, 84, 4),
|
||||
(0.620 * W, 0.90 * H, 56, 5),
|
||||
]
|
||||
deep, mid, light = hex2rgb("#B43F65"), hex2rgb("#E28CA1"), hex2rgb("#F5C3CE")
|
||||
leaf_c = hex2rgb("#8A9A7B")
|
||||
for cx, cy, r, seed in roses:
|
||||
for i in range(3):
|
||||
ang = R.uniform(0, 6.28)
|
||||
d.polygon(leaf_pts(cx + math.cos(ang) * r * 0.9, cy + math.sin(ang) * r * 0.9, r * 0.85, r * 0.30, ang), fill=rgba(leaf_c, 235))
|
||||
_rose(d, cx, cy, r, deep, mid, light, seed=seed)
|
||||
# stems curling from cluster
|
||||
stem = cubic((0.86 * W, 0.98 * H), (0.83 * W, 0.90 * H), (0.84 * W, 0.84 * H), (0.855 * W, 0.80 * H), n=30)
|
||||
d.line(stem, fill=rgba(mix(leaf_c, "#000000", 0.15), 200), width=10, joint="curve")
|
||||
comp(img, lay, 0.8)
|
||||
|
||||
# loose petals around the figure (right half only)
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
for _ in range(14):
|
||||
x = R.uniform(0.58, 0.97) * W
|
||||
y = R.uniform(0.08, 0.85) * H
|
||||
s = R.uniform(20, 46)
|
||||
col = mid if R.random() < 0.5 else light
|
||||
d.polygon(petal_pts(x, y, s, R.uniform(0, 6.28)), fill=rgba(col, R.randint(120, 200)))
|
||||
for _ in range(10):
|
||||
x, y = R.uniform(0.60, 0.98) * W, R.uniform(0.10, 0.80) * H
|
||||
d.ellipse([x - 5, y - 5, x + 5, y + 5], fill=rgba(hex2rgb("#FFFFFF"), R.randint(90, 160)))
|
||||
comp(img, lay, 1.2)
|
||||
|
||||
# soft light rays upper-right
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
for i, ang in enumerate([-0.5, -0.28, -0.06]):
|
||||
x0, y0 = 0.99 * W, -0.05 * H
|
||||
x1 = 0.60 * W + i * 0.09 * W
|
||||
d.polygon([(x0, y0), (x0 - 0.10 * W, y0), (x1 - 0.06 * W, 0.75 * H), (x1 + 0.10 * W, 0.75 * H)], fill=rgba(hex2rgb("#FFFFFF"), 26))
|
||||
comp(img, lay, 30)
|
||||
return img
|
||||
|
||||
|
||||
def _ingot(d, cx, cy, w, gold, deep):
|
||||
"""Abstract gold ingot (no characters)."""
|
||||
body = smooth_path([
|
||||
((cx - w * 0.5, cy + w * 0.10), (cx - w * 0.62, cy - w * 0.28), (cx - w * 0.30, cy - w * 0.30), (cx - w * 0.16, cy - w * 0.12)),
|
||||
((cx - w * 0.16, cy - w * 0.12), (cx - w * 0.08, cy - w * 0.02), (cx + w * 0.08, cy - w * 0.02), (cx + w * 0.16, cy - w * 0.12)),
|
||||
((cx + w * 0.16, cy - w * 0.12), (cx + w * 0.30, cy - w * 0.30), (cx + w * 0.62, cy - w * 0.28), (cx + w * 0.5, cy + w * 0.10)),
|
||||
((cx + w * 0.5, cy + w * 0.10), (cx + w * 0.3, cy + w * 0.30), (cx - w * 0.3, cy + w * 0.30), (cx - w * 0.5, cy + w * 0.10)),
|
||||
])
|
||||
d.polygon(body, fill=rgba(gold, 255))
|
||||
d.ellipse([cx - w * 0.16, cy - w * 0.16, cx + w * 0.16, cy + w * 0.06], fill=rgba(mix(gold, "#ffffff", 0.35), 255))
|
||||
d.line([(cx - w * 0.30, cy + w * 0.16), (cx + w * 0.30, cy + w * 0.16)], fill=rgba(deep, 130), width=max(2, int(w * 0.03)))
|
||||
|
||||
|
||||
def scene_fortune_forge() -> "Image":
|
||||
R = rng(20260118)
|
||||
img = gradient(W, H, [(0.0, "#FFFBEF"), (0.5, "#FDF2D8"), (0.85, "#F8E3BC"), (1.0, "#F3D6A2")], "d1")
|
||||
glow(img, 0.76 * W, 0.34 * H, 0.5 * W, hex2rgb("#FFF6DC"), 80)
|
||||
glow(img, 0.88 * W, 0.80 * H, 0.36 * W, hex2rgb("#F0B64E"), 70)
|
||||
glow(img, 0.70 * W, 0.18 * H, 0.30 * W, hex2rgb("#E8604C"), 26)
|
||||
|
||||
# faint auspicious clouds far left (low info)
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
for cx, cy, s in [(0.10 * W, 0.78 * H, 60), (0.30 * W, 0.22 * H, 46), (0.44 * W, 0.60 * H, 40)]:
|
||||
pts, tail = cloud_curl_pts(cx, cy, s)
|
||||
d.line(pts, fill=rgba(hex2rgb("#E4C493"), 70), width=9, joint="curve")
|
||||
d.line(tail, fill=rgba(hex2rgb("#E4C493"), 60), width=9, joint="curve")
|
||||
comp(img, lay, 3)
|
||||
|
||||
# lucky programmer mascot
|
||||
mascot_programmer(
|
||||
img, 0.718 * W, 0.41 * H, 152,
|
||||
skin=hex2rgb("#F3CFAE"), hair_c=hex2rgb("#3A2A22"),
|
||||
cloth_c=hex2rgb("#B23A2C"), accent_c=hex2rgb("#E8AD38"), glasses_c=hex2rgb("#4A3226"),
|
||||
)
|
||||
|
||||
# coins, ingots, clouds, jade beads (right side)
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
gold, gold_deep = hex2rgb("#E8AD38"), hex2rgb("#B97F1E")
|
||||
verm = hex2rgb("#C8402F")
|
||||
jade = hex2rgb("#3E7C5F")
|
||||
coins = [
|
||||
(0.620 * W, 0.30 * H, 54, 0.3), (0.650 * W, 0.62 * H, 40, -0.4), (0.860 * W, 0.24 * H, 62, 0.2),
|
||||
(0.920 * W, 0.46 * H, 44, 0.5), (0.600 * W, 0.82 * H, 48, -0.2), (0.880 * W, 0.70 * H, 38, 0.1),
|
||||
]
|
||||
for cx, cy, r, rot in coins:
|
||||
outer, hole = coin_pts(cx, cy, r, rot)
|
||||
d.polygon(outer, fill=rgba(gold, 255))
|
||||
d.polygon(hole, fill=rgba(mix(gold, "#7a5310", 0.45), 255))
|
||||
inner, inner_hole = coin_pts(cx, cy, r * 0.74, rot)
|
||||
d.line(inner, fill=rgba(gold_deep, 160), width=max(3, int(r * 0.06)))
|
||||
d.polygon(inner_hole, fill=rgba(gold, 255))
|
||||
_ingot(d, 0.845 * W, 0.90 * H, 300, gold, gold_deep)
|
||||
_ingot(d, 0.640 * W, 0.94 * H, 210, mix(gold, "#ffffff", 0.12), gold_deep)
|
||||
# auspicious clouds around mascot
|
||||
for cx, cy, s, col, alp in [
|
||||
(0.600 * W, 0.52 * H, 42, verm, 200), (0.905 * W, 0.58 * H, 60, verm, 190),
|
||||
(0.855 * W, 0.16 * H, 44, mix(verm, gold, 0.4), 170),
|
||||
]:
|
||||
pts, tail = cloud_curl_pts(cx, cy, s)
|
||||
d.line(pts, fill=rgba(col, alp), width=11, joint="curve")
|
||||
d.line(tail, fill=rgba(col, int(alp * 0.85)), width=11, joint="curve")
|
||||
# jade beads strand
|
||||
for i in range(5):
|
||||
bx, by = 0.925 * W + math.sin(i * 0.9) * 26, 0.80 * H + i * 46
|
||||
d.ellipse([bx - 17, by - 17, bx + 17, by + 17], fill=rgba(mix(jade, "#ffffff", 0.08 * (i % 2)), 240))
|
||||
d.ellipse([bx - 6, by - 8, bx + 4, by + 2], fill=rgba(mix(jade, "#ffffff", 0.6), 160))
|
||||
# sparks
|
||||
for _ in range(16):
|
||||
x, y = R.uniform(0.58, 0.97) * W, R.uniform(0.10, 0.92) * H
|
||||
star4(d, x, y, R.uniform(8, 22), gold if R.random() < 0.6 else verm, R.randint(120, 210))
|
||||
comp(img, lay, 0.6)
|
||||
|
||||
# warm confetti dots
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
for _ in range(26):
|
||||
x, y = R.uniform(0.56, 0.98) * W, R.uniform(0.08, 0.95) * H
|
||||
r = R.uniform(3, 8)
|
||||
col = [gold, verm, jade][R.randint(0, 2)]
|
||||
d.ellipse([x - r, y - r, x + r, y + r], fill=rgba(col, R.randint(60, 140)))
|
||||
comp(img, lay, 1.5)
|
||||
return img
|
||||
|
||||
|
||||
def scene_crimson_horizon() -> "Image":
|
||||
"""Pearl-white sky + coral-red future city. No people."""
|
||||
R = rng(20260119)
|
||||
img = gradient(W, H, [(0.0, "#FFFAF8"), (0.5, "#FBECE9"), (0.85, "#F5D8D3"), (1.0, "#F0C9C3")], "v")
|
||||
glow(img, 0.74 * W, 0.30 * H, 0.42 * W, hex2rgb("#FFFFFF"), 80)
|
||||
|
||||
# sun disc with soft rings
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
sun_c = hex2rgb("#F2A3A0")
|
||||
d.ellipse([0.74 * W - 150, 0.28 * H - 150, 0.74 * W + 150, 0.28 * H + 150], fill=rgba(mix(sun_c, "#ffffff", 0.25), 255))
|
||||
for rr, aa in [(210, 70), (280, 45)]:
|
||||
d.ellipse([0.74 * W - rr, 0.28 * H - rr, 0.74 * W + rr, 0.28 * H + rr], outline=rgba(sun_c, aa), width=6)
|
||||
comp(img, lay, 1.5)
|
||||
glow(img, 0.74 * W, 0.28 * H, 320, sun_c, 46)
|
||||
|
||||
# distant faint hills across the width (kept very light)
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
hills = smooth_path([
|
||||
((0, 0.74 * H), (0.18 * W, 0.68 * H), (0.36 * W, 0.72 * H), (0.52 * W, 0.66 * H)),
|
||||
((0.52 * W, 0.66 * H), (0.72 * W, 0.60 * H), (0.88 * W, 0.66 * H), (W, 0.62 * H)),
|
||||
((W, 0.62 * H), (W, 0.80 * H), (0.5 * W, 0.82 * H), (0, 0.80 * H)),
|
||||
])
|
||||
d.polygon(hills, fill=rgba(hex2rgb("#EFCFC9"), 110))
|
||||
comp(img, lay, 6)
|
||||
|
||||
# future city skyline, right half only
|
||||
deep = hex2rgb("#B92B38")
|
||||
mid = hex2rgb("#D3606A")
|
||||
light = hex2rgb("#E89B9B")
|
||||
pale = hex2rgb("#F3C2BC")
|
||||
base_y = 0.985 * H
|
||||
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
|
||||
def tower(x, w, h, col, style="flat"):
|
||||
pts = [(x, base_y), (x, base_y - h)]
|
||||
if style == "step":
|
||||
pts += [(x + w * 0.18, base_y - h), (x + w * 0.18, base_y - h * 1.12), (x + w * 0.82, base_y - h * 1.12), (x + w * 0.82, base_y - h)]
|
||||
elif style == "spire":
|
||||
pts += [(x + w * 0.30, base_y - h), (x + w * 0.5, base_y - h * 1.38), (x + w * 0.70, base_y - h)]
|
||||
elif style == "dome":
|
||||
arc = ellipse_poly(x + w / 2, base_y - h, w / 2, w * 0.62, a0=math.pi, a1=2 * math.pi)
|
||||
pts += arc
|
||||
elif style == "slant":
|
||||
pts += [(x + w * 0.85, base_y - h * 1.06)]
|
||||
pts += [(x + w, base_y - h if style != "slant" else base_y - h * 0.9), (x + w, base_y)]
|
||||
d.polygon(pts, fill=rgba(col, 255))
|
||||
return x + w
|
||||
|
||||
# back row (pale)
|
||||
x = 0.535 * W
|
||||
for w, h, st in [(120, 300, "flat"), (90, 420, "slant"), (150, 260, "dome"), (80, 520, "spire"), (130, 340, "step"), (100, 240, "flat"), (140, 400, "slant"), (95, 300, "dome"), (120, 360, "step")]:
|
||||
x = tower(x, w * 1.5, h * 1.05, pale, st) + 18
|
||||
comp(img, lay, 1.2)
|
||||
|
||||
# mid row (light/mid)
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
x = 0.56 * W
|
||||
for w, h, st, col in [(110, 430, "step", light), (85, 600, "spire", mid), (140, 380, "dome", light), (95, 700, "slant", mid), (120, 470, "step", light), (80, 560, "spire", mid), (150, 350, "flat", light), (105, 640, "slant", mid)]:
|
||||
x = tower(x, w * 1.5, h * 1.05, col, st) + 26
|
||||
comp(img, lay, 0.8)
|
||||
|
||||
# front row (deep) with light band windows
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
x = 0.585 * W
|
||||
fronts = [(130, 640, "step"), (95, 820, "spire"), (160, 520, "dome"), (110, 900, "slant"), (135, 600, "step"), (100, 740, "spire"), (150, 480, "dome")]
|
||||
for w, h, st in fronts:
|
||||
x0 = x
|
||||
x = tower(x, w * 1.5, h * 1.05, deep, st) + 34
|
||||
for fy in range(int(base_y - h * 1.05 + 60), int(base_y - 60), 92):
|
||||
d.rectangle([x0 + 14, fy, x0 + w * 1.5 - 14, fy + 12], fill=rgba(hex2rgb("#F6D9D4"), 150))
|
||||
# monorail arc sweeping to the right edge
|
||||
rail = cubic((0.60 * W, 0.86 * H), (0.75 * W, 0.78 * H), (0.90 * W, 0.80 * H), (1.02 * W, 0.72 * H), n=60)
|
||||
d.line(rail, fill=rgba(mix(deep, "#000000", 0.1), 200), width=12, joint="curve")
|
||||
comp(img, lay, 0.6)
|
||||
|
||||
# birds + floating particles
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
for bx, by, s in [(0.60 * W, 0.20 * H, 22), (0.64 * W, 0.16 * H, 16), (0.90 * W, 0.14 * H, 20), (0.86 * W, 0.22 * H, 14)]:
|
||||
d.line(cubic((bx - s, by), (bx - s * 0.4, by - s * 0.7), (bx + s * 0.4, by - s * 0.7), (bx + s, by), n=16), fill=rgba(deep, 170), width=5, joint="curve")
|
||||
for _ in range(14):
|
||||
x, y = R.uniform(0.56, 0.98) * W, R.uniform(0.08, 0.55) * H
|
||||
r = R.uniform(3, 7)
|
||||
d.ellipse([x - r, y - r, x + r, y + r], fill=rgba(mid, R.randint(50, 110)))
|
||||
comp(img, lay, 1.0)
|
||||
return img
|
||||
|
||||
|
||||
def _sprig(d, x, y, length, angle, n, col, col2, leaf_w=0.30):
|
||||
"""Sage sprig: curved stem with opposite oval leaves."""
|
||||
ca, sa = math.cos(angle), math.sin(angle)
|
||||
tip = (x + ca * length, y + sa * length)
|
||||
bend = (x + ca * length * 0.5 - sa * length * 0.12, y + sa * length * 0.5 + ca * length * 0.12)
|
||||
stem = cubic((x, y), bend, (bend[0] + ca * 10, bend[1] + sa * 10), tip, n=40)
|
||||
d.line(stem, fill=rgba(mix(col, "#000000", 0.12), 230), width=7, joint="curve")
|
||||
for i in range(n):
|
||||
t = 0.18 + 0.78 * i / max(1, n - 1)
|
||||
px = x + ca * length * t - sa * length * 0.10 * math.sin(t * math.pi)
|
||||
py = y + sa * length * t + ca * length * 0.10 * math.sin(t * math.pi)
|
||||
ll = length * (0.20 - 0.10 * t)
|
||||
for sgn in (-1, 1):
|
||||
la = angle + sgn * (1.15 - 0.4 * t)
|
||||
d.polygon(leaf_pts(px, py, ll, ll * leaf_w * 2.2, la, curl=0.1), fill=rgba(col if (i + sgn) % 2 == 0 else col2, 245))
|
||||
|
||||
|
||||
def scene_sage_breeze() -> "Image":
|
||||
R = rng(20260120)
|
||||
img = gradient(W, H, [(0.0, "#FAFAF3"), (0.55, "#F2F2E6"), (1.0, "#E7E9D8")], "d1")
|
||||
from artkit import paper_texture
|
||||
|
||||
paper_texture(img, "#8a946f", alpha=7, seed=11)
|
||||
glow(img, 0.76 * W, 0.30 * H, 0.5 * W, hex2rgb("#FFFFFF"), 60)
|
||||
glow(img, 0.88 * W, 0.85 * H, 0.35 * W, hex2rgb("#B9CBA8"), 55)
|
||||
|
||||
# faint sprig silhouettes far left
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
sage, sage2 = hex2rgb("#47735F"), hex2rgb("#7A9B84")
|
||||
_sprig(d, 0.10 * W, 0.95 * H, 330, -1.25, 6, sage2, sage2)
|
||||
_sprig(d, 0.30 * W, 0.98 * H, 260, -1.05, 5, sage2, sage2)
|
||||
alpha = lay.split()[3].point(lambda v: int(v * 0.45))
|
||||
lay.putalpha(alpha)
|
||||
comp(img, lay, 1.5)
|
||||
|
||||
# adult man
|
||||
man_bust(
|
||||
img, 0.722 * W, 0.40 * H, 150,
|
||||
skin=hex2rgb("#EDCBAA"), hair_c=hex2rgb("#3B352E"),
|
||||
cloth_c=hex2rgb("#57765F"), collar_c=hex2rgb("#EFF0E2"), style="shirt",
|
||||
)
|
||||
|
||||
# open book in his hands (foreground)
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
bx, by, bw = 0.722 * W, 0.90 * H, 520
|
||||
cover_l = [(bx - bw / 2 - 12, by - 66), (bx, by - 56), (bx, by + 34), (bx - bw / 2 - 12, by + 24)]
|
||||
cover_r = [(bx + bw / 2 + 12, by - 66), (bx, by - 56), (bx, by + 34), (bx + bw / 2 + 12, by + 24)]
|
||||
d.polygon(cover_l, fill=rgba(mix(sage, "#000000", 0.18), 255))
|
||||
d.polygon(cover_r, fill=rgba(mix(sage, "#000000", 0.18), 255))
|
||||
page_l = [(bx - bw / 2, by - 78), (bx, by - 68), (bx, by + 22), (bx - bw / 2, by + 12)]
|
||||
page_r = [(bx + bw / 2, by - 78), (bx, by - 68), (bx, by + 22), (bx + bw / 2, by + 12)]
|
||||
d.polygon(page_l, fill=rgba(hex2rgb("#FCFBF2"), 255))
|
||||
d.polygon(page_r, fill=rgba(hex2rgb("#F2F0E0"), 255))
|
||||
d.line([(bx, by - 68), (bx, by + 22)], fill=rgba(mix(sage, "#000000", 0.05), 200), width=5)
|
||||
for i in range(3):
|
||||
t = i / 2
|
||||
yy0 = by - 60 + i * 22
|
||||
d.line([(bx - bw * 0.42, yy0 - 6 * (1 - t)), (bx - bw * 0.07, yy0 + 2)], fill=rgba(sage2, 110), width=6)
|
||||
d.line([(bx + bw * 0.07, yy0 + 2), (bx + bw * 0.42, yy0 - 6 * (1 - t))], fill=rgba(sage2, 110), width=6)
|
||||
comp(img, lay, 0.5)
|
||||
|
||||
# sage sprigs foreground right + tall behind
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
_sprig(d, 0.60 * W, 0.99 * H, 380, -1.35, 7, sage, sage2)
|
||||
_sprig(d, 0.86 * W, 1.0 * H, 430, -1.15, 8, sage, sage2)
|
||||
_sprig(d, 0.94 * W, 0.99 * H, 340, -1.45, 6, sage2, sage)
|
||||
_sprig(d, 0.665 * W, 0.995 * H, 300, -0.9, 5, sage2, sage)
|
||||
comp(img, lay, 0.8)
|
||||
|
||||
# drifting seeds / pollen
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
for _ in range(18):
|
||||
x, y = R.uniform(0.55, 0.98) * W, R.uniform(0.10, 0.85) * H
|
||||
r = R.uniform(2.5, 6)
|
||||
d.ellipse([x - r, y - r, x + r, y + r], fill=rgba(sage2, R.randint(60, 130)))
|
||||
for _ in range(6):
|
||||
x, y = R.uniform(0.05, 0.45) * W, R.uniform(0.15, 0.9) * H
|
||||
r = R.uniform(2, 5)
|
||||
d.ellipse([x - r, y - r, x + r, y + r], fill=rgba(sage2, R.randint(25, 50)))
|
||||
comp(img, lay, 1.5)
|
||||
return img
|
||||
|
||||
|
||||
def _pencil(d, cx, cy, length, angle, body_c, wood_c, tip_c, eraser_c):
|
||||
ca, sa = math.cos(angle), math.sin(angle)
|
||||
|
||||
def tr(p):
|
||||
x, y = p
|
||||
return (cx + x * ca - y * sa, cy + x * sa + y * ca)
|
||||
|
||||
wdt = length * 0.075
|
||||
body = [tr(p) for p in [(-length / 2, -wdt / 2), (length * 0.32, -wdt / 2), (length * 0.32, wdt / 2), (-length / 2, wdt / 2)]]
|
||||
d.polygon(body, fill=rgba(body_c, 255))
|
||||
wood = [tr(p) for p in [(length * 0.32, -wdt / 2), (length * 0.44, 0), (length * 0.32, wdt / 2)]]
|
||||
d.polygon(wood, fill=rgba(wood_c, 255))
|
||||
tip = [tr(p) for p in [(length * 0.40, -wdt * 0.22), (length * 0.44, 0), (length * 0.40, wdt * 0.22)]]
|
||||
d.polygon(tip, fill=rgba(tip_c, 255))
|
||||
era = [tr(p) for p in [(-length / 2, -wdt / 2), (-length * 0.44, -wdt / 2), (-length * 0.44, wdt / 2), (-length / 2, wdt / 2)]]
|
||||
d.polygon(era, fill=rgba(eraser_c, 255))
|
||||
band = [tr(p) for p in [(-length * 0.44, -wdt / 2), (-length * 0.40, -wdt / 2), (-length * 0.40, wdt / 2), (-length * 0.44, wdt / 2)]]
|
||||
d.polygon(band, fill=rgba(mix(body_c, "#000000", 0.35), 255))
|
||||
|
||||
|
||||
def _washi(cx, cy, w, h, angle, col, pattern_c, style="dots"):
|
||||
pad = 12
|
||||
sw, sh = int(w + pad * 2), int(h + pad * 2)
|
||||
lay = Image.new("RGBA", (sw, sh), (0, 0, 0, 0))
|
||||
dd = ImageDraw.Draw(lay)
|
||||
dd.polygon(superellipse_poly(sw / 2, sh / 2, w / 2, h / 2, power=6), fill=rgba(col, 215))
|
||||
if style == "dots":
|
||||
for i in range(-2, 3):
|
||||
for j in (-1, 0, 1):
|
||||
dd.ellipse([sw / 2 + i * w / 6 - 7, sh / 2 + j * h / 3 - 7, sw / 2 + i * w / 6 + 7, sh / 2 + j * h / 3 + 7], fill=rgba(pattern_c, 130))
|
||||
else:
|
||||
for i in range(-3, 4):
|
||||
dd.line([(sw / 2 + i * w / 7 - h, sh / 2 - h / 2), (sw / 2 + i * w / 7 + h, sh / 2 + h / 2)], fill=rgba(pattern_c, 120), width=8)
|
||||
lay = lay.rotate(math.degrees(angle), resample=Image.BICUBIC, expand=True)
|
||||
out = new_layer()
|
||||
out.alpha_composite(lay, (int(cx - lay.width / 2), int(cy - lay.height / 2)))
|
||||
return out
|
||||
|
||||
|
||||
def _paper_plane(d, cx, cy, s, angle, col):
|
||||
ca, sa = math.cos(angle), math.sin(angle)
|
||||
|
||||
def tr(p):
|
||||
x, y = p
|
||||
return (cx + x * ca - y * sa, cy + x * sa + y * ca)
|
||||
|
||||
d.polygon([tr((s, 0)), tr((-s * 0.8, -s * 0.55)), tr((-s * 0.35, 0))], fill=rgba(col, 255))
|
||||
d.polygon([tr((s, 0)), tr((-s * 0.35, 0)), tr((-s * 0.8, s * 0.55))], fill=rgba(mix(col, "#000000", 0.12), 255))
|
||||
d.polygon([tr((-s * 0.35, 0)), tr((-s * 0.62, s * 0.12)), tr((-s * 0.5, -s * 0.10))], fill=rgba(mix(col, "#000000", 0.22), 255))
|
||||
|
||||
|
||||
def _paper_clip(cx, cy, s, angle, col):
|
||||
span = int(s * 1.6)
|
||||
lay = Image.new("RGBA", (span, span), (0, 0, 0, 0))
|
||||
dd = ImageDraw.Draw(lay)
|
||||
ox, oy = span / 2, span / 2
|
||||
w, h = s * 0.42, s
|
||||
lw = max(3, int(s * 0.09))
|
||||
|
||||
def T(pts):
|
||||
return [(px + ox, py + oy) for px, py in pts]
|
||||
|
||||
dd.line(T(ellipse_poly(0, -h * 0.22, w, h * 0.30, a0=math.pi, a1=2 * math.pi)), fill=rgba(col, 230), width=lw)
|
||||
dd.line(T([(-w, -h * 0.22), (-w, h * 0.35)]), fill=rgba(col, 230), width=lw)
|
||||
dd.line(T([(w, -h * 0.22), (w, h * 0.15)]), fill=rgba(col, 230), width=lw)
|
||||
dd.line(T(ellipse_poly(0, h * 0.35, w, h * 0.28, a0=0, a1=math.pi)), fill=rgba(col, 230), width=lw)
|
||||
dd.line(T([(0, -h * 0.52), (0, h * 0.10)]), fill=rgba(col, 230), width=lw)
|
||||
lay = lay.rotate(math.degrees(angle), resample=Image.BICUBIC, expand=True)
|
||||
out = new_layer()
|
||||
out.alpha_composite(lay, (int(cx - lay.width / 2), int(cy - lay.height / 2)))
|
||||
return out
|
||||
|
||||
|
||||
def scene_spark_notebook() -> "Image":
|
||||
R = rng(20260121)
|
||||
img = gradient(W, H, [(0.0, "#FFFBF0"), (0.55, "#FCF5E2"), (1.0, "#F7ECD2")], "v")
|
||||
|
||||
# notebook grid (very faint) + margin line
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
teal = hex2rgb("#007B78")
|
||||
for gx in range(0, W, 92):
|
||||
d.line([(gx, 0), (gx, H)], fill=rgba(teal, 14), width=2)
|
||||
for gy in range(0, H, 92):
|
||||
d.line([(0, gy), (W, gy)], fill=rgba(teal, 14), width=2)
|
||||
d.line([(0.055 * W, 0), (0.055 * W, H)], fill=rgba(hex2rgb("#EB5757"), 40), width=4)
|
||||
comp(img, lay, 0.5)
|
||||
glow(img, 0.76 * W, 0.32 * H, 0.48 * W, hex2rgb("#FFFFFF"), 55)
|
||||
|
||||
# anime adult character
|
||||
anime_adult(
|
||||
img, 0.722 * W, 0.41 * H, 150,
|
||||
skin=hex2rgb("#F7DAC8"), hair_c=hex2rgb("#35ADA4"),
|
||||
cloth_c=hex2rgb("#F2C94C"), iris_c=hex2rgb("#007B78"), style="bob",
|
||||
)
|
||||
|
||||
# stationery around (right side)
|
||||
yellow = hex2rgb("#F2C94C")
|
||||
coral = hex2rgb("#F2994A")
|
||||
red = hex2rgb("#EB5757")
|
||||
teal2 = hex2rgb("#42D1C6")
|
||||
img.alpha_composite(_washi(0.640 * W, 0.165 * H, 300, 84, -0.35, teal2, hex2rgb("#FFFFFF"), "stripes"))
|
||||
img.alpha_composite(_washi(0.905 * W, 0.80 * H, 320, 88, 0.28, yellow, coral, "dots"))
|
||||
img.alpha_composite(_washi(0.615 * W, 0.70 * H, 240, 76, 0.42, coral, hex2rgb("#FFFFFF"), "dots"))
|
||||
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
_pencil(d, 0.880 * W, 0.205 * H, 340, -0.5, yellow, hex2rgb("#EBCB9B"), hex2rgb("#4A4A4A"), red)
|
||||
_pencil(d, 0.640 * W, 0.885 * H, 300, 0.35, teal2, hex2rgb("#EBCB9B"), hex2rgb("#4A4A4A"), coral)
|
||||
_paper_plane(d, 0.885 * W, 0.62 * H, 90, -0.35, red)
|
||||
_paper_plane(d, 0.635 * W, 0.30 * H, 66, 0.25, coral)
|
||||
for _ in range(14):
|
||||
x, y = R.uniform(0.56, 0.97) * W, R.uniform(0.08, 0.92) * H
|
||||
star4(d, x, y, R.uniform(8, 20), [teal, yellow, coral][R.randint(0, 2)], R.randint(110, 200))
|
||||
comp(img, lay, 0.6)
|
||||
img.alpha_composite(_paper_clip(0.93 * W, 0.42 * H, 64, 0.5, teal))
|
||||
img.alpha_composite(_paper_clip(0.60 * W, 0.52 * H, 52, -0.4, coral))
|
||||
return img
|
||||
|
||||
|
||||
def scene_violet_starlight() -> "Image":
|
||||
R = rng(20260122)
|
||||
img = gradient(W, H, [(0.0, "#F4F1FF"), (0.38, "#DDD5F8"), (0.72, "#A794E3"), (1.0, "#7A63C4")], "d1")
|
||||
glow(img, 0.80 * W, 0.24 * H, 0.34 * W, hex2rgb("#EDE7FF"), 90)
|
||||
|
||||
# moon with halo
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
mx, my, mr = 0.845 * W, 0.205 * H, 120
|
||||
d.ellipse([mx - mr, my - mr, mx + mr, my + mr], fill=rgba(hex2rgb("#F2EDFF"), 235))
|
||||
d.ellipse([mx - mr * 0.68, my - mr * 0.5, mx - mr * 0.1, my + mr * 0.1], fill=rgba(hex2rgb("#DDD2F5"), 120))
|
||||
d.ellipse([mx + mr * 0.15, my + mr * 0.25, mx + mr * 0.6, my + mr * 0.62], fill=rgba(hex2rgb("#DDD2F5"), 100))
|
||||
comp(img, lay, 2)
|
||||
glow(img, mx, my, 300, hex2rgb("#E6DCFF"), 60)
|
||||
|
||||
# star field, denser to the right
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
for _ in range(170):
|
||||
x = R.betavariate(2.2, 1.4) * W
|
||||
y = R.betavariate(1.6, 1.2) * H * 0.9
|
||||
r = R.uniform(1.2, 4.2)
|
||||
a = R.randint(60, 220)
|
||||
d.ellipse([x - r, y - r, x + r, y + r], fill=rgba(hex2rgb("#FFFFFF"), a))
|
||||
for _ in range(16):
|
||||
x = R.uniform(0.5, 0.99) * W
|
||||
y = R.uniform(0.05, 0.75) * H
|
||||
star4(d, x, y, R.uniform(10, 26), hex2rgb("#FFFFFF"), R.randint(140, 230), thin=0.14)
|
||||
comp(img, lay, 0.8)
|
||||
|
||||
# silhouette muse
|
||||
silhouette_muse(
|
||||
img, 0.715 * W, 0.38 * H, 138,
|
||||
body_c=hex2rgb("#4C3E8F"), rim_c=hex2rgb("#D9CCFF"),
|
||||
)
|
||||
|
||||
# glowing butterflies
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
flies = [
|
||||
(0.615 * W, 0.30 * H, 46, -0.4), (0.80 * W, 0.52 * H, 58, 0.3), (0.885 * W, 0.38 * H, 40, -0.2),
|
||||
(0.67 * W, 0.66 * H, 34, 0.5), (0.93 * W, 0.68 * H, 48, -0.5), (0.565 * W, 0.55 * H, 30, 0.2),
|
||||
]
|
||||
v1, v2 = hex2rgb("#8F79E0"), hex2rgb("#C9B8FF")
|
||||
for cx, cy, s, ang in flies:
|
||||
polys, body = butterfly_pts(cx, cy, s, ang, flap=R.uniform(0.8, 1.1))
|
||||
for p in polys:
|
||||
d.polygon(p, fill=rgba(v1, 235))
|
||||
d.polygon(body, fill=rgba(mix(v1, "#000000", 0.3), 255))
|
||||
comp(img, lay, 1.0)
|
||||
for cx, cy, s, ang in flies:
|
||||
glow(img, cx, cy, s * 2.6, v2, 34)
|
||||
|
||||
# mist along the bottom
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
for _ in range(9):
|
||||
x, y = R.uniform(0.45, 1.0) * W, R.uniform(0.9, 1.02) * H
|
||||
d.ellipse([x - 320, y - 60, x + 320, y + 60], fill=rgba(hex2rgb("#B7A9E8"), 42))
|
||||
comp(img, lay, 26)
|
||||
return img
|
||||
|
||||
|
||||
def scene_cyan_stage() -> "Image":
|
||||
R = rng(20260123)
|
||||
img = gradient(W, H, [(0.0, "#F3FDFE"), (0.5, "#DCF4F8"), (1.0, "#BDE7EF")], "d1")
|
||||
glow(img, 0.74 * W, 0.30 * H, 0.5 * W, hex2rgb("#FFFFFF"), 70)
|
||||
|
||||
# stage floor + light rings
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
stage_c = hex2rgb("#8FD3DF")
|
||||
d.ellipse([0.48 * W, 0.86 * H, 1.05 * W, 1.10 * H], fill=rgba(mix(stage_c, "#ffffff", 0.45), 160))
|
||||
for rr, aa in [(300, 120), (420, 80), (540, 50)]:
|
||||
d.ellipse([0.74 * W - rr, 0.965 * H - rr * 0.22, 0.74 * W + rr, 0.965 * H + rr * 0.22], outline=rgba(hex2rgb("#37D7E4"), aa), width=6)
|
||||
comp(img, lay, 1.5)
|
||||
|
||||
# spotlights from the top
|
||||
beam(img, (0.58 * W, -0.06 * H), (0.70 * W, 0.92 * H), 90, 420, hex2rgb("#FFFFFF"), 60, blur=30)
|
||||
beam(img, (0.74 * W, -0.06 * H), (0.75 * W, 0.95 * H), 110, 520, hex2rgb("#BDF3F8"), 70, blur=34)
|
||||
beam(img, (0.90 * W, -0.06 * H), (0.80 * W, 0.92 * H), 90, 420, hex2rgb("#FFFFFF"), 55, blur=30)
|
||||
|
||||
# holographic ring behind the performer (flat ellipse around the torso)
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
ring = ring_pts(0.725 * W, 0.56 * H, 430, 9, squash=0.42)
|
||||
d.polygon(ring, fill=rgba(hex2rgb("#37D7E4"), 60))
|
||||
comp(img, lay, 4)
|
||||
|
||||
# performer
|
||||
performer(
|
||||
img, 0.722 * W, 0.42 * H, 148,
|
||||
skin=hex2rgb("#F3D2BA"), hair_c=hex2rgb("#1E3A44"),
|
||||
cloth_c=hex2rgb("#EAFBFD"), glow_c=hex2rgb("#18B7C6"),
|
||||
)
|
||||
|
||||
# equalizer bars on the stage (clearly audio, low alpha)
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
for i in range(12):
|
||||
bx = 0.520 * W + i * 32
|
||||
bh = 40 + 110 * abs(math.sin(i * 1.7 + 0.6))
|
||||
d.polygon(superellipse_poly(bx, 0.945 * H - bh / 2, 10, bh / 2, power=5), fill=rgba(hex2rgb("#18B7C6"), 70))
|
||||
comp(img, lay, 2)
|
||||
|
||||
# floating notes-free particles: dots + sparkles
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
for _ in range(22):
|
||||
x, y = R.uniform(0.55, 0.99) * W, R.uniform(0.08, 0.8) * H
|
||||
r = R.uniform(2.5, 7)
|
||||
d.ellipse([x - r, y - r, x + r, y + r], fill=rgba(hex2rgb("#18B7C6"), R.randint(50, 130)))
|
||||
for _ in range(8):
|
||||
x, y = R.uniform(0.58, 0.97) * W, R.uniform(0.10, 0.7) * H
|
||||
star4(d, x, y, R.uniform(9, 20), hex2rgb("#37D7E4"), R.randint(110, 190))
|
||||
comp(img, lay, 1.2)
|
||||
return img
|
||||
|
||||
|
||||
def scene_noir_gold() -> "Image":
|
||||
R = rng(20260124)
|
||||
img = gradient(W, H, [(0.0, "#FDFAF1"), (0.5, "#F6ECD4"), (1.0, "#ECDAB4")], "d1")
|
||||
glow(img, 0.74 * W, 0.30 * H, 0.5 * W, hex2rgb("#FFF8E2"), 80)
|
||||
|
||||
gold = hex2rgb("#D9B45B")
|
||||
gold_deep = hex2rgb("#A9822F")
|
||||
noir = hex2rgb("#211C15")
|
||||
|
||||
# black curtain swag along the top-right with a scalloped hem
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
hem = []
|
||||
hem.extend(cubic((0.62 * W, -0.02 * H), (0.68 * W, 0.10 * H), (0.72 * W, 0.13 * H), (0.77 * W, 0.135 * H), n=24))
|
||||
hem.extend(cubic((0.77 * W, 0.135 * H), (0.80 * W, 0.14 * H), (0.83 * W, 0.13 * H), (0.86 * W, 0.10 * H), n=18)[1:])
|
||||
hem.extend(cubic((0.86 * W, 0.10 * H), (0.90 * W, 0.06 * H), (0.95 * W, 0.03 * H), (1.03 * W, 0.02 * H), n=24)[1:])
|
||||
swag = hem + [(1.03 * W, -0.06 * H), (0.62 * W, -0.06 * H)]
|
||||
d.polygon(swag, fill=rgba(noir, 245))
|
||||
# soft fold shading under the hem
|
||||
for i in range(3):
|
||||
fx = 0.70 * W + i * 0.075 * W
|
||||
fold = cubic((fx, 0.02 * H), (fx - 0.012 * W, 0.07 * H), (fx - 0.005 * W, 0.10 * H), (fx + 0.01 * W, 0.125 * H), n=20)
|
||||
d.line(fold, fill=rgba(mix(noir, "#ffffff", 0.10), 160), width=10, joint="curve")
|
||||
# right edge drape
|
||||
drape = smooth_path([
|
||||
((0.972 * W, 0.0), (0.995 * W, 0.22 * H), (0.975 * W, 0.48 * H), (1.0 * W, 0.70 * H)),
|
||||
((1.0 * W, 0.70 * H), (1.035 * W, 0.45 * H), (1.035 * W, 0.2 * H), (1.015 * W, 0.0)),
|
||||
])
|
||||
d.polygon(drape, fill=rgba(noir, 240))
|
||||
comp(img, lay, 1.2)
|
||||
# gold fringe following the hem
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
d.line(hem, fill=rgba(gold, 210), width=7, joint="curve")
|
||||
comp(img, lay, 1.0)
|
||||
|
||||
# art-deco fan arcs behind the man (kept below the chin)
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
fcx, fcy = 0.725 * W, 0.92 * H
|
||||
for rr in range(200, 470, 66):
|
||||
arc = ellipse_poly(fcx, fcy, rr, rr, a0=math.pi * 1.18, a1=math.pi * 1.82)
|
||||
d.line(arc, fill=rgba(gold_deep, 90), width=5, joint="curve")
|
||||
comp(img, lay, 1.5)
|
||||
|
||||
# golden spotlights
|
||||
beam(img, (0.60 * W, -0.05 * H), (0.70 * W, 0.95 * H), 80, 380, hex2rgb("#FFE9AE"), 80, blur=30)
|
||||
beam(img, (0.88 * W, -0.02 * H), (0.77 * W, 0.95 * H), 90, 430, hex2rgb("#FFDF9E"), 70, blur=32)
|
||||
|
||||
# man in black suit
|
||||
man_bust(
|
||||
img, 0.722 * W, 0.41 * H, 150,
|
||||
skin=hex2rgb("#E8C3A0"), hair_c=hex2rgb("#241F19"),
|
||||
cloth_c=noir, collar_c=hex2rgb("#FBF4E4"), style="suit",
|
||||
)
|
||||
# gold pocket square
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
d.polygon([(0.722 * W + 0.62 * 150, 0.41 * H + 1.9 * 180), (0.722 * W + 0.82 * 150, 0.41 * H + 1.86 * 180), (0.722 * W + 0.72 * 150, 0.41 * H + 2.06 * 180)], fill=rgba(gold, 255))
|
||||
comp(img, lay, 1.5)
|
||||
|
||||
# stage floor curve bottom-right
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
floor = smooth_path([
|
||||
((0.52 * W, 1.01 * H), (0.66 * W, 0.92 * H), (0.86 * W, 0.90 * H), (1.02 * W, 0.96 * H)),
|
||||
((1.02 * W, 0.96 * H), (1.02 * W, 1.03 * H), (0.7 * W, 1.04 * H), (0.52 * W, 1.01 * H)),
|
||||
])
|
||||
d.polygon(floor, fill=rgba(noir, 235))
|
||||
edge = cubic((0.53 * W, 1.005 * H), (0.66 * W, 0.925 * H), (0.86 * W, 0.905 * H), (1.01 * W, 0.955 * H), n=50)
|
||||
d.line(edge, fill=rgba(gold, 200), width=7, joint="curve")
|
||||
comp(img, lay, 1.0)
|
||||
|
||||
# gold dust
|
||||
lay = new_layer()
|
||||
d = ImageDraw.Draw(lay)
|
||||
for _ in range(30):
|
||||
x, y = R.uniform(0.55, 0.99) * W, R.uniform(0.08, 0.9) * H
|
||||
r = R.uniform(2, 6.5)
|
||||
d.ellipse([x - r, y - r, x + r, y + r], fill=rgba(gold if R.random() < 0.7 else gold_deep, R.randint(70, 160)))
|
||||
for _ in range(10):
|
||||
x, y = R.uniform(0.58, 0.97) * W, R.uniform(0.10, 0.75) * H
|
||||
star4(d, x, y, R.uniform(8, 18), gold, R.randint(120, 200))
|
||||
comp(img, lay, 1.2)
|
||||
return img
|
||||
|
||||
|
||||
SCENES = {
|
||||
"official-rose-dawn": scene_rose_dawn,
|
||||
"official-fortune-forge": scene_fortune_forge,
|
||||
"official-crimson-horizon": scene_crimson_horizon,
|
||||
"official-sage-breeze": scene_sage_breeze,
|
||||
"official-spark-notebook": scene_spark_notebook,
|
||||
"official-violet-starlight": scene_violet_starlight,
|
||||
"official-cyan-stage": scene_cyan_stage,
|
||||
"official-noir-gold": scene_noir_gold,
|
||||
}
|
||||