| 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 |