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

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,59 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.0.0-rc10] - 2026-11-05 [PUBLISHED]
4
+
5
+ ### Added
6
+
7
+ - **Doctor runs the variable-manifest check.** `kmwc check-vars` only ran inside `npm run build`, so a
8
+ red manifest went unseen until a build: web-treeview's `check:vars` failed on core's
9
+ non-contract `--base-accent-color` while doctor passed. Doctor now reports the same verdict
10
+ (`vars`): missing / dead `--base-*` and dead `--<prefix>-*` entries are errors, undocumented
11
+ `--<prefix>-*` a warning. `loadManifestVerdict()` is shared by both.
12
+ - **Opt-outs for markup that is right on purpose:**
13
+ - an element whose own `style="…"` sets fixed colours carries `data-kmwc-fixed-colors` (doctor's
14
+ colour check). Markup a render callback draws inside a shadow root can't be styled by page CSS,
15
+ so inline is the only way there (web-treeview's context-menu role chips);
16
+ - `data-kmwc-undefined` on an element a demo leaves undefined on purpose — check-pages no longer
17
+ reports it (core's PG01 registers its element on a button press). The "never defined" problem
18
+ names the attribute;
19
+ - `<!-- kmwc-other-ports: why -->` in a doc that names other repos' ports on purpose (a dated
20
+ plan, a cross-repo review) — doctor's port scan skips it.
21
+
22
+ ### Changed
23
+
24
+ - **Doctor: the loader check tells a collision from a rename.** On a core that still draws
25
+ `<prefix>-loader`, CSS that styles both classes has its own element called `<prefix>__loader`
26
+ (web-multiselect's loading-state row). That was reported as a rename done too early (error); it is
27
+ now a warning that core's loader takes the name — and those rules — with the bump, so rename the
28
+ component's element in the same change.
29
+
30
+ ### Fixed
31
+
32
+ - **Fragment pages: `<link>` / `<style>` text inside an attribute value stayed put.** The composer
33
+ moved a `<link>` it found in a tooltip (`title="…the <link> in <head>"`) into `<head>` as an empty
34
+ element, and the tooltip lost its text (web-multiselect). It now reads whole start tags, quoted
35
+ attribute values included, and moves only real `<link>` / `<style>` elements.
36
+ - **Inline code in light mode** read at ~3.3:1 — the raw accent on the pale chip. The text keeps the
37
+ accent hue at most 0.5 lightness (≥ 4.5:1 for every accent the spec tries, light and dark).
38
+ - **Doctor: a rulebook suite that never ran counted as a pass.** Only exit 2 was read as "could
39
+ not run"; any other exit without `FAIL` lines passed. On Windows a bare `bash` can be WSL's, which
40
+ can't read `C:/…` paths — every suite exited 127 and doctor reported the rulebook green (found when
41
+ `make publish-rc` ran the tests from PowerShell). Doctor now runs Git for Windows' bash (from PATH
42
+ or the install root; `KMWC_BASH` overrides) and warns on any exit but 0, or 1 with `FAIL` lines.
43
+
44
+ ### Internal
45
+
46
+ - **Tests on the critical paths.** Doctor end to end on fixture repos (a healthy repo is clean, each
47
+ broken one gets its finding; `doctor.js` 26% → 98% of lines); a real `vite build` of the playground
48
+ must emit exactly what doctor checks package.json against, with the UMD global and every export
49
+ `index.d.ts` declares; `kmwc new` (full and `--minimal`) scaffolds a repo doctor finds clean;
50
+ `kmwc sync` rewrites only what it owns and `--check` writes nothing; `check-release` keeps an rc off
51
+ `latest`. Composer cases for single-quoted values, `<style>` text and a `>` inside an attribute.
52
+ - **`npm run test:e2e:coverage`** (maintainer) — the dev-server e2e run with line coverage of the
53
+ kit's browser code (`src/examples/*.js`, 84%), snapshot before every navigation.
54
+ - **README:** a sentence about `data-kmwc-undefined` had landed inside the rc09 What's New bullet;
55
+ it's back in the check-pages docs.
56
+
3
57
  ## [1.0.0-rc09] - 2026-11-04 [PUBLISHED]
4
58
 
5
59
  ### Added
package/README.md CHANGED
@@ -19,16 +19,20 @@ 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-rc10
23
+
24
+ - **Doctor — runs the variable-manifest check** — `kmwc check-vars` only ran inside `npm run build`, so a red manifest stayed invisible until someone built. During the devkit pass, web-treeview's `check:vars` failed on core's `--base-accent-color` (a name outside the base-variables contract) while `kmwc doctor` reported nothing. Doctor now reports the same verdict under `vars`: `--base-*` reads missing from the manifest, manifest entries the CSS no longer reads, and dead `--<prefix>-*` entries are errors; undocumented `--<prefix>-*` variables are a warning. Both commands share one function, `loadManifestVerdict()`, so they can't disagree.
25
+ - **Doctor and check-pages — opt-outs for markup that is right on purpose** — The migrations of web-treeview, web-multiselect and web-components-core hit three findings that could never pass. Markup a render callback draws inside a shadow root can only be coloured inline, so an element whose `style="…"` sets fixed colours on purpose now carries `data-kmwc-fixed-colors` (treeview's context-menu role chips). An element a demo leaves undefined on purpose carries `data-kmwc-undefined`, and check-pages skips it (core's PG01 registers its element on a button press); the "never defined" message names the attribute. A doc that names other repos' ports on purpose, such as a dated plan or a cross-repo review, carries `<!-- kmwc-other-ports: why -->`, and doctor's port scan skips it.
26
+ - **Doctor — the loader check tells a collision from a rename** — web-multiselect has its own loading-state element called `.ms__loader`, next to core rc17's `.ms-loader`. Doctor read it as a rename done before the core bump and reported an error. On a core that still draws `<prefix>-loader`, CSS that styles both classes now gets a warning instead: from core 6d6b768 on, core's default loader takes that class name and picks up the component's rules. The fix is to rename the component's own element in the same change as the core bump.
27
+ - **Fragment pages — text inside attribute values stays put** — The page composer moves a page's `<link>` and `<style>` elements into `<head>`, but it also matched them inside attribute values. web-multiselect's tooltip `title="…the <link> in <head>"` turned into an empty `<link>` in the head, and the tooltip lost its text; a `<style>` written inside an attribute would even have been applied to the whole page. The composer now reads whole start tags, quoted values included, and moves only real elements.
28
+ - **Examples kit — inline code readable in light mode** — Inline `code` used the raw accent colour on its pale chip, which read at about 3.3:1 for the default accent. The text keeps the accent's hue at no more than 0.5 lightness. The e2e spec checks five accents in both themes for at least 4.5:1.
29
+ - **Devkit tests — the critical paths** — The suite now runs a real library build of the playground and checks that the emitted files, the UMD global and every export `index.d.ts` declares are exactly what package.json and doctor expect. It also checks that `kmwc new` scaffolds a repo doctor finds clean, that `kmwc sync` never rewrites a local file or an existing script, and that `check-release` keeps an rc off `latest`. Doctor itself is tested end to end on fixture repos.
30
+
22
31
  ## What's New in v1.0.0-rc09
23
32
 
24
33
  - **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
34
  - **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.
26
35
 
27
- ## What's New in v1.0.0-rc08
28
-
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.
31
-
32
36
  ## Catalogue
33
37
 
34
38
  <!-- kmwc:catalogue:start — generated by `npm run readme`, do not edit inside -->
@@ -36,7 +40,7 @@ them in the same shape: `@keenmate/web-components-devkit`.
36
40
 
37
41
  | Component | Package | Prefix | Dev port | Links | Devkit | About |
38
42
  |---|---|---|---:|---|:---:|---|
39
- | **web-multiselect** | `@keenmate/web-multiselect` | `ms` | 12100 | [repo](https://github.com/keenmate/web-multiselect) · [examples](https://examples.web-multiselect.keenmate.dev) · [docs](https://web-multiselect.keenmate.dev) · `web-multiselect-showcase` · `web-multiselect-docs` | — | Lightweight multiselect web component with typeahead search, rich content support, and excellent keyboard… |
43
+ | **web-multiselect** | `@keenmate/web-multiselect` | `ms` | 12100 | [repo](https://github.com/keenmate/web-multiselect) · [examples](https://examples.web-multiselect.keenmate.dev) · [docs](https://web-multiselect.keenmate.dev) · `web-multiselect-showcase` · `web-multiselect-docs` | ✅ | Lightweight multiselect web component with typeahead search, rich content support, and excellent keyboard… |
40
44
  | **web-daterangepicker** | `@keenmate/web-daterangepicker` | `drp` | 12200 | [repo](https://github.com/keenmate/web-daterangepicker) · [docs](https://web-daterangepicker.keenmate.dev) · `web-daterangepicker-showcase` | ✅ | Lightweight date picker web component with excellent keyboard navigation and range selection |
41
45
  | **web-treeview** | `@keenmate/web-treeview` | `wtv` | 12300 | [repo](https://github.com/keenmate/web-treeview) · `web-treeview-showcase` | ✅ | Lightweight treeview web component with hierarchical data display, search, expand/collapse, drag-drop, and… |
42
46
  | **web-commandpalette** | `@keenmate/web-commandpalette` | `cp` | 12400 | [repo](https://github.com/keenmate/web-commandpalette) · [examples](https://examples.web-commandpalette.keenmate.dev) | ✅ | Framework-agnostic command-palette web component — modes, multi-step commands, scoped + global search,… |
@@ -385,6 +389,9 @@ and the e2e suite before building and publishing.
385
389
  base-variables, color-scheme, readme-structure, custom-elements-manifest,
386
390
  responsive-overlay, publish-command, example-pages) with `bash`, passing `kmwc.prefix`, and
387
391
  reports each `FAIL` as an error pointing at the matching `.checks.md` entry.
392
+ On Windows that is Git for Windows' bash (a bare `bash` may be WSL's, which can't
393
+ read `C:/` paths); `KMWC_BASH` overrides it. A suite that exits with anything but 0,
394
+ or 1 with `FAIL` lines, never ran — a warning, never a pass.
388
395
  A `library` runs only naming-conventions, readme-structure, publish-command
389
396
  and example-pages; the others check what a custom element ships (its CSS,
390
397
  manifest, overlay).
@@ -405,7 +412,9 @@ generated files:
405
412
  silently.
406
413
  - **Ports** — no script pins a `--port` of its own, the repo's configs, Makefile
407
414
  and docs stay inside its registry block, and no sibling checkout (a registry
408
- entry at `../<id>`) listens on a port inside it.
415
+ entry at `../<id>`) listens on a port inside it. A doc that names other repos'
416
+ ports on purpose (a dated plan, a cross-repo review) carries
417
+ `<!-- kmwc-other-ports: why -->` and isn't scanned.
409
418
  - **Analytics** — no other repo uses the same Plausible site id.
410
419
  - **Page CSS** — a page `<style>` or `examples-local.css` rule on a kit-owned
411
420
  selector (`.kbd`, `kbd`, `mark`, `.pill`, `.card` …) and single-declaration
@@ -415,12 +424,19 @@ generated files:
415
424
  it stays put when the theme switch flips the kit to dark. `var()` fallbacks and
416
425
  `light-dark()` pairs follow the theme. Fixed colours on purpose (a theme gallery, a
417
426
  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.
427
+ gets a `/* kmwc-fixed-colors: why */` comment right before it. An element whose
428
+ `style="…"` sets them on purpose carries the same attribute — markup a render
429
+ callback draws inside a shadow root can't be reached by page CSS at all.
419
430
  - **Loader class** — the component CSS styles the class the *installed* core gives
420
431
  its default loader (`<prefix>__loader` from core 6d6b768 on, `<prefix>-loader`
421
432
  before). Renaming the selector on one side of the core bump leaves the loader
422
433
  unstyled, and a VALIDATION-NOTES `## C-NC-8` would hide the old class from the
423
- rulebook.
434
+ rulebook. On a core that still draws `<prefix>-loader`, CSS that styles both
435
+ classes has an element of its own called `<prefix>__loader`: a warning, because
436
+ core's loader takes that name (and those rules) with the bump.
437
+ - **Variable manifest** — the same verdict as `kmwc check-vars`, which otherwise
438
+ only runs inside `npm run build`: `--base-*` reads and the manifest agree, no
439
+ dead `--<prefix>-*` entries (errors), no undocumented ones (warning).
424
440
 
425
441
  `kmwc check-pages` is the browser half: it loads every example page and reports
426
442
  what doctor can't see in the source — a script error, a 404, a fixed width that
@@ -428,7 +444,8 @@ pushes a phone sideways, and page CSS that can never apply: a rule (in the page'
428
444
  `<style>` or `examples-local.css`) whose element exists only inside a component's
429
445
  shadow root, such as the rows a `renderNodeCallback` draws. Page CSS stops at the
430
446
  shadow boundary — style those through a CSS variable, `::part` or the component's
431
- styles hook (`getCustomStylesCallback`).
447
+ styles hook (`getCustomStylesCallback`). An element a demo leaves undefined on
448
+ purpose (registered on a button press) carries `data-kmwc-undefined`.
432
449
 
433
450
  **Package contents.** Doctor runs `npm pack --dry-run` and fails on anything
434
451
  dev-only that would reach a consumer's `node_modules`: `CLAUDE.md` at any
@@ -524,6 +541,7 @@ registry edits), and move the component's own code into that layout with
524
541
  npm install
525
542
  npm test # unit tests (config, registry, templates, sync, checks, intake, plugin, kit)
526
543
  npm run test:e2e # playground: the kit + scaffold in a real browser, dev server AND built site
544
+ npm run test:e2e:coverage # the same specs on the dev server + line coverage of the kit's browser code
527
545
  npm run readme # regenerate the catalogue
528
546
  npm run check:spotlight-mirror # what changed in web-commandpalette since the spotlight was aligned
529
547
  make dev # the playground's dev server
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keenmate/web-components-devkit",
3
- "version": "1.0.0-rc09",
3
+ "version": "1.0.0-rc10",
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",
@@ -47,6 +47,7 @@
47
47
  "readme:check": "node src/cli/kmwc.js readme --check",
48
48
  "test": "node --test \"test/**/*.test.js\"",
49
49
  "test:e2e": "cd playground && npx playwright test",
50
+ "test:e2e:coverage": "node scripts/e2e-coverage.js",
50
51
  "check:contract": "base-contract-check src templates playground",
51
52
  "check:spotlight-mirror": "node scripts/check-spotlight-mirror.js",
52
53
  "playground": "cd playground && vite",
@@ -23,6 +23,8 @@ const SETTLE_MS = 600;
23
23
  const VITE_CONFIG_FILES = ['vite.config.ts', 'vite.config.mts', 'vite.config.mjs', 'vite.config.js'];
24
24
  const MAX_OFFENDERS = 3;
25
25
  const LOCAL_STYLESHEET_FILE = 'examples-local.css';
26
+ // An element a demo leaves undefined on purpose (registered on a button press) opts out.
27
+ export const UNDEFINED_ON_PURPOSE_ATTRIBUTE = 'data-kmwc-undefined';
26
28
  // Requests a page doesn't control (favicon probes, analytics in a built site).
27
29
  const IGNORED_REQUEST_PATTERN = /favicon\.ico|stats\.keenmate\.services|plausible/;
28
30
 
@@ -96,7 +98,7 @@ export function getPageProblems(pageReport) {
96
98
  for (const { selector, host } of pageReport.shadowOnlyRules || []) {
97
99
  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
100
  }
99
- if (pageReport.undefinedElements.length) problems.push(`never defined: <${pageReport.undefinedElements.join('>, <')}>`);
101
+ if (pageReport.undefinedElements.length) problems.push(`never defined: <${pageReport.undefinedElements.join('>, <')}> (left undefined on purpose? add ${UNDEFINED_ON_PURPOSE_ATTRIBUTE})`);
100
102
  if (pageReport.scrollWidth > pageReport.clientWidth) {
101
103
  const offenderText = pageReport.offenders.length ? ` — widest: ${pageReport.offenders.join(', ')}` : '';
102
104
  problems.push(`page is ${pageReport.scrollWidth}px wide in a ${pageReport.clientWidth}px viewport${offenderText}`);
@@ -108,9 +110,10 @@ export function getPageProblems(pageReport) {
108
110
  * Runs in the page: overflow, undefined custom elements. A culprit is the deepest
109
111
  * element — or the text — past the right edge, outside anything that scrolls or
110
112
  * clips on purpose (a code block, a table wrapper) and outside hidden or fixed
111
- * layers (a closed nav panel, a toast), which don't widen the page.
113
+ * layers (a closed nav panel, a toast), which don't widen the page. Undefined elements
114
+ * carrying `undefinedAttribute` are left undefined on purpose and don't count.
112
115
  */
113
- function inspectPage(maxOffenders) {
116
+ function inspectPage({ maxOffenders, undefinedAttribute }) {
114
117
  const viewportWidth = document.documentElement.clientWidth;
115
118
  // Shadow roots count: a component's own internals can widen the page too.
116
119
  const getParent = (element) => element.parentElement || element.getRootNode().host || null;
@@ -157,7 +160,7 @@ function inspectPage(maxOffenders) {
157
160
  offenders.push(`${describe(parent)} "${textNode.textContent.trim().slice(0, 40)}"`);
158
161
  }
159
162
  }
160
- const undefinedElements = [...new Set([...document.querySelectorAll(':not(:defined)')].map((element) => element.localName).filter((name) => name.includes('-')))];
163
+ const undefinedElements = [...new Set([...document.querySelectorAll(`:not(:defined):not([${undefinedAttribute}])`)].map((element) => element.localName).filter((name) => name.includes('-')))];
161
164
  return {
162
165
  scrollWidth: document.documentElement.scrollWidth,
163
166
  clientWidth: viewportWidth,
@@ -182,7 +185,7 @@ async function loadPageReport(browser, pageUrl, width, pageRules = []) {
182
185
  try {
183
186
  await page.goto(pageUrl, { waitUntil: 'networkidle', timeout: 30_000 });
184
187
  await page.waitForTimeout(SETTLE_MS);
185
- const inspection = await page.evaluate(inspectPage, MAX_OFFENDERS);
188
+ const inspection = await page.evaluate(inspectPage, { maxOffenders: MAX_OFFENDERS, undefinedAttribute: UNDEFINED_ON_PURPOSE_ATTRIBUTE });
186
189
  const shadowOnlyRules = pageRules.length ? await page.evaluate(findShadowOnlyRules, pageRules) : [];
187
190
  return { width, errors: [...new Set(errors)], ...inspection, shadowOnlyRules };
188
191
  } catch (error) {
@@ -77,18 +77,14 @@ export function checkManifestVariables({ cssText, manifest, prefix, internalVari
77
77
  };
78
78
  }
79
79
 
80
- export async function run(args) {
81
- const config = getKmwcConfig(args.root);
82
- const isStrict = !!args.strict;
83
- if (!config.cssVariables.isEnabled) {
84
- console.log('kmwc check-vars: off (kmwc.cssVariables is false) — nothing to check.');
85
- return 0;
86
- }
80
+ /**
81
+ * The repo's manifest checked against its CSS sources → { verdict, prefix }, or { skippedReason }
82
+ * when there's nothing to check. Shared by `kmwc check-vars` and doctor.
83
+ */
84
+ export function loadManifestVerdict(config) {
85
+ if (!config.cssVariables.isEnabled) return { skippedReason: 'off (kmwc.cssVariables is false)' };
87
86
  const manifestPath = resolve(config.root, config.cssVariables.manifest);
88
- if (!existsSync(manifestPath)) {
89
- console.log(`kmwc check-vars: no ${config.cssVariables.manifest} — nothing to check.`);
90
- return 0;
91
- }
87
+ if (!existsSync(manifestPath)) return { skippedReason: `no ${config.cssVariables.manifest}` };
92
88
  const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
93
89
  const prefix = config.prefix || manifest.prefix;
94
90
  if (!prefix) throw new Error('no variable prefix — set kmwc.prefix or "prefix" in the manifest');
@@ -101,6 +97,17 @@ export async function run(args) {
101
97
  internalVariables: config.cssVariables.internalVariables,
102
98
  checkMode: config.cssVariables.checkMode,
103
99
  });
100
+ return { verdict, prefix };
101
+ }
102
+
103
+ export async function run(args) {
104
+ const config = getKmwcConfig(args.root);
105
+ const isStrict = !!args.strict;
106
+ const { verdict, prefix, skippedReason } = loadManifestVerdict(config);
107
+ if (skippedReason) {
108
+ console.log(`kmwc check-vars: ${skippedReason} — nothing to check.`);
109
+ return 0;
110
+ }
104
111
 
105
112
  const presenceWord = config.cssVariables.checkMode === 'consumed' ? 'consumed via var()' : 'declared or read';
106
113
  const renderSection = (title, names) => `${title} (${names.length}):\n` + names.map((name) => ` --${name}`).join('\n');
@@ -19,6 +19,7 @@ import { spawnSync } from 'node:child_process';
19
19
  import { getKmwcConfig, getPortsForBlock, getRegistry, LEGACY_FILES, NON_PAGE_HTML_FILES, PACKAGE_LEAK_RULES, PACKAGE_FILES_EXCLUSIONS } from '../../config.js';
20
20
  import { getSyncPlan, getScriptsPlan, CLAUDE_BLOCK_START } from '../sync-plan.js';
21
21
  import { checkIsFragment } from '../../vite/page-composer.js';
22
+ import { loadManifestVerdict } from './check-vars.js';
22
23
  import {
23
24
  VALIDATION_NOTES_FILE,
24
25
  loadAcceptedCheckIds,
@@ -28,6 +29,7 @@ import {
28
29
  getPortBlockRange,
29
30
  getScriptPortMismatches,
30
31
  loadStalePortMentions,
32
+ OTHER_PORTS_MARKER,
31
33
  loadSiblingPortCollisions,
32
34
  loadOwnPortsOutsideBlock,
33
35
  loadPlausibleSiteId,
@@ -38,6 +40,7 @@ import {
38
40
  FIXED_COLORS_MARKER,
39
41
  getHardcodedColors,
40
42
  loadCoreLoader,
43
+ getLoaderClassVerdict,
41
44
  loadStyledLoaderClasses,
42
45
  } from '../repo-checks.js';
43
46
 
@@ -98,15 +101,40 @@ export function getSuiteFailures(suiteOutput) {
98
101
  .map((match) => ({ checkId: match[1], reason: match[2].trim() }));
99
102
  }
100
103
 
104
+ const GIT_BASH_RELATIVE_PATH = join('bin', 'bash.exe');
105
+ // PATH entries Git for Windows adds — each sits one or two levels under the install root.
106
+ const GIT_PATH_ENTRY_PATTERN = /^(.*[\\/]Git)[\\/](?:cmd|bin|usr[\\/]bin|mingw64[\\/]bin)[\\/]?$/i;
107
+
108
+ /**
109
+ * The bash that runs the rulebook scripts. On Windows a bare `bash` can resolve to WSL's
110
+ * (`System32\bash.exe`, `WindowsApps\bash.exe`), which can't read `C:/…` paths: every suite
111
+ * exits 127 and the rulebook never runs. So: KMWC_BASH when set, else Git for Windows'
112
+ * bash (found from PATH or the usual install roots), else `bash`. Exported for tests.
113
+ */
114
+ export function getBashPath({ platform = process.platform, env = process.env, checkExists = existsSync } = {}) {
115
+ if (env.KMWC_BASH) return env.KMWC_BASH;
116
+ if (platform !== 'win32') return 'bash';
117
+ const pathEntries = (env.PATH || env.Path || '').split(';').filter(Boolean);
118
+ const gitRoots = [
119
+ ...pathEntries.map((entry) => entry.match(GIT_PATH_ENTRY_PATTERN)?.[1]).filter(Boolean),
120
+ ...[env.ProgramFiles, env.ProgramW6432, env.LOCALAPPDATA && join(env.LOCALAPPDATA, 'Programs')].filter(Boolean).map((directory) => join(directory, 'Git')),
121
+ ];
122
+ return gitRoots.map((gitRoot) => join(gitRoot, GIT_BASH_RELATIVE_PATH)).find((bashPath) => checkExists(bashPath)) || 'bash';
123
+ }
124
+
101
125
  function runRulebookSuite(config, suite) {
102
126
  const scriptPath = join(config.rulebook.path, `${suite.name}.checks.sh`).replace(/\\/g, '/');
103
127
  const scriptArgs = [scriptPath, config.root.replace(/\\/g, '/')];
104
128
  if (suite.isPrefixed && config.prefix) scriptArgs.push(config.prefix);
105
- return spawnSync('bash', scriptArgs, { encoding: 'utf8', env: { ...process.env, NO_COLOR: '1' } });
129
+ return spawnSync(getBashPath(), scriptArgs, { encoding: 'utf8', env: { ...process.env, NO_COLOR: '1' } });
106
130
  }
107
131
 
108
- /** Run every check; returns [{ level: 'pass'|'warning'|'error', checkName, message, fixHint? }]. */
132
+ /**
133
+ * Run every check; returns [{ level: 'pass'|'warning'|'error', checkName, message, fixHint? }].
134
+ * `doctorConfig.registry` replaces components.json (tests use a registry of fixture repos).
135
+ */
109
136
  export function checkComponent(config, doctorConfig = {}) {
137
+ const registry = doctorConfig.registry || getRegistry();
110
138
  const results = [];
111
139
  const addPass = (checkName, message) => results.push({ level: 'pass', checkName, message });
112
140
  const addWarning = (checkName, message, fixHint) => results.push({ level: 'warning', checkName, message, fixHint });
@@ -240,7 +268,7 @@ export function checkComponent(config, doctorConfig = {}) {
240
268
  }
241
269
 
242
270
  // ── registry cross-check ─────────────────────────────────────────────────
243
- const registryEntry = getRegistry().components.find((component) => component.id === config.id);
271
+ const registryEntry = registry.components.find((component) => component.id === config.id);
244
272
  if (!registryEntry) {
245
273
  addWarning('registry', `"${config.id}" is not in the devkit components.json`, 'add an entry (README catalogue + port allocation)');
246
274
  } else {
@@ -254,7 +282,7 @@ export function checkComponent(config, doctorConfig = {}) {
254
282
  `set package.json#kmwc.ports to { "dev": ${registryPorts.dev}, "image": ${registryPorts.image} } (components.json port block ${registryEntry.port})`
255
283
  );
256
284
  } else addPass('registry', `ports match the registry (dev ${config.ports.dev}, image ${config.ports.image})`);
257
- checkPorts(config, registryEntry.port, { addPass, addWarning, addError });
285
+ checkPorts(config, registryEntry.port, registry, { addPass, addWarning, addError });
258
286
  }
259
287
  if (registryEntry.prefix && config.prefix && registryEntry.prefix !== config.prefix) {
260
288
  addError('registry', `kmwc.prefix "${config.prefix}" ≠ registry prefix "${registryEntry.prefix}"`, 'use the reserved prefix');
@@ -277,9 +305,12 @@ export function checkComponent(config, doctorConfig = {}) {
277
305
  // ── core's default loader class ──────────────────────────────────────────
278
306
  if (config.prefix && config.kind !== LIBRARY_KIND) checkLoaderClass(config, { addPass, addWarning, addError });
279
307
 
308
+ // ── variable manifest (what `kmwc check-vars` checks; it only runs in the build) ──
309
+ if (config.hasPackageConfig) checkVariableManifest(config, { addPass, addWarning, addError });
310
+
280
311
  // ── analytics ────────────────────────────────────────────────────────────
281
312
  const plausibleSiteId = loadPlausibleSiteId(config.root);
282
- const sharingRepoIds = loadSharedPlausibleSiteIds(config, getRegistry(), plausibleSiteId);
313
+ const sharingRepoIds = loadSharedPlausibleSiteIds(config, registry, plausibleSiteId);
283
314
  if (sharingRepoIds.length) {
284
315
  addError(
285
316
  'analytics',
@@ -298,7 +329,7 @@ export function checkComponent(config, doctorConfig = {}) {
298
329
  }
299
330
 
300
331
  /** Ports: script --port flags, own configs and docs outside the block, siblings inside it. */
301
- function checkPorts(config, portBlock, { addPass, addWarning, addError }) {
332
+ function checkPorts(config, portBlock, registry, { addPass, addWarning, addError }) {
302
333
  const [firstPort, lastPort] = getPortBlockRange(portBlock);
303
334
  const blockLabel = `${firstPort}–${lastPort}`;
304
335
  const scriptPortMismatches = getScriptPortMismatches(config.packageJson.scripts, config.ports);
@@ -307,7 +338,7 @@ function checkPorts(config, portBlock, { addPass, addWarning, addError }) {
307
338
  }
308
339
  const ownStrayPorts = loadOwnPortsOutsideBlock(config, portBlock).filter(({ file }) => !file.startsWith('package.json#scripts.'));
309
340
  for (const { port, file } of ownStrayPorts) addError('ports', `${file} uses port ${port}, outside this repo's block ${blockLabel}`, 'take ports from kmwc.ports (the devkit presets do)');
310
- const siblingCollisions = loadSiblingPortCollisions(config, getRegistry());
341
+ const siblingCollisions = loadSiblingPortCollisions(config, registry);
311
342
  for (const { id, port, file } of siblingCollisions) {
312
343
  addError('ports', `${id} uses port ${port} (${file}) — inside this repo's block ${blockLabel}`, `move ${id} to its own components.json block`);
313
344
  }
@@ -316,7 +347,7 @@ function checkPorts(config, portBlock, { addPass, addWarning, addError }) {
316
347
  addWarning(
317
348
  'ports',
318
349
  `docs mention ports outside ${blockLabel}: ${stalePortMentions.map(({ file, port }) => `${file} ${port}`).join(', ')}`,
319
- `update them to ${getPortsForBlock(portBlock).dev} (dev) / ${getPortsForBlock(portBlock).image} (image)`
350
+ `update them to ${getPortsForBlock(portBlock).dev} (dev) / ${getPortsForBlock(portBlock).image} (image); a doc that names other repos' ports on purpose gets <!-- ${OTHER_PORTS_MARKER}: why -->`
320
351
  );
321
352
  }
322
353
  if (!scriptPortMismatches.length && !ownStrayPorts.length && !siblingCollisions.length && !stalePortMentions.length) {
@@ -330,13 +361,19 @@ function checkPorts(config, portBlock, { addPass, addWarning, addError }) {
330
361
  * before (or after) the core bump leaves the default loader unstyled, and no rulebook
331
362
  * check sees that (a VALIDATION-NOTES `## C-NC-8` even hides a leftover old class).
332
363
  */
333
- function checkLoaderClass(config, { addPass, addError }) {
364
+ function checkLoaderClass(config, { addPass, addWarning, addError }) {
334
365
  const coreLoader = loadCoreLoader(config.root);
335
366
  if (!coreLoader?.separator) return;
336
367
  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) {
368
+ const { isCoreClassStyled, renamedClasses, collidingClasses } = getLoaderClassVerdict(coreLoader.separator, loadStyledLoaderClasses(config.root, config.prefix));
369
+ for (const { file, className } of collidingClasses) {
370
+ addWarning(
371
+ 'loader',
372
+ `${file} styles .${className} next to core's .${coreClassName} — from core 6d6b768 on, core draws its default loader as <span class="${className}"> and it picks up these rules`,
373
+ `rename the component's own .${className} element (e.g. .${config.prefix}__loading) in the same change as the core bump`
374
+ );
375
+ }
376
+ for (const { file, className } of renamedClasses) {
340
377
  addError(
341
378
  'loader',
342
379
  `${file} styles .${className}, but core ${coreLoader.version} draws the default loader as <span class="${coreClassName}"> — it renders unstyled`,
@@ -345,7 +382,30 @@ function checkLoaderClass(config, { addPass, addError }) {
345
382
  : `keep .${coreClassName} until the component builds on a core that draws <prefix>__loader, then rename in the same change`
346
383
  );
347
384
  }
348
- if (styledLoaderClasses.length && !mismatchedClasses.length) addPass('loader', `CSS styles .${coreClassName}, the class core ${coreLoader.version} draws`);
385
+ if (isCoreClassStyled && !renamedClasses.length) addPass('loader', `CSS styles .${coreClassName}, the class core ${coreLoader.version} draws`);
386
+ }
387
+
388
+ /** The variable manifest matches the CSS — the same verdict as `kmwc check-vars` (undocumented = warning). */
389
+ function checkVariableManifest(config, { addPass, addWarning, addError }) {
390
+ let manifestVerdict;
391
+ try {
392
+ manifestVerdict = loadManifestVerdict(config);
393
+ } catch (error) {
394
+ addError('vars', `kmwc check-vars can't run: ${error.message}`, 'fix kmwc.cssVariables (sources / manifest / prefix)');
395
+ return;
396
+ }
397
+ const { verdict, prefix, skippedReason } = manifestVerdict;
398
+ if (skippedReason) return;
399
+ const fixHint = 'edit the manifest to match the CSS (npm run check:vars lists every name)';
400
+ const listNames = (names) => names.map((name) => `--${name}`).join(', ');
401
+ if (verdict.missingBaseVariables.length) addError('vars', `--base-* read in CSS but missing from the manifest: ${listNames(verdict.missingBaseVariables)}`, fixHint);
402
+ if (verdict.unusedBaseVariables.length) addError('vars', `--base-* in the manifest but not read in CSS: ${listNames(verdict.unusedBaseVariables)}`, fixHint);
403
+ if (verdict.unusedComponentVariables.length) addError('vars', `--${prefix}-* in the manifest but not in CSS: ${listNames(verdict.unusedComponentVariables)}`, fixHint);
404
+ if (verdict.undocumentedComponentVariables.length) {
405
+ addWarning('vars', `--${prefix}-* in CSS but missing from the manifest: ${listNames(verdict.undocumentedComponentVariables)}`, `${fixHint}; runtime internals go in kmwc.cssVariables.internalVariables`);
406
+ }
407
+ const isInSync = !['missingBaseVariables', 'unusedBaseVariables', 'unusedComponentVariables', 'undocumentedComponentVariables'].some((key) => verdict[key].length);
408
+ if (isInSync) addPass('vars', `variable manifest in sync — ${verdict.counts.base} base, ${verdict.counts.component} component vars`);
349
409
  }
350
410
 
351
411
  /** Packed paths that break PACKAGE_LEAK_RULES → [{ path, label }]. Pure — exported for tests. */
@@ -413,24 +473,29 @@ function checkRulebook(config, { addPass, addWarning, addError }) {
413
473
  return;
414
474
  }
415
475
  const checkName = `rulebook:${suite.name}`;
416
- if (suiteResult.status === 2) {
417
- const reason = mapToPlainText(suiteResult.stderr || suiteResult.stdout).trim().split(/\r?\n/)[0];
418
- addWarning(checkName, `suite could not run: ${reason}`);
476
+ const failures = getSuiteFailures(suiteResult.stdout);
477
+ // A suite passes with exit 0 and fails with exit 1 + FAIL lines. Anything else (2 = usage,
478
+ // 127 = the script wasn't found — WSL's bash on a C:/ path) means it never ran: say so,
479
+ // never count it as a pass.
480
+ const hasRun = suiteResult.status === 0 || (suiteResult.status === 1 && failures.length > 0);
481
+ if (!hasRun) {
482
+ const reason = mapToPlainText(suiteResult.stderr || suiteResult.stdout).trim().split(/\r?\n/)[0] || 'no output';
483
+ addWarning(checkName, `suite could not run (exit ${suiteResult.status}): ${reason}`, `bash used: ${getBashPath()} — set KMWC_BASH to Git for Windows' bash.exe if this is WSL's`);
419
484
  continue;
420
485
  }
421
- const failures = getSuiteFailures(suiteResult.stdout);
422
486
  for (const failure of failures) {
423
487
  // An architectural decision recorded in VALIDATION-NOTES.md (the rulebook's accepted-deviation file).
424
488
  if (acceptedCheckIds.has(failure.checkId)) addPass(checkName, `${failure.checkId} accepted deviation (${VALIDATION_NOTES_FILE}): ${failure.reason}`);
425
489
  else addError(checkName, `${failure.checkId} ${failure.reason}`, `see ${suite.name}.checks.md → ${failure.checkId}`);
426
490
  }
427
- if (!failures.length) addPass(checkName, suiteResult.status === 0 ? 'all auto checks pass' : `exit ${suiteResult.status}`);
491
+ if (!failures.length) addPass(checkName, 'all auto checks pass');
428
492
  }
429
493
  }
430
494
 
431
- export async function run(args) {
495
+ /** `registry` is for tests (fixture repos); the CLI always uses components.json. */
496
+ export async function run(args, { registry } = {}) {
432
497
  const config = getKmwcConfig(args.root);
433
- const results = checkComponent(config, { shouldRunRulebook: !args['no-rulebook'], shouldCheckPackage: !args['no-pack'] });
498
+ const results = checkComponent(config, { shouldRunRulebook: !args['no-rulebook'], shouldCheckPackage: !args['no-pack'], registry });
434
499
  const errorCount = results.filter((result) => result.level === 'error').length;
435
500
  const warningCount = results.filter((result) => result.level === 'warning').length;
436
501
 
@@ -109,6 +109,12 @@ export function getMentionedPorts(text) {
109
109
  return getUniqueSorted(patterns.flatMap((pattern) => [...text.matchAll(pattern)].map((match) => Number(match[1]))));
110
110
  }
111
111
 
112
+ /**
113
+ * A doc that names other repos' ports on purpose (a dated plan, a cross-repo review) carries
114
+ * `<!-- kmwc-other-ports: why -->` and isn't scanned.
115
+ */
116
+ export const OTHER_PORTS_MARKER = 'kmwc-other-ports';
117
+
112
118
  /** Mentions in README.md / CLAUDE.md / docs/*.md of ports outside the repo's block → [{ file, port }]. */
113
119
  export function loadStalePortMentions(root, portBlock) {
114
120
  const blockRange = getPortBlockRange(portBlock);
@@ -116,7 +122,7 @@ export function loadStalePortMentions(root, portBlock) {
116
122
  const docFiles = [...DOC_FILES, ...(existsSync(docsDir) ? readdirSync(docsDir).filter((name) => name.endsWith('.md')).map((name) => `${DOCS_DIR}/${name}`) : [])];
117
123
  return docFiles.flatMap((file) => {
118
124
  const text = readText(join(root, file));
119
- return text ? getMentionedPorts(text).filter((port) => !checkIsInRange(port, blockRange)).map((port) => ({ file, port })) : [];
125
+ return text && !text.includes(OTHER_PORTS_MARKER) ? getMentionedPorts(text).filter((port) => !checkIsInRange(port, blockRange)).map((port) => ({ file, port })) : [];
120
126
  });
121
127
  }
122
128
 
@@ -252,7 +258,8 @@ export function getKitDuplicates(pageText, kitClassNames, { isStylesheet = false
252
258
 
253
259
  /**
254
260
  * 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.
261
+ * block or an element with a `style="…"` that carries the attribute, or one rule right
262
+ * after a `/* kmwc-fixed-colors *\/` comment.
256
263
  */
257
264
  export const FIXED_COLORS_ATTRIBUTE = 'data-kmwc-fixed-colors';
258
265
  export const FIXED_COLORS_MARKER = 'kmwc-fixed-colors';
@@ -283,7 +290,7 @@ export function checkIsHardcodedColor(property, value) {
283
290
  * Hard-coded colours in one page's `<style>` blocks and `style="…"` attributes (or in
284
291
  * examples-local.css): they stay the same in dark mode while the kit around them flips.
285
292
  * `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.
293
+ * block, an element carrying the attribute and a rule marked `/* kmwc-fixed-colors *\/` are skipped. Returns [{ found }]. Pure.
287
294
  */
288
295
  export function getHardcodedColors(pageText, { isStylesheet = false } = {}) {
289
296
  const styleText = isStylesheet
@@ -302,7 +309,12 @@ export function getHardcodedColors(pageText, { isStylesheet = false } = {}) {
302
309
  }
303
310
  }
304
311
  if (isStylesheet) return hardcodedColors;
305
- for (const [, inlineStyle] of pageText.matchAll(/\sstyle="([^"]*)"/g)) {
312
+ for (const match of pageText.matchAll(/\sstyle="([^"]*)"/g)) {
313
+ // The element's own attribute opts its style="…" out: markup a render callback draws
314
+ // inside a shadow root can't be reached by page CSS, so inline is the only way there.
315
+ const tagText = pageText.slice(pageText.lastIndexOf('<', match.index), pageText.indexOf('>', match.index + match[0].length) + 1);
316
+ if (tagText.includes(FIXED_COLORS_ATTRIBUTE)) continue;
317
+ const inlineStyle = match[1];
306
318
  for (const declaration of inlineStyle.split(';')) {
307
319
  const [property, ...valueParts] = declaration.split(':');
308
320
  const value = valueParts.join(':').trim();
@@ -358,6 +370,24 @@ function getSourceStylesheets(directory, relativeDirectory = SOURCE_DIR) {
358
370
  });
359
371
  }
360
372
 
373
+ /**
374
+ * Styled loader classes vs the separator core draws → { isCoreClassStyled, renamedClasses,
375
+ * collidingClasses }. A class with the other separator is a rename done too early / too late —
376
+ * unless core is still on `-` and its class is styled too: then the `__loader` is the
377
+ * component's own element (web-multiselect's loading-state row), which collides once core
378
+ * draws `<prefix>__loader`. Pure.
379
+ */
380
+ export function getLoaderClassVerdict(coreSeparator, styledLoaderClasses) {
381
+ const isCoreClassStyled = styledLoaderClasses.some(({ separator }) => separator === coreSeparator);
382
+ const otherClasses = styledLoaderClasses.filter(({ separator }) => separator !== coreSeparator);
383
+ const isOwnLoaderElement = coreSeparator === '-' && isCoreClassStyled;
384
+ return {
385
+ isCoreClassStyled,
386
+ renamedClasses: isOwnLoaderElement ? [] : otherClasses,
387
+ collidingClasses: isOwnLoaderElement ? otherClasses : [],
388
+ };
389
+ }
390
+
361
391
  /** Loader classes styled in the component's src CSS: [{ file, className, separator }]. */
362
392
  export function loadStyledLoaderClasses(root, prefix) {
363
393
  return getSourceStylesheets(join(root, SOURCE_DIR)).flatMap((file) =>
@@ -120,9 +120,10 @@
120
120
  --kmwc-console-err-color: #fca5a5;
121
121
  /* Dark: the chip is a step up from the main surface (slate on a slate page, not a
122
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. */
123
+ mid-dark accent (treeview's #059669) stays readable on it. Light: the same hue at
124
+ most 0.5 lightness — the raw accent read at ~3.3:1 on the pale chip. */
124
125
  --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));
126
+ --kmwc-inline-code-color: light-dark(oklch(from var(--kmwc-accent-color) min(l, 0.5) c h), oklch(from var(--kmwc-accent-color) max(l, 0.8) c h));
126
127
 
127
128
  /* ======================================================================
128
129
  COLORS — SEMANTIC
@@ -39,7 +39,11 @@ const INCLUDE_PATTERN = /<kmwc-include\b([^>]*?)(?:\/>|>([\s\S]*?)<\/kmwc-includ
39
39
  const DEMO_PATTERN = /<kmwc-demo\b([^>]*)>([\s\S]*?)<\/kmwc-demo>/g;
40
40
  const SHOWN_SCRIPT_PATTERN = /<script\b([^>]*\bdata-kmwc-show\b[^>]*)>([\s\S]*?)<\/script>/g;
41
41
  const PAGE_META_PATTERN = /<meta\s+name="kmwc:([a-z-]+)"\s+content="([^"]*)"\s*\/?>\s*/g;
42
- const HEAD_ELEMENT_PATTERN = /<link\b[^>]*>\s*|<style\b[^>]*>[\s\S]*?<\/style>\s*/g;
42
+ // A <style> element, or ANY start tag (quoted attribute values may hold `>` and `<link>`):
43
+ // matching whole tags left to right means the text `<link>` inside a tooltip's title=""
44
+ // is consumed with its tag and never read as an element. Only <style> and <link> move.
45
+ const HEAD_ELEMENT_PATTERN = /<style(?=[\s>])(?:"[^"]*"|'[^']*'|[^>"'])*>[\s\S]*?<\/style>\s*|<([a-zA-Z][\w-]*)(?:"[^"]*"|'[^']*'|[^>"'])*>\s*/g;
46
+ const HEAD_ELEMENT_TAGS = ['style', 'link'];
43
47
  const ATTRIBUTE_PATTERN = /([a-zA-Z][\w-]*)(?:\s*=\s*"([^"]*)")?/g;
44
48
 
45
49
  const HTML_COMMENT_PATTERN = /<!--[\s\S]*?-->/g;
@@ -201,7 +205,8 @@ export function renderLayout(fragmentHtml, { config, pagePath }) {
201
205
  const bodyContent = mapOutsideComments(
202
206
  htmlWithoutMeta,
203
207
  (text) =>
204
- text.replace(HEAD_ELEMENT_PATTERN, (element) => {
208
+ text.replace(HEAD_ELEMENT_PATTERN, (element, tagName = 'style') => {
209
+ if (!HEAD_ELEMENT_TAGS.includes(tagName.toLowerCase())) return element;
205
210
  headElements.push(` ${element.trim()}`);
206
211
  return '';
207
212
  }),
@@ -31,7 +31,8 @@ locally — every local fork is how they drifted apart.
31
31
  too — no inline styles for them. Page colours come from the kit's `--kmwc-*`
32
32
  tokens or `light-dark()` — a literal doesn't follow dark mode (doctor warns);
33
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).
34
+ (one rule: a `/* kmwc-fixed-colors: why */` comment before it; an element's own
35
+ `style="…"`: the attribute on the element).
35
36
  If a second component needs a pattern, it belongs in the devkit.
36
37
  - **Example pages are fragments:** no `<html>`/`<head>`/`<header>` — start with
37
38
  `<meta name="kmwc:title" content="…">`, the devkit layout adds the rest. Shared text