返回 DeepSeek-Reasonix
theme-pack.test.ts
根目录 / desktop / frontend / src / __tests__ / theme-pack.test.ts
1 // Run: tsx src/__tests__/theme-pack.test.ts
2
3 import { readFileSync } from "node:fs";
4 import { dirname, resolve } from "node:path";
5 import { fileURLToPath } from "node:url";
6 import {
7 applyConfiguredBaseAppearance,
8 applyThemePack,
9 applyThemeScene,
10 beginThemePreview,
11 cancelThemePreview,
12 clearThemePack,
13 draftPackView,
14 getActiveThemePack,
15 getBaseAppearance,
16 isSafeBackgroundURL,
17 isSafeHex,
18 isThemeTokenKey,
19 registerTrustedThemeBackgroundURLs,
20 setBaseAppearance,
21 themePackKind,
22 } from "../lib/themePack";
23 import { applyTheme, getThemeStyle, THEME_STYLES } from "../lib/theme";
24 import {
25 baseCodeReadabilityStylesheet,
26 codeReadabilityRatios,
27 contrastRatio,
28 deriveCreationCodeReadabilityPalette,
29 deriveCodeReadabilityPalette,
30 } from "../lib/codeReadability";
31 import { BASE_STYLE_PREVIEW_PALETTES, themePreviewPalette } from "../lib/themePreviewPalette";
32 import { themePreviewCodePalette, themePreviewPaneAlpha } from "../components/ThemePreviewSurface";
33 import {
34 activateThemePack,
35 applyExperienceToDOM,
36 cancelGlobalPreview,
37 configuredBaseStyleForSync,
38 isPreviewActive,
39 startGlobalPreview,
40 } from "../lib/themeExperience";
41 import { installDesktopHostStub } from "./desktopHostStub";
42
43 const testDir = dirname(fileURLToPath(import.meta.url));
44 const packSource = readFileSync(resolve(testDir, "../lib/themePack.ts"), "utf8");
45 const stylesSource = readFileSync(resolve(testDir, "../styles.css"), "utf8");
46 const appViewSource = readFileSync(resolve(testDir, "../app-shell/AppRuntimeView.tsx"), "utf8");
47 const exportOwnerSource = readFileSync(resolve(testDir, "../app-runtime/useSessionExportCommands.ts"), "utf8");
48 const composerRouterSource = readFileSync(resolve(testDir, "../app-runtime/useComposerRouter.ts"), "utf8");
49 const librarySource = readFileSync(resolve(testDir, "../components/ThemeLibrary.tsx"), "utf8");
50 const gallerySource = readFileSync(resolve(testDir, "../components/ThemeGallery.tsx"), "utf8");
51 const previewSurfaceSource = readFileSync(resolve(testDir, "../components/ThemePreviewSurface.tsx"), "utf8");
52 const confirmDialogSource = readFileSync(resolve(testDir, "../components/ConfirmDialog.tsx"), "utf8");
53 const overviewSource = readFileSync(resolve(testDir, "../components/AppearanceOverview.tsx"), "utf8");
54 const settingsSource = readFileSync(resolve(testDir, "../components/SettingsPanel.tsx"), "utf8");
55 const experienceSource = readFileSync(resolve(testDir, "../lib/themeExperience.ts"), "utf8");
56 const bridgeSource = readFileSync(resolve(testDir, "../lib/bridge.ts"), "utf8");
57 const viteSource = readFileSync(resolve(testDir, "../../vite.config.ts"), "utf8");
58 const localeEn = readFileSync(resolve(testDir, "../locales/en.ts"), "utf8");
59 const localeZh = readFileSync(resolve(testDir, "../locales/zh.ts"), "utf8");
60 const localeZhTW = readFileSync(resolve(testDir, "../locales/zh-TW.ts"), "utf8");
61
62 let passed = 0;
63 let failed = 0;
64
65 function ok(value: boolean, label: string) {
66 if (value) {
67 process.stdout.write(` PASS ${label}\n`);
68 passed += 1;
69 } else {
70 process.stdout.write(` FAIL ${label}\n`);
71 failed += 1;
72 }
73 }
74
75 // Minimal DOM for applyThemePack
76 const attrs = new Map<string, string>();
77 const styleProps = new Map<string, string>();
78 type MockHead = {
79 appendChild: (el: MockStyleElement) => MockStyleElement;
80 removeChild: (el: MockStyleElement) => void;
81 };
82
83 type MockStyleElement = {
84 id: string;
85 textContent: string;
86 parentElement: MockHead | null;
87 remove: () => void;
88 };
89 const headChildren: MockStyleElement[] = [];
90 const mockHead: MockHead = {
91 appendChild(el: MockStyleElement) {
92 const existing = headChildren.indexOf(el);
93 if (existing >= 0) headChildren.splice(existing, 1);
94 headChildren.push(el);
95 el.parentElement = mockHead;
96 return el;
97 },
98 removeChild(el: MockStyleElement) {
99 const idx = headChildren.indexOf(el);
100 if (idx >= 0) headChildren.splice(idx, 1);
101 el.parentElement = null;
102 },
103 };
104
105 function createMockStyleElement(): MockStyleElement {
106 const el: MockStyleElement = {
107 id: "",
108 textContent: "",
109 parentElement: null,
110 remove() {
111 mockHead.removeChild(el);
112 el.textContent = "";
113 },
114 };
115 return el;
116 }
117
118 function styleText(id: string): string {
119 return headChildren.find((el) => el.id === id)?.textContent || "";
120 }
121
122 (globalThis as unknown as { document: unknown }).document = {
123 documentElement: {
124 setAttribute(k: string, v: string) {
125 attrs.set(k, v);
126 },
127 removeAttribute(k: string) {
128 attrs.delete(k);
129 },
130 style: {
131 setProperty(k: string, v: string) {
132 styleProps.set(k, v);
133 },
134 removeProperty(k: string) {
135 styleProps.delete(k);
136 },
137 },
138 },
139 head: mockHead,
140 getElementById(id: string) {
141 return headChildren.find((el) => el.id === id) || null;
142 },
143 createElement(tag: string) {
144 if (tag === "style") return createMockStyleElement();
145 return {};
146 },
147 querySelector() {
148 return null;
149 },
150 };
151
152 (globalThis as unknown as { window: unknown }).window = {
153 matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {} }),
154 location: { href: "http://127.0.0.1:5197/", origin: "http://127.0.0.1:5197" },
155 };
156
157 console.log("\ntheme pack contract");
158
159 ok(isSafeHex("#aabbcc"), "accepts #RRGGBB");
160 ok(isSafeHex("#aabbccdd"), "accepts #RRGGBBAA");
161 ok(!isSafeHex("url(x)"), "rejects url()");
162 ok(!isSafeHex("linear-gradient(red,blue)"), "rejects gradient");
163 ok(isThemeTokenKey("accent") && !isThemeTokenKey("hack"), "token whitelist");
164
165 const translucentCodePalette = deriveCodeReadabilityPalette("dark", "graphite", {
166 bg: "#102030",
167 bgSoft: "#ffffff33",
168 fg: "#f4f5f7",
169 borderSoft: "#ffffff22",
170 });
171 ok(/^#[0-9a-f]{6}$/.test(translucentCodePalette.background), "code background is flattened to an opaque color");
172 ok(translucentCodePalette.background === "#404d59", "transparent bgSoft is composited over the theme background");
173 ok(
174 Object.values(codeReadabilityRatios(translucentCodePalette)).every((ratio) => ratio >= 4.5),
175 "every code and diff text role reaches WCAG AA on its rendered background",
176 );
177 ok(
178 /^#[0-9a-f]{6}$/.test(translucentCodePalette.additionBackground) &&
179 /^#[0-9a-f]{6}$/.test(translucentCodePalette.deletionBackground),
180 "diff row backgrounds are pre-composited opaque colors",
181 );
182 ok(
183 contrastRatio(translucentCodePalette.addition, translucentCodePalette.additionBackground) >= 4.5 &&
184 contrastRatio(translucentCodePalette.deletion, translucentCodePalette.deletionBackground) >= 4.5,
185 "diff semantic text reaches WCAG AA on the final tinted row",
186 );
187 const adversarialMidtonePalette = deriveCodeReadabilityPalette("dark", "graphite", {
188 bg: "#3fcd1c",
189 bgSoft: "#cd1ce4",
190 fg: "#3fcd1c",
191 ok: "#15803d",
192 err: "#dc2626",
193 });
194 ok(
195 Object.values(codeReadabilityRatios(adversarialMidtonePalette)).every((ratio) => ratio >= 4.5),
196 "midtone custom themes keep every syntax role readable on plain and tinted diff rows",
197 );
198 let generatedPaletteMinimum = Number.POSITIVE_INFINITY;
199 for (let index = 0; index < 512; index += 1) {
200 const sample = ((index * 2654435761) >>> 0).toString(16).padStart(8, "0");
201 const palette = deriveCodeReadabilityPalette(index % 2 === 0 ? "dark" : "light", "graphite", {
202 bg: `#${sample.slice(0, 6)}`,
203 bgSoft: `#${sample.slice(2, 8)}`,
204 fg: `#${sample.slice(0, 6)}`,
205 ok: `#${sample.slice(1, 7)}`,
206 err: `#${sample.slice(2, 8)}`,
207 });
208 generatedPaletteMinimum = Math.min(generatedPaletteMinimum, ...Object.values(codeReadabilityRatios(palette)));
209 }
210 ok(generatedPaletteMinimum >= 4.5, "generated custom palettes preserve WCAG AA across every rendered code surface");
211 const invertedDarkPack = deriveCodeReadabilityPalette("dark", "graphite", { bg: "#ffffff", bgSoft: "#fafafa" });
212 ok(invertedDarkPack.string === "#0a3069", "syntax direction follows final code luminance instead of global dark mode");
213
214 const baseReadabilityCSS = baseCodeReadabilityStylesheet(THEME_STYLES);
215 for (const style of THEME_STYLES) {
216 for (const mode of ["dark", "light"] as const) {
217 const palette = deriveCodeReadabilityPalette(mode, style);
218 const basePack = draftPackView({
219 id: `base-${style}`,
220 name: style,
221 baseStyle: style,
222 tokens: {},
223 recipes: { density: "comfortable", corners: "soft" },
224 });
225 ok(
226 Object.values(codeReadabilityRatios(palette)).every((ratio) => ratio >= 4.5),
227 `${style} ${mode} base code palette reaches WCAG AA`,
228 );
229 ok(
230 JSON.stringify(themePreviewCodePalette(basePack, mode)) === JSON.stringify(palette),
231 `${style} ${mode} preview uses the live code palette`,
232 );
233 }
234 }
235 for (const mode of ["dark", "light"] as const) {
236 ok(
237 Object.values(codeReadabilityRatios(deriveCreationCodeReadabilityPalette(mode))).every((ratio) => ratio >= 4.5),
238 `Creation ${mode} code palette reaches WCAG AA`,
239 );
240 }
241 ok(
242 baseReadabilityCSS.includes(':root[data-theme-style="graphite"]') &&
243 baseReadabilityCSS.includes('.app--creation{--code-bg:'),
244 "base stylesheet installs complete root and Creation code palettes",
245 );
246
247 ok(isSafeBackgroundURL("/__reasonix_theme_asset/my-theme/abc/background.png"), "asset URL allowed");
248 ok(isSafeBackgroundURL("data:image/png;base64,aaa"), "data URL allowed");
249 ok(!isSafeBackgroundURL("https://evil.example/bg.png"), "remote URL rejected");
250 const bundledOfficialBackground = "http://127.0.0.1:5197/@fs/workspace/desktop/themes/official/official-rose-dawn/background.webp";
251 registerTrustedThemeBackgroundURLs([bundledOfficialBackground, "https://evil.example/assets/background-fake.webp"]);
252 ok(isSafeBackgroundURL(bundledOfficialBackground), "registered same-origin official dev background allowed");
253 ok(!isSafeBackgroundURL("https://evil.example/assets/background-fake.webp"), "cross-origin bundled background rejected");
254
255 const draft = draftPackView({
256 id: "preview-pack",
257 name: "Preview",
258 baseStyle: "graphite",
259 tokens: { dark: { accent: "#ff0000", fg: "#ffffff" }, light: { accent: "#0000ff" } },
260 recipes: { density: "compact", corners: "round" },
261 background: {
262 focusX: 0.2,
263 focusY: 0.8,
264 safeArea: "left",
265 homeOpacity: 1,
266 taskOpacity: 0.2,
267 overlayStrength: 0.5,
268 paneOpacity: 0.50,
269 },
270 backgroundUrl: "/__reasonix_theme_asset/preview-pack/deadbeef/background.png",
271 });
272
273 const tokenOnlyPreview = draftPackView({
274 id: "token-only-preview",
275 name: "Token Only Preview",
276 baseStyle: "graphite",
277 tokens: { dark: { accent: "#ff0000" } },
278 recipes: { density: "comfortable", corners: "soft" },
279 });
280 ok(themePreviewPaneAlpha(tokenOnlyPreview, "home") === 1, "token-only preview keeps opaque panes");
281 ok(themePreviewPaneAlpha(draft, "home") === 0.5, "background preview applies configured pane opacity");
282
283 applyThemePack(draft);
284 ok(attrs.get("data-theme-pack") === "preview-pack", "sets data-theme-pack");
285 ok(attrs.get("data-theme-has-bg") === "true", "marks background present");
286 ok(styleProps.has("--theme-bg-image"), "sets background image var");
287 ok(styleText("reasonix-theme-pack-overlay").includes("--accent:#ff0000"), "injects dark accent override");
288 ok(styleText("reasonix-theme-pack-overlay").includes("--code-bg:#101115"), "injects an opaque code readability island");
289 ok(styleText("reasonix-theme-pack-overlay").includes("--hl-comment:"), "injects contrast-checked syntax roles");
290 ok(styleText("reasonix-theme-pack-overlay").includes("--r:14px"), "applies round corners recipe");
291
292 const twoSceneDraft = draftPackView({
293 ...draft,
294 taskBackground: { focusX: 0.8, focusY: 0.3, safeArea: "right", opacity: 0.35, overlayStrength: 0.7, paneOpacity: 0.68 },
295 taskBackgroundUrl: "/__reasonix_theme_asset/preview-pack/deadbeef/background-task.png",
296 });
297 applyThemePack(twoSceneDraft);
298 ok(styleProps.get("--theme-bg-task-image")?.includes("background-task.png") === true, "sets independent task image var");
299 ok(styleProps.get("--theme-bg-task-opacity") === "0.35", "sets independent task opacity");
300 ok(styleProps.get("--theme-pane-card-pct") === "76%", "computes home card pane opacity");
301 ok(styleProps.get("--theme-pane-task-card-pct") === "82%", "computes task card pane opacity");
302 ok(styleProps.get("--theme-pane-session-hover-pct") === "76%", "computes home session-hover opacity tier");
303 ok(styleProps.get("--theme-pane-child-pct") === "80%", "computes home child opacity tier");
304 ok(styleProps.get("--theme-pane-interact-pct") === "90%", "computes home interaction opacity tier");
305 ok(styleProps.get("--theme-pane-overlay-pct") === "90%", "computes home operational overlay opacity tier");
306 ok(styleProps.get("--theme-pane-task-session-hover-pct") === "94%", "computes task session-hover opacity tier");
307 ok(styleProps.get("--theme-pane-task-child-pct") === "98%", "computes task child opacity tier");
308 ok(styleProps.get("--theme-pane-task-interact-pct") === "100%", "caps task interaction opacity tier");
309 ok(styleProps.get("--theme-pane-task-overlay-pct") === "100%", "caps task operational overlay opacity tier");
310 ok(attrs.get("data-theme-safe-area") === "right", "task background controls safe area");
311
312 for (const [paneOpacity, expected] of [[0, "40%"], [0.5, "90%"], [1, "100%"]] as const) {
313 const opacityDraft = draftPackView({
314 ...twoSceneDraft,
315 background: { ...twoSceneDraft.background!, paneOpacity },
316 taskBackground: { ...twoSceneDraft.taskBackground!, paneOpacity },
317 });
318 applyThemePack(opacityDraft);
319 ok(styleProps.get("--theme-pane-overlay-pct") === expected, `home overlay follows pane opacity ${paneOpacity}`);
320 ok(styleProps.get("--theme-pane-task-overlay-pct") === expected, `task overlay follows pane opacity ${paneOpacity}`);
321 }
322
323 // Older shells and partial mocks can expose the independent task scene without
324 // the newly added paneOpacity field. It must inherit the home pane value rather
325 // than falling through clamp01(undefined)'s generic midpoint.
326 const legacyTaskPaneDraft = draftPackView({
327 ...twoSceneDraft,
328 taskBackground: { ...twoSceneDraft.taskBackground! },
329 });
330 delete (legacyTaskPaneDraft.taskBackground as { paneOpacity?: number }).paneOpacity;
331 applyThemePack(legacyTaskPaneDraft);
332 ok(styleProps.get("--theme-pane-task-alpha") === "0.5", "legacy task scene inherits home pane opacity");
333
334 applyThemeScene("task");
335 ok(attrs.get("data-theme-scene") === "task", "scene task on root");
336
337 applyThemeScene("home");
338 ok(attrs.get("data-theme-scene") === "home", "scene home on root");
339
340 // Preview cancel restores previous (null) pack
341 clearThemePack();
342 ok(
343 [
344 "--theme-pane-session-hover-pct",
345 "--theme-pane-child-pct",
346 "--theme-pane-interact-pct",
347 "--theme-pane-overlay-pct",
348 "--theme-pane-task-session-hover-pct",
349 "--theme-pane-task-child-pct",
350 "--theme-pane-task-interact-pct",
351 "--theme-pane-task-overlay-pct",
352 ].every((property) => !styleProps.has(property)),
353 "clearing a pack removes every extended pane opacity tier",
354 );
355 applyThemePack(tokenOnlyPreview);
356 ok(!attrs.has("data-theme-has-bg"), "token-only themes keep operational overlays on the opaque base surface");
357 ok(!styleProps.has("--theme-pane-overlay-pct"), "token-only themes do not inject home overlay transparency");
358 ok(!styleProps.has("--theme-pane-task-overlay-pct"), "token-only themes do not inject task overlay transparency");
359 clearThemePack();
360 ok(styleText("reasonix-base-code-readability").includes("--code-add-bg:"), "applyTheme installs the base code readability stylesheet");
361 beginThemePreview(draft);
362 ok(attrs.get("data-theme-pack") === "preview-pack", "preview applies pack");
363 cancelThemePreview();
364 ok(!attrs.has("data-theme-pack"), "cancel restores cleared pack");
365
366 // A failed persistent activation must keep the preview snapshot reversible.
367 clearThemePack();
368 applyTheme("dark", "graphite", { persist: false });
369 startGlobalPreview(draft);
370 const activationStub = installDesktopHostStub(({
371 main: {
372 App: {
373 async ActivateThemePack() {
374 throw new Error("activation failed");
375 },
376 },
377 },
378 }).main.App);
379 let activationRejected = false;
380 try {
381 await activateThemePack(draft.id);
382 } catch {
383 activationRejected = true;
384 }
385 ok(activationRejected, "activation failure surfaces to caller");
386 ok(isPreviewActive(), "activation failure keeps preview reversible");
387 cancelGlobalPreview();
388 ok(!attrs.has("data-theme-pack") && getThemeStyle() === "graphite", "cancel restores appearance after activation failure");
389 activationStub.uninstall();
390
391 // Save-and-apply must commit the preview before editor unmount cleanup can
392 // restore the old snapshot while the gallery reload is in flight.
393 const saveEditorStart = gallerySource.indexOf("const saveEditor = async");
394 const saveEditorEnd = gallerySource.indexOf("if (immersive && selectedPack)", saveEditorStart);
395 const saveEditorSource = gallerySource.slice(saveEditorStart, saveEditorEnd);
396 ok(saveEditorSource.includes("activate: false"), "save-and-apply defers persistent activation to the experience controller");
397 ok(
398 (saveEditorSource.match(/activateThemePack\(saved\.id\)/g) || []).length === 1,
399 "save-and-apply persists activation exactly once",
400 );
401 ok(
402 saveEditorSource.indexOf("await activateThemePack(saved.id)") < saveEditorSource.indexOf("setEditor(null)"),
403 "save-and-apply activates before editor unmount",
404 );
405
406 // Restore-default must restore config baseStyle, not leave pack baseStyle.
407 setBaseAppearance("dark", "graphite");
408 applyTheme("dark", "graphite", { persist: false });
409 const aurora = draftPackView({
410 id: "aurora",
411 name: "Aurora",
412 baseStyle: "aurora",
413 tokens: {},
414 recipes: { density: "comfortable", corners: "soft" },
415 });
416 applyThemePack(aurora);
417 ok(attrs.get("data-theme-pack") === "aurora", "aurora pack active");
418 ok(getThemeStyle() === "aurora", "pack switches live style to aurora");
419 clearThemePack();
420 ok(!attrs.has("data-theme-pack"), "clear removes data-theme-pack");
421 ok(getThemeStyle() === "graphite", "clear restores config graphite style");
422
423 // Generic settings refreshes must update the configured restore target without
424 // replacing an active pack's effective style in the live DOM.
425 applyThemePack(aurora);
426 applyConfiguredBaseAppearance("light", "slate");
427 ok(getActiveThemePack()?.id === "aurora", "settings refresh preserves the active pack");
428 ok(attrs.get("data-theme-pack") === "aurora", "settings refresh preserves the pack DOM marker");
429 ok(getThemeStyle() === "aurora", "settings refresh preserves the pack effective style");
430 ok(
431 getBaseAppearance()?.theme === "light" && getBaseAppearance()?.style === "slate",
432 "settings refresh updates the configured restore appearance",
433 );
434 clearThemePack();
435 ok(getThemeStyle() === "slate", "clear restores the configured appearance after settings refresh");
436
437 // React owners must not replace the configured base style with a pack's
438 // effective style. Direct reset entry points depend on this restore snapshot.
439 const activeAuroraExperience = {
440 themeMode: "dark" as const,
441 baseStyle: "graphite" as const,
442 effectiveStyle: "aurora" as const,
443 activeThemeId: aurora.id,
444 activePack: aurora,
445 };
446 applyExperienceToDOM(activeAuroraExperience);
447 ok(configuredBaseStyleForSync(activeAuroraExperience) === null, "active pack effective style is not mirrored as configured base");
448 clearThemePack();
449 ok(getThemeStyle() === "graphite", "direct reset still restores configured base after experience sync");
450 ok(
451 configuredBaseStyleForSync({ ...activeAuroraExperience, activeThemeId: undefined, activePack: null, baseStyle: "slate", effectiveStyle: "slate" }) === "slate",
452 "inactive experience still synchronizes a newly selected base style",
453 );
454
455 // Density recipe must land in overlay CSS and have stylesheet consumers.
456 ok(styleText("reasonix-theme-pack-overlay").includes("--theme-density-pad") || packSource.includes("--theme-density-pad:6px"), "compact density vars defined in pack builder");
457 const compactDraft = draftPackView({
458 id: "dense",
459 name: "Dense",
460 baseStyle: "graphite",
461 tokens: {},
462 recipes: { density: "compact", corners: "soft" },
463 });
464 applyThemePack(compactDraft);
465 ok(styleText("reasonix-theme-pack-overlay").includes("--theme-density-pad:6px"), "compact density injected");
466 ok(styleText("reasonix-theme-pack-overlay").includes("--theme-row-h:28px"), "compact row height injected");
467 ok(stylesSource.includes("padding: var(--theme-density-pad"), "density pad consumed by cards");
468 ok(stylesSource.includes("gap: var(--theme-density-gap"), "density gap consumed");
469 ok(stylesSource.includes("--list-row-height: var(--theme-row-h)"), "density maps to list row height");
470
471 // Layout must go transparent when a background is active so theme-bg is visible.
472 ok(
473 /data-theme-has-bg="true"\][^}]*\.layout\s*\{[^}]*background:\s*transparent/s.test(stylesSource),
474 "layout background transparent when theme has background",
475 );
476 const transparencyStart = stylesSource.indexOf("Extended pane transparency");
477 const transparencyEnd = stylesSource.indexOf("Density recipe consumers", transparencyStart);
478 const transparencySlice = stylesSource.slice(transparencyStart, transparencyEnd);
479 const unguardedTransparencySelectors = transparencySlice
480 .split("\n")
481 .filter((line) => line.includes(":root[data-theme-pack]") && !line.includes('[data-theme-has-bg="true"]'));
482 ok(unguardedTransparencySelectors.length === 0, "token-only packs keep opaque layout surfaces");
483 ok(
484 !transparencySlice.includes(".app:not(.app--creation) .code,") &&
485 !transparencySlice.includes(".app:not(.app--creation) .diff,") &&
486 transparencySlice.includes(".app:not(.app--creation) .md-code,"),
487 "pane transparency keeps block code and diff opaque while preserving inline-code styling",
488 );
489 ok(stylesSource.includes("var(--theme-pane-card-pct, 88%)"), "home cards consume pane opacity tier");
490 ok(stylesSource.includes("var(--theme-pane-task-card-pct, 88%)"), "task cards consume pane opacity tier");
491 ok(stylesSource.includes("var(--tp-pane-card-pct, 88%)"), "preview cards consume the same pane opacity tier");
492 ok(stylesSource.includes(':root[data-theme-has-bg="true"] .theme-bg'), "background layer only displays for packs with backgrounds");
493
494 // Unmount must cancel preview.
495 ok(librarySource.includes("cancelThemePreview()"), "ThemeLibrary cleanup cancels preview");
496
497 // Import confirm reuses staged import (replace=true empty path).
498 ok(
499 librarySource.includes("ImportThemePack(\"\", true)") || librarySource.includes("ImportThemePack('', true)"),
500 "import confirm reuses staged path without re-picking",
501 );
502 ok(librarySource.includes("needsReplace"), "import handles needsReplace result");
503
504 // Theme confirmations stay inside the Reasonix UI instead of opening native
505 // browser/system prompts.
506 ok(!gallerySource.includes("window.confirm"), "ThemeGallery does not use native confirm dialogs");
507 ok(!librarySource.includes("window.confirm"), "ThemeLibrary does not use native confirm dialogs");
508 ok(gallerySource.includes("useConfirmDialog") && librarySource.includes("useConfirmDialog"), "theme flows share the Reasonix confirm dialog");
509 ok(confirmDialogSource.includes('role="dialog"') && confirmDialogSource.includes('aria-modal="true"'), "confirm dialog exposes accessible modal semantics");
510 ok(confirmDialogSource.includes('request.tone === "danger"') && confirmDialogSource.includes("btn--danger"), "destructive confirmations use danger styling");
511 ok(confirmDialogSource.includes('event.key === "Escape"') && confirmDialogSource.includes("restoreFocusRef"), "confirm dialog supports Escape and focus restoration");
512 ok(gallerySource.includes("moreActionsRef") && gallerySource.includes("moreActionsRef.current?.focus()"), "gallery cancellation restores focus after closing its overflow menu");
513
514 // Source contracts
515 ok(packSource.includes("reasonix-theme-pack-overlay"), "overlay style id stable");
516 ok(packSource.includes("appendChild(el)"), "overlay style appended last for priority");
517 ok(packSource.includes("baseAppearance"), "tracks base appearance for restore");
518 ok(stylesSource.includes(".theme-bg"), "background layer CSS present");
519 ok(stylesSource.includes("data-theme-scene=\"task\""), "task scene CSS present");
520 // Theme pack section must not *apply* backdrop-filter (comments may mention it).
521 const themeBgIdx = stylesSource.indexOf("Theme Pack V1");
522 const themeBgSlice = themeBgIdx >= 0 ? stylesSource.slice(themeBgIdx) : "";
523 ok(
524 !/^\s*backdrop-filter\s*:/m.test(themeBgSlice) && !/^\s*-webkit-backdrop-filter\s*:/m.test(themeBgSlice),
525 "theme pack CSS does not apply backdrop-filter",
526 );
527 ok(themeBgSlice.includes(".theme-bg__overlay"), "overlay wash element styled");
528 ok(exportOwnerSource.includes("applyThemeScene"), "session export owner wires scene from session content");
529 ok(appViewSource.includes("ThemeBackground"), "App mounts background layer");
530 ok(composerRouterSource.includes("ResetThemePack") || composerRouterSource.includes("theme reset") || composerRouterSource.includes('arg === "reset"'), "reset entry exists");
531
532 console.log("\nofficial themes (kind/grouping/i18n)");
533
534 // kind resolution with legacy fallback.
535 ok(themePackKind({ kind: "official", builtin: true }) === "official", "kind official passthrough");
536 ok(themePackKind({ kind: "base", builtin: true }) === "base", "kind base passthrough");
537 ok(themePackKind({ kind: "user", builtin: false }) === "user", "kind user passthrough");
538 ok(themePackKind({ builtin: true }) === "base", "legacy builtin=true falls back to base");
539 ok(themePackKind({ builtin: false }) === "user", "legacy builtin=false falls back to user");
540
541 // Redesigned experience: overview home + independent gallery (select ≠ apply).
542 ok(overviewSource.includes("appearance-overview"), "appearance overview present");
543 ok(overviewSource.includes("settings.themeGallery.browse"), "overview has browse themes");
544 ok(overviewSource.includes("settings.themeGallery.disable") || overviewSource.includes("handleDisable"), "overview can disable pack");
545 const settingsPageShell = settingsSource.slice(settingsSource.indexOf("function SettingsPageShell"), settingsSource.indexOf("export function settingsPageLayout"));
546 ok(settingsPageShell.includes("aria-label={settingsTabPageTitle(tab, t)}") && !settingsPageShell.includes("settings-page__header"), "settings pages retain accessible names without a duplicate visual header");
547 ok(overviewSource.includes("initialCreateBaseStyle"), "base-style copy opens a prefilled theme editor");
548 ok(overviewSource.includes('role="radiogroup"') && overviewSource.includes("aria-checked"), "overview segmented controls expose selection semantics");
549 ok(/<SettingsOptions\s+layout="field"/.test(overviewSource), "overview choices use the shared field-width control");
550 ok(!overviewSource.includes('<div\n className="set-seg'), "overview does not retain standalone segmented controls");
551 ok(/settings-options--field\s*\{\s*width: 424px;\s*max-width: 100%/.test(readFileSync(resolve(testDir, "../components/SettingsOptions.css"), "utf8")), "overview choices share the bounded responsive field width");
552 ok(stylesSource.includes(".appearance-overview__segmented { justify-self: stretch; width: 100%; }"), "overview segmented controls expand on narrow screens");
553 const creationCardSwatchRule =
554 stylesSource.match(/:root\[data-theme-style\] \.app--creation \.theme-card \.theme-card__swatches \{([^}]*)\}/)?.[1] ?? "";
555 const creationHeroRule =
556 stylesSource.match(/:root\[data-theme-style\] \.app--creation \.appearance-overview__thumb-base\.theme-card__swatches \{([^}]*)\}/)?.[1] ?? "";
557 const creationHeroSwatchRule =
558 stylesSource.match(/:root\[data-theme-style\] \.app--creation \.appearance-overview__thumb-base \.theme-card__swatch \{([^}]*)\}/)?.[1] ?? "";
559 const creationGraphiteAccentRule =
560 stylesSource.match(/:root\[data-theme-style\] \.app--creation \.theme-card__swatches\[data-theme-style-card="graphite"\] \.theme-card__swatch--accent \{([^}]*)\}/)?.[1] ?? "";
561 ok(
562 creationCardSwatchRule.includes("height: 7px") && !/^\.app--creation \.theme-card__swatches,$/m.test(stylesSource),
563 "Creation compact swatches stay scoped to real theme cards",
564 );
565 ok(
566 creationHeroRule.includes("min-height: 108px") && creationHeroSwatchRule.includes("flex: 1"),
567 "Creation appearance hero keeps readable swatch dimensions",
568 );
569 ok(
570 creationGraphiteAccentRule.includes("linear-gradient(128deg, #604116") && creationGraphiteAccentRule.includes("#f3d77b"),
571 "Creation Graphite appearance hero keeps the gold accent palette",
572 );
573 ok(
574 overviewSource.includes('fontFamily === "custom"') && overviewSource.includes("onCustomFontNameChange(e.target.value)"),
575 "custom UI font selection exposes an editable font name",
576 );
577 ok(
578 overviewSource.includes('monoFontFamily === "custom"') && overviewSource.includes("onCustomMonoFontNameChange(e.target.value)"),
579 "custom monospace font selection exposes an editable font name",
580 );
581 ok(
582 overviewSource.includes("fontFamilyLabel(f, t)") && overviewSource.includes("monoFontFamilyLabel(f, t)"),
583 "font family selectors render localized names",
584 );
585 ok(overviewSource.includes("appearance-base-style-help"), "active pack explains why base style is locked");
586 ok(gallerySource.includes('role="listbox"') || gallerySource.includes("role=\"listbox\""), "gallery cards are listbox options");
587 ok(gallerySource.includes("settings.themeGallery.apply"), "apply lives in gallery detail");
588 ok(gallerySource.includes("setSelected") || gallerySource.includes("onSelectPack"), "card click selects without applying");
589 ok(gallerySource.includes("changeTab") && gallerySource.includes("nextPacks[0]"), "changing gallery groups synchronizes the selected detail");
590 ok(gallerySource.includes("ActivateThemePack") || experienceSource.includes("activateThemePack"), "apply path uses activate API");
591 ok(experienceSource.includes("ActivateBaseStyle") || experienceSource.includes("activateBaseStyle"), "base style API wired");
592 ok(experienceSource.includes("selectedThemeId") || gallerySource.includes("selected"), "selection is frontend state");
593 ok(gallerySource.includes("loading=\"lazy\"") || gallerySource.includes('loading="lazy"'), "gallery thumbs lazy-load");
594 ok(gallerySource.includes("ThemePreviewSurface") || gallerySource.includes("theme-preview-surface"), "isolated detail preview");
595 ok(previewSurfaceSource.includes("theme-preview-surface__code-island"), "theme preview includes real code and diff samples");
596 ok(gallerySource.includes('themePackKind(pack) === "base"') && gallerySource.includes('variant="thumbnail"'), "base gallery cards render semantic UI thumbnails");
597 ok(gallerySource.includes('themePackKind(p) === "base"'), "immersive rail renders base-style thumbnails");
598 for (const style of ["graphite", "aurora", "slate", "carbon", "nocturne", "amber"] as const) {
599 const basePack = { id: style, name: style, baseStyle: style, builtin: true, kind: "base" as const, active: false, hasBackground: false, tokens: {}, recipes: {} };
600 for (const mode of ["light", "dark"] as const) {
601 const palette = themePreviewPalette(basePack, mode);
602 ok(palette === BASE_STYLE_PREVIEW_PALETTES[style][mode], `${style} ${mode} uses its canonical preview palette`);
603 }
604 }
605 ok(new Set(Object.values(BASE_STYLE_PREVIEW_PALETTES).map((modes) => modes.dark.accent)).size === 6, "six base previews have distinct dark accents");
606 ok(
607 !gallerySource.includes('tab === "catalog" && !immersive') &&
608 gallerySource.includes("if (!immersive)") &&
609 gallerySource.includes("previewPackGlobally(pack)"),
610 "all gallery card clicks immediately start a global preview",
611 );
612 ok(gallerySource.includes("setPreviewingId(pack.id)"), "gallery preview state is visible in theme details");
613 ok(gallerySource.includes("nextTab !== tab") && gallerySource.includes("cancelGlobalPreview();"), "leaving all themes restores the prior appearance");
614 ok(gallerySource.includes("ThemePreviewControls"), "detail and immersive views share preview controls");
615 ok((gallerySource.match(/role="radiogroup"/g) || []).length >= 2, "appearance and scene previews are separate radio groups");
616 ok(gallerySource.includes("aria-checked={mode ===") && gallerySource.includes("aria-checked={scene ==="), "preview controls expose selected values");
617 ok(gallerySource.includes("handlePreviewRadioKey") && gallerySource.includes("tabIndex={mode ==="), "preview radios support arrow keys and roving focus");
618 ok(gallerySource.includes("if (!immersive || !selectedPack) return") && gallerySource.includes("previewPackGlobally(selectedPack)"), "immersive selection automatically starts a global preview");
619 ok(gallerySource.includes("closeImmersivePreview") && gallerySource.includes("cancelGlobalPreview();"), "leaving immersive preview restores the prior appearance");
620 ok(!gallerySource.includes("settings.themeGallery.tempPreview"), "redundant global-trial button is removed");
621 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");
622 ok(gallerySource.includes("filter((section) => section.packs.length > 0)"), "immersive rail hides empty groups");
623 ok(!gallerySource.includes("theme-gallery__tabs--compact"), "immersive rail has no duplicate bottom tab navigation");
624 ok(gallerySource.includes("theme-gallery__detail-status"), "active theme uses a status badge");
625 ok(!gallerySource.includes("disabled={busy || isActive}"), "active status is not rendered as a disabled primary action");
626 ok(gallerySource.includes("theme-gallery__detail-user-actions"), "user theme edit and export actions are visible outside the overflow menu");
627 ok((gallerySource.match(/role="menuitem"/g) || []).length === 1 && gallerySource.includes("settings.themeLibrary.delete"), "user theme overflow menu keeps only delete");
628 ok(gallerySource.includes("defaultTaskBackground") && gallerySource.includes("taskBackgroundDataUrl"), "editor supports an independent workspace image");
629 ok(gallerySource.includes("themeTokenKeys()") && gallerySource.includes('type="color"'), "editor exposes semantic theme colors");
630 ok(gallerySource.includes('type="range"') && gallerySource.includes("settings.themeEditor.opacity"), "editor exposes scene opacity controls");
631 ok(gallerySource.includes('aria-checked={safeArea === area}') && gallerySource.includes("settings.themeEditor.safeAreaHint"), "content-area control exposes radio semantics and guidance");
632 ok(gallerySource.includes("beginThemePreview(draft)"), "editor changes are previewed live");
633 ok(bridgeSource.includes("GetThemeExperience"), "bridge exposes GetThemeExperience");
634 ok(bridgeSource.includes("ActivateBaseStyle"), "bridge exposes ActivateBaseStyle");
635 ok(bridgeSource.includes("DisableThemePack"), "bridge exposes DisableThemePack");
636
637 // Gallery navigation merges built-in choices while keeping their semantics.
638 ok(gallerySource.includes('["catalog", t("settings.themeGallery.tabAll"), catalogPacks.length]'), "gallery combines official and base packs in all themes");
639 ok(gallerySource.includes('id: "official"') && gallerySource.includes('id: "base"'), "all themes keeps flagship and base sections");
640 ok(gallerySource.includes('role="group"') && gallerySource.includes("theme-gallery__section-head"), "catalog sections retain accessible grouping");
641 ok(!gallerySource.includes('["base", t("settings.themeGallery.tabBase"), groups.base.length]'), "base styles are no longer a separate top-level tab");
642 ok(gallerySource.includes("selectionSeeded.current") && gallerySource.includes("packs.length === 0"), "empty user tab is not overwritten by selection seeding");
643 ok(!overviewSource.includes("theme-card-grid"), "overview no longer renders long style card grid");
644
645 // Localized official names/descriptions in all three locales.
646 const OFFICIAL_IDS = [
647 "official-rose-dawn",
648 "official-fortune-forge",
649 "official-crimson-horizon",
650 "official-sage-breeze",
651 "official-spark-notebook",
652 "official-violet-starlight",
653 "official-cyan-stage",
654 "official-noir-gold",
655 ];
656 for (const id of OFFICIAL_IDS) {
657 for (const suffix of ["name", "description"]) {
658 const key = `settings.themes.official.${id}.${suffix}`;
659 ok(localeEn.includes(`"${key}"`), `en has ${key}`);
660 ok(localeZh.includes(`"${key}"`), `zh has ${key}`);
661 ok(localeZhTW.includes(`"${key}"`), `zh-TW has ${key}`);
662 }
663 }
664 for (const key of [
665 "settings.themeGallery.title",
666 "settings.themeGallery.apply",
667 "settings.themeGallery.browse",
668 "settings.themeGallery.paletteLabel",
669 "settings.themeGallery.appearancePreview",
670 "settings.themeGallery.scenePreview",
671 "settings.themeGallery.scenePreviewHint",
672 "settings.themeGallery.tabAll",
673 "settings.themeGallery.sectionFlagship",
674 "settings.themeEditor.safeAreaHint",
675 "settings.themeLibrary.confirmDeleteTitle",
676 "settings.themeLibrary.confirmReplaceImportTitle",
677 "settings.themeLibrary.replaceConfirm",
678 "settings.themeLibrary.exportRightsTitle",
679 "settings.themeLibrary.exportConfirm",
680 ]) {
681 ok(localeEn.includes(`"${key}"`) && localeZh.includes(`"${key}"`) && localeZhTW.includes(`"${key}"`), `gallery key ${key} in all locales`);
682 }
683
684 // Mock parity: 6 base + 8 official mock packs so browser dev matches the shell.
685 ok((bridgeSource.match(/kind: "base"/g) || []).length === 6, "mock has 6 base packs");
686 ok((bridgeSource.match(/kind: "official"/g) || []).length === 8, "mock has 8 official packs");
687 ok((bridgeSource.match(/previewUrl: new URL\("\.\.\/\.\.\/\.\.\/themes\/official\//g) || []).length === 8, "browser mock has 8 real official previews");
688 ok((bridgeSource.match(/backgroundUrl: new URL\("\.\.\/\.\.\/\.\.\/themes\/official\//g) || []).length === 8, "browser mock has 8 real official backgrounds");
689 ok((bridgeSource.match(/paneOpacity:\s*0\.50/g) || []).length === 8, "browser mock gives every official theme the product pane opacity");
690 ok(viteSource.includes('resolve(configDir, "../themes/official")'), "Vite dev server permits only the official theme asset directory");
691 ok(stylesSource.includes("container: theme-gallery / inline-size"), "gallery establishes its own responsive container");
692 ok(stylesSource.includes("@container theme-gallery (max-width: 760px)"), "gallery collapses from its content width");
693 ok(gallerySource.includes('import { createPortal } from "react-dom"') && gallerySource.includes("document.body"), "theme editor escapes settings containing blocks through a body portal");
694 ok(gallerySource.includes('role="dialog"') && gallerySource.includes("aria-labelledby={titleId}"), "theme editor portal retains accessible dialog semantics");
695 ok(stylesSource.includes("container: theme-editor / inline-size"), "theme editor establishes an independent responsive container");
696 ok(stylesSource.includes("@container theme-editor (max-width: 920px)"), "theme editor collapses from its own width");
697 ok(stylesSource.includes(".theme-editor__setting-row .set-seg__btn { flex: 1; min-width: 0; }"), "all editor segmented setting buttons share available width");
698 ok(stylesSource.includes("grid-template-columns: repeat(3, minmax(0, 1fr))"), "base appearance options wrap at narrow editor widths");
699 ok(stylesSource.includes(".theme-gallery__preview-control"), "preview dimensions have labeled layout styling");
700 ok(gallerySource.includes("settings.themeGallery.scenePreviewHint") && gallerySource.includes("theme-gallery__preview-help"), "scene preview explains home and workspace behavior");
701 ok(localeZh.includes('"settings.themeGallery.sceneHome": "首页展示"') && localeZh.includes('"settings.themeGallery.sceneTask": "工作区展示"'), "scene options use explicit Chinese labels");
702 ok(localeZh.includes('"settings.themeGallery.subtitle": "点击主题即可全局预览,应用后才会保存"'), "gallery explains click-to-preview and apply-to-save semantics");
703 ok(
704 localeEn.includes('"settings.themeGallery.restoreGraphite": "Restore Graphite appearance"') &&
705 localeEn.includes("detailed typography are preserved"),
706 "English restore copy names Graphite and preserves detailed typography",
707 );
708 ok(
709 localeZh.includes('"settings.themeGallery.restoreGraphite": "恢复石墨基础外观"') &&
710 localeZh.includes("保留明暗模式、字体、字号及详细排版设置") &&
711 localeZhTW.includes('"settings.themeGallery.restoreGraphite": "恢復石墨基礎外觀"') &&
712 localeZhTW.includes("保留明暗模式、字型、字號及詳細排版設定"),
713 "Chinese restore copy localizes Graphite as 石墨 and preserves detailed typography",
714 );
715 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");
716 ok(stylesSource.includes(".theme-gallery__rail-section-head") && stylesSource.includes(".theme-gallery__rail-section-items"), "immersive rail groups have lightweight headings and item stacks");
717 ok(stylesSource.includes(".theme-gallery__detail-status"), "active status has dedicated non-button styling");
718 ok(stylesSource.includes(".theme-editor__setting-hint"), "content-area guidance has dedicated responsive styling");
719 ok(stylesSource.includes("background: var(--code-bg, var(--bg-soft))"), "code and diff surfaces consume the opaque code background");
720 ok(
721 stylesSource.includes("--diff-row-bg: var(--code-add-bg") &&
722 stylesSource.includes("background: var(--tp-code-add-bg)") &&
723 stylesSource.includes("background: var(--tp-code-del-bg)"),
724 "live and preview diff rows consume the same pre-composited safe backgrounds",
725 );
726 ok(localeZh.includes('"settings.themeEditor.safeArea": "界面内容区域"') && localeZh.includes('"settings.themeEditor.safeAreaHint": "选择文字和卡片主要显示的位置;建议避开图片主体。"'), "Chinese content-area copy explains foreground placement");
727
728 // Pack overlay stays at :root — Workbench/Creation element-scoped auto-light
729 // selectors must keep winning in their subtree (theme never overrides them).
730 ok(!packSource.includes(".app--"), "pack overlay never targets layout-scoped selectors");
731 ok(packSource.includes("prefers-color-scheme: light"), "auto mode follows system light/dark");
732
733 // Keep ThemeLibrary available for any residual editor helpers.
734 ok(librarySource.includes("ThemeLibrary") || librarySource.includes("ThemeEditor"), "ThemeLibrary module retained for editor/helpers");
735
736 console.log(`\n${passed} passed, ${failed} failed`);
737 if (failed > 0) process.exit(1);
738
738 lines TYPESCRIPT