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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
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
+
3
24
  ## [1.0.0-rc08] - 2026-11-04 [PUBLISHED]
4
25
 
5
26
  ### Added
package/README.md CHANGED
@@ -19,19 +19,16 @@ 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
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.
26
+
22
27
  ## What's New in v1.0.0-rc08
23
28
 
24
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.
25
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.
26
31
 
27
- ## What's New in v1.0.0-rc07
28
-
29
- - **CLI — `kmwc check-pages` finds what only a browser can see** — Migrating web-treeview turned up pages that pushed a phone sideways: an iOS-Files mockup 420px wide and a theme gallery built on `minmax(500px, 1fr)`. No static check could have caught that. `kmwc check-pages` loads every example page in Chromium at 1440, 390 and 320px. It reports page and console errors, failed requests, custom elements that never get defined, and pages wider than the viewport, naming the widest elements. It looks inside shadow roots and at long words that overflow their box. It ignores layers that can't widen a page: scrolling code blocks, hidden or fixed layers, a closed chapter-nav panel. It starts the repo's dev server, or `--url` points it at one already running (for example the built site on the preview port), and `--widths` changes the widths. `kmwc sync` adds a `check:pages` script and a `make check-pages` target.
30
- - **Doctor — catches what the treeview and daterangepicker migrations got wrong** — Doctor now checks that every `./dist/…` target in package.json is a file the library build actually emits, and that the old UMD name matches `kmwc.library.globalName`. Without that, the preset's defaults would quietly have renamed treeview's global (`WebTreeView` → `WebTreeview`) and its CSS (`web-treeview.css` → `style.css`). Doctor also reports scripts that pin their own `--port`, config, Makefile and doc ports outside the repo's registry block, and sibling repos that listen inside it. This found web-daterangepicker on treeview's 12300 and web-multiselect / web-dropzone on daterangepicker's 12200. A Plausible site id that another repo also uses is an error, because treeview's traffic was being counted as web-multiselect's. Page CSS that duplicates kit classes (`.kbd`, `mark`, inline field widths, `margin-left: auto`) is a warning.
31
- - **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.
32
- - **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.
33
- - **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.
34
-
35
32
  ## Catalogue
36
33
 
37
34
  <!-- kmwc:catalogue:start — generated by `npm run readme`, do not edit inside -->
@@ -365,7 +362,7 @@ kmwc doctor [--json] devkit checks + package contents + the rulebook's
365
362
  kmwc check-vars [--strict] component-variables.manifest.json ⇔ CSS
366
363
  kmwc check-pages [--url u] every example page in Chromium at 1440 / 390 / 320px: page and console
367
364
  errors, failed requests, never-defined elements, a page wider than the
368
- 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);
369
366
  --widths 1440,390,320
370
367
  kmwc check-release --tag t latest ⇔ release version, rc ⇔ -rcN, version not yet on npm
371
368
  kmwc new web-<name> scaffold a component (--prefix, --title, --tag, --dir, --port, --minimal, --register)
@@ -427,7 +424,11 @@ generated files:
427
424
 
428
425
  `kmwc check-pages` is the browser half: it loads every example page and reports
429
426
  what doctor can't see in the source — a script error, a 404, a fixed width that
430
- 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`).
431
432
 
432
433
  **Package contents.** Doctor runs `npm pack --dry-run` and fails on anything
433
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-rc08",
3
+ "version": "1.0.0-rc09",
4
4
  "description": "Shared dev, example-site and build infrastructure for Keenmate web components: Vite preset, examples kit (chapter nav, copy-link, controls store, shared styles), Playwright preset and the `kmwc` CLI (sync, doctor, check-vars, new, readme).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,24 +1,28 @@
1
1
  /**
2
2
  * kmwc check-pages — load every example page in a real browser and report what
3
3
  * a reader would hit: page errors, console errors, failed requests, custom
4
- * elements that never get defined, and a page wider than the viewport (a fixed
5
- * width or a long word that pushes a phone sideways).
4
+ * elements that never get defined, a page wider than the viewport (a fixed
5
+ * width or a long word that pushes a phone sideways), and page CSS that can
6
+ * never apply because what it targets lives only inside a shadow root (classes
7
+ * a render callback puts into the component).
6
8
  *
7
9
  * Runs against the repo's own dev server (started here on kmwc.ports.dev), or a
8
10
  * server you already have (--url, e.g. the built site on the preview port).
9
11
  * Widths default to desktop + two phones. Uses the component's own
10
12
  * @playwright/test (see getPeerModule) — `npx playwright install chromium` once.
11
13
  */
12
- import { existsSync } from 'node:fs';
14
+ import { existsSync, readFileSync } from 'node:fs';
13
15
  import { join } from 'node:path';
14
16
  import { getKmwcConfig, getPeerModule, loadPeerModule } from '../../config.js';
15
17
  import { getExamplePages } from '../../vite/index.js';
18
+ import { getRuleSelectors } from '../repo-checks.js';
16
19
 
17
20
  export const DEFAULT_WIDTHS = [1440, 390, 320];
18
21
  const VIEWPORT_HEIGHT = 900;
19
22
  const SETTLE_MS = 600;
20
23
  const VITE_CONFIG_FILES = ['vite.config.ts', 'vite.config.mts', 'vite.config.mjs', 'vite.config.js'];
21
24
  const MAX_OFFENDERS = 3;
25
+ const LOCAL_STYLESHEET_FILE = 'examples-local.css';
22
26
  // Requests a page doesn't control (favicon probes, analytics in a built site).
23
27
  const IGNORED_REQUEST_PATTERN = /favicon\.ico|stats\.keenmate\.services|plausible/;
24
28
 
@@ -30,12 +34,68 @@ export function parseWidths(widthsText) {
30
34
  return widths;
31
35
  }
32
36
 
37
+ /**
38
+ * The element a selector styles, as a selector of its own: the last compound with
39
+ * pseudo-classes / -elements dropped (`.tree .badge:hover::after` → `.badge`), or
40
+ * null when there's no class / id / attribute to look for (`:root`, `div`, `from`). Pure.
41
+ */
42
+ export function getSelectorSubject(selector) {
43
+ let depth = 0;
44
+ let start = 0;
45
+ for (let index = 0; index < selector.length; index++) {
46
+ const character = selector[index];
47
+ if (character === '(' || character === '[') depth++;
48
+ else if (character === ')' || character === ']') depth--;
49
+ else if (depth === 0 && /[\s>+~]/.test(character)) start = index + 1;
50
+ }
51
+ const subject = selector.slice(start).replace(/::?[\w-]+(\([^()]*\))?/g, '').trim();
52
+ return /[.#[]/.test(subject) ? subject : null;
53
+ }
54
+
55
+ /** A page's own CSS rules (its `<style>` blocks + examples-local.css) → [{ selector, subject }]. */
56
+ export function loadPageRules(root, pagePath) {
57
+ const pageText = readFileSync(pagePath, 'utf8');
58
+ const localStylesheetPath = join(root, LOCAL_STYLESHEET_FILE);
59
+ const styleText = [
60
+ ...[...pageText.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((match) => match[1]),
61
+ existsSync(localStylesheetPath) ? readFileSync(localStylesheetPath, 'utf8') : '',
62
+ ].join('\n');
63
+ const selectors = [...new Set(getRuleSelectors(styleText))];
64
+ return selectors.map((selector) => ({ selector, subject: getSelectorSubject(selector) })).filter((rule) => rule.subject);
65
+ }
66
+
67
+ /**
68
+ * Runs in the page: page rules whose subject matches nothing in the document but
69
+ * something inside a shadow root → [{ selector, host }]. Page CSS stops at the
70
+ * shadow boundary, so such a rule never applies.
71
+ */
72
+ export function findShadowOnlyRules(rules) {
73
+ const getShadowRoots = (root) =>
74
+ [...root.querySelectorAll('*')].filter((element) => element.shadowRoot).flatMap((element) => [element.shadowRoot, ...getShadowRoots(element.shadowRoot)]);
75
+ const shadowRoots = getShadowRoots(document);
76
+ const safeQuery = (root, selector) => {
77
+ try {
78
+ return root.querySelector(selector);
79
+ } catch {
80
+ return null;
81
+ }
82
+ };
83
+ return rules.flatMap(({ selector, subject }) => {
84
+ if (safeQuery(document, subject)) return [];
85
+ const shadowRoot = shadowRoots.find((root) => safeQuery(root, subject));
86
+ return shadowRoot ? [{ selector, host: shadowRoot.host.localName }] : [];
87
+ });
88
+ }
89
+
33
90
  /**
34
91
  * Problems in one page load's report → [string]. Pure — exported for tests.
35
- * @param {{ width: number, errors: string[], undefinedElements: string[], scrollWidth: number, clientWidth: number, offenders: string[] }} pageReport
92
+ * @param {{ width: number, errors: string[], undefinedElements: string[], scrollWidth: number, clientWidth: number, offenders: string[], shadowOnlyRules?: { selector: string, host: string }[] }} pageReport
36
93
  */
37
94
  export function getPageProblems(pageReport) {
38
95
  const problems = [...pageReport.errors];
96
+ for (const { selector, host } of pageReport.shadowOnlyRules || []) {
97
+ problems.push(`page CSS "${selector}" only matches inside <${host}>'s shadow root — page styles can't reach it (use a CSS variable, ::part or the component's styles hook)`);
98
+ }
39
99
  if (pageReport.undefinedElements.length) problems.push(`never defined: <${pageReport.undefinedElements.join('>, <')}>`);
40
100
  if (pageReport.scrollWidth > pageReport.clientWidth) {
41
101
  const offenderText = pageReport.offenders.length ? ` — widest: ${pageReport.offenders.join(', ')}` : '';
@@ -106,7 +166,7 @@ function inspectPage(maxOffenders) {
106
166
  };
107
167
  }
108
168
 
109
- async function loadPageReport(browser, pageUrl, width) {
169
+ async function loadPageReport(browser, pageUrl, width, pageRules = []) {
110
170
  const page = await browser.newPage({ viewport: { width, height: VIEWPORT_HEIGHT } });
111
171
  const errors = [];
112
172
  page.on('pageerror', (error) => errors.push(`page error: ${error.message.split('\n')[0]}`));
@@ -123,7 +183,8 @@ async function loadPageReport(browser, pageUrl, width) {
123
183
  await page.goto(pageUrl, { waitUntil: 'networkidle', timeout: 30_000 });
124
184
  await page.waitForTimeout(SETTLE_MS);
125
185
  const inspection = await page.evaluate(inspectPage, MAX_OFFENDERS);
126
- return { width, errors: [...new Set(errors)], ...inspection };
186
+ const shadowOnlyRules = pageRules.length ? await page.evaluate(findShadowOnlyRules, pageRules) : [];
187
+ return { width, errors: [...new Set(errors)], ...inspection, shadowOnlyRules };
127
188
  } catch (error) {
128
189
  return { width, errors: [...new Set([...errors, `load failed: ${error.message.split('\n')[0]}`])], scrollWidth: 0, clientWidth: 0, offenders: [], undefinedElements: [] };
129
190
  } finally {
@@ -154,7 +215,8 @@ async function startDevServer(config) {
154
215
  export async function run(args) {
155
216
  const config = getKmwcConfig(args.root);
156
217
  const widths = parseWidths(args.widths);
157
- const pageFiles = Object.values(getExamplePages(config)).map((pagePath) => pagePath.replace(/\\/g, '/').split('/').pop());
218
+ const pagePaths = Object.values(getExamplePages(config));
219
+ const pageFiles = pagePaths.map((pagePath) => pagePath.replace(/\\/g, '/').split('/').pop());
158
220
  if (!pageFiles.length) {
159
221
  console.log('kmwc check-pages: no example pages — nothing to check.');
160
222
  return 0;
@@ -172,10 +234,13 @@ export async function run(args) {
172
234
  let problemCount = 0;
173
235
  try {
174
236
  console.log(`kmwc check-pages — ${pageFiles.length} page(s) × ${widths.join(' / ')}px on ${baseUrl}\n`);
175
- for (const pageFile of pageFiles) {
237
+ for (const [pageIndex, pageFile] of pageFiles.entries()) {
176
238
  const pageProblems = [];
177
- for (const width of widths) {
178
- for (const problem of getPageProblems(await loadPageReport(browser, `${baseUrl}/${pageFile}`, width))) pageProblems.push(`@${width}px ${problem}`);
239
+ const pageRules = loadPageRules(config.root, pagePaths[pageIndex]);
240
+ for (const [widthIndex, width] of widths.entries()) {
241
+ // Shadow-only rules don't depend on the width: check them on the first load only.
242
+ const report = await loadPageReport(browser, `${baseUrl}/${pageFile}`, width, widthIndex === 0 ? pageRules : []);
243
+ for (const problem of getPageProblems(report)) pageProblems.push(`@${width}px ${problem}`);
179
244
  }
180
245
  problemCount += pageProblems.length;
181
246
  console.log(`${pageProblems.length ? '✗' : '✓'} ${pageFile}${pageProblems.map((problem) => `\n ${problem}`).join('')}`);
@@ -213,13 +213,13 @@
213
213
 
214
214
  button.secondary,
215
215
  button.btn-secondary {
216
- background: var(--kmwc-neutral-color);
216
+ background: var(--kmwc-neutral-fill-color);
217
217
  color: var(--kmwc-text-color-on-neutral);
218
218
  }
219
219
 
220
220
  button.secondary:hover,
221
221
  button.btn-secondary:hover {
222
- background: color-mix(in srgb, var(--kmwc-neutral-color) 80%, black);
222
+ background: var(--kmwc-neutral-fill-color-hover);
223
223
  }
224
224
 
225
225
  button.btn-outline {
@@ -137,10 +137,15 @@
137
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))));
138
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)));
139
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)));
140
145
  --kmwc-badge-new-bg: light-dark(#e53e3e, #f56565);
141
146
  /* Text on the neutral / badge fills: the contract's on-colour formula on
142
147
  their own background, not on the accent (a light primary would turn it black). */
143
- --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));
144
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));
145
150
 
146
151
  /* ======================================================================
@@ -54,4 +54,6 @@ locally — every local fork is how they drifted apart.
54
54
  `*.checks.sh` suites. A rulebook failure that is a real architectural decision
55
55
  goes into `VALIDATION-NOTES.md` under a `## C-XX-N — …` heading (deferrals
56
56
  don't count). After changing a page, `npx kmwc check-pages` loads every page at
57
- desktop and phone widths and reports errors and horizontal overflow.
57
+ desktop and phone widths and reports errors, horizontal overflow and page CSS
58
+ that only matches inside a shadow root (markup a render callback draws there is
59
+ out of page CSS's reach — style it through the component's styles hook).