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

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,82 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.0.0-rc11] - 2026-11-06 [PUBLISHED]
4
+
5
+ ### Added
6
+
7
+ - **Doctor understands monorepos: `kmwc.packages`.** Doctor assumed the repo root was the package.
8
+ In web-dropzone (npm workspaces: a private root holding the example site,
9
+ `packages/web-dropzone` the component, `packages/web-dropzone-core` a headless library) that gave
10
+ 113 errors that said little: `npm pack` on the private root "leaked" 101 files npm never publishes,
11
+ six rulebook suites couldn't find a component at the root, the overlay suite scanned
12
+ `node_modules`, and the loader check silently skipped — while the component's real findings
13
+ (129 non-BEM classes, no Custom Elements Manifest setup …) never showed. `"packages": [{ "path",
14
+ "kind": "component" | "library" }]` lists what the repo publishes: doctor packs each package, runs
15
+ the rulebook suites for its kind inside it (`rulebook:<suite>@<path>`; publish-command and
16
+ example-pages stay repo-wide at the root), and checks the loader class per component package.
17
+ Doctor warns when npm workspaces aren't all listed, errors on a listed path without a
18
+ package.json, and never packs a private workspace root. Without `packages` a repo is one package
19
+ at its root, as before.
20
+ - **Every example page gets the footer.** Only the index had one ("Created by Keenmate s.r.o. |
21
+ GitHub | web-components-devkit vX"); the other pages just ended. The kit now appends the same
22
+ line to every page but the index (`mountPageFooter()`, `.kmwc-page-footer`), inside the layout's
23
+ container. The index and the page footer render it from one function (`renderFooterLineHtml()`),
24
+ so they can't drift.
25
+
26
+ ## [1.0.0-rc10] - 2026-11-05 [PUBLISHED]
27
+
28
+ ### Added
29
+
30
+ - **Doctor runs the variable-manifest check.** `kmwc check-vars` only ran inside `npm run build`, so a
31
+ red manifest went unseen until a build: web-treeview's `check:vars` failed on core's
32
+ non-contract `--base-accent-color` while doctor passed. Doctor now reports the same verdict
33
+ (`vars`): missing / dead `--base-*` and dead `--<prefix>-*` entries are errors, undocumented
34
+ `--<prefix>-*` a warning. `loadManifestVerdict()` is shared by both.
35
+ - **Opt-outs for markup that is right on purpose:**
36
+ - an element whose own `style="…"` sets fixed colours carries `data-kmwc-fixed-colors` (doctor's
37
+ colour check). Markup a render callback draws inside a shadow root can't be styled by page CSS,
38
+ so inline is the only way there (web-treeview's context-menu role chips);
39
+ - `data-kmwc-undefined` on an element a demo leaves undefined on purpose — check-pages no longer
40
+ reports it (core's PG01 registers its element on a button press). The "never defined" problem
41
+ names the attribute;
42
+ - `<!-- kmwc-other-ports: why -->` in a doc that names other repos' ports on purpose (a dated
43
+ plan, a cross-repo review) — doctor's port scan skips it.
44
+
45
+ ### Changed
46
+
47
+ - **Doctor: the loader check tells a collision from a rename.** On a core that still draws
48
+ `<prefix>-loader`, CSS that styles both classes has its own element called `<prefix>__loader`
49
+ (web-multiselect's loading-state row). That was reported as a rename done too early (error); it is
50
+ now a warning that core's loader takes the name — and those rules — with the bump, so rename the
51
+ component's element in the same change.
52
+
53
+ ### Fixed
54
+
55
+ - **Fragment pages: `<link>` / `<style>` text inside an attribute value stayed put.** The composer
56
+ moved a `<link>` it found in a tooltip (`title="…the <link> in <head>"`) into `<head>` as an empty
57
+ element, and the tooltip lost its text (web-multiselect). It now reads whole start tags, quoted
58
+ attribute values included, and moves only real `<link>` / `<style>` elements.
59
+ - **Inline code in light mode** read at ~3.3:1 — the raw accent on the pale chip. The text keeps the
60
+ accent hue at most 0.5 lightness (≥ 4.5:1 for every accent the spec tries, light and dark).
61
+ - **Doctor: a rulebook suite that never ran counted as a pass.** Only exit 2 was read as "could
62
+ not run"; any other exit without `FAIL` lines passed. On Windows a bare `bash` can be WSL's, which
63
+ can't read `C:/…` paths — every suite exited 127 and doctor reported the rulebook green (found when
64
+ `make publish-rc` ran the tests from PowerShell). Doctor now runs Git for Windows' bash (from PATH
65
+ or the install root; `KMWC_BASH` overrides) and warns on any exit but 0, or 1 with `FAIL` lines.
66
+
67
+ ### Internal
68
+
69
+ - **Tests on the critical paths.** Doctor end to end on fixture repos (a healthy repo is clean, each
70
+ broken one gets its finding; `doctor.js` 26% → 98% of lines); a real `vite build` of the playground
71
+ must emit exactly what doctor checks package.json against, with the UMD global and every export
72
+ `index.d.ts` declares; `kmwc new` (full and `--minimal`) scaffolds a repo doctor finds clean;
73
+ `kmwc sync` rewrites only what it owns and `--check` writes nothing; `check-release` keeps an rc off
74
+ `latest`. Composer cases for single-quoted values, `<style>` text and a `>` inside an attribute.
75
+ - **`npm run test:e2e:coverage`** (maintainer) — the dev-server e2e run with line coverage of the
76
+ kit's browser code (`src/examples/*.js`, 84%), snapshot before every navigation.
77
+ - **README:** a sentence about `data-kmwc-undefined` had landed inside the rc09 What's New bullet;
78
+ it's back in the check-pages docs.
79
+
3
80
  ## [1.0.0-rc09] - 2026-11-04 [PUBLISHED]
4
81
 
5
82
  ### Added
package/README.md CHANGED
@@ -19,15 +19,19 @@ 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-rc09
22
+ ## What's New in v1.0.0-rc11
23
23
 
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.
24
+ - **Doctor — monorepos: `kmwc.packages` lists what the repo publishes** — Doctor used to assume the repo root was the package. In web-dropzone (npm workspaces: a private root that holds the example site, the component in `packages/web-dropzone`, a headless library in `packages/web-dropzone-core`) that produced 113 errors that said little. `npm pack` on the private root "leaked" 101 files npm never publishes, six rulebook suites found no component at the root, and the overlay suite scanned `node_modules`. Meanwhile the component's real findings never showed. Now `"packages": [{ "path", "kind": "component" | "library" }]` in `package.json#kmwc` lists the published packages. Doctor packs each one and runs the rulebook suites for its kind inside it (reported as `rulebook:<suite>@<path>`). The publish-command and example-pages suites stay repo-wide, and the loader check runs per component package. Doctor warns when npm workspaces aren't all listed, errors on a listed path without a `package.json`, and never packs a private workspace root. A repo without `packages` is still one package at its root.
25
+ - **Examples kit — every example page ends with the footer** — Only the index page had the "Created by Keenmate s.r.o. | GitHub | web-components-devkit vX" footer; the other example pages just stopped. The kit now appends the same line to every page except the index (`mountPageFooter()`, `.kmwc-page-footer`), at the end of the layout's container. The index footer and the page footer are rendered by one function, `renderFooterLineHtml()`, so they can't drift apart. The index keeps its own extra links (README, API docs); the page footer carries Keenmate, the repo's GitHub link and the devkit version.
26
26
 
27
- ## What's New in v1.0.0-rc08
27
+ ## What's New in v1.0.0-rc10
28
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.
29
+ - **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.
30
+ - **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.
31
+ - **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.
32
+ - **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.
33
+ - **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.
34
+ - **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.
31
35
 
32
36
  ## Catalogue
33
37
 
@@ -36,11 +40,11 @@ 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,… |
43
- | **web-dropzone** | `@keenmate/web-dropzone` | `dz` | 12500 | [repo](https://github.com/keenmate/web-dropzone) | — | Lightweight file dropzone web component with drag-drop, previews, validation, and multiple display modes. |
47
+ | **web-dropzone** | `@keenmate/web-dropzone` | `dz` | 12500 | [repo](https://github.com/keenmate/web-dropzone) | ✅ | Lightweight file dropzone web component with drag-drop, previews, validation, and multiple display modes. |
44
48
  | **web-grid** | `@keenmate/web-grid` | `wg` | 12600 | [repo](https://github.com/keenmate/web-grid) · [docs](https://web-grid.keenmate.com) · [showcase](https://web-grid.keenmate.com) | — | Framework-agnostic data grid web component with sorting, filtering, inline editing, and keyboard navigation |
45
49
  | **web-editor** | `@keenmate/web-editor` | `km` | 12700 | [examples](https://examples.web-editor.keenmate.dev) | ✅ | Keenmate Web Editor — a framework-agnostic WYSIWYG / Markdown editor built on ProseMirror. |
46
50
  | **web-switch** | `@keenmate/web-switch` | — | 12800 | [repo](https://github.com/keenmate/web-switch) | — | Framework-agnostic web component wrapper for @keenmate/svelte-switch. Works with React, Vue, Angular, or… |
@@ -102,7 +106,7 @@ in `package.json#kmwc`, and gets the rest.
102
106
  | Piece | Import / command | Replaces |
103
107
  |---|---|---|
104
108
  | Vite preset | `@keenmate/web-components-devkit/vite` → `defineComponentConfig()` | per-repo `vite.config.ts`, `vite.examples.config.ts` |
105
- | Examples kit (auto-injected) | shared styles, chapter nav, copy-link buttons, theme switch, version badge, analytics | `examples-shared.css`, `examples-chapter-nav.js`, `examples-copy-link.js`, `plausible-snippet.html`, Dockerfile `sed` |
109
+ | Examples kit (auto-injected) | shared styles, chapter nav, copy-link buttons, theme switch, version badge, page footer, analytics | `examples-shared.css`, `examples-chapter-nav.js`, `examples-copy-link.js`, `plausible-snippet.html`, Dockerfile `sed` |
106
110
  | Fragment pages | `<meta name="kmwc:title">` pages + `<kmwc-include>` partials + `<kmwc-demo>` (live markup = snippet) | the head / header / container boilerplate copied into every page, hand-copied `.code-block` snippets |
107
111
  | Examples helpers | `@keenmate/web-components-devkit/examples` → `attachControlsPersistence`, `renderExamplesIndex`, `mountExamplesKit` | `examples-controls-store.js`, 150 lines of inline index-page CSS |
108
112
  | Playwright preset | `@keenmate/web-components-devkit/playwright` → `definePlaywrightConfig()` | per-repo `playwright.config.ts` |
@@ -175,7 +179,11 @@ Every field is optional. Defaults are derived from the package.
175
179
  // BlissFramework checkout above it. env KMWC_RULEBOOK_PATH wins
176
180
  "skippedChecks": [] // e.g. ["responsive-overlay"]
177
181
  },
178
- "sync": { "skippedFiles": [] } // e.g. ["Makefile"] to opt a file out
182
+ "sync": { "skippedFiles": [] }, // e.g. ["Makefile"] to opt a file out
183
+ "packages": [ // monorepos only (npm workspaces): what doctor checks
184
+ { "path": "packages/web-dropzone", "kind": "component" },
185
+ { "path": "packages/web-dropzone-core", "kind": "library" }
186
+ ]
179
187
  }
180
188
  ```
181
189
 
@@ -189,6 +197,16 @@ manifest check (no `check:vars` script, `kmwc check-vars` skips), for variables
189
197
  the checker can't see, such as names built by string interpolation. Doctor runs
190
198
  only the rulebook suites that apply to a library (see below).
191
199
 
200
+ **A monorepo** (npm workspaces, e.g. web-dropzone: a private root with the example
201
+ site, `packages/web-dropzone` + `packages/web-dropzone-core`) lists its published
202
+ packages in `kmwc.packages`, each with its kind. The root keeps the devkit's
203
+ example site, generated files, ports and registry checks; doctor packs each
204
+ package (`npm pack`), runs the rulebook suites for its kind *in* the package
205
+ (reported as `rulebook:<suite>@<path>`), and checks the loader class in each
206
+ component package. publish-command and example-pages stay repo-wide, at the root.
207
+ Without `packages` the root is the one package — doctor warns when npm workspaces
208
+ exist but aren't listed, and doesn't pack a private workspace root.
209
+
192
210
  ### Vite preset
193
211
 
194
212
  ```ts
@@ -332,6 +350,9 @@ doctor` lists the ones still repeating the head/header boilerplate.
332
350
  shows `v1.2.3`, or `v1.2.3 (built …)` in a built site.
333
351
  - **Landing page**: `renderExamplesIndex({ pages })` renders the header, cards,
334
352
  footer and cross-page nav from the registry plus `kmwc` config.
353
+ - **Page footer**: every other example page ends with the same line as the
354
+ index footer — Keenmate, the repo's GitHub link, the devkit version — inside
355
+ the layout's container (`.kmwc-page-footer`).
335
356
  - **Late content**: the kit mounts one task after DOM-ready. Pages that render
336
357
  sections after a fetch call `mountExamplesKit()`.
337
358
  - **Analytics**: the Plausible snippet goes into **built** pages only, and only
@@ -385,6 +406,9 @@ and the e2e suite before building and publishing.
385
406
  base-variables, color-scheme, readme-structure, custom-elements-manifest,
386
407
  responsive-overlay, publish-command, example-pages) with `bash`, passing `kmwc.prefix`, and
387
408
  reports each `FAIL` as an error pointing at the matching `.checks.md` entry.
409
+ On Windows that is Git for Windows' bash (a bare `bash` may be WSL's, which can't
410
+ read `C:/` paths); `KMWC_BASH` overrides it. A suite that exits with anything but 0,
411
+ or 1 with `FAIL` lines, never ran — a warning, never a pass.
388
412
  A `library` runs only naming-conventions, readme-structure, publish-command
389
413
  and example-pages; the others check what a custom element ships (its CSS,
390
414
  manifest, overlay).
@@ -405,7 +429,9 @@ generated files:
405
429
  silently.
406
430
  - **Ports** — no script pins a `--port` of its own, the repo's configs, Makefile
407
431
  and docs stay inside its registry block, and no sibling checkout (a registry
408
- entry at `../<id>`) listens on a port inside it.
432
+ entry at `../<id>`) listens on a port inside it. A doc that names other repos'
433
+ ports on purpose (a dated plan, a cross-repo review) carries
434
+ `<!-- kmwc-other-ports: why -->` and isn't scanned.
409
435
  - **Analytics** — no other repo uses the same Plausible site id.
410
436
  - **Page CSS** — a page `<style>` or `examples-local.css` rule on a kit-owned
411
437
  selector (`.kbd`, `kbd`, `mark`, `.pill`, `.card` …) and single-declaration
@@ -415,12 +441,19 @@ generated files:
415
441
  it stays put when the theme switch flips the kit to dark. `var()` fallbacks and
416
442
  `light-dark()` pairs follow the theme. Fixed colours on purpose (a theme gallery, a
417
443
  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.
444
+ gets a `/* kmwc-fixed-colors: why */` comment right before it. An element whose
445
+ `style="…"` sets them on purpose carries the same attribute — markup a render
446
+ callback draws inside a shadow root can't be reached by page CSS at all.
419
447
  - **Loader class** — the component CSS styles the class the *installed* core gives
420
448
  its default loader (`<prefix>__loader` from core 6d6b768 on, `<prefix>-loader`
421
449
  before). Renaming the selector on one side of the core bump leaves the loader
422
450
  unstyled, and a VALIDATION-NOTES `## C-NC-8` would hide the old class from the
423
- rulebook.
451
+ rulebook. On a core that still draws `<prefix>-loader`, CSS that styles both
452
+ classes has an element of its own called `<prefix>__loader`: a warning, because
453
+ core's loader takes that name (and those rules) with the bump.
454
+ - **Variable manifest** — the same verdict as `kmwc check-vars`, which otherwise
455
+ only runs inside `npm run build`: `--base-*` reads and the manifest agree, no
456
+ dead `--<prefix>-*` entries (errors), no undocumented ones (warning).
424
457
 
425
458
  `kmwc check-pages` is the browser half: it loads every example page and reports
426
459
  what doctor can't see in the source — a script error, a 404, a fixed width that
@@ -428,7 +461,8 @@ pushes a phone sideways, and page CSS that can never apply: a rule (in the page'
428
461
  `<style>` or `examples-local.css`) whose element exists only inside a component's
429
462
  shadow root, such as the rows a `renderNodeCallback` draws. Page CSS stops at the
430
463
  shadow boundary — style those through a CSS variable, `::part` or the component's
431
- styles hook (`getCustomStylesCallback`).
464
+ styles hook (`getCustomStylesCallback`). An element a demo leaves undefined on
465
+ purpose (registered on a button press) carries `data-kmwc-undefined`.
432
466
 
433
467
  **Package contents.** Doctor runs `npm pack --dry-run` and fails on anything
434
468
  dev-only that would reach a consumer's `node_modules`: `CLAUDE.md` at any
@@ -524,6 +558,7 @@ registry edits), and move the component's own code into that layout with
524
558
  npm install
525
559
  npm test # unit tests (config, registry, templates, sync, checks, intake, plugin, kit)
526
560
  npm run test:e2e # playground: the kit + scaffold in a real browser, dev server AND built site
561
+ npm run test:e2e:coverage # the same specs on the dev server + line coverage of the kit's browser code
527
562
  npm run readme # regenerate the catalogue
528
563
  npm run check:spotlight-mirror # what changed in web-commandpalette since the spotlight was aligned
529
564
  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-rc11",
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');
@@ -14,11 +14,12 @@
14
14
  * Errors fail (exit 1); warnings don't.
15
15
  */
16
16
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
17
- import { join } from 'node:path';
17
+ import { join, relative, resolve } from 'node:path';
18
18
  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
 
@@ -63,8 +66,9 @@ export const RULEBOOK_SUITES = [
63
66
  { name: 'readme-structure', isPrefixed: false, isComponentOnly: false },
64
67
  { name: 'custom-elements-manifest', isPrefixed: false, isComponentOnly: true },
65
68
  { name: 'responsive-overlay', isPrefixed: true, isComponentOnly: true },
66
- { name: 'publish-command', isPrefixed: false, isComponentOnly: false },
67
- { name: 'example-pages', isPrefixed: false, isComponentOnly: false },
69
+ // Repo-wide: one /publish command and one example site per repo, checked at its root.
70
+ { name: 'publish-command', isPrefixed: false, isComponentOnly: false, isRepoWide: true },
71
+ { name: 'example-pages', isPrefixed: false, isComponentOnly: false, isRepoWide: true },
68
72
  ];
69
73
 
70
74
  const LIBRARY_KIND = 'library';
@@ -98,15 +102,40 @@ export function getSuiteFailures(suiteOutput) {
98
102
  .map((match) => ({ checkId: match[1], reason: match[2].trim() }));
99
103
  }
100
104
 
101
- function runRulebookSuite(config, suite) {
105
+ const GIT_BASH_RELATIVE_PATH = join('bin', 'bash.exe');
106
+ // PATH entries Git for Windows adds — each sits one or two levels under the install root.
107
+ const GIT_PATH_ENTRY_PATTERN = /^(.*[\\/]Git)[\\/](?:cmd|bin|usr[\\/]bin|mingw64[\\/]bin)[\\/]?$/i;
108
+
109
+ /**
110
+ * The bash that runs the rulebook scripts. On Windows a bare `bash` can resolve to WSL's
111
+ * (`System32\bash.exe`, `WindowsApps\bash.exe`), which can't read `C:/…` paths: every suite
112
+ * exits 127 and the rulebook never runs. So: KMWC_BASH when set, else Git for Windows'
113
+ * bash (found from PATH or the usual install roots), else `bash`. Exported for tests.
114
+ */
115
+ export function getBashPath({ platform = process.platform, env = process.env, checkExists = existsSync } = {}) {
116
+ if (env.KMWC_BASH) return env.KMWC_BASH;
117
+ if (platform !== 'win32') return 'bash';
118
+ const pathEntries = (env.PATH || env.Path || '').split(';').filter(Boolean);
119
+ const gitRoots = [
120
+ ...pathEntries.map((entry) => entry.match(GIT_PATH_ENTRY_PATTERN)?.[1]).filter(Boolean),
121
+ ...[env.ProgramFiles, env.ProgramW6432, env.LOCALAPPDATA && join(env.LOCALAPPDATA, 'Programs')].filter(Boolean).map((directory) => join(directory, 'Git')),
122
+ ];
123
+ return gitRoots.map((gitRoot) => join(gitRoot, GIT_BASH_RELATIVE_PATH)).find((bashPath) => checkExists(bashPath)) || 'bash';
124
+ }
125
+
126
+ function runRulebookSuite(config, suite, checkedRoot) {
102
127
  const scriptPath = join(config.rulebook.path, `${suite.name}.checks.sh`).replace(/\\/g, '/');
103
- const scriptArgs = [scriptPath, config.root.replace(/\\/g, '/')];
128
+ const scriptArgs = [scriptPath, checkedRoot.replace(/\\/g, '/')];
104
129
  if (suite.isPrefixed && config.prefix) scriptArgs.push(config.prefix);
105
- return spawnSync('bash', scriptArgs, { encoding: 'utf8', env: { ...process.env, NO_COLOR: '1' } });
130
+ return spawnSync(getBashPath(), scriptArgs, { encoding: 'utf8', env: { ...process.env, NO_COLOR: '1' } });
106
131
  }
107
132
 
108
- /** Run every check; returns [{ level: 'pass'|'warning'|'error', checkName, message, fixHint? }]. */
133
+ /**
134
+ * Run every check; returns [{ level: 'pass'|'warning'|'error', checkName, message, fixHint? }].
135
+ * `doctorConfig.registry` replaces components.json (tests use a registry of fixture repos).
136
+ */
109
137
  export function checkComponent(config, doctorConfig = {}) {
138
+ const registry = doctorConfig.registry || getRegistry();
110
139
  const results = [];
111
140
  const addPass = (checkName, message) => results.push({ level: 'pass', checkName, message });
112
141
  const addWarning = (checkName, message, fixHint) => results.push({ level: 'warning', checkName, message, fixHint });
@@ -240,7 +269,7 @@ export function checkComponent(config, doctorConfig = {}) {
240
269
  }
241
270
 
242
271
  // ── registry cross-check ─────────────────────────────────────────────────
243
- const registryEntry = getRegistry().components.find((component) => component.id === config.id);
272
+ const registryEntry = registry.components.find((component) => component.id === config.id);
244
273
  if (!registryEntry) {
245
274
  addWarning('registry', `"${config.id}" is not in the devkit components.json`, 'add an entry (README catalogue + port allocation)');
246
275
  } else {
@@ -254,7 +283,7 @@ export function checkComponent(config, doctorConfig = {}) {
254
283
  `set package.json#kmwc.ports to { "dev": ${registryPorts.dev}, "image": ${registryPorts.image} } (components.json port block ${registryEntry.port})`
255
284
  );
256
285
  } else addPass('registry', `ports match the registry (dev ${config.ports.dev}, image ${config.ports.image})`);
257
- checkPorts(config, registryEntry.port, { addPass, addWarning, addError });
286
+ checkPorts(config, registryEntry.port, registry, { addPass, addWarning, addError });
258
287
  }
259
288
  if (registryEntry.prefix && config.prefix && registryEntry.prefix !== config.prefix) {
260
289
  addError('registry', `kmwc.prefix "${config.prefix}" ≠ registry prefix "${registryEntry.prefix}"`, 'use the reserved prefix');
@@ -274,12 +303,22 @@ export function checkComponent(config, doctorConfig = {}) {
274
303
  if (!outputMismatches.length) addPass('library', 'package.json targets match the library build outputs');
275
304
  }
276
305
 
306
+ // ── monorepo: the workspace packages doctor checks ───────────────────────
307
+ checkWorkspacePackages(config, { addPass, addWarning, addError });
308
+
277
309
  // ── core's default loader class ──────────────────────────────────────────
278
- if (config.prefix && config.kind !== LIBRARY_KIND) checkLoaderClass(config, { addPass, addWarning, addError });
310
+ if (config.prefix && config.kind !== LIBRARY_KIND) {
311
+ for (const checkedPackage of config.packages.filter((packageEntry) => packageEntry.kind === 'component')) {
312
+ checkLoaderClass(config, checkedPackage, { addPass, addWarning, addError });
313
+ }
314
+ }
315
+
316
+ // ── variable manifest (what `kmwc check-vars` checks; it only runs in the build) ──
317
+ if (config.hasPackageConfig) checkVariableManifest(config, { addPass, addWarning, addError });
279
318
 
280
319
  // ── analytics ────────────────────────────────────────────────────────────
281
320
  const plausibleSiteId = loadPlausibleSiteId(config.root);
282
- const sharingRepoIds = loadSharedPlausibleSiteIds(config, getRegistry(), plausibleSiteId);
321
+ const sharingRepoIds = loadSharedPlausibleSiteIds(config, registry, plausibleSiteId);
283
322
  if (sharingRepoIds.length) {
284
323
  addError(
285
324
  'analytics',
@@ -289,7 +328,9 @@ export function checkComponent(config, doctorConfig = {}) {
289
328
  } else if (plausibleSiteId) addPass('analytics', `Plausible site id ${plausibleSiteId} is this repo's own`);
290
329
 
291
330
  // ── package contents ─────────────────────────────────────────────────────
292
- if (doctorConfig.shouldCheckPackage !== false) checkPackageContents(config, { addPass, addWarning, addError });
331
+ if (doctorConfig.shouldCheckPackage !== false) {
332
+ for (const checkedPackage of config.packages) checkPackageContents(checkedPackage, { addPass, addWarning, addError });
333
+ }
293
334
 
294
335
  // ── rulebook suites ──────────────────────────────────────────────────────
295
336
  if (doctorConfig.shouldRunRulebook !== false) checkRulebook(config, { addPass, addWarning, addError });
@@ -298,7 +339,7 @@ export function checkComponent(config, doctorConfig = {}) {
298
339
  }
299
340
 
300
341
  /** Ports: script --port flags, own configs and docs outside the block, siblings inside it. */
301
- function checkPorts(config, portBlock, { addPass, addWarning, addError }) {
342
+ function checkPorts(config, portBlock, registry, { addPass, addWarning, addError }) {
302
343
  const [firstPort, lastPort] = getPortBlockRange(portBlock);
303
344
  const blockLabel = `${firstPort}–${lastPort}`;
304
345
  const scriptPortMismatches = getScriptPortMismatches(config.packageJson.scripts, config.ports);
@@ -307,7 +348,7 @@ function checkPorts(config, portBlock, { addPass, addWarning, addError }) {
307
348
  }
308
349
  const ownStrayPorts = loadOwnPortsOutsideBlock(config, portBlock).filter(({ file }) => !file.startsWith('package.json#scripts.'));
309
350
  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());
351
+ const siblingCollisions = loadSiblingPortCollisions(config, registry);
311
352
  for (const { id, port, file } of siblingCollisions) {
312
353
  addError('ports', `${id} uses port ${port} (${file}) — inside this repo's block ${blockLabel}`, `move ${id} to its own components.json block`);
313
354
  }
@@ -316,7 +357,7 @@ function checkPorts(config, portBlock, { addPass, addWarning, addError }) {
316
357
  addWarning(
317
358
  'ports',
318
359
  `docs mention ports outside ${blockLabel}: ${stalePortMentions.map(({ file, port }) => `${file} ${port}`).join(', ')}`,
319
- `update them to ${getPortsForBlock(portBlock).dev} (dev) / ${getPortsForBlock(portBlock).image} (image)`
360
+ `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
361
  );
321
362
  }
322
363
  if (!scriptPortMismatches.length && !ownStrayPorts.length && !siblingCollisions.length && !stalePortMentions.length) {
@@ -330,13 +371,23 @@ function checkPorts(config, portBlock, { addPass, addWarning, addError }) {
330
371
  * before (or after) the core bump leaves the default loader unstyled, and no rulebook
331
372
  * check sees that (a VALIDATION-NOTES `## C-NC-8` even hides a leftover old class).
332
373
  */
333
- function checkLoaderClass(config, { addPass, addError }) {
334
- const coreLoader = loadCoreLoader(config.root);
374
+ function checkLoaderClass(config, checkedPackage, { addPass, addWarning, addError }) {
375
+ const coreLoader = loadCoreLoader(checkedPackage.root);
335
376
  if (!coreLoader?.separator) return;
336
377
  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) {
378
+ const styledLoaderClasses = loadStyledLoaderClasses(checkedPackage.root, config.prefix).map((styledClass) => ({
379
+ ...styledClass,
380
+ file: getPackageFilePath(checkedPackage, styledClass.file),
381
+ }));
382
+ const { isCoreClassStyled, renamedClasses, collidingClasses } = getLoaderClassVerdict(coreLoader.separator, styledLoaderClasses);
383
+ for (const { file, className } of collidingClasses) {
384
+ addWarning(
385
+ 'loader',
386
+ `${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`,
387
+ `rename the component's own .${className} element (e.g. .${config.prefix}__loading) in the same change as the core bump`
388
+ );
389
+ }
390
+ for (const { file, className } of renamedClasses) {
340
391
  addError(
341
392
  'loader',
342
393
  `${file} styles .${className}, but core ${coreLoader.version} draws the default loader as <span class="${coreClassName}"> — it renders unstyled`,
@@ -345,7 +396,30 @@ function checkLoaderClass(config, { addPass, addError }) {
345
396
  : `keep .${coreClassName} until the component builds on a core that draws <prefix>__loader, then rename in the same change`
346
397
  );
347
398
  }
348
- if (styledLoaderClasses.length && !mismatchedClasses.length) addPass('loader', `CSS styles .${coreClassName}, the class core ${coreLoader.version} draws`);
399
+ if (isCoreClassStyled && !renamedClasses.length) addPass('loader', `CSS styles .${coreClassName}, the class core ${coreLoader.version} draws`);
400
+ }
401
+
402
+ /** The variable manifest matches the CSS — the same verdict as `kmwc check-vars` (undocumented = warning). */
403
+ function checkVariableManifest(config, { addPass, addWarning, addError }) {
404
+ let manifestVerdict;
405
+ try {
406
+ manifestVerdict = loadManifestVerdict(config);
407
+ } catch (error) {
408
+ addError('vars', `kmwc check-vars can't run: ${error.message}`, 'fix kmwc.cssVariables (sources / manifest / prefix)');
409
+ return;
410
+ }
411
+ const { verdict, prefix, skippedReason } = manifestVerdict;
412
+ if (skippedReason) return;
413
+ const fixHint = 'edit the manifest to match the CSS (npm run check:vars lists every name)';
414
+ const listNames = (names) => names.map((name) => `--${name}`).join(', ');
415
+ if (verdict.missingBaseVariables.length) addError('vars', `--base-* read in CSS but missing from the manifest: ${listNames(verdict.missingBaseVariables)}`, fixHint);
416
+ if (verdict.unusedBaseVariables.length) addError('vars', `--base-* in the manifest but not read in CSS: ${listNames(verdict.unusedBaseVariables)}`, fixHint);
417
+ if (verdict.unusedComponentVariables.length) addError('vars', `--${prefix}-* in the manifest but not in CSS: ${listNames(verdict.unusedComponentVariables)}`, fixHint);
418
+ if (verdict.undocumentedComponentVariables.length) {
419
+ addWarning('vars', `--${prefix}-* in CSS but missing from the manifest: ${listNames(verdict.undocumentedComponentVariables)}`, `${fixHint}; runtime internals go in kmwc.cssVariables.internalVariables`);
420
+ }
421
+ const isInSync = !['missingBaseVariables', 'unusedBaseVariables', 'unusedComponentVariables', 'undocumentedComponentVariables'].some((key) => verdict[key].length);
422
+ if (isInSync) addPass('vars', `variable manifest in sync — ${verdict.counts.base} base, ${verdict.counts.component} component vars`);
349
423
  }
350
424
 
351
425
  /** Packed paths that break PACKAGE_LEAK_RULES → [{ path, label }]. Pure — exported for tests. */
@@ -358,10 +432,56 @@ export function getPackageLeaks(packedPaths) {
358
432
  });
359
433
  }
360
434
 
435
+ /** `src/x.css` in a workspace package → `packages/web-x/src/x.css`; unchanged at the repo root. */
436
+ function getPackageFilePath(checkedPackage, filePath) {
437
+ return checkedPackage.path === '.' ? filePath : `${checkedPackage.path.replace(/\\/g, '/').replace(/\/$/, '')}/${filePath}`;
438
+ }
439
+
440
+ /** The workspace directories package.json declares (`packages/*` → each subdirectory with a package.json). */
441
+ function getWorkspaceRoots(config) {
442
+ const workspaces = config.packageJson.workspaces;
443
+ const workspacePatterns = Array.isArray(workspaces) ? workspaces : workspaces?.packages || [];
444
+ return workspacePatterns.flatMap((pattern) => {
445
+ if (!pattern.endsWith('/*')) return [resolve(config.root, pattern)];
446
+ const parentPath = resolve(config.root, pattern.slice(0, -2));
447
+ if (!existsSync(parentPath)) return [];
448
+ return readdirSync(parentPath)
449
+ .map((entryName) => join(parentPath, entryName))
450
+ .filter((entryPath) => existsSync(join(entryPath, 'package.json')));
451
+ });
452
+ }
453
+
454
+ /**
455
+ * A monorepo lists its packages in kmwc.packages, or doctor checks only the root (which a
456
+ * private workspace root never publishes). Every workspace package is listed; every listed one exists.
457
+ */
458
+ function checkWorkspacePackages(config, { addPass, addWarning, addError }) {
459
+ for (const checkedPackage of config.packages) {
460
+ if (!existsSync(join(checkedPackage.root, 'package.json'))) addError('packages', `kmwc.packages lists ${checkedPackage.path}, which has no package.json`, 'fix the path');
461
+ }
462
+ const workspaceRoots = getWorkspaceRoots(config);
463
+ if (!workspaceRoots.length) return;
464
+ const toRepoPath = (absolutePath) => relative(config.root, absolutePath).replace(/\\/g, '/');
465
+ if (!config.packageJson.kmwc?.packages) {
466
+ addWarning(
467
+ 'packages',
468
+ `npm workspaces (${workspaceRoots.map(toRepoPath).join(', ')}) but no kmwc.packages — doctor checks the root as the package`,
469
+ `list them: "packages": [${workspaceRoots.map((workspaceRoot) => `{ "path": "${toRepoPath(workspaceRoot)}", "kind": "component" | "library" }`).join(', ')}]`
470
+ );
471
+ return;
472
+ }
473
+ const listedRoots = new Set(config.packages.map((checkedPackage) => checkedPackage.root));
474
+ const unlistedRoots = workspaceRoots.filter((workspaceRoot) => !listedRoots.has(workspaceRoot));
475
+ for (const workspaceRoot of unlistedRoots) {
476
+ addWarning('packages', `workspace package ${toRepoPath(workspaceRoot)} is not in kmwc.packages — doctor doesn't check it`, `add { "path": "${toRepoPath(workspaceRoot)}", "kind": … }`);
477
+ }
478
+ if (!unlistedRoots.length) addPass('packages', `kmwc.packages lists every workspace package (${config.packages.map((checkedPackage) => `${checkedPackage.path} ${checkedPackage.kind}`).join(', ')})`);
479
+ }
480
+
361
481
  /** The file list `npm publish` would ship (no lifecycle scripts, nothing written). */
362
- function getPackedPaths(config) {
482
+ function getPackedPaths(packageRoot) {
363
483
  const packResult = spawnSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], {
364
- cwd: config.root,
484
+ cwd: packageRoot,
365
485
  encoding: 'utf8',
366
486
  shell: process.platform === 'win32',
367
487
  });
@@ -370,26 +490,37 @@ function getPackedPaths(config) {
370
490
  return packReport[0].files.map((file) => file.path);
371
491
  }
372
492
 
373
- function checkPackageContents(config, { addPass, addWarning, addError }) {
493
+ /**
494
+ * One package's `npm pack` list. A private workspace root is skipped: npm never publishes it and,
495
+ * having no `files`, it would "leak" everything (list the packages in kmwc.packages). A private
496
+ * single package (the devkit playground) is still checked — it stands in for a real one.
497
+ */
498
+ function checkPackageContents(checkedPackage, { addPass, addWarning, addError }) {
499
+ const packageLabel = checkedPackage.path === '.' ? '' : `${checkedPackage.path}: `;
500
+ const packageJson = JSON.parse(readFileSync(join(checkedPackage.root, 'package.json'), 'utf8'));
501
+ if (packageJson.private && packageJson.workspaces) {
502
+ addPass('package', `${packageLabel}private workspace root — never published, contents not checked`);
503
+ return;
504
+ }
374
505
  let packedPaths;
375
506
  try {
376
- packedPaths = getPackedPaths(config);
507
+ packedPaths = getPackedPaths(checkedPackage.root);
377
508
  } catch {
378
509
  packedPaths = null;
379
510
  }
380
511
  if (!packedPaths) {
381
- addWarning('package', 'npm pack --dry-run failed — package contents not checked');
512
+ addWarning('package', `${packageLabel}npm pack --dry-run failed — package contents not checked`);
382
513
  return;
383
514
  }
384
515
  const packageLeaks = getPackageLeaks(packedPaths);
385
516
  for (const packageLeak of packageLeaks) {
386
517
  addError(
387
518
  'package',
388
- `${packageLeak.path} would be published (${packageLeak.label})`,
389
- `exclude it in package.json "files" (e.g. ${PACKAGE_FILES_EXCLUSIONS.map((exclusion) => `"${exclusion}"`).join(', ')}) or narrow the glob`
519
+ `${getPackageFilePath(checkedPackage, packageLeak.path)} would be published (${packageLeak.label})`,
520
+ `exclude it in ${packageLabel ? `${checkedPackage.path}/` : ''}package.json "files" (e.g. ${PACKAGE_FILES_EXCLUSIONS.map((exclusion) => `"${exclusion}"`).join(', ')}) or narrow the glob`
390
521
  );
391
522
  }
392
- if (!packageLeaks.length) addPass('package', `${packedPaths.length} packed file(s), no dev-only files`);
523
+ if (!packageLeaks.length) addPass('package', `${packageLabel}${packedPaths.length} packed file(s), no dev-only files`);
393
524
  }
394
525
 
395
526
  function checkRulebook(config, { addPass, addWarning, addError }) {
@@ -398,39 +529,55 @@ function checkRulebook(config, { addPass, addWarning, addError }) {
398
529
  return;
399
530
  }
400
531
  const skippedSuites = new Set(config.rulebook.skippedChecks);
401
- const acceptedCheckIds = loadAcceptedCheckIds(config.root);
402
- const suites = getRulebookSuites(config.kind);
403
- if (suites.length < RULEBOOK_SUITES.length) {
404
- const componentOnlySuiteNames = RULEBOOK_SUITES.filter((suite) => !suites.includes(suite)).map((suite) => suite.name);
405
- addPass('rulebook', `${config.kind}: component-only suites not run (${componentOnlySuiteNames.join(', ')})`);
532
+ const isMonorepo = config.packages.some((checkedPackage) => checkedPackage.path !== '.');
533
+ // [{ suite, checkedRoot, checkName, acceptedCheckIds }]: a package's suites run in the package
534
+ // (for its kind), the repo-wide ones once at the root. A single-package repo runs all at the root.
535
+ const suiteRuns = [];
536
+ for (const checkedPackage of config.packages) {
537
+ const suites = getRulebookSuites(checkedPackage.kind).filter((suite) => !isMonorepo || !suite.isRepoWide);
538
+ const packageSuffix = isMonorepo ? `@${checkedPackage.path}` : '';
539
+ if (suites.length < RULEBOOK_SUITES.filter((suite) => !isMonorepo || !suite.isRepoWide).length) {
540
+ const componentOnlySuiteNames = RULEBOOK_SUITES.filter((suite) => suite.isComponentOnly).map((suite) => suite.name);
541
+ addPass(`rulebook${packageSuffix}`, `${checkedPackage.kind}: component-only suites not run (${componentOnlySuiteNames.join(', ')})`);
542
+ }
543
+ const acceptedCheckIds = new Set([...loadAcceptedCheckIds(config.root), ...loadAcceptedCheckIds(checkedPackage.root)]);
544
+ for (const suite of suites) suiteRuns.push({ suite, checkedRoot: checkedPackage.root, checkName: `rulebook:${suite.name}${packageSuffix}`, acceptedCheckIds });
545
+ }
546
+ if (isMonorepo) {
547
+ const acceptedCheckIds = loadAcceptedCheckIds(config.root);
548
+ for (const suite of RULEBOOK_SUITES.filter((repoSuite) => repoSuite.isRepoWide)) suiteRuns.push({ suite, checkedRoot: config.root, checkName: `rulebook:${suite.name}`, acceptedCheckIds });
406
549
  }
407
- for (const suite of suites) {
550
+ for (const { suite, checkedRoot, checkName, acceptedCheckIds } of suiteRuns) {
408
551
  if (skippedSuites.has(suite.name)) continue;
409
552
  if (!existsSync(join(config.rulebook.path, `${suite.name}.checks.sh`))) continue;
410
- const suiteResult = runRulebookSuite(config, suite);
553
+ const suiteResult = runRulebookSuite(config, suite, checkedRoot);
411
554
  if (suiteResult.error) {
412
555
  addWarning('rulebook', `cannot run bash (${suiteResult.error.code}) — rulebook checks skipped`, 'install Git Bash / run from a POSIX shell');
413
556
  return;
414
557
  }
415
- 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}`);
558
+ const failures = getSuiteFailures(suiteResult.stdout);
559
+ // A suite passes with exit 0 and fails with exit 1 + FAIL lines. Anything else (2 = usage,
560
+ // 127 = the script wasn't found — WSL's bash on a C:/ path) means it never ran: say so,
561
+ // never count it as a pass.
562
+ const hasRun = suiteResult.status === 0 || (suiteResult.status === 1 && failures.length > 0);
563
+ if (!hasRun) {
564
+ const reason = mapToPlainText(suiteResult.stderr || suiteResult.stdout).trim().split(/\r?\n/)[0] || 'no output';
565
+ 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
566
  continue;
420
567
  }
421
- const failures = getSuiteFailures(suiteResult.stdout);
422
568
  for (const failure of failures) {
423
569
  // An architectural decision recorded in VALIDATION-NOTES.md (the rulebook's accepted-deviation file).
424
570
  if (acceptedCheckIds.has(failure.checkId)) addPass(checkName, `${failure.checkId} accepted deviation (${VALIDATION_NOTES_FILE}): ${failure.reason}`);
425
571
  else addError(checkName, `${failure.checkId} ${failure.reason}`, `see ${suite.name}.checks.md → ${failure.checkId}`);
426
572
  }
427
- if (!failures.length) addPass(checkName, suiteResult.status === 0 ? 'all auto checks pass' : `exit ${suiteResult.status}`);
573
+ if (!failures.length) addPass(checkName, 'all auto checks pass');
428
574
  }
429
575
  }
430
576
 
431
- export async function run(args) {
577
+ /** `registry` is for tests (fixture repos); the CLI always uses components.json. */
578
+ export async function run(args, { registry } = {}) {
432
579
  const config = getKmwcConfig(args.root);
433
- const results = checkComponent(config, { shouldRunRulebook: !args['no-rulebook'], shouldCheckPackage: !args['no-pack'] });
580
+ const results = checkComponent(config, { shouldRunRulebook: !args['no-rulebook'], shouldCheckPackage: !args['no-pack'], registry });
434
581
  const errorCount = results.filter((result) => result.level === 'error').length;
435
582
  const warningCount = results.filter((result) => result.level === 'warning').length;
436
583
 
@@ -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) =>
package/src/config.d.ts CHANGED
@@ -73,6 +73,19 @@ export interface KmwcPackageConfig {
73
73
  skippedChecks?: string[];
74
74
  };
75
75
  sync?: { skippedFiles?: string[] };
76
+ /**
77
+ * The packages this repo publishes, for a monorepo whose root is not the package (npm
78
+ * workspaces): doctor runs the package-contents check and the rulebook suites for each one's
79
+ * kind there. Default: one package, the repo root, with the repo's kind.
80
+ */
81
+ packages?: Array<{ path: string; kind: 'component' | 'library' }>;
82
+ }
83
+
84
+ /** One package doctor checks: `path` as configured, `root` resolved. */
85
+ export interface KmwcPackage {
86
+ path: string;
87
+ root: string;
88
+ kind: 'component' | 'library';
76
89
  }
77
90
 
78
91
  export interface KmwcConfig {
@@ -131,6 +144,7 @@ export interface KmwcConfig {
131
144
  };
132
145
  rulebook: { path: string; skippedChecks: string[] };
133
146
  sync: { skippedFiles: string[] };
147
+ packages: KmwcPackage[];
134
148
  }
135
149
 
136
150
  export const DEVKIT_ROOT: string;
package/src/config.js CHANGED
@@ -183,6 +183,23 @@ export function getKmwcConfig(root = process.cwd()) {
183
183
  return mapKmwcConfig(getJsonFile(packageJsonPath), root);
184
184
  }
185
185
 
186
+ const PACKAGE_KINDS = ['component', 'library'];
187
+
188
+ /**
189
+ * `kmwc.packages` → [{ path, root, kind }]; without it, one package at the repo root with the
190
+ * repo's kind (a showcase / docs / test repo checks as a component, as before).
191
+ */
192
+ function mapPackages(packageEntries, root, repoKind) {
193
+ if (!packageEntries) return [{ path: '.', root, kind: repoKind === 'library' ? 'library' : 'component' }];
194
+ if (!Array.isArray(packageEntries) || !packageEntries.length) throw new Error('kmwc.packages: expected a non-empty array of { path, kind }');
195
+ return packageEntries.map((packageEntry) => {
196
+ if (!packageEntry?.path || !PACKAGE_KINDS.includes(packageEntry.kind)) {
197
+ throw new Error(`kmwc.packages: ${JSON.stringify(packageEntry)} needs a "path" and a "kind" (${PACKAGE_KINDS.join(' | ')})`);
198
+ }
199
+ return { path: packageEntry.path, root: resolve(root, packageEntry.path), kind: packageEntry.kind };
200
+ });
201
+ }
202
+
186
203
  /** Map a parsed package.json to the resolved config (pure — used by tests). */
187
204
  export function mapKmwcConfig(packageJson, root = process.cwd()) {
188
205
  const packageConfig = packageJson.kmwc || {};
@@ -199,6 +216,7 @@ export function mapKmwcConfig(packageJson, root = process.cwd()) {
199
216
  const cssVariables = packageConfig.cssVariables === false ? { checkMode: 'off' } : packageConfig.cssVariables || {};
200
217
  const rulebook = packageConfig.rulebook || {};
201
218
  const devPort = ports.dev ?? 5173;
219
+ const kind = packageConfig.kind || getRegistryKind(id);
202
220
 
203
221
  return {
204
222
  root,
@@ -206,7 +224,7 @@ export function mapKmwcConfig(packageJson, root = process.cwd()) {
206
224
  packageJson,
207
225
  id,
208
226
  packageName: packageJson.name || id,
209
- kind: packageConfig.kind || getRegistryKind(id),
227
+ kind,
210
228
  version: packageJson.version || '0.1.0',
211
229
  title: packageConfig.title || mapToTitleCase(id),
212
230
  tagline: packageConfig.tagline || packageJson.description || '',
@@ -275,6 +293,7 @@ export function mapKmwcConfig(packageJson, root = process.cwd()) {
275
293
  sync: {
276
294
  skippedFiles: packageConfig.sync?.skippedFiles || [],
277
295
  },
296
+ packages: mapPackages(packageConfig.packages, root, kind),
278
297
  };
279
298
  }
280
299
 
@@ -97,6 +97,12 @@ export const INDEX_PAGE_CLASSES = {
97
97
  devkit: 'kmwc-index__devkit',
98
98
  };
99
99
 
100
+ /** The footer the kit appends to every example page but the index (page-footer.js). */
101
+ export const PAGE_FOOTER_CLASSES = {
102
+ root: 'kmwc-page-footer',
103
+ devkit: 'kmwc-page-footer__devkit',
104
+ };
105
+
100
106
  /** <kmwc-demo> output (rendered at build time by page-composer.js) + the kit's copy button. */
101
107
  export const DEMO_CLASSES = {
102
108
  root: 'kmwc-demo',
@@ -0,0 +1,19 @@
1
+ /* ==============================================================================
2
+ EXAMPLES KIT — PAGE FOOTER
3
+ ==============================================================================
4
+ The footer the kit appends to every example page but the index
5
+ (page-footer.js); the same line as the index footer. */
6
+ @layer kmwc {
7
+ .kmwc-page-footer {
8
+ text-align: center;
9
+ margin-top: 3rem;
10
+ padding: 1.5rem 0 1rem;
11
+ border-top: 1px solid var(--kmwc-border-color);
12
+ color: var(--kmwc-text-color-3);
13
+ font-size: 0.875rem;
14
+ }
15
+
16
+ .kmwc-page-footer p {
17
+ margin: 0;
18
+ }
19
+ }
@@ -31,3 +31,4 @@
31
31
  @import './spotlight.css';
32
32
  @import './theme-switch.css';
33
33
  @import './index-page.css';
34
+ @import './page-footer.css';
@@ -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
@@ -19,6 +19,7 @@
19
19
  import { getPageConfig } from './page-config.js';
20
20
  import { getVersionLabel } from './version-badge.js';
21
21
  import { INDEX_PAGE_CLASSES } from './constants.js';
22
+ import { renderFooterLineHtml } from './page-footer.js';
22
23
 
23
24
  const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
24
25
  const mapToEscapedHtml = (text) => String(text ?? '').replace(/[&<>"']/g, (character) => HTML_ESCAPES[character]);
@@ -32,10 +33,6 @@ export function renderExamplesIndex(indexConfig) {
32
33
  const pages = indexConfig.pages || [];
33
34
  const title = indexConfig.title ?? pageConfig.title;
34
35
  const tagline = indexConfig.tagline ?? pageConfig.tagline;
35
- const footerLinks = [
36
- ...(pageConfig.repository ? [{ label: 'GitHub', href: pageConfig.repository }] : []),
37
- ...(indexConfig.links || []),
38
- ];
39
36
  const classes = INDEX_PAGE_CLASSES;
40
37
 
41
38
  document.body.classList.add(classes.body);
@@ -63,13 +60,7 @@ export function renderExamplesIndex(indexConfig) {
63
60
  .join('')}
64
61
  </div>
65
62
  <footer class="${classes.footer}">
66
- <p>Created by <a href="https://github.com/keenmate" target="_blank" rel="noopener">Keenmate s.r.o.</a>${footerLinks
67
- .map((link) => ` | <a href="${mapToEscapedHtml(link.href)}" target="_blank" rel="noopener">${mapToEscapedHtml(link.label)}</a>`)
68
- .join('')}${
69
- pageConfig.devkitVersion
70
- ? ` | <a class="${classes.devkit}" href="${mapToEscapedHtml(pageConfig.devkitHomepage)}" target="_blank" rel="noopener">web-components-devkit v${mapToEscapedHtml(pageConfig.devkitVersion)}</a>`
71
- : ''
72
- }</p>
63
+ ${renderFooterLineHtml(pageConfig, { links: indexConfig.links, devkitClass: classes.devkit })}
73
64
  </footer>`;
74
65
  containerEl.prepend(pageElement);
75
66
 
@@ -11,7 +11,7 @@ export interface KmwcPageConfig {
11
11
  isSpotlightEnabled: boolean;
12
12
  repository: string;
13
13
  homepage: string;
14
- /** The @keenmate/web-components-devkit version that built the page (shown in the index footer). */
14
+ /** The @keenmate/web-components-devkit version that built the page (shown in the page footers). */
15
15
  devkitVersion: string;
16
16
  devkitHomepage: string;
17
17
  }
@@ -93,6 +93,10 @@ export function renderVersionBadges(root?: ParentNode): void;
93
93
  export function attachDemoCopyButtons(root?: ParentNode): void;
94
94
  export function getVersionLabel(pageConfig?: KmwcPageConfig): string;
95
95
 
96
+ /** Append the footer (Keenmate | GitHub | devkit version) to an example page; none on the index. */
97
+ export function mountPageFooter(): HTMLElement | null;
98
+ /** The footer line the index and the page footer share. */
99
+ export function renderFooterLineHtml(pageConfig: KmwcPageConfig, footerConfig: { links?: Array<{ label: string; href: string }>; devkitClass: string }): string;
96
100
  export function mountThemeSwitch(): { unmount(): void };
97
101
  export function applyTheme(theme: Theme): void;
98
102
  export function getSavedTheme(): Theme;
@@ -118,6 +122,7 @@ export const SPOTLIGHT_FULLSCREEN_MEDIA: string;
118
122
  export const CHAPTER_CODE_PATTERN: RegExp;
119
123
  export const COPY_LINK_CLASSES: Record<string, string>;
120
124
  export const INDEX_PAGE_CLASSES: Record<string, string>;
125
+ export const PAGE_FOOTER_CLASSES: { root: string; devkit: string };
121
126
  export const THEME_SWITCH_CLASSES: Record<string, string>;
122
127
  export const DEMO_CLASSES: Record<string, string>;
123
128
  export const THEMES: Theme[];
@@ -15,6 +15,7 @@ export { searchChapters, checkMatchesAny, renderHighlightedText } from './chapte
15
15
  export { attachCopyLinks, copyText, getSectionUrl } from './copy-link.js';
16
16
  export { renderVersionBadges, getVersionLabel } from './version-badge.js';
17
17
  export { attachDemoCopyButtons } from './demo-code.js';
18
+ export { mountPageFooter, renderFooterLineHtml } from './page-footer.js';
18
19
  export { mountThemeSwitch, applyTheme, getSavedTheme } from './theme-switch.js';
19
20
  export { ensureSectionAnchors, generateSlug, getHeadingLabel, getChapterCode, mapToCodeKey, findSectionByHash } from './sections.js';
20
21
  export { getPageConfig, attachDomReadyHandler } from './page-config.js';
@@ -5,15 +5,16 @@ import { attachCopyLinks } from './copy-link.js';
5
5
  import { renderVersionBadges } from './version-badge.js';
6
6
  import { mountThemeSwitch } from './theme-switch.js';
7
7
  import { attachDemoCopyButtons } from './demo-code.js';
8
+ import { mountPageFooter } from './page-footer.js';
8
9
  import { INDEX_PAGE_CLASSES } from './constants.js';
9
10
 
10
11
  /**
11
12
  * (Re)mount the page-level helpers on the current DOM (copy-links, chapter
12
13
  * nav, short chapter links, spotlight, version badges, demo copy buttons,
13
- * theme switch). Runs automatically once per page; call it again after
14
+ * theme switch, page footer). Runs automatically once per page; call it again after
14
15
  * rendering sections asynchronously. Idempotent: copy-link / demo copy buttons
15
- * skip elements that already have one, the nav, spotlight and theme switch are
16
- * remounted.
16
+ * skip elements that already have one, the nav, spotlight, theme switch and
17
+ * footer are remounted.
17
18
  */
18
19
  export function mountExamplesKit() {
19
20
  // Copy-links before the nav: the nav strips the buttons back out when
@@ -26,4 +27,5 @@ export function mountExamplesKit() {
26
27
  renderVersionBadges();
27
28
  attachDemoCopyButtons();
28
29
  mountThemeSwitch();
30
+ mountPageFooter();
29
31
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The footer every example page ends with — Keenmate, the repo, and the devkit
3
+ * that built the page. The index page renders the same line in its own footer
4
+ * (index-page.js); the other pages get it from the kit (mountPageFooter).
5
+ */
6
+ import { getPageConfig } from './page-config.js';
7
+ import { INDEX_PAGE_CLASSES, PAGE_FOOTER_CLASSES } from './constants.js';
8
+
9
+ const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
10
+ const mapToEscapedHtml = (text) => String(text ?? '').replace(/[&<>"']/g, (character) => HTML_ESCAPES[character]);
11
+
12
+ /**
13
+ * The footer line: "Created by Keenmate s.r.o. | GitHub | …links | web-components-devkit vX".
14
+ * @param {import('./index.js').KmwcPageConfig} pageConfig
15
+ * @param {{ links?: Array<{ label: string, href: string }>, devkitClass: string }} footerConfig
16
+ */
17
+ export function renderFooterLineHtml(pageConfig, { links = [], devkitClass }) {
18
+ const footerLinks = [...(pageConfig.repository ? [{ label: 'GitHub', href: pageConfig.repository }] : []), ...links];
19
+ const linksHtml = footerLinks
20
+ .map((link) => ` | <a href="${mapToEscapedHtml(link.href)}" target="_blank" rel="noopener">${mapToEscapedHtml(link.label)}</a>`)
21
+ .join('');
22
+ const devkitHtml = pageConfig.devkitVersion
23
+ ? ` | <a class="${devkitClass}" href="${mapToEscapedHtml(pageConfig.devkitHomepage)}" target="_blank" rel="noopener">web-components-devkit v${mapToEscapedHtml(pageConfig.devkitVersion)}</a>`
24
+ : '';
25
+ return `<p>Created by <a href="https://github.com/keenmate" target="_blank" rel="noopener">Keenmate s.r.o.</a>${linksHtml}${devkitHtml}</p>`;
26
+ }
27
+
28
+ /**
29
+ * Append the footer to an example page — inside the layout's `.container`
30
+ * when there is one, else at the end of <body>. Remounting replaces it; the
31
+ * index page keeps its own footer and gets none.
32
+ * @returns {HTMLElement | null}
33
+ */
34
+ export function mountPageFooter() {
35
+ document.querySelector(`.${PAGE_FOOTER_CLASSES.root}`)?.remove();
36
+ if (document.body.classList.contains(INDEX_PAGE_CLASSES.body)) return null;
37
+
38
+ const footerElement = document.createElement('footer');
39
+ footerElement.className = PAGE_FOOTER_CLASSES.root;
40
+ footerElement.innerHTML = renderFooterLineHtml(getPageConfig(), { devkitClass: PAGE_FOOTER_CLASSES.devkit });
41
+ const containerElement = document.querySelector('body > .container');
42
+ (containerElement || document.body).append(footerElement);
43
+ return footerElement;
44
+ }
@@ -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