From f41b95057b77a5c356432c0b9e2ef5cd9a029c4a Mon Sep 17 00:00:00 2001 From: t Date: Thu, 8 Oct 2026 09:17:06 +0530 Subject: [PATCH] fix: move the dark-theme rule from webjs check to webjs doctor A dark theme is a design convention, not source correctness, and webjs check carries only rules that break at runtime (the #461 precedent moved vendor-gitignore out of check for the same reason). The detection from #1626 moves unchanged into a doctor probe, DARK_THEME_UNREACHABLE, a warning by default and gateable through webjs.doctor.gate. The scaffold gates it error next to UNMARKED_ASSET_LINKS, because the scaffold's own AGENTS.md mandates light-dark() tokens and the generated layout writes them, so a fresh app is green and an app that drops them fails its own CI; any other app only sees a warning. The check rule, its RULES entry and its test are removed; the red/green cases live in the doctor tests with the ungated-warn / gated-fail counterfactual pair. Closes #1628 Claude-Session: https://claude.ai/code/session_01SZ72LSPAo4NvYvBDSD6RLo --- .agents/skills/webjs/references/built-ins.md | 2 + .agents/skills/webjs/references/styling.md | 2 +- AGENTS.md | 2 +- packages/cli/AGENTS.md | 2 +- packages/cli/lib/create.js | 8 +- packages/cli/lib/doctor/codes.js | 1 + packages/cli/lib/doctor/probes/dark-theme.js | 91 ++++++++++++++ packages/cli/lib/doctor/runner.js | 2 + packages/server/src/check/rules-styling.js | 73 ----------- packages/server/src/check/rules.js | 5 - packages/server/src/check/runner.js | 2 - .../test/check/dark-theme-unreachable.test.js | 113 ------------------ test/cli/doctor.test.mjs | 73 +++++++++++ .../scaffold-template-validation.test.js | 1 + website/app/docs/configuration/page.ts | 2 + website/app/docs/troubleshooting/page.ts | 2 +- 16 files changed, 182 insertions(+), 199 deletions(-) create mode 100644 packages/cli/lib/doctor/probes/dark-theme.js delete mode 100644 packages/server/src/check/rules-styling.js delete mode 100644 packages/server/test/check/dark-theme-unreachable.test.js diff --git a/.agents/skills/webjs/references/built-ins.md b/.agents/skills/webjs/references/built-ins.md index 4c235c5ff..5ab49c48b 100644 --- a/.agents/skills/webjs/references/built-ins.md +++ b/.agents/skills/webjs/references/built-ins.md @@ -298,6 +298,8 @@ Two guarantees worth knowing. A result that could not check (a network or toolch `APP_ICON` warns while the favicon is still the scaffold's placeholder `app/icon.svg` (marked `data-webjs-placeholder`). Replace it with the app's own icon (`references/routing-and-pages.md`, "App icon and manifest"). +`DARK_THEME_UNREACHABLE` warns when the app defines colour tokens with a light value only and nothing applies a dark half: no `light-dark()`, no `@media (prefers-color-scheme: dark)` rule, no theme script in the root layout. The scaffold gates it `error` in its `package.json` because its AGENTS.md mandates `light-dark()` tokens; the fix is the token block in `references/styling.md`. + ### Dependency audit allowlist `webjs audit` runs `npm audit` or `bun audit` (by the nearest lockfile, so a workspace member uses the root's) and fails on any advisory at or above `webjs.audit.level` (default `high`) that `webjs.audit.ignore` does not list. The scaffold's `Security: dependency audit` CI step runs it. diff --git a/.agents/skills/webjs/references/styling.md b/.agents/skills/webjs/references/styling.md index 3b820c6a5..7957644a8 100644 --- a/.agents/skills/webjs/references/styling.md +++ b/.agents/skills/webjs/references/styling.md @@ -193,7 +193,7 @@ Whichever form you use, a token nothing references is dropped in both, so an unu ``` -`light-dark()` is a native CSS function (CSS Color 5, Baseline 2024), not a library, so nothing to import. A single-theme app drops the `[data-theme]` rules and gives each token one colour. `webjs check` enforces the floor with `dark-theme-unreachable`: an app whose tokens have a light value only, with no `light-dark()`, no `prefers-color-scheme` rule and no theme script in the root layout, fails the check, because the scaffold stylesheet's `.dark` block would then be dead CSS and the OS dark setting ignored. +`light-dark()` is a native CSS function (CSS Color 5, Baseline 2024), not a library, so nothing to import. A single-theme app drops the `[data-theme]` rules and gives each token one colour. `webjs doctor` reports the floor as `DARK_THEME_UNREACHABLE` (the scaffold gates it `error`): an app whose tokens have a light value only, with no `light-dark()`, no `prefers-color-scheme` rule and no theme script in the root layout, because the scaffold stylesheet's `.dark` block would then be dead CSS and the OS dark setting ignored. **A manual theme toggle** writes `data-theme` on `` (`light` / `dark`, or removes it for "follow the OS"). If you use `@webjsdev/ui` components, ALSO keep the `.dark` class in sync (the ui kit keys its own tokens off `.dark`), and apply the saved choice in a tiny inline `\${children}\`; -} -`, - }); - try { assert.equal(hits(await checkConventions(dir)).length, 0); } finally { await rm(dir, { recursive: true, force: true }); } -}); - -test('silent when the app defines no colour tokens; ignores the compiled tailwind.css and comments', async () => { - const dir = await makeApp({ - 'public/input.css': '@import "tailwindcss";\n/* --background: #fff is set per page */\n', - 'public/tailwind.css': ':root{--background:#fff;--foreground:#000}', - 'app/layout.ts': PLAIN_LAYOUT, - }); - try { assert.equal(hits(await checkConventions(dir)).length, 0); } finally { await rm(dir, { recursive: true, force: true }); } -}); diff --git a/test/cli/doctor.test.mjs b/test/cli/doctor.test.mjs index 6e05decc1..e77bd40c7 100644 --- a/test/cli/doctor.test.mjs +++ b/test/cli/doctor.test.mjs @@ -1985,3 +1985,76 @@ test('app-icon: warns on the placeholder; passes on an own icon', () => { writeFileSync(join(app, 'app', 'icon.svg'), ''); assert.equal(checkAppIcon(app).status, 'pass', 'an own icon passes'); }); + +// DARK_THEME_UNREACHABLE (#1628): colour tokens with a light value only, no +// light-dark(), no prefers-color-scheme rule and no theme script, leave the +// stylesheet's .dark block dead and the OS setting ignored. A design convention +// (the scaffold's AGENTS.md mandates light-dark() tokens), so doctor WARNS and +// the scaffold gates it error; the kit-only stylesheet is the shape six of +// seven recorded agent builds shipped. +const { checkDarkTheme } = await import(resolve(CLI_LIB_DIR, 'doctor', 'probes', 'dark-theme.js')); +const KIT_CSS = `@import "tailwindcss"; +@theme inline { --color-background: var(--background); --color-foreground: var(--foreground); } +:root { --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); } +.dark { --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); } +`; +const PLAIN_LAYOUT = [ + "import { html } from '@webjsdev/core';", + 'export default function RootLayout({ children }) {', + ' return html`
${children}
`;', + '}', +].join('\n'); +const layoutWith = (head) => PLAIN_LAYOUT.replace('
', head + '
'); + +test('dark-theme: warns on the kit-only stylesheet and on single-value layout tokens', async () => { + const app = tmpDir(); + assert.equal((await checkDarkTheme(app)).status, 'pass', 'no app/ directory: nothing to check'); + write(app, 'public/input.css', KIT_CSS); + write(app, 'app/layout.ts', PLAIN_LAYOUT); + const r = await checkDarkTheme(app); + assert.equal(r.status, 'warn'); + assert.match(r.message, /public\/input\.css with a light value only/); + assert.match(r.fix, /light-dark\(LIGHT, DARK\)/); + assert.match(r.fix, /prefers-color-scheme: dark/); + assert.match(r.fix, /head ") }, + 'no colour tokens at all': { 'public/input.css': '@import "tailwindcss";\n/* --background: #fff is set per page */\n', 'public/tailwind.css': ':root{--background:#fff;--foreground:#000}', 'app/layout.ts': PLAIN_LAYOUT }, + }; + for (const [label, files] of Object.entries(cases)) { + const app = tmpDir(); + for (const [rel, content] of Object.entries(files)) write(app, rel, content); + assert.equal((await checkDarkTheme(app)).status, 'pass', label); + } +}); + +test('dark-theme: carries the DARK_THEME_UNREACHABLE code, warns ungated and fails the exit gated', async () => { + const fixture = (gate) => { + const dir = tmpDir(); + write(dir, 'package.json', JSON.stringify({ name: 'x', ...(gate ? { webjs: { doctor: { gate } } } : {}) })); + write(dir, 'tsconfig.json', JSON.stringify({ compilerOptions: { erasableSyntaxOnly: true } })); + write(dir, 'public/input.css', KIT_CSS); + write(dir, 'app/layout.ts', PLAIN_LAYOUT); + return dir; + }; + const results = await runDoctorChecks(fixture(null), { vendor: noPinVendor }); + const r = byName(results, 'dark-theme'); + assert.equal(r.code, 'DARK_THEME_UNREACHABLE'); + assert.equal(r.status, 'warn'); + const ungated = runCliArgs(fixture(null), []); + assert.equal(ungated.status, 0, `ungated, the warning does not fail the exit: ${ungated.stdout}`); + assert.match(ungated.stdout, /\[warn\] .*DARK_THEME_UNREACHABLE/); + const gated = runCliArgs(fixture({ DARK_THEME_UNREACHABLE: 'error' }), []); + assert.equal(gated.status, 1, 'gated to error, the same warning fails the exit'); + assert.match(gated.stdout, /\[fail\] .*\(DARK_THEME_UNREACHABLE, gated: error\)/); +}); diff --git a/test/scaffolds/scaffold-template-validation.test.js b/test/scaffolds/scaffold-template-validation.test.js index a8e787a10..5b7a65ee6 100644 --- a/test/scaffolds/scaffold-template-validation.test.js +++ b/test/scaffolds/scaffold-template-validation.test.js @@ -136,6 +136,7 @@ for (const template of ['full-stack', 'api']) { await scaffoldApp('my-app', cwd, { template, install: false }); const pkg = JSON.parse(await readFile(join(cwd, 'my-app', 'package.json'), 'utf8')); assert.equal(pkg.webjs.doctor.gate.UNMARKED_ASSET_LINKS, 'error'); + assert.equal(pkg.webjs.doctor.gate.DARK_THEME_UNREACHABLE, 'error', 'the scaffold AGENTS.md mandates light-dark() tokens, so the scaffold enforces them (#1628)'); assert.equal(pkg.scripts.doctor, 'webjs doctor'); const ci = await readFile(join(cwd, 'my-app', '.github', 'workflows', 'ci.yml'), 'utf8'); assert.match(ci, /^\s+- run: npm run ci$/m, 'the workflow runs the declared step list'); diff --git a/website/app/docs/configuration/page.ts b/website/app/docs/configuration/page.ts index 381d04bfb..d72f2a277 100644 --- a/website/app/docs/configuration/page.ts +++ b/website/app/docs/configuration/page.ts @@ -69,6 +69,8 @@ webjs doctor --strict # also fail on EVERY remaining warning, not just hard fa

One more check, WORKSPACE_OVERRIDES, warns when the app is a member of an npm or bun workspace and its own package.json carries overrides (or resolutions): package managers honour those only at the workspace root, so the block is silently ignored there. Move it into the root package.json.

+

DARK_THEME_UNREACHABLE warns when the app defines colour tokens (--background / --foreground in the root layout, public/*.css or styles/**.css) with a light value only and nothing applies a dark half: no light-dark(), no @media (prefers-color-scheme: dark) rule, no theme script in the root layout, so the OS dark setting is ignored and any .dark block is dead CSS. A design convention rather than a runtime break, so it is a doctor check and not a webjs check rule; the scaffold gates it error because its AGENTS.md mandates light-dark() tokens.

+

And APP_ICON warns while the app's favicon is still the placeholder app/icon.svg the scaffold shipped. Replace it with the app's own icon (see App icon and manifest).

webjs audit

diff --git a/website/app/docs/troubleshooting/page.ts b/website/app/docs/troubleshooting/page.ts index 25ec4e73d..f5c4b6776 100644 --- a/website/app/docs/troubleshooting/page.ts +++ b/website/app/docs/troubleshooting/page.ts @@ -150,7 +150,7 @@ export default function Troubleshooting() {

Fix: remove the shell tags from the nested layout or page and return just the content. Move any <html lang> / <body class> customization to the root layout, which the framework respects and splices its required tags into. See Routing. The shell-in-non-root-layout check rule flags this, framework invariant 8.

The app has no dark mode, and the stylesheet's .dark block never applies

-

Symptom: webjs check flags dark-theme-unreachable on public/input.css or app/layout.ts. In a browser with the OS set to dark, the app stays light.

+

Symptom: webjs doctor reports DARK_THEME_UNREACHABLE (a warning, or a failure in a scaffolded app, whose package.json gates it error) naming public/input.css or app/layout.ts. In a browser with the OS set to dark, the app stays light.

Cause: the file defines colour tokens (--background, --foreground) with a light value only. The scaffold's stylesheet keeps the ui kit's .dark { ... } token block, but nothing ever adds that class or reads the OS setting: no light-dark(), no @media (prefers-color-scheme: dark) rule, no theme script in the root layout. The dark half is dead CSS, and a light-mode screenshot cannot show it.

Fix: write each colour token once as light-dark(LIGHT, DARK) under color-scheme: light dark in the root layout (the block the skill's references/styling.md shows, and what the generated layout does), or give the tokens an @media (prefers-color-scheme: dark) half, or add a head <script> that applies the saved theme (data-theme or the .dark class). Then look at the app with the OS in dark mode; light mode passing proves nothing about dark.