@keenmate/web-components-devkit 1.0.0-rc07 → 1.0.0-rc09

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.0.0-rc09] - 2026-11-04 [PUBLISHED]
4
+
5
+ ### Added
6
+
7
+ - **check-pages: page CSS that only matches inside a shadow root.** A page's own rules (its `<style>`
8
+ blocks and `examples-local.css`) are matched against the loaded page. A rule whose element (the last
9
+ compound, pseudo-classes dropped) exists nowhere in the document but inside a component's shadow root
10
+ never applies — page CSS stops at the shadow boundary. web-treeview styled the rows its
11
+ `renderNodeCallback` draws that way (custom-layout: `.file-icon`, `.node-badge`, `.badge-new` …;
12
+ responsive: `.isco-row`, `.dev-badge` …, 18 rules); against its pre-fix pages the check reports
13
+ exactly those. Checked once per page (first width).
14
+
15
+ ### Fixed
16
+
17
+ - **The grey secondary button in dark mode** (`button.secondary` / `.btn-secondary`) had white text on
18
+ `#9ca3af` at 2.54:1. That grey sits just under the on-colour formula's 0.34 luminance switch, so the
19
+ formula picked white. It showed on 9 web-treeview pages ("Clear", "Reset", "Move up" …) and on every
20
+ other site's grey buttons. The button now has its own fill, `--kmwc-neutral-fill-color`
21
+ (`#6b7280` light / `#4b5563` dark, white text ≥ 4.5:1 in both, hovered too), and
22
+ `--kmwc-neutral-fill-color-hover`. Muted dots and pills keep the light `--kmwc-neutral-color`.
23
+
24
+ ## [1.0.0-rc08] - 2026-11-04 [PUBLISHED]
25
+
26
+ ### Added
27
+
28
+ - **Doctor: hard-coded colours in page CSS** (warning). A colour literal (`#hex`, `rgb()` / `hsl()` /
29
+ `oklch()`, a named colour) on a colour property in a page `<style>`, a `style="…"` or
30
+ `examples-local.css` stays the same when the theme switch flips the kit to dark. web-treeview's
31
+ migration kept 10 pages' own `<style>` blocks, and 8 of them had search bars, status bars, badges and
32
+ panels that didn't follow dark mode. rc07 only flagged rules that restyle a kit class, so nothing
33
+ reported them. `var()` fallbacks and `light-dark()` pairs pass. A block that shows fixed colours on
34
+ purpose (a theme gallery, an iOS mockup) opts out with `<style data-kmwc-fixed-colors>`, or one
35
+ rule with a `/* kmwc-fixed-colors: why */` comment before it (the playground's e2e fixture red).
36
+ - **Doctor: the loader class matches the installed core** (error). Core 6d6b768 renamed the default
37
+ loader's class from `<prefix>-loader` to `<prefix>__loader`, with no release that emits both. A
38
+ component that renames its selector before the core bump, or forgets to after it, ships an unstyled
39
+ loader. No rulebook check sees that, and a VALIDATION-NOTES `## C-NC-8` hides a leftover `.x-loader`
40
+ (drp has one for other classes). Doctor reads which class the installed core's `render-loader.js`
41
+ draws and fails on a `src/**/*.css` selector for the other one.
42
+
43
+ ### Fixed
44
+
45
+ - **Inline `<code>` in dark mode** was the accent colour on a neutral `#2b2b2b` chip: about 3:1, and
46
+ web-treeview's `#059669` green read dim in prose and tables. The chip is now a step up from the main
47
+ surface (`oklch(from --kmwc-main-bg calc(l + 0.07) …)`, slate on slate), and the text is a new
48
+ `--kmwc-inline-code-color`: the accent hue at a lightness of at least 0.8 in dark, the accent as-is in
49
+ light. An e2e spec checks 4.5:1 for the default, green, navy and amber accents.
50
+
3
51
  ## [1.0.0-rc07] - 2026-11-03 [PUBLISHED]
4
52
 
5
53
  Found migrating web-treeview — each item is something doctor missed or got wrong there.
package/README.md CHANGED
@@ -19,20 +19,15 @@ them in the same shape: `@keenmate/web-components-devkit`.
19
19
  > positioning) lives in [`@keenmate/web-components-core`](../web-components-core).
20
20
  > Nothing here ships inside a component's bundle.
21
21
 
22
- ## What's New in v1.0.0-rc07
22
+ ## What's New in v1.0.0-rc09
23
23
 
24
- - **CLI — `kmwc check-pages` finds what only a browser can see** — Migrating web-treeview turned up pages that pushed a phone sideways: an iOS-Files mockup 420px wide and a theme gallery built on `minmax(500px, 1fr)`. No static check could have caught that. `kmwc check-pages` loads every example page in Chromium at 1440, 390 and 320px. It reports page and console errors, failed requests, custom elements that never get defined, and pages wider than the viewport, naming the widest elements. It looks inside shadow roots and at long words that overflow their box. It ignores layers that can't widen a page: scrolling code blocks, hidden or fixed layers, a closed chapter-nav panel. It starts the repo's dev server, or `--url` points it at one already running (for example the built site on the preview port), and `--widths` changes the widths. `kmwc sync` adds a `check:pages` script and a `make check-pages` target.
25
- - **Doctor — catches what the treeview and daterangepicker migrations got wrong** — Doctor now checks that every `./dist/…` target in package.json is a file the library build actually emits, and that the old UMD name matches `kmwc.library.globalName`. Without that, the preset's defaults would quietly have renamed treeview's global (`WebTreeView` → `WebTreeview`) and its CSS (`web-treeview.css` → `style.css`). Doctor also reports scripts that pin their own `--port`, config, Makefile and doc ports outside the repo's registry block, and sibling repos that listen inside it. This found web-daterangepicker on treeview's 12300 and web-multiselect / web-dropzone on daterangepicker's 12200. A Plausible site id that another repo also uses is an error, because treeview's traffic was being counted as web-multiselect's. Page CSS that duplicates kit classes (`.kbd`, `mark`, inline field widths, `margin-left: auto`) is a warning.
26
- - **Doctor — `VALIDATION-NOTES.md` records accepted deviations** — The rulebook lets a component record an architectural decision instead of following a rule. Doctor still reported those as errors (treeview's C-CST-4, C-CSS-1 and C-NC-6). A rulebook check id used as a `##` heading in `VALIDATION-NOTES.md` (`## C-CST-4 / C-CST-10 — …`) now counts as a pass, and `--verbose` lists them. A mention in plain prose doesn't count.
27
- - **check-vars — input variables are not dead** — Some component variables are public knobs that are read with a fallback and never declared. Treeview forwards `--wtv-context-menu-*` to core's private `--_km-menu-*` so a page-wide `--km-menu-*` still applies, and declaring them would break that chain. check-vars called all 26 of them dead. In the default mode a variable now counts as present when it is declared or read, and a read-with-fallback missing from the manifest is reported as undocumented. `cssVariables.sources` also takes a package path (`@keenmate/web-components-core/dist/menu/styles.js`) for the `--base-*` reads of a core module the component renders.
28
- - **Example pages — fit a 320px phone, and injected scripts land in the right place** — A chapter heading that names an API outside `<code>` now breaks instead of widening the page. On a phone the index title is 2rem. Below 400px, body, card and demo padding shrink, so a 250px inline calendar fits inside a card. `mark` no longer splits a word. The Vite plugin now puts its head block after the real `<head>` tag (not `<header>` or the text "<head>"). It puts the kit script before the last `</body>`, so it can no longer land inside a page's inline script and break it. That's the failure web-multiselect's sed-based Dockerfile caused on its live action-buttons page.
24
+ - **check-pages — page CSS that can't reach into a shadow root** — web-treeview's custom-layout and responsive pages styled the rows their `renderNodeCallback` draws with page CSS (`.file-icon`, `.node-badge`, `.isco-row`, `.dev-badge` …, 18 rules). Those rows live in the tree's shadow root, and page CSS stops at the shadow boundary, so none of the rules ever applied. Nothing in the source shows where a render callback's output ends up, so this needs the loaded page. `kmwc check-pages` now reads the page's own rules (its `<style>` blocks and `examples-local.css`) and finds what each one styles: the last part of the selector, without pseudo-classes. It reports a rule when that element exists nowhere in the document but does exist inside a component's shadow root, and names the component. Run against treeview's pages from before its fix, it lists exactly those 18 rules. The fix is a CSS variable, `::part`, or the component's styles hook (`getCustomStylesCallback`).
25
+ - **Examples kit — the grey secondary button in dark mode** — `button.secondary` / `.btn-secondary` had white text on `#9ca3af` in dark mode, at 2.54:1. That grey sits just under the 0.34 luminance switch in the formula that picks a button's text colour, so the formula chose white. It showed on 9 web-treeview pages ("Clear", "Reset", "Move up" …) and on every other component site's grey buttons. The button now has its own fill, `--kmwc-neutral-fill-color` (`#6b7280` light / `#4b5563` dark), plus a hover token, so white text stays at 4.5:1 or better in both themes, hovered too. Muted status dots and pills keep the lighter `--kmwc-neutral-color`. An e2e spec checks the contrast in both themes.
29
26
 
30
- ## What's New in v1.0.0-rc06
27
+ ## What's New in v1.0.0-rc08
31
28
 
32
- - **Example pages — `<kbd>`, `<mark>`, field widths and `.push-end`** — Every component documents a keyboard map and most highlight search matches. Pages either left `<kbd>` in the browser's look (core's Interaction page had 18 bare keys) or styled `<mark>` themselves. A `<kbd>` now looks like the spotlight's key hints and a `<mark>` like its match highlight. Native fields are about 20 characters wide, so pages set inline widths; core had 14. A number field is now 7rem by default. `.w-xs` / `.w-sm` / `.w-md` / `.w-lg` (4 / 6 / 9 / 12rem) size a short field, and a `.form-group` still stretches it to the row. `.push-end` moves an item to the end of a `.controls` row. See the playground's WG08 and FC12.
33
- - **Example pages — phone and contrast fixes** — Inline `<code>` now breaks anywhere. Before, a long identifier such as `resolvePresentation(mode, env, map)` in a heading made a page 350px wide on a 320px screen; code inside a `<pre>` keeps its lines. Muted log text on the light `.log-panel` (`--muted` lines, the seq / time / source cells, the empty placeholder) measured 2.83:1 in the light theme. It now uses `--kmwc-text-color-2`, and a spec checks every tone on both panels in both themes against WCAG AA's 4.5:1. Timeline rows wrap, so on a phone or with a long source name the detail drops under the cells instead of showing one letter per line. `.css-readout` gets a top margin after the demo it measures.
34
- - **CLI — `kmwc <command> --help` no longer runs the command** — Only a top-level `--help` was handled, and unknown flags were silently ignored, so `kmwc sync --help` did a full sync and overwrote core's hand-written Makefile. Every command now lists the flags it accepts. `--help` / `-h` after any command prints that command's usage and exits 0, and an unknown option is an error (exit 2). A boolean flag no longer takes the next word as its value.
35
- - **Libraries — `library: false`, `cssVariables: false`, and a doctor that knows the kind** — A repo like web-components-core builds its package with `tsc`. A plain `vite build` (the devkit's library mode) would empty its `dist/`, so `"kmwc": { "library": false }` now makes it refuse to run, while `vite build --mode examples` still builds the site. `"cssVariables": false` turns off the variables-manifest check, so sync stops re-adding `check:vars`. Doctor reads the registry kind (or `kmwc.kind`): a `library` runs only naming-conventions, readme-structure, publish-command and example-pages. On core that drops 12 errors and 4 "suite could not run" warnings that didn't apply to a library.
29
+ - **Doctor — hard-coded colours in example pages** — web-treeview's devkit migration kept each page's own `<style>` block, and on 8 of its 14 pages the search bars, status bars, badges and panels kept their light colours when the theme switch went dark. rc07 only flagged page CSS that restyles a kit class, so nothing reported them. Doctor now warns about a colour literal (`#hex`, `rgb()` / `hsl()` / `oklch()`, a named colour) on any colour property in a page `<style>`, a `style="…"` attribute or `examples-local.css`. `var()` fallbacks, `light-dark()` pairs, custom properties and `rgb(from var(…))` pass, because they follow the theme. A block that shows fixed colours on purpose, such as a theme gallery or a mockup, opts out with `<style data-kmwc-fixed-colors>`, and a single rule with a `/* kmwc-fixed-colors: why */` comment before it.
30
+ - **Doctor — the loader class matches the installed core** — Core 6d6b768 renamed its default loader's class from `<prefix>-loader` to `<prefix>__loader`, with no release that emits both. A component that renames its selector before the core bump, or forgets to after it, ships a loader with no styles. No rulebook check sees that, and a `## C-NC-8` heading in VALIDATION-NOTES.md would hide a leftover `.x-loader` from the BEM check. Doctor reads which class the installed core's `dist/dom/render-loader.js` draws and fails on any selector in the component's `src/**/*.css` that styles the other one. The error names the file and says which side should change.
36
31
 
37
32
  ## Catalogue
38
33
 
@@ -367,7 +362,7 @@ kmwc doctor [--json] devkit checks + package contents + the rulebook's
367
362
  kmwc check-vars [--strict] component-variables.manifest.json ⇔ CSS
368
363
  kmwc check-pages [--url u] every example page in Chromium at 1440 / 390 / 320px: page and console
369
364
  errors, failed requests, never-defined elements, a page wider than the
370
- viewport. Starts the dev server unless --url (e.g. the built site);
365
+ viewport, page CSS that only matches inside a shadow root. Starts the dev server unless --url (e.g. the built site);
371
366
  --widths 1440,390,320
372
367
  kmwc check-release --tag t latest ⇔ release version, rc ⇔ -rcN, version not yet on npm
373
368
  kmwc new web-<name> scaffold a component (--prefix, --title, --tag, --dir, --port, --minimal, --register)
@@ -416,10 +411,24 @@ generated files:
416
411
  selector (`.kbd`, `kbd`, `mark`, `.pill`, `.card` …) and single-declaration
417
412
  inline styles a kit class covers (`width` on a field → `.w-*`,
418
413
  `margin-left: auto` → `.push-end`, `margin-top: 1rem` → `.mt-1`) are warnings.
414
+ So is a hard-coded colour (`#hex`, `rgb()`, `white` …) in page CSS or a `style="…"`:
415
+ it stays put when the theme switch flips the kit to dark. `var()` fallbacks and
416
+ `light-dark()` pairs follow the theme. Fixed colours on purpose (a theme gallery, a
417
+ mockup, a test fixture) go in a `<style data-kmwc-fixed-colors>` block, or one rule
418
+ gets a `/* kmwc-fixed-colors: why */` comment right before it.
419
+ - **Loader class** — the component CSS styles the class the *installed* core gives
420
+ its default loader (`<prefix>__loader` from core 6d6b768 on, `<prefix>-loader`
421
+ before). Renaming the selector on one side of the core bump leaves the loader
422
+ unstyled, and a VALIDATION-NOTES `## C-NC-8` would hide the old class from the
423
+ rulebook.
419
424
 
420
425
  `kmwc check-pages` is the browser half: it loads every example page and reports
421
426
  what doctor can't see in the source — a script error, a 404, a fixed width that
422
- pushes a phone sideways.
427
+ pushes a phone sideways, and page CSS that can never apply: a rule (in the page's
428
+ `<style>` or `examples-local.css`) whose element exists only inside a component's
429
+ shadow root, such as the rows a `renderNodeCallback` draws. Page CSS stops at the
430
+ shadow boundary — style those through a CSS variable, `::part` or the component's
431
+ styles hook (`getCustomStylesCallback`).
423
432
 
424
433
  **Package contents.** Doctor runs `npm pack --dry-run` and fails on anything
425
434
  dev-only that would reach a consumer's `node_modules`: `CLAUDE.md` at any
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keenmate/web-components-devkit",
3
- "version": "1.0.0-rc07",
3
+ "version": "1.0.0-rc09",
4
4
  "description": "Shared dev, example-site and build infrastructure for Keenmate web components: Vite preset, examples kit (chapter nav, copy-link, controls store, shared styles), Playwright preset and the `kmwc` CLI (sync, doctor, check-vars, new, readme).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,24 +1,28 @@
1
1
  /**
2
2
  * kmwc check-pages — load every example page in a real browser and report what
3
3
  * a reader would hit: page errors, console errors, failed requests, custom
4
- * elements that never get defined, and a page wider than the viewport (a fixed
5
- * width or a long word that pushes a phone sideways).
4
+ * elements that never get defined, a page wider than the viewport (a fixed
5
+ * width or a long word that pushes a phone sideways), and page CSS that can
6
+ * never apply because what it targets lives only inside a shadow root (classes
7
+ * a render callback puts into the component).
6
8
  *
7
9
  * Runs against the repo's own dev server (started here on kmwc.ports.dev), or a
8
10
  * server you already have (--url, e.g. the built site on the preview port).
9
11
  * Widths default to desktop + two phones. Uses the component's own
10
12
  * @playwright/test (see getPeerModule) — `npx playwright install chromium` once.
11
13
  */
12
- import { existsSync } from 'node:fs';
14
+ import { existsSync, readFileSync } from 'node:fs';
13
15
  import { join } from 'node:path';
14
16
  import { getKmwcConfig, getPeerModule, loadPeerModule } from '../../config.js';
15
17
  import { getExamplePages } from '../../vite/index.js';
18
+ import { getRuleSelectors } from '../repo-checks.js';
16
19
 
17
20
  export const DEFAULT_WIDTHS = [1440, 390, 320];
18
21
  const VIEWPORT_HEIGHT = 900;
19
22
  const SETTLE_MS = 600;
20
23
  const VITE_CONFIG_FILES = ['vite.config.ts', 'vite.config.mts', 'vite.config.mjs', 'vite.config.js'];
21
24
  const MAX_OFFENDERS = 3;
25
+ const LOCAL_STYLESHEET_FILE = 'examples-local.css';
22
26
  // Requests a page doesn't control (favicon probes, analytics in a built site).
23
27
  const IGNORED_REQUEST_PATTERN = /favicon\.ico|stats\.keenmate\.services|plausible/;
24
28
 
@@ -30,12 +34,68 @@ export function parseWidths(widthsText) {
30
34
  return widths;
31
35
  }
32
36
 
37
+ /**
38
+ * The element a selector styles, as a selector of its own: the last compound with
39
+ * pseudo-classes / -elements dropped (`.tree .badge:hover::after` → `.badge`), or
40
+ * null when there's no class / id / attribute to look for (`:root`, `div`, `from`). Pure.
41
+ */
42
+ export function getSelectorSubject(selector) {
43
+ let depth = 0;
44
+ let start = 0;
45
+ for (let index = 0; index < selector.length; index++) {
46
+ const character = selector[index];
47
+ if (character === '(' || character === '[') depth++;
48
+ else if (character === ')' || character === ']') depth--;
49
+ else if (depth === 0 && /[\s>+~]/.test(character)) start = index + 1;
50
+ }
51
+ const subject = selector.slice(start).replace(/::?[\w-]+(\([^()]*\))?/g, '').trim();
52
+ return /[.#[]/.test(subject) ? subject : null;
53
+ }
54
+
55
+ /** A page's own CSS rules (its `<style>` blocks + examples-local.css) → [{ selector, subject }]. */
56
+ export function loadPageRules(root, pagePath) {
57
+ const pageText = readFileSync(pagePath, 'utf8');
58
+ const localStylesheetPath = join(root, LOCAL_STYLESHEET_FILE);
59
+ const styleText = [
60
+ ...[...pageText.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((match) => match[1]),
61
+ existsSync(localStylesheetPath) ? readFileSync(localStylesheetPath, 'utf8') : '',
62
+ ].join('\n');
63
+ const selectors = [...new Set(getRuleSelectors(styleText))];
64
+ return selectors.map((selector) => ({ selector, subject: getSelectorSubject(selector) })).filter((rule) => rule.subject);
65
+ }
66
+
67
+ /**
68
+ * Runs in the page: page rules whose subject matches nothing in the document but
69
+ * something inside a shadow root → [{ selector, host }]. Page CSS stops at the
70
+ * shadow boundary, so such a rule never applies.
71
+ */
72
+ export function findShadowOnlyRules(rules) {
73
+ const getShadowRoots = (root) =>
74
+ [...root.querySelectorAll('*')].filter((element) => element.shadowRoot).flatMap((element) => [element.shadowRoot, ...getShadowRoots(element.shadowRoot)]);
75
+ const shadowRoots = getShadowRoots(document);
76
+ const safeQuery = (root, selector) => {
77
+ try {
78
+ return root.querySelector(selector);
79
+ } catch {
80
+ return null;
81
+ }
82
+ };
83
+ return rules.flatMap(({ selector, subject }) => {
84
+ if (safeQuery(document, subject)) return [];
85
+ const shadowRoot = shadowRoots.find((root) => safeQuery(root, subject));
86
+ return shadowRoot ? [{ selector, host: shadowRoot.host.localName }] : [];
87
+ });
88
+ }
89
+
33
90
  /**
34
91
  * Problems in one page load's report → [string]. Pure — exported for tests.
35
- * @param {{ width: number, errors: string[], undefinedElements: string[], scrollWidth: number, clientWidth: number, offenders: string[] }} pageReport
92
+ * @param {{ width: number, errors: string[], undefinedElements: string[], scrollWidth: number, clientWidth: number, offenders: string[], shadowOnlyRules?: { selector: string, host: string }[] }} pageReport
36
93
  */
37
94
  export function getPageProblems(pageReport) {
38
95
  const problems = [...pageReport.errors];
96
+ for (const { selector, host } of pageReport.shadowOnlyRules || []) {
97
+ problems.push(`page CSS "${selector}" only matches inside <${host}>'s shadow root — page styles can't reach it (use a CSS variable, ::part or the component's styles hook)`);
98
+ }
39
99
  if (pageReport.undefinedElements.length) problems.push(`never defined: <${pageReport.undefinedElements.join('>, <')}>`);
40
100
  if (pageReport.scrollWidth > pageReport.clientWidth) {
41
101
  const offenderText = pageReport.offenders.length ? ` — widest: ${pageReport.offenders.join(', ')}` : '';
@@ -106,7 +166,7 @@ function inspectPage(maxOffenders) {
106
166
  };
107
167
  }
108
168
 
109
- async function loadPageReport(browser, pageUrl, width) {
169
+ async function loadPageReport(browser, pageUrl, width, pageRules = []) {
110
170
  const page = await browser.newPage({ viewport: { width, height: VIEWPORT_HEIGHT } });
111
171
  const errors = [];
112
172
  page.on('pageerror', (error) => errors.push(`page error: ${error.message.split('\n')[0]}`));
@@ -123,7 +183,8 @@ async function loadPageReport(browser, pageUrl, width) {
123
183
  await page.goto(pageUrl, { waitUntil: 'networkidle', timeout: 30_000 });
124
184
  await page.waitForTimeout(SETTLE_MS);
125
185
  const inspection = await page.evaluate(inspectPage, MAX_OFFENDERS);
126
- return { width, errors: [...new Set(errors)], ...inspection };
186
+ const shadowOnlyRules = pageRules.length ? await page.evaluate(findShadowOnlyRules, pageRules) : [];
187
+ return { width, errors: [...new Set(errors)], ...inspection, shadowOnlyRules };
127
188
  } catch (error) {
128
189
  return { width, errors: [...new Set([...errors, `load failed: ${error.message.split('\n')[0]}`])], scrollWidth: 0, clientWidth: 0, offenders: [], undefinedElements: [] };
129
190
  } finally {
@@ -154,7 +215,8 @@ async function startDevServer(config) {
154
215
  export async function run(args) {
155
216
  const config = getKmwcConfig(args.root);
156
217
  const widths = parseWidths(args.widths);
157
- const pageFiles = Object.values(getExamplePages(config)).map((pagePath) => pagePath.replace(/\\/g, '/').split('/').pop());
218
+ const pagePaths = Object.values(getExamplePages(config));
219
+ const pageFiles = pagePaths.map((pagePath) => pagePath.replace(/\\/g, '/').split('/').pop());
158
220
  if (!pageFiles.length) {
159
221
  console.log('kmwc check-pages: no example pages — nothing to check.');
160
222
  return 0;
@@ -172,10 +234,13 @@ export async function run(args) {
172
234
  let problemCount = 0;
173
235
  try {
174
236
  console.log(`kmwc check-pages — ${pageFiles.length} page(s) × ${widths.join(' / ')}px on ${baseUrl}\n`);
175
- for (const pageFile of pageFiles) {
237
+ for (const [pageIndex, pageFile] of pageFiles.entries()) {
176
238
  const pageProblems = [];
177
- for (const width of widths) {
178
- for (const problem of getPageProblems(await loadPageReport(browser, `${baseUrl}/${pageFile}`, width))) pageProblems.push(`@${width}px ${problem}`);
239
+ const pageRules = loadPageRules(config.root, pagePaths[pageIndex]);
240
+ for (const [widthIndex, width] of widths.entries()) {
241
+ // Shadow-only rules don't depend on the width: check them on the first load only.
242
+ const report = await loadPageReport(browser, `${baseUrl}/${pageFile}`, width, widthIndex === 0 ? pageRules : []);
243
+ for (const problem of getPageProblems(report)) pageProblems.push(`@${width}px ${problem}`);
179
244
  }
180
245
  problemCount += pageProblems.length;
181
246
  console.log(`${pageProblems.length ? '✗' : '✓'} ${pageFile}${pageProblems.map((problem) => `\n ${problem}`).join('')}`);
@@ -34,6 +34,11 @@ import {
34
34
  loadSharedPlausibleSiteIds,
35
35
  loadKitClassNames,
36
36
  getKitDuplicates,
37
+ FIXED_COLORS_ATTRIBUTE,
38
+ FIXED_COLORS_MARKER,
39
+ getHardcodedColors,
40
+ loadCoreLoader,
41
+ loadStyledLoaderClasses,
37
42
  } from '../repo-checks.js';
38
43
 
39
44
  const DEVKIT_PACKAGE_NAME = '@keenmate/web-components-devkit';
@@ -197,6 +202,23 @@ export function checkComponent(config, doctorConfig = {}) {
197
202
  }
198
203
  if (pageFiles.length && !kitDuplicateSources.length) addPass('pages', 'no page CSS re-implements the kit');
199
204
 
205
+ // Page CSS colours that stay put when the theme switch flips the kit to dark.
206
+ const hardcodedColorSources = [
207
+ ...pageFiles.map((pageFile) => ({ file: pageFile, hardcodedColors: getHardcodedColors(readFileSync(join(config.root, pageFile), 'utf8')) })),
208
+ ...(existsSync(localStylesheetPath)
209
+ ? [{ file: LOCAL_STYLESHEET_FILE, hardcodedColors: getHardcodedColors(readFileSync(localStylesheetPath, 'utf8'), { isStylesheet: true }) }]
210
+ : []),
211
+ ].filter((source) => source.hardcodedColors.length);
212
+ for (const { file, hardcodedColors } of hardcodedColorSources) {
213
+ const shownColors = hardcodedColors.slice(0, 3).map(({ found }) => found).join('; ');
214
+ addWarning(
215
+ 'pages',
216
+ `${file}: ${hardcodedColors.length} hard-coded colour(s) that don't follow dark mode — ${shownColors}${hardcodedColors.length > 3 ? ' …' : ''}`,
217
+ `use the kit's --kmwc-* tokens or light-dark(); fixed colours on purpose: <style ${FIXED_COLORS_ATTRIBUTE}>, or /* ${FIXED_COLORS_MARKER} */ before one rule`
218
+ );
219
+ }
220
+ if (pageFiles.length && !hardcodedColorSources.length) addPass('pages', 'page CSS colours follow the theme');
221
+
200
222
  // ── generated files ──────────────────────────────────────────────────────
201
223
  if (config.hasPackageConfig) {
202
224
  for (const planItem of getSyncPlan(config)) {
@@ -252,6 +274,9 @@ export function checkComponent(config, doctorConfig = {}) {
252
274
  if (!outputMismatches.length) addPass('library', 'package.json targets match the library build outputs');
253
275
  }
254
276
 
277
+ // ── core's default loader class ──────────────────────────────────────────
278
+ if (config.prefix && config.kind !== LIBRARY_KIND) checkLoaderClass(config, { addPass, addWarning, addError });
279
+
255
280
  // ── analytics ────────────────────────────────────────────────────────────
256
281
  const plausibleSiteId = loadPlausibleSiteId(config.root);
257
282
  const sharingRepoIds = loadSharedPlausibleSiteIds(config, getRegistry(), plausibleSiteId);
@@ -299,6 +324,30 @@ function checkPorts(config, portBlock, { addPass, addWarning, addError }) {
299
324
  }
300
325
  }
301
326
 
327
+ /**
328
+ * The CSS styles the loader class the INSTALLED core draws. Core 6d6b768 renamed
329
+ * `<prefix>-loader` → `<prefix>__loader` with no overlap release; renaming the CSS
330
+ * before (or after) the core bump leaves the default loader unstyled, and no rulebook
331
+ * check sees that (a VALIDATION-NOTES `## C-NC-8` even hides a leftover old class).
332
+ */
333
+ function checkLoaderClass(config, { addPass, addError }) {
334
+ const coreLoader = loadCoreLoader(config.root);
335
+ if (!coreLoader?.separator) return;
336
+ const coreClassName = `${config.prefix}${coreLoader.separator}loader`;
337
+ const styledLoaderClasses = loadStyledLoaderClasses(config.root, config.prefix);
338
+ const mismatchedClasses = styledLoaderClasses.filter(({ separator }) => separator !== coreLoader.separator);
339
+ for (const { file, className } of mismatchedClasses) {
340
+ addError(
341
+ 'loader',
342
+ `${file} styles .${className}, but core ${coreLoader.version} draws the default loader as <span class="${coreClassName}"> — it renders unstyled`,
343
+ coreLoader.separator === '__'
344
+ ? `rename .${className} → .${coreClassName} (CSS, tests, docs; keep the --${config.prefix}-loader-* tokens)`
345
+ : `keep .${coreClassName} until the component builds on a core that draws <prefix>__loader, then rename in the same change`
346
+ );
347
+ }
348
+ if (styledLoaderClasses.length && !mismatchedClasses.length) addPass('loader', `CSS styles .${coreClassName}, the class core ${coreLoader.version} draws`);
349
+ }
350
+
302
351
  /** Packed paths that break PACKAGE_LEAK_RULES → [{ path, label }]. Pure — exported for tests. */
303
352
  export function getPackageLeaks(packedPaths) {
304
353
  return packedPaths
@@ -247,3 +247,120 @@ export function getKitDuplicates(pageText, kitClassNames, { isStylesheet = false
247
247
  }
248
248
  return duplicates;
249
249
  }
250
+
251
+ // ── hard-coded colours in page CSS ───────────────────────────────────────────
252
+
253
+ /**
254
+ * Fixed colours on purpose (a theme gallery, a mockup, a test fixture): a page `<style>`
255
+ * block with the attribute, or one rule right after a `/* kmwc-fixed-colors *\/` comment.
256
+ */
257
+ export const FIXED_COLORS_ATTRIBUTE = 'data-kmwc-fixed-colors';
258
+ export const FIXED_COLORS_MARKER = 'kmwc-fixed-colors';
259
+ const MARKED_RULE_PATTERN = new RegExp(`/\\*[^*]*${FIXED_COLORS_MARKER}[\\s\\S]*?\\*/\\s*[^{}]*\\{[^{}]*\\}`, 'g');
260
+ const COLOR_PROPERTY_PATTERN = /color|background|border|outline|shadow|fill|stroke|text-decoration/i;
261
+ const COLOR_LITERAL_PATTERN =
262
+ /#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch)\(\s*(?!from\b)|\b(?:white|black|red|green|blue|gray|grey|silver|orange|yellow|purple|pink|navy|teal|maroon|olive|lime|aqua|fuchsia|gold|crimson)\b/i;
263
+
264
+ /** A declaration value with every `var(…)` collapsed — a literal fallback never shows while the kit token exists. */
265
+ function mapWithoutVarFallbacks(value) {
266
+ let previous;
267
+ let current = value;
268
+ do {
269
+ previous = current;
270
+ current = current.replace(/var\([^()]*\)/gi, 'var()');
271
+ } while (current !== previous);
272
+ return current;
273
+ }
274
+
275
+ /** True when a colour declaration's value is a literal that ignores dark mode. Pure. */
276
+ export function checkIsHardcodedColor(property, value) {
277
+ if (!COLOR_PROPERTY_PATTERN.test(property) || property.startsWith('--')) return false;
278
+ const plainValue = mapWithoutVarFallbacks(value);
279
+ return !/light-dark\(/i.test(plainValue) && COLOR_LITERAL_PATTERN.test(plainValue);
280
+ }
281
+
282
+ /**
283
+ * Hard-coded colours in one page's `<style>` blocks and `style="…"` attributes (or in
284
+ * examples-local.css): they stay the same in dark mode while the kit around them flips.
285
+ * `var()` fallbacks and `light-dark()` pairs follow the theme; a `<style data-kmwc-fixed-colors>`
286
+ * block and a rule marked `/* kmwc-fixed-colors *\/` are skipped. Returns [{ found }]. Pure.
287
+ */
288
+ export function getHardcodedColors(pageText, { isStylesheet = false } = {}) {
289
+ const styleText = isStylesheet
290
+ ? pageText
291
+ : [...pageText.matchAll(/<style\b([^>]*)>([\s\S]*?)<\/style>/gi)]
292
+ .filter((match) => !match[1].includes(FIXED_COLORS_ATTRIBUTE))
293
+ .map((match) => match[2])
294
+ .join('\n');
295
+ const hardcodedColors = [];
296
+ const checkedText = styleText.replace(MARKED_RULE_PATTERN, '').replace(/\/\*[\s\S]*?\*\//g, '');
297
+ for (const [, selector, body] of checkedText.matchAll(/([^{};@]+?)\s*\{([^{}]*)\}/g)) {
298
+ for (const declaration of body.split(';')) {
299
+ const [property, ...valueParts] = declaration.split(':');
300
+ const value = valueParts.join(':').trim();
301
+ if (value && checkIsHardcodedColor(property.trim(), value)) hardcodedColors.push({ found: `${selector.trim().replace(/\s+/g, ' ')} { ${property.trim()}: ${value} }` });
302
+ }
303
+ }
304
+ if (isStylesheet) return hardcodedColors;
305
+ for (const [, inlineStyle] of pageText.matchAll(/\sstyle="([^"]*)"/g)) {
306
+ for (const declaration of inlineStyle.split(';')) {
307
+ const [property, ...valueParts] = declaration.split(':');
308
+ const value = valueParts.join(':').trim();
309
+ if (value && checkIsHardcodedColor(property.trim(), value)) hardcodedColors.push({ found: `style="${property.trim()}: ${value}"` });
310
+ }
311
+ }
312
+ return hardcodedColors;
313
+ }
314
+
315
+ // ── core's default loader class ──────────────────────────────────────────────
316
+
317
+ const CORE_PACKAGE_PATH = join('node_modules', '@keenmate', 'web-components-core');
318
+ const CORE_RENDER_LOADER_FILE = join('dist', 'dom', 'render-loader.js');
319
+ const SOURCE_DIR = 'src';
320
+ const SKIPPED_SOURCE_DIRS = new Set(['node_modules', 'dist']);
321
+
322
+ /**
323
+ * The separator in the class core's default loader gets: `'__'` (`<prefix>__loader`),
324
+ * `'-'` (`<prefix>-loader`, before core 6d6b768), or null when the build doesn't say. Pure.
325
+ */
326
+ export function getCoreLoaderSeparator(renderLoaderText) {
327
+ const match =
328
+ renderLoaderText.match(/\$\{\s*[\w$.]+\s*\}(__|-)loader(?![\w-])/) || renderLoaderText.match(/\+\s*["'`](__|-)loader["'`]/);
329
+ return match ? match[1] : null;
330
+ }
331
+
332
+ /** The loader classes a stylesheet styles: [{ className, separator }]. Pure. */
333
+ export function getStyledLoaderClasses(cssText, prefix) {
334
+ const pattern = new RegExp(`\.(${prefix}(__|-)loader)(?![\w-])`, 'g');
335
+ const plainText = cssText.replace(/\/\*[\s\S]*?\*\//g, '');
336
+ const classes = new Map([...plainText.matchAll(pattern)].map((match) => [match[1], match[2]]));
337
+ return [...classes].map(([className, separator]) => ({ className, separator }));
338
+ }
339
+
340
+ /** The installed core (walking up for hoisted installs): { version, separator } or null. */
341
+ export function loadCoreLoader(root) {
342
+ for (let directory = resolve(root); ; directory = resolve(directory, '..')) {
343
+ const packageText = readText(join(directory, CORE_PACKAGE_PATH, 'package.json'));
344
+ if (packageText) {
345
+ const renderLoaderText = readText(join(directory, CORE_PACKAGE_PATH, CORE_RENDER_LOADER_FILE));
346
+ return { version: JSON.parse(packageText).version, separator: renderLoaderText ? getCoreLoaderSeparator(renderLoaderText) : null };
347
+ }
348
+ if (resolve(directory, '..') === directory) return null;
349
+ }
350
+ }
351
+
352
+ function getSourceStylesheets(directory, relativeDirectory = SOURCE_DIR) {
353
+ if (!existsSync(directory)) return [];
354
+ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
355
+ const relativePath = `${relativeDirectory}/${entry.name}`;
356
+ if (entry.isDirectory()) return SKIPPED_SOURCE_DIRS.has(entry.name) ? [] : getSourceStylesheets(join(directory, entry.name), relativePath);
357
+ return entry.name.endsWith('.css') ? [relativePath] : [];
358
+ });
359
+ }
360
+
361
+ /** Loader classes styled in the component's src CSS: [{ file, className, separator }]. */
362
+ export function loadStyledLoaderClasses(root, prefix) {
363
+ return getSourceStylesheets(join(root, SOURCE_DIR)).flatMap((file) =>
364
+ getStyledLoaderClasses(readFileSync(join(root, file), 'utf8'), prefix).map((styledClass) => ({ file, ...styledClass }))
365
+ );
366
+ }
@@ -118,7 +118,7 @@
118
118
  padding: 0.2rem 0.4rem;
119
119
  border-radius: 3px;
120
120
  font-size: 0.9em;
121
- color: var(--kmwc-accent-color);
121
+ color: var(--kmwc-inline-code-color);
122
122
  font-family: var(--kmwc-mono-font-family);
123
123
  /* A long identifier has no break opportunity — break it anywhere
124
124
  rather than widen the page on a phone. */
@@ -213,13 +213,13 @@
213
213
 
214
214
  button.secondary,
215
215
  button.btn-secondary {
216
- background: var(--kmwc-neutral-color);
216
+ background: var(--kmwc-neutral-fill-color);
217
217
  color: var(--kmwc-text-color-on-neutral);
218
218
  }
219
219
 
220
220
  button.secondary:hover,
221
221
  button.btn-secondary:hover {
222
- background: color-mix(in srgb, var(--kmwc-neutral-color) 80%, black);
222
+ background: var(--kmwc-neutral-fill-color-hover);
223
223
  }
224
224
 
225
225
  button.btn-outline {
@@ -118,7 +118,11 @@
118
118
  --kmwc-console-info-color: #7dd3fc;
119
119
  --kmwc-console-warn-color: #fcd34d;
120
120
  --kmwc-console-err-color: #fca5a5;
121
- --kmwc-inline-code-bg: light-dark(#edf2f7, #2b2b2b);
121
+ /* Dark: the chip is a step up from the main surface (slate on a slate page, not a
122
+ neutral grey), and the text keeps the accent hue but at least 0.8 lightness, so a
123
+ mid-dark accent (treeview's #059669) stays readable on it. */
124
+ --kmwc-inline-code-bg: light-dark(#edf2f7, oklch(from var(--kmwc-main-bg) calc(l + 0.07) c h));
125
+ --kmwc-inline-code-color: light-dark(var(--kmwc-accent-color), oklch(from var(--kmwc-accent-color) max(l, 0.8) c h));
122
126
 
123
127
  /* ======================================================================
124
128
  COLORS — SEMANTIC
@@ -133,10 +137,15 @@
133
137
  --kmwc-danger-fill-color-hover: var(--base-danger-color-hover, light-dark(color-mix(in srgb, black 15%, var(--kmwc-danger-fill-color)), color-mix(in srgb, white 15%, var(--kmwc-danger-fill-color))));
134
138
  --kmwc-text-color-on-danger: var(--base-on-danger-text-color, color(from color(from var(--kmwc-danger-fill-color) srgb calc(0.21 * r * r * (0.4 + r * (0.82 - 0.22 * r)) + 0.72 * g * g * (0.4 + g * (0.82 - 0.22 * g)) + 0.07 * b * b * (0.4 + b * (0.82 - 0.22 * b))) 0 0) srgb clamp(0, (0.34 - r) * 1e9, 1) clamp(0, (0.34 - r) * 1e9, 1) clamp(0, (0.34 - r) * 1e9, 1)));
135
139
  --kmwc-neutral-color: light-dark(#6b7280, #9ca3af);
140
+ /* A grey button needs its own fill: the dark-mode neutral (#9ca3af) sits just under the
141
+ on-colour formula's 0.34 switch, so it got white text at 2.5:1. Dots and pills keep the
142
+ light grey; the button takes a dark one (white text ≥ 4.5:1 in both themes). */
143
+ --kmwc-neutral-fill-color: light-dark(#6b7280, #4b5563);
144
+ --kmwc-neutral-fill-color-hover: light-dark(color-mix(in srgb, black 15%, var(--kmwc-neutral-fill-color)), color-mix(in srgb, white 12%, var(--kmwc-neutral-fill-color)));
136
145
  --kmwc-badge-new-bg: light-dark(#e53e3e, #f56565);
137
146
  /* Text on the neutral / badge fills: the contract's on-colour formula on
138
147
  their own background, not on the accent (a light primary would turn it black). */
139
- --kmwc-text-color-on-neutral: color(from color(from var(--kmwc-neutral-color) srgb calc(0.21 * r * r * (0.4 + r * (0.82 - 0.22 * r)) + 0.72 * g * g * (0.4 + g * (0.82 - 0.22 * g)) + 0.07 * b * b * (0.4 + b * (0.82 - 0.22 * b))) 0 0) srgb clamp(0, (0.34 - r) * 1e9, 1) clamp(0, (0.34 - r) * 1e9, 1) clamp(0, (0.34 - r) * 1e9, 1));
148
+ --kmwc-text-color-on-neutral: color(from color(from var(--kmwc-neutral-fill-color) srgb calc(0.21 * r * r * (0.4 + r * (0.82 - 0.22 * r)) + 0.72 * g * g * (0.4 + g * (0.82 - 0.22 * g)) + 0.07 * b * b * (0.4 + b * (0.82 - 0.22 * b))) 0 0) srgb clamp(0, (0.34 - r) * 1e9, 1) clamp(0, (0.34 - r) * 1e9, 1) clamp(0, (0.34 - r) * 1e9, 1));
140
149
  --kmwc-text-color-on-badge-new: color(from color(from var(--kmwc-badge-new-bg) srgb calc(0.21 * r * r * (0.4 + r * (0.82 - 0.22 * r)) + 0.72 * g * g * (0.4 + g * (0.82 - 0.22 * g)) + 0.07 * b * b * (0.4 + b * (0.82 - 0.22 * b))) 0 0) srgb clamp(0, (0.34 - r) * 1e9, 1) clamp(0, (0.34 - r) * 1e9, 1) clamp(0, (0.34 - r) * 1e9, 1));
141
150
 
142
151
  /* ======================================================================
@@ -28,7 +28,10 @@ locally — every local fork is how they drifted apart.
28
28
  element out. Log lines take the kit tones (`.log-line--ok|info|warn|err|muted`,
29
29
  `--action`, timeline cells `.log-line__time` …) — don't colour them per page.
30
30
  `<kbd>`, `<mark>`, field widths (`.w-xs` … `.w-lg`) and `.push-end` are kit
31
- too — no inline styles for them.
31
+ too — no inline styles for them. Page colours come from the kit's `--kmwc-*`
32
+ tokens or `light-dark()` — a literal doesn't follow dark mode (doctor warns);
33
+ a demo that shows fixed colours on purpose goes in `<style data-kmwc-fixed-colors>`
34
+ (one rule: a `/* kmwc-fixed-colors: why */` comment before it).
32
35
  If a second component needs a pattern, it belongs in the devkit.
33
36
  - **Example pages are fragments:** no `<html>`/`<head>`/`<header>` — start with
34
37
  `<meta name="kmwc:title" content="…">`, the devkit layout adds the rest. Shared text
@@ -51,4 +54,6 @@ locally — every local fork is how they drifted apart.
51
54
  `*.checks.sh` suites. A rulebook failure that is a real architectural decision
52
55
  goes into `VALIDATION-NOTES.md` under a `## C-XX-N — …` heading (deferrals
53
56
  don't count). After changing a page, `npx kmwc check-pages` loads every page at
54
- desktop and phone widths and reports errors and horizontal overflow.
57
+ desktop and phone widths and reports errors, horizontal overflow and page CSS
58
+ that only matches inside a shadow root (markup a render callback draws there is
59
+ out of page CSS's reach — style it through the component's styles hook).