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