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