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