`,
- * `| `, ` | `, ``. Native semantics + accessibility tree
- * work out of the box.
- *
- * shadcn parity:
- * Table container (scroll wrapper) → tableContainerClass()
- * Table → tableClass()
- * TableHeader / TableBody / TableFooter
- * → tableHeaderClass() / tableBodyClass() / tableFooterClass()
- * TableRow → tableRowClass()
- * TableHead / TableCell / TableCaption
- * → tableHeadClass() / tableCellClass() / tableCaptionClass()
- *
- * A11y (required for accessible output): every header cell needs a scope
- * (scope="col" on a column header, scope="row" on a row header) so screen
- * readers map cells to their headers. Add a naming the table's
- * purpose (it can be visually hidden if a heading already names it).
- *
- * Design tokens used: --muted, --muted-foreground, --foreground.
- *
- * @example
- * ```html
- *
- *
- *
- *
- *
- * | Vivek |
- * Active |
- *
- *
- * Users
- *
- *
- * ```
- */
-
-export const tableContainerClass = (): string => 'relative w-full overflow-x-auto';
-
-export const tableClass = (): string => 'w-full caption-bottom text-sm';
-
-export const tableHeaderClass = (): string => '[&_tr]:border-b';
-
-export const tableBodyClass = (): string => '[&_tr:last-child]:border-0';
-
-export const tableFooterClass = (): string =>
- 'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0';
-
-export const tableRowClass = (): string =>
- 'border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted';
-
-export const tableHeadClass = (): string =>
- 'h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]';
-
-export const tableCellClass = (): string =>
- 'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]';
-
-export const tableCaptionClass = (): string => 'mt-4 text-sm text-muted-foreground';
diff --git a/website/lib/docs-llms.server.ts b/website/lib/docs-llms.server.ts
index 698e23037..47fa0968d 100644
--- a/website/lib/docs-llms.server.ts
+++ b/website/lib/docs-llms.server.ts
@@ -20,6 +20,7 @@
import { readFile, readdir } from 'node:fs/promises';
import { join, basename, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
+import { siteUrl } from '#lib/env.ts';
// Resolve the app root from THIS module's location, not process.cwd().
// This module lives at `website/lib/docs-llms.server.ts`, so the app root
@@ -93,7 +94,7 @@ export function originFor(req?: Request): string {
/* fall through */
}
}
- return ((globalThis as any).process?.env?.SITE_URL || 'https://webjs.dev').replace(/\/$/, '');
+ return siteUrl();
}
/**
diff --git a/website/lib/env.ts b/website/lib/env.ts
new file mode 100644
index 000000000..5cd0ddc36
--- /dev/null
+++ b/website/lib/env.ts
@@ -0,0 +1,27 @@
+/**
+ * The site's canonical origin, read once from the environment.
+ *
+ * Four modules (app/sitemap.ts, app/robots.ts, app/llms.txt/route.ts, and
+ * lib/docs-llms.server.ts) each wrote their own
+ * `(globalThis as any).process?.env?.SITE_URL` for this, which is one missing
+ * type worked around four times, and four places for the fallback origin to
+ * drift apart.
+ *
+ * The `globalThis` hop rather than a bare `process.env`: every caller today is
+ * server-only (two metadata routes, a route handler, and a `.server.ts`), so
+ * `process` does exist for all of them. It is written this way so the module
+ * stays importable from a browser-loading module too, since the origin is
+ * public information already present in the rendered HTML and there is nothing
+ * here that needs the server boundary. A bare `process.env` would throw at
+ * module load the moment such an importer appeared.
+ */
+type SiteEnv = { SITE_URL?: string };
+
+/**
+ * The canonical origin, with any trailing slash removed so callers can
+ * concatenate a path onto it without doubling the separator.
+ */
+export function siteUrl(): string {
+ const env = (globalThis as { process?: { env?: SiteEnv } }).process?.env;
+ return (env?.SITE_URL || 'https://webjs.dev').replace(/\/$/, '');
+}
diff --git a/website/lib/links.ts b/website/lib/links.ts
index f348000e5..88c946599 100644
--- a/website/lib/links.ts
+++ b/website/lib/links.ts
@@ -2,15 +2,14 @@ import { html } from '@webjsdev/core';
/**
* Shared, browser-safe link config for the site chrome (header + footer),
- * imported by both app/layout.ts and app/page.ts so the cross-app URLs and the
- * new-tab cue are declared once instead of duplicated across the two files.
+ * imported by both app/layout.ts and app/page.ts so the paths and the new-tab
+ * cue are declared once instead of duplicated across the two files.
*
- * Sibling app URLs are read from env so the same code works across `webjs dev`
- * and any deployment target, guarded against `process` being undefined since
- * these modules also load on the client. Each falls back to its production
- * domain, and `.env` overrides it to the localhost dev port.
+ * Every entry is a literal. There is no env read here any more: the docs and
+ * the component gallery used to be sibling apps needing a configurable URL
+ * each, and both moved in-app (#1098, #1099), so what is left is same-origin
+ * paths and a few fixed external URLs.
*/
-const env = (globalThis as any).process?.env ?? {};
/**
* The documentation is served by THIS app under /docs, so it is a plain
diff --git a/website/modules/ui/utils/examples.ts b/website/modules/ui/utils/examples.ts
index 639ed90d9..8e77f25ef 100644
--- a/website/modules/ui/utils/examples.ts
+++ b/website/modules/ui/utils/examples.ts
@@ -136,7 +136,14 @@ import {
// Registry the frozen holes are evaluated against. Listing every imported
// helper here also keeps each import "used" after the freeze.
-const HELPERS: Record string> = {
+//
+// The helpers take heterogeneous arguments (none, a size, a variant object),
+// and every call goes through the `new Function` evaluator below rather than a
+// typed call site, so the only contract this map can honestly state is
+// "callable, returns a class string". `never[]` says exactly that: each
+// concrete helper is assignable to it, and it refuses a direct call with
+// invented arguments, which `any[]` would have waved through.
+const HELPERS: Record string> = {
accordionClass, accordionContentClass, accordionItemClass, accordionTriggerClass,
alertClass, alertDescriptionClass, alertTitleClass,
alertDialogContentClass, alertDialogDescriptionClass, alertDialogFooterClass,
diff --git a/website/public/input.css b/website/public/input.css
index cf541f2aa..a52653954 100644
--- a/website/public/input.css
+++ b/website/public/input.css
@@ -145,76 +145,38 @@
means the kit's neutral shadcn palette, not this site's warm editorial one.
The old ui.webjs.dev declared these values on :root, which it could afford
because nothing else lived on that domain. Here they must not leak, so every
- raw value is declared on the preview container instead. Mirrors what
- `webjs ui init` writes into a user's globals.css (packages/ui/packages/
- registry/themes/index.css), which is the point: what you see is what you get.
+ raw value is declared on the preview container instead.
+
+ The VALUES are the kit's, the same ones `webjs ui init` writes into a user's
+ globals.css (packages/ui/packages/registry/themes/index.css), which is the
+ point: what you see is what you get. The SHAPE differs on two counts, both
+ deliberate. The kit keys its dark half off a `.dark` class, while this site
+ keys the whole page off `[data-theme]` (see the `dark` custom variant at the
+ top of this file), and each colour here is written ONCE via light-dark()
+ rather than as a light block plus a dark block. `color-scheme` inherits, so
+ the container picks up whatever the root resolved and every light-dark()
+ below lands on the matching side with no selector of its own.
-------------------------------------------------------------------------- */
.ui-preview {
- --background: oklch(1 0 0);
- --foreground: oklch(0.145 0 0);
- --card: oklch(1 0 0);
- --card-foreground: oklch(0.145 0 0);
- --popover: oklch(1 0 0);
- --popover-foreground: oklch(0.145 0 0);
- --primary: oklch(0.205 0 0);
- --primary-foreground: oklch(0.985 0 0);
- --secondary: oklch(0.97 0 0);
- --secondary-foreground: oklch(0.205 0 0);
- --muted: oklch(0.97 0 0);
- --muted-foreground: oklch(0.556 0 0);
- --accent: oklch(0.97 0 0);
- --accent-foreground: oklch(0.205 0 0);
- --destructive: oklch(0.577 0.245 27.325);
- --destructive-foreground: oklch(0.97 0.01 17);
- --border: oklch(0.922 0 0);
- --input: oklch(0.922 0 0);
- --ring: oklch(0.708 0 0);
-}
-
-@media (prefers-color-scheme: dark) {
- :root:not([data-theme='light']) .ui-preview {
- --background: oklch(0.145 0 0);
- --foreground: oklch(0.985 0 0);
- --card: oklch(0.205 0 0);
- --card-foreground: oklch(0.985 0 0);
- --popover: oklch(0.205 0 0);
- --popover-foreground: oklch(0.985 0 0);
- --primary: oklch(0.922 0 0);
- --primary-foreground: oklch(0.205 0 0);
- --secondary: oklch(0.269 0 0);
- --secondary-foreground: oklch(0.985 0 0);
- --muted: oklch(0.269 0 0);
- --muted-foreground: oklch(0.708 0 0);
- --accent: oklch(0.371 0 0);
- --accent-foreground: oklch(0.985 0 0);
- --destructive: oklch(0.704 0.191 22.216);
- --destructive-foreground: oklch(0.58 0.22 27);
- --border: oklch(1 0 0 / 10%);
- --input: oklch(1 0 0 / 15%);
- --ring: oklch(0.556 0 0);
- }
-}
-
-:root[data-theme='dark'] .ui-preview {
- --background: oklch(0.145 0 0);
- --foreground: oklch(0.985 0 0);
- --card: oklch(0.205 0 0);
- --card-foreground: oklch(0.985 0 0);
- --popover: oklch(0.205 0 0);
- --popover-foreground: oklch(0.985 0 0);
- --primary: oklch(0.922 0 0);
- --primary-foreground: oklch(0.205 0 0);
- --secondary: oklch(0.269 0 0);
- --secondary-foreground: oklch(0.985 0 0);
- --muted: oklch(0.269 0 0);
- --muted-foreground: oklch(0.708 0 0);
- --accent: oklch(0.371 0 0);
- --accent-foreground: oklch(0.985 0 0);
- --destructive: oklch(0.704 0.191 22.216);
- --destructive-foreground: oklch(0.58 0.22 27);
- --border: oklch(1 0 0 / 10%);
- --input: oklch(1 0 0 / 15%);
- --ring: oklch(0.556 0 0);
+ --background: light-dark(oklch(1 0 0), oklch(0.145 0 0));
+ --foreground: light-dark(oklch(0.145 0 0), oklch(0.985 0 0));
+ --card: light-dark(oklch(1 0 0), oklch(0.205 0 0));
+ --card-foreground: light-dark(oklch(0.145 0 0), oklch(0.985 0 0));
+ --popover: light-dark(oklch(1 0 0), oklch(0.205 0 0));
+ --popover-foreground: light-dark(oklch(0.145 0 0), oklch(0.985 0 0));
+ --primary: light-dark(oklch(0.205 0 0), oklch(0.922 0 0));
+ --primary-foreground: light-dark(oklch(0.985 0 0), oklch(0.205 0 0));
+ --secondary: light-dark(oklch(0.97 0 0), oklch(0.269 0 0));
+ --secondary-foreground: light-dark(oklch(0.205 0 0), oklch(0.985 0 0));
+ --muted: light-dark(oklch(0.97 0 0), oklch(0.269 0 0));
+ --muted-foreground: light-dark(oklch(0.556 0 0), oklch(0.708 0 0));
+ --accent: light-dark(oklch(0.97 0 0), oklch(0.371 0 0));
+ --accent-foreground: light-dark(oklch(0.205 0 0), oklch(0.985 0 0));
+ --destructive: light-dark(oklch(0.577 0.245 27.325), oklch(0.704 0.191 22.216));
+ --destructive-foreground: light-dark(oklch(0.97 0.01 17), oklch(0.58 0.22 27));
+ --border: light-dark(oklch(0.922 0 0), oklch(1 0 0 / 10%));
+ --input: light-dark(oklch(0.922 0 0), oklch(1 0 0 / 15%));
+ --ring: light-dark(oklch(0.708 0 0), oklch(0.556 0 0));
}
@layer base {
@@ -247,24 +209,14 @@
* one tokenizer, so the palette is defined once here, globally, rather than
* inline per page). Class names: t-com/t-str/t-kw/t-fn/t-type/t-num/t-punc/t-id.
*/
+/* One light-dark() per token, same rule as the palettes above: the dark half
+ used to be written twice, once per theme selector. .t-com / .t-punc / .t-id
+ read palette tokens that already carry their own pair. */
.t-com { color: var(--fg-subtle); font-style: italic; }
-.t-str { color: oklch(0.52 0.13 150); }
-.t-kw { color: oklch(0.52 0.16 295); font-weight: 600; }
-.t-fn { color: oklch(0.52 0.15 250); }
-.t-type{ color: oklch(0.52 0.10 200); }
-.t-num { color: oklch(0.55 0.12 215); }
+.t-str { color: light-dark(oklch(0.52 0.13 150), oklch(0.80 0.14 150)); }
+.t-kw { color: light-dark(oklch(0.52 0.16 295), oklch(0.76 0.14 295)); font-weight: 600; }
+.t-fn { color: light-dark(oklch(0.52 0.15 250), oklch(0.75 0.13 250)); }
+.t-type{ color: light-dark(oklch(0.52 0.10 200), oklch(0.80 0.10 200)); }
+.t-num { color: light-dark(oklch(0.55 0.12 215), oklch(0.82 0.12 215)); }
.t-punc{ color: var(--fg-muted); }
.t-id { color: var(--fg); }
-
-:root[data-theme='dark'] .t-str { color: oklch(0.80 0.14 150); }
-:root[data-theme='dark'] .t-kw { color: oklch(0.76 0.14 295); }
-:root[data-theme='dark'] .t-fn { color: oklch(0.75 0.13 250); }
-:root[data-theme='dark'] .t-type{ color: oklch(0.80 0.10 200); }
-:root[data-theme='dark'] .t-num { color: oklch(0.82 0.12 215); }
-@media (prefers-color-scheme: dark) {
- :root:not([data-theme='light']) .t-str { color: oklch(0.80 0.14 150); }
- :root:not([data-theme='light']) .t-kw { color: oklch(0.76 0.14 295); }
- :root:not([data-theme='light']) .t-fn { color: oklch(0.75 0.13 250); }
- :root:not([data-theme='light']) .t-type{ color: oklch(0.80 0.10 200); }
- :root:not([data-theme='light']) .t-num { color: oklch(0.82 0.12 215); }
-}
diff --git a/website/test/components/browser/preview-tabs.test.js b/website/test/components/browser/preview-tabs.test.js
index bf1fd93d8..8c713a6d3 100644
--- a/website/test/components/browser/preview-tabs.test.js
+++ b/website/test/components/browser/preview-tabs.test.js
@@ -83,6 +83,31 @@ suite('preview-tabs', () => {
assert.equal(selected(), 'tab-preview', 'Home selects the first tab');
});
+ test('moves focus onto the newly-selected tab', async () => {
+ // Follow-focus is the other half of the roving tabindex: the group is one
+ // tab stop, so a keyboard user who arrows to a tab must LAND on it, not be
+ // left focused on the one they arrowed away from.
+ //
+ // Asserted separately from the tabindex test because it exercises a
+ // different mechanism. Selection re-renders, and the focus call is deferred
+ // behind updateComplete so it runs after the roving tabindex is committed;
+ // a focus that fired before the commit would target an element still
+ // carrying tabindex="-1". Focus inside a shadow root reports as the HOST at
+ // document level, so the inner element is read off shadowRoot.activeElement.
+ const host = await track();
+ const bar = q(host, '[role="tablist"]');
+ q(host, '#tab-preview').focus();
+ assert.equal(host.shadowRoot.activeElement?.id, 'tab-preview', 'starts focused on Preview');
+
+ bar.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, composed: true }));
+ await host.updateComplete;
+ await tick();
+
+ assert.equal(host.shadowRoot.activeElement?.id, 'tab-code', 'ArrowRight moves focus to Code');
+ assert.equal(host.shadowRoot.activeElement?.getAttribute('tabindex'), '0',
+ 'the focused tab is the one carrying the roving tabindex');
+ });
+
test('shows exactly one panel at a time, and keeps both slots mounted', async () => {
// Both slots must stay in the tree: the projected demo contains ui-* elements
// that capture their innerHTML on connect, so a rebuild would be destructive.
diff --git a/website/test/ssr/design-tokens.test.ts b/website/test/ssr/design-tokens.test.ts
index cce6c23e8..259292957 100644
--- a/website/test/ssr/design-tokens.test.ts
+++ b/website/test/ssr/design-tokens.test.ts
@@ -6,6 +6,15 @@
* colours presented as the same token. Review round 1 found exactly that
* (three drifted ACCENTS values); this test is what makes the next drift a
* red build instead of a silent lie.
+ *
+ * It ALSO pins the shape the layout declares them in. Each per-theme colour is
+ * one light-dark(LIGHT, DARK) declaration, which is the rule the framework
+ * teaches its own users (the skill's references/styling.md) and which the site
+ * itself did not follow until #1216: the dark half used to be written twice,
+ * once under the OS media query and once under the toggle's attribute, so an
+ * edit to either copy drifted the two paths apart with nothing to catch it.
+ * Both halves of this file matter, since a duplicated block would still let
+ * the value assertions below pass.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
@@ -17,26 +26,64 @@ import { SWATCHES, ACCENTS } from '#lib/design/tokens.ts';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
const layout = readFileSync(resolve(ROOT, 'app/layout.ts'), 'utf8');
-/** Extract `--name: value;` pairs from one CSS block of layout.ts. */
-function declarations(block: string): Map {
- const map = new Map();
- for (const m of block.matchAll(/(--[a-z-]+):\s*([^;]+);/g)) {
- if (!map.has(m[1])) map.set(m[1], m[2].trim());
+/** The one `:root { ... }` block that declares the palette. */
+const rootBlock = layout.slice(layout.indexOf(':root {'), layout.indexOf('@media (prefers-color-scheme: dark)'));
+
+/**
+ * Extract `--name: light-dark(LIGHT, DARK);` pairs. The split is on the comma
+ * at paren depth 0, because both sides are themselves function calls carrying
+ * commas of their own (`oklch(...)`, `color-mix(...)`).
+ */
+function themePairs(block: string): Map {
+ const map = new Map();
+ for (const m of block.matchAll(/(--[a-z-]+):\s*light-dark\(([\s\S]*?)\);/g)) {
+ const inner = m[2];
+ let depth = 0;
+ let split = -1;
+ for (let i = 0; i < inner.length; i++) {
+ if (inner[i] === '(') depth++;
+ else if (inner[i] === ')') depth--;
+ else if (inner[i] === ',' && depth === 0) { split = i; break; }
+ }
+ if (split === -1) continue;
+ if (!map.has(m[1])) {
+ map.set(m[1], { light: inner.slice(0, split).trim(), dark: inner.slice(split + 1).trim() });
+ }
}
return map;
}
-// The light theme is the bare `:root { ... }` block; dark is the explicit
-// `:root[data-theme='dark']` block (the media-query copy mirrors it).
-const rootBlock = layout.slice(layout.indexOf(':root {'), layout.indexOf('@media (prefers-color-scheme: dark)'));
-const darkStart = layout.indexOf(":root[data-theme='dark']");
-const darkBlock = layout.slice(darkStart, layout.indexOf('}', layout.indexOf('--shadow:', darkStart)));
-const light = declarations(rootBlock);
-const dark = declarations(darkBlock);
+const pairs = themePairs(rootBlock);
for (const entry of [...SWATCHES, ...ACCENTS]) {
test(`${entry.token} matches app/layout.ts in both themes`, () => {
- assert.equal(entry.light, light.get(entry.token), `${entry.token} (light) drifted from layout.ts`);
- assert.equal(entry.dark, dark.get(entry.token), `${entry.token} (dark) drifted from layout.ts`);
+ const pair = pairs.get(entry.token);
+ assert.ok(pair, `${entry.token} is not declared as a light-dark() pair in the layout's :root block`);
+ assert.equal(entry.light, pair.light, `${entry.token} (light) drifted from layout.ts`);
+ assert.equal(entry.dark, pair.dark, `${entry.token} (dark) drifted from layout.ts`);
});
}
+
+test('the layout declares its palette once, not as duplicated dark blocks', () => {
+ // Only the non-colour overrides may sit under a theme selector. Both blocks
+ // are single-line, so a colour creeping back in shows up here as a
+ // light-dark()-free `--token: ` on the same line.
+ const overrides = [...layout.matchAll(/:root(?::not\(\[data-theme='light'\]\)|\[data-theme='dark'\])\s*\{([^}]*)\}/g)]
+ .map((m) => m[1])
+ .join('\n');
+ const declared = [...overrides.matchAll(/(--[a-z-]+):/g)].map((m) => m[1]);
+ assert.deepEqual(
+ [...new Set(declared)].sort(),
+ ['--cta-mix', '--glow-strength', '--shadow-spread'],
+ 'only NON-colour tokens may keep a per-theme override; a colour belongs in a light-dark() pair on :root',
+ );
+});
+
+test('every theme state is reachable from color-scheme alone', () => {
+ // light-dark() resolves off the used value of color-scheme, so these three
+ // declarations are the entire theme mechanism. Losing one silently pins the
+ // whole palette to one side.
+ assert.match(rootBlock, /color-scheme:\s*light dark;/, 'the default (follow the OS) scheme is missing');
+ assert.match(layout, /:root\[data-theme='dark'\]\s*\{\s*color-scheme:\s*dark;\s*\}/, "the toggle's forced-dark scheme is missing");
+ assert.match(layout, /:root\[data-theme='light'\]\s*\{\s*color-scheme:\s*light;\s*\}/, "the toggle's forced-light scheme is missing");
+});
diff --git a/website/test/ssr/kit-surfaces.test.ts b/website/test/ssr/kit-surfaces.test.ts
new file mode 100644
index 000000000..63a265f65
--- /dev/null
+++ b/website/test/ssr/kit-surfaces.test.ts
@@ -0,0 +1,118 @@
+/**
+ * Two surfaces this site shares with a scaffolded app, both of which drifted
+ * before #1216 with nothing to catch either one.
+ *
+ * 1. components/ui/ belongs to `webjs ui add`, exactly as it does in a real
+ * app, so this site tracks nothing there. Eleven byte-identical copies of
+ * the generated modules/ui/components/ mirror were committed into it by
+ * accident and sat unimported until someone read the directory.
+ * 2. The .ui-preview palette in public/input.css declares each kit colour once
+ * via light-dark(), rather than as a light block plus two dark blocks
+ * saying the same thing.
+ */
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync, readdirSync, existsSync } from 'node:fs';
+import { resolve, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
+
+test('components/ui/ is left empty for webjs ui add', () => {
+ const dir = resolve(ROOT, 'components/ui');
+ const entries = existsSync(dir) ? readdirSync(dir).filter((f) => !f.startsWith('.')) : [];
+ assert.deepEqual(
+ entries,
+ [],
+ 'components/ui/ is reserved for `webjs ui add`; the gallery previews import from modules/ui/components/ instead',
+ );
+});
+
+// Comments are stripped first, so a selector named in explanatory prose is not
+// mistaken for a live rule (the comment above .ui-preview names data-theme to
+// explain why the block does NOT use it).
+const inputCss = readFileSync(resolve(ROOT, 'public/input.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
+const previewBlock = inputCss.slice(inputCss.indexOf('.ui-preview {'), inputCss.indexOf('}', inputCss.indexOf('.ui-preview {')));
+
+test('the .ui-preview palette declares each colour once via light-dark()', () => {
+ const declared = [...previewBlock.matchAll(/(--[a-z-]+):/g)].map((m) => m[1]);
+ const paired = [...previewBlock.matchAll(/(--[a-z-]+):\s*light-dark\(/g)].map((m) => m[1]);
+ assert.ok(declared.length > 0, 'the .ui-preview block was not found in public/input.css');
+ assert.deepEqual(
+ declared.filter((t) => !paired.includes(t)),
+ [],
+ 'every .ui-preview token is a per-theme colour, so each one takes a light-dark() pair',
+ );
+});
+
+test('.ui-preview keeps no duplicate dark block', () => {
+ // color-scheme inherits from the root, so the container needs no theme
+ // selector of its own. One reintroduced here is the duplication that
+ // light-dark() replaced.
+ assert.equal(
+ /(?:prefers-color-scheme|data-theme)[^{]*\.ui-preview\s*\{/.test(inputCss),
+ false,
+ '.ui-preview resolves its theme through inherited color-scheme, not a per-theme selector',
+ );
+});
+
+/**
+ * The general rule, swept across every stylesheet and every page/layout that
+ * writes CSS. The two palettes above are the ones that were duplicated worst,
+ * but they were not the only ones: the syntax-highlight classes in input.css
+ * and the home page's code-sample tokens each carried the same pair of
+ * verbatim dark blocks. A per-file assertion would have kept missing them, so
+ * this asserts the rule itself.
+ */
+test('no colour is declared under a per-theme selector anywhere', () => {
+ // modules/ is deliberately absent: its only subtree is the gitignored
+ // modules/ui/components/ mirror of the @webjsdev/ui registry, which is the
+ // kit's code rather than this site's, and modules/ui/utils holds no CSS.
+ // Add it here the moment a feature module starts writing styles.
+ const files = [
+ 'public/input.css',
+ ...['app', 'lib', 'components'].flatMap(function walk(dir: string): string[] {
+ const abs = resolve(ROOT, dir);
+ if (!existsSync(abs)) return [];
+ return readdirSync(abs, { withFileTypes: true }).flatMap((e) =>
+ e.isDirectory() ? walk(`${dir}/${e.name}`) : e.name.endsWith('.ts') ? [`${dir}/${e.name}`] : [],
+ );
+ }),
+ ];
+
+ // Only these three NON-colour tokens may sit under a theme selector. Every
+ // colour belongs in a light-dark() pair instead.
+ const ALLOWED = new Set(['--glow-strength', '--cta-mix', '--shadow-spread']);
+ const offenders: string[] = [];
+
+ for (const rel of files) {
+ // Comments are stripped so prose naming a selector is not read as a rule,
+ // and `pre` blocks in the docs pages are left alone: those are code
+ // SAMPLES teaching the reader, not this site's own styling.
+ const src = readFileSync(resolve(ROOT, rel), 'utf8')
+ .replace(/\/\*[\s\S]*?\*\//g, '')
+ .replace(//g, '');
+ // Terminate on the first closing brace, NOT on one at the start of a line:
+ // a single-line rule (`:root[data-theme='dark'] .t-str { color: ... }`) is
+ // the exact shape the highlight classes used, and anchoring to a newline
+ // walked straight past it.
+ const re = /(?:@media\s*\(prefers-color-scheme:\s*dark\)|\[data-theme=['"]dark['"]\])([\s\S]{0,900}?)\}/g;
+ for (const m of src.matchAll(re)) {
+ for (const d of m[1].matchAll(/(--[a-z-]+):\s*([^;]+);/g)) {
+ if (ALLOWED.has(d[1])) continue;
+ offenders.push(`${rel}: ${d[1]}`);
+ }
+ // A bare colour on a class inside a theme block (the .t-* highlight
+ // shape) has no custom property to catch, so look for it directly.
+ for (const d of m[1].matchAll(/\bcolor:\s*(oklch|#|rgb|hsl)/g)) {
+ offenders.push(`${rel}: a bare ${d[1]} colour`);
+ }
+ }
+ }
+
+ assert.deepEqual(
+ offenders,
+ [],
+ 'these belong in a light-dark(LIGHT, DARK) pair, not under a per-theme selector',
+ );
+});
|