@motion-proto/live-tokens 0.77.0 → 0.78.0

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.
Files changed (37) hide show
  1. package/.claude/skills/live-tokens-check-compliance/SKILL.md +2 -0
  2. package/.claude/skills/live-tokens-create-page/SKILL.md +5 -4
  3. package/.claude/skills/live-tokens-fix-findings/SKILL.md +7 -0
  4. package/CHANGELOG.md +53 -0
  5. package/bin/check-page.mjs +131 -9
  6. package/bin/cli.mjs +25 -3
  7. package/bin/contractRunner.mjs +343 -29
  8. package/bin/lib/pageRoutes.mjs +309 -0
  9. package/package.json +5 -3
  10. package/src/editor/overlay/ColumnsOverlay.svelte +1 -1
  11. package/src/editor/overlay/LiveEditorOverlay.svelte +1 -0
  12. package/src/editor/overlay/LiveTokensRouter.svelte +1 -0
  13. package/src/editor/skill-atlas/skillSources.generated.ts +3 -3
  14. package/src/editor/skill-atlas/trees/check-compliance.ts +18 -18
  15. package/src/editor/skill-atlas/trees/create-page.ts +26 -26
  16. package/src/editor/skill-atlas/trees/fix-findings.ts +46 -11
  17. package/src/testing-js/{chunk-7TI7Z6Y6.js → chunk-GNIUPIU2.js} +2 -2
  18. package/src/testing-js/{chunk-ZMSX6CXR.js → chunk-NB3NZRBM.js} +13 -2
  19. package/src/testing-js/chunk-NB3NZRBM.js.map +1 -0
  20. package/src/testing-js/{chunk-L73N4NSO.js → chunk-WIZ6W7UT.js} +2 -2
  21. package/src/testing-js/component-alias.contract.js +2 -2
  22. package/src/testing-js/component-editor.contract.js +3 -3
  23. package/src/testing-js/component-render.contract.js +3 -3
  24. package/src/testing-js/index.d.ts +8 -2
  25. package/src/testing-js/index.js +19 -1
  26. package/src/testing-js/index.js.map +1 -1
  27. package/src/testing-js/page-compliance.contract.js +829 -0
  28. package/src/testing-js/page-compliance.contract.js.map +1 -0
  29. package/src/testing-js/{vitest-BE6uGF31.d.ts → vitest-C-wNWcoA.d.ts} +21 -1
  30. package/src/testing-js/vitest.d.ts +1 -1
  31. package/src/testing-js/vitest.js +1 -1
  32. package/template/README.md +16 -9
  33. package/template/package.json +1 -1
  34. package/template/src/pages/Home.svelte +13 -0
  35. package/src/testing-js/chunk-ZMSX6CXR.js.map +0 -1
  36. /package/src/testing-js/{chunk-7TI7Z6Y6.js.map → chunk-GNIUPIU2.js.map} +0 -0
  37. /package/src/testing-js/{chunk-L73N4NSO.js.map → chunk-WIZ6W7UT.js.map} +0 -0
@@ -9,6 +9,8 @@ Run `npx live-tokens report`. The CLI prints a report with the sections in the R
9
9
 
10
10
  `report` never runs the component contract suites. The `check-component` test run does, and it is what live-tokens-create-component calls to validate a component's runtime behavior.
11
11
 
12
+ `report` stays static for pages too. The `check-page` test run is what live-tokens-create-page calls for a rendered page, in a browser against the project's own route, and it reports `page-component-paint`, `page-text-style`, `page-contrast`, `page-grid`, and `page-overflow` findings the same way `report`'s static findings read: by rule, with a fix.
13
+
12
14
  ## Workflow
13
15
 
14
16
  When `report` is an unknown command, route the dependency upgrade to **live-tokens-fix-findings**. Resume the audit after the upgrade.
@@ -17,7 +17,7 @@ Assemble the page from the shipped components at their defaults and the theme's
17
17
  6. Write the page CSS in design tokens.
18
18
  7. Set the hierarchy: one text style per element, the shipped size on every control, one primary action, and one space step per position.
19
19
  8. Add the route, with a lazy import and the source path.
20
- 9. Run **live-tokens-check-compliance**, then check the rendered page.
20
+ 9. Run **live-tokens-check-compliance** and `npx live-tokens check-page <file> --tests --strict`, then check the rendered page.
21
21
  10. Reply with the sections and the layout each took, the components placed, the route, and the compliance result.
22
22
 
23
23
  ## Layout
@@ -34,7 +34,7 @@ Decide the sections before the columns. Read the page top to bottom and name eac
34
34
  | Grid of equals | The reader compares or scans items of one kind. | Equal spans. Up to seven per section. |
35
35
  | Single column | The reader fills a form or reads at length. | Half the columns (6), centered. |
36
36
 
37
- The stage is the canvas, player, or strip the work is about. Stretch a section's containers to one height (`align-items: stretch`) so their bottom edges align. Below the scaffold's 768px breakpoint, a section's columns stack in reading order.
37
+ The stage is the canvas, player, or strip the work is about. Stretch a section's containers to one height (`align-items: stretch`) so their bottom edges align.
38
38
 
39
39
  ### Grid
40
40
 
@@ -49,6 +49,8 @@ To place a section's children at page-column positions:
49
49
 
50
50
  A grid that follows the page columns takes `var(--columns-count)` or a `calc()` of it as its count, so it stays in step with `ColumnsOverlay`. A local grid of two or three equal columns writes its own count. A column number in `grid-column` is fixed to the count read in step 1.
51
51
 
52
+ No scaffold collapses the page grid on a phone: the theme's own column gutter alone exceeds a phone's width at the full column count, so every page writes its own `@media (max-width: 767px)` rule setting `grid-template-columns: 1fr` and `column-gap: 0`, with each section's children spanning `grid-column: 1 / -1`. A section's columns then stack in reading order.
53
+
52
54
  ### Separation
53
55
 
54
56
  The page shows one thing, and every other element stays out of its way. Each element that is not content costs attention: a hairline, a border, a header bar, a shadow. Keep an element only when it serves a purpose no other element serves.
@@ -163,7 +165,7 @@ const pages = {
163
165
 
164
166
  ## Verify
165
167
 
166
- Run **live-tokens-check-compliance**. Its report carries both checkers' findings by rule, and **live-tokens-fix-findings** takes the fix list. Repeat until the page is clean.
168
+ Run **live-tokens-check-compliance**, then `npx live-tokens check-page <file> --tests --strict`. The Playwright suite runs against the page's own route and proves what only a rendered page can: the cascade leaves every component painting from its semantic properties, every run of text sits in one shipped text style, every text and surface pair meets AA, sections sit on the page grid, and nothing overflows. Each finding carries a rule id and a line; `--off=<rule>` silences a rule for one run. The two reports carry every finding by rule, and **live-tokens-fix-findings** takes the fix list. Repeat until the page is clean.
167
169
 
168
170
  The checkers cannot see a layout. Open the page at the width it is built for and check each line below.
169
171
 
@@ -172,7 +174,6 @@ The checkers cannot see a layout. Open the page at the width it is built for and
172
174
  - No label is larger than the page's body copy.
173
175
  - A line of copy runs 45 to 90 characters.
174
176
  - The containers in a section align at the bottom.
175
- - Every control stays inside its wrapper. A `width: 100%` field takes `box-sizing: border-box`.
176
177
  - The actions sit where the eye goes last, with the one primary at the end.
177
178
  - Every row of actions holds an action that leaves without committing.
178
179
  - An action that destroys saved work confirms in a `Dialog`.
@@ -80,6 +80,13 @@ When `package.json` has no `check:design` script, add `"check:design": "live-tok
80
80
  | `control-size` | Delete the `size` prop. The shipped default is the page's size. When that default is wrong for the project, retune the component in `/live-tokens/components`. |
81
81
  | `multiple-primary` | Keep the action that completes the main task `primary`. A Button with no `variant` counts as `primary`. Use `secondary` for supporting or related actions and `outline` for unrelated or informational actions. |
82
82
  | `danger-without-dialog` | Open a `Dialog` from the danger Button or IconButton and run the action from the Dialog's confirm. The rule fires once per page, when the page imports no Dialog. For other actions, assign emphasis by the action's relationship to the main task. |
83
+ | `native-control` | Replace the native element with the shipped component the message names: Button or IconButton, Input, MenuSelect. |
84
+ | `property-override` | Delete the declaration from the page. Retune the component's token for the whole project at `/live-tokens/components`. |
85
+ | `page-component-paint` | The finding names the page file and the line of the instance whose part painted a value its semantic property never resolves to. Remove the global rule reaching past the component, from `site.css` or the page's own CSS, and retune the component's token for the whole project at `/live-tokens/components`. |
86
+ | `page-text-style` | The finding names the page file and the line of the text element and the nearest bundle it missed. Set the container's text style directly on the text element, one shipped bundle from `npx live-tokens tokens --scale heading` (or `body`, `editorial`, `code`), rather than leaving it to inherit from an ancestor typed for a different role. |
87
+ | `page-contrast` | The finding names the page file and the line of the text element, the surface ancestor, and both computed colors. Pick the text token the surface pairs with, from the Color by role table above. |
88
+ | `page-grid` | The finding names the page file and the line of the section whose edge sits off a column line. Move the edge to the line: place it by page-column numbers per the Grid section of **live-tokens-create-page**, and give a centered section symmetric insets rather than a margin, a width, or a transform. |
89
+ | `page-overflow` | The finding names the page file and the line of the element or the instance that overflows. Give the control its shipped width, remove a fixed width that does not fit its column at the viewport, and set `overflow-x: auto` only on an element deliberately meant to scroll, such as a code block. When the overflow is the page grid itself at a phone width, collapse it: `grid-template-columns: 1fr`, `column-gap: 0`, per the Grid section of **live-tokens-create-page**. |
83
90
  | `hardcoded-columns` | `repeat(var(--columns-count), 1fr)` for the page grid. `calc(var(--columns-count) - 2)` for a sub-grid spanning fewer columns. |
84
91
  | `site-css-in-main` | Delete the import from `main.ts`. Add it to each page's `<script>`. Page CSS then stays off the editor routes. |
85
92
  | `missing-source` | Add `source: 'src/...'` to the route entry. |
package/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.78.0 — A page proves compliance as rendered
4
+
5
+ ### Added
6
+
7
+ - **A page proves compliance as rendered, not just in source.** `check-page
8
+ --tests` opens each page's own route in a Playwright browser, against the
9
+ consumer's own app, and reports five rule ids no static read of the source
10
+ could: `page-component-paint` (every shipped component instance paints its
11
+ contracted parts from its semantic properties, never from a page-wide or
12
+ `site.css` rule that reaches past them), `page-text-style` (every run of
13
+ text sits in one shipped text style, never inherited from a container typed
14
+ for a different role), `page-contrast` (every text and surface pair the
15
+ page composes meets AA), `page-grid` (every section's edges land on the
16
+ page's own column lines), and `page-overflow` (nothing overflows its
17
+ container, at either viewport). Coverage is reported by page, rule, and
18
+ viewport; a rule that observed nothing on a page reports `inapplicable`
19
+ with a reason, never a silent pass. `live-tokens.testing.ts` gains two
20
+ settings: `pageRoutes`, for a route the app's own route table cannot
21
+ express, and `pageViewports`, to replace the two sizes every rule runs at.
22
+ - **Two static rules close source-level gaps `check-page` left open.**
23
+ `native-control` flags a raw `<button>`, `<input>`, `<select>`, or
24
+ `<textarea>` where a shipped component belongs. `property-override` flags a
25
+ page that declares or sets a component token's name directly, in a style
26
+ block, an inline `style`, a `style:` directive, or `setProperty`, rather
27
+ than retuning the component for the whole project at
28
+ `/live-tokens/components`.
29
+ - **The consumer acceptance gate now covers a page.** `check:smoke-page-tests`
30
+ packs the built package into a fresh project outside the repository,
31
+ scaffolds it with `create`, and proves the documented command against the
32
+ template's own clean page and against the same page under a deliberate
33
+ `site.css` override: the clean page passes, the override fails
34
+ `page-component-paint` at the page file, and source-tree hashes prove the
35
+ isolated run never touched the project's own data. Wired into
36
+ `prepublishOnly` beside the existing component gate. Both gates now carry a
37
+ twenty-minute deadline of their own, naming the step that did not finish.
38
+
39
+ ### Changed
40
+
41
+ - **The `create` template's `test:design` runs both suites:**
42
+ `live-tokens check-component --tests && live-tokens check-page --tests`.
43
+ - **The live-tokens-create-page skill's Verify step runs the rendered check.**
44
+ After live-tokens-check-compliance, it runs `check-page --tests --strict`
45
+ on the page it built. The manual line "every control stays inside its
46
+ wrapper" moves to the automated `page-overflow` rule; every other
47
+ editorial line stays.
48
+ - **The `create` template's own Home page collapses to one column below
49
+ 768px.** Its `.stub` held a fixed twelve-column span with no phone
50
+ breakpoint, so `page-overflow` failed at 390x844, the defect
51
+ `check:smoke-page-tests` now catches on any page written to that shape.
52
+ The template ships no exclusion mechanism, so the fix is the page's own
53
+ `@media (max-width: 767px)` rule, the same shape every page-defects
54
+ fixture already takes.
55
+
3
56
  ## 0.77.0 — A consumer component runs the same suites
4
57
 
5
58
  ### Added
@@ -33,10 +33,24 @@ export const PAGE_RULES = {
33
33
  'control-size': 'warn',
34
34
  'multiple-primary': 'warn',
35
35
  'danger-without-dialog': 'warn',
36
+ 'native-control': 'warn',
37
+ 'property-override': 'warn',
38
+ // `--tests` (bin/contractRunner.mjs's `runPageTests`). Fixed by design
39
+ // decision 9, same reasoning as `check-component`'s own `contract-*` rules:
40
+ // every one is an error, including the setup rules, which `--tests` treats
41
+ // as never-silenceable (see cli.mjs).
42
+ 'page-component-paint': 'error',
43
+ 'page-text-style': 'error',
44
+ 'page-contrast': 'error',
45
+ 'page-grid': 'error',
46
+ 'page-overflow': 'error',
47
+ 'tests-not-installed': 'error',
48
+ 'tests-setup': 'error',
49
+ 'tests-incomplete': 'error',
36
50
  };
37
51
 
38
52
  // Directories that hold the system, not pages built on it.
39
- const NOT_PAGES = ['src/system', 'src/editor', 'src/lib', 'src/live-tokens'];
53
+ export const NOT_PAGES = ['src/system', 'src/editor', 'src/lib', 'src/live-tokens'];
40
54
 
41
55
  export const COMPONENT_IMPORT =
42
56
  /(?:@motion-proto\/live-tokens\/components|[./][^'"]*\/system\/components)\/([A-Za-z0-9]+)\.svelte$/;
@@ -194,6 +208,61 @@ function tagAttributes(code, start) {
194
208
  return { attrs, end: tagEnd };
195
209
  }
196
210
 
211
+ // The shipped component that owns each native control's paint.
212
+ const NATIVE_CONTROLS = {
213
+ button: 'Button or IconButton',
214
+ input: 'Input',
215
+ select: 'MenuSelect',
216
+ textarea: 'Input',
217
+ };
218
+
219
+ /**
220
+ * `{@html ...}` blanked, brace-balanced, so a tag textually present inside a
221
+ * raw HTML string is not mistaken for markup the page authored.
222
+ */
223
+ function blankHtmlExpressions(code) {
224
+ let out = code;
225
+ const re = /\{@html\b/g;
226
+ let m;
227
+ while ((m = re.exec(out)) !== null) {
228
+ const start = m.index;
229
+ let depth = 0;
230
+ let i = start;
231
+ for (; i < out.length; i++) {
232
+ if (out[i] === '{') depth++;
233
+ else if (out[i] === '}' && --depth === 0) {
234
+ i++;
235
+ break;
236
+ }
237
+ }
238
+ out = out.slice(0, start) + ' '.repeat(i - start) + out.slice(i);
239
+ re.lastIndex = start;
240
+ }
241
+ return out;
242
+ }
243
+
244
+ /** A raw `<button>`, `<input>`, `<select>`, or `<textarea>` where a shipped
245
+ component belongs. `<input type="hidden">` paints nothing and is exempt.
246
+ A tag inside `<script>` is markup the page assembles into a string, not
247
+ markup it renders, so the script block is blanked the same way
248
+ `codeRegion` blanks `<style>`, keeping offsets aligned with `code`. */
249
+ function checkNativeControls(code, add) {
250
+ const scan = blankHtmlExpressions(code).replace(/<script[^>]*>[\s\S]*?<\/script>/g, (m) => ' '.repeat(m.length));
251
+ for (const tag of Object.keys(NATIVE_CONTROLS)) {
252
+ for (const m of scan.matchAll(new RegExp(`<${tag}(?=[\\s/>])`, 'g'))) {
253
+ const parsed = tagAttributes(scan, m.index);
254
+ if (tag === 'input' && parsed?.attrs.some((a) => a.name.toLowerCase() === 'type' && a.value?.toLowerCase() === 'hidden')) {
255
+ continue;
256
+ }
257
+ add(
258
+ 'native-control',
259
+ m.index,
260
+ `<${tag}> is a native control; use ${NATIVE_CONTROLS[tag]} so it paints from the theme.`,
261
+ );
262
+ }
263
+ }
264
+ }
265
+
197
266
  /**
198
267
  * Props a page passes that the component does not declare, values outside a
199
268
  * prop's union, and a shipped component the page sizes itself.
@@ -274,6 +343,22 @@ function checkDestructiveActions(code, imports, add) {
274
343
  }
275
344
  }
276
345
 
346
+ // Which component's :global(:root) block declared each component token,
347
+ // cached per vocabulary since checkFile runs once per page.
348
+ const tokenOwnersCache = new WeakMap();
349
+ function componentTokenOwners(vocab) {
350
+ let owners = tokenOwnersCache.get(vocab);
351
+ if (owners) return owners;
352
+ owners = new Map();
353
+ for (const comp of vocab.components.values()) {
354
+ for (const name of comp.tokens.keys()) {
355
+ if (!owners.has(name)) owners.set(name, comp.name);
356
+ }
357
+ }
358
+ tokenOwnersCache.set(vocab, owners);
359
+ return owners;
360
+ }
361
+
277
362
  /** The object literal enclosing `index`, found by balancing braces outward. */
278
363
  function enclosingObject(text, index) {
279
364
  let depth = 0;
@@ -335,6 +420,7 @@ function checkFile(file, text, vocab, root) {
335
420
  checkComponentUsage(code, imports, add);
336
421
  checkPrimaryActions(code, imports, add);
337
422
  checkDestructiveActions(code, imports, add);
423
+ checkNativeControls(code, add);
338
424
 
339
425
  for (const m of code.matchAll(/['"](\/live-tokens[^'"]*)['"]\s*:/g)) {
340
426
  add('reserved-route', m.index, `route '${m[1]}' is inside the reserved /live-tokens/* namespace`);
@@ -360,16 +446,52 @@ function checkFile(file, text, vocab, root) {
360
446
 
361
447
  // A page may also mint a custom property outside its <style> block — a
362
448
  // `style:--x={...}` directive or an el.style.setProperty call — and those are
363
- // just as declared as one written in CSS.
364
- const declaredHere = new Set();
449
+ // just as declared as one written in CSS. Each name keeps the earliest site
450
+ // it was declared at, so property-override reports one finding per name.
451
+ const declaredSites = new Map();
452
+ const declareAt = (name, index) => {
453
+ if (!declaredSites.has(name) || index < declaredSites.get(name)) declaredSites.set(name, index);
454
+ };
365
455
  const regions = styleRegions(text, file);
366
- for (const region of regions) {
367
- for (const m of neutralise(region.text).matchAll(/(?:^|[;{])\s*(--[a-z0-9-]+)\s*:/gim)) {
368
- declaredHere.add(m[1]);
456
+ const styleBlockDeclarations = (regionList, at) => {
457
+ for (const region of regionList) {
458
+ for (const m of neutralise(region.text).matchAll(/(?:^|[;{])\s*(--[a-z0-9-]+)\s*:/gim)) {
459
+ at(m[1], region.offset + m.index);
460
+ }
369
461
  }
370
- }
462
+ };
463
+ styleBlockDeclarations(regions, declareAt);
371
464
  for (const m of text.matchAll(/(?:style:|setProperty\(\s*['"`]|['"`])(--[a-z0-9-]+)/g)) {
372
- declaredHere.add(m[1]);
465
+ declareAt(m[1], m.index);
466
+ }
467
+
468
+ // property-override needs a narrower set than declaredSites: the bare-quote
469
+ // alternative above exists only to suppress unknown-token on a name any
470
+ // quoted string mentions, so it also matches a read like
471
+ // getPropertyValue("--x"). A real declaration is a style-block rule, an
472
+ // inline `style="--x: ..."` attribute, a `style:--x=` directive, or a
473
+ // setProperty('--x', ...) call.
474
+ const overrideSites = new Map();
475
+ const overrideAt = (name, index) => {
476
+ if (!overrideSites.has(name) || index < overrideSites.get(name)) overrideSites.set(name, index);
477
+ };
478
+ styleBlockDeclarations(regions, overrideAt);
479
+ if (code !== null) styleBlockDeclarations(inlineStyleRegions(code), overrideAt);
480
+ for (const m of text.matchAll(/(?:style:|setProperty\(\s*['"`])(--[a-z0-9-]+)/g)) {
481
+ overrideAt(m[1], m.index);
482
+ }
483
+
484
+ // A name the vocabulary already ties to a component is that component's
485
+ // token, so declaring it here is one instance overriding the whole
486
+ // project's retuning surface at /live-tokens/components.
487
+ for (const [name, index] of overrideSites) {
488
+ if (!vocab.componentTokens.has(name)) continue;
489
+ const owner = componentTokenOwners(vocab).get(name) ?? 'a shipped component';
490
+ add(
491
+ 'property-override',
492
+ index,
493
+ `${name} overrides ${owner}'s token here instead of the whole project; retune it at /live-tokens/components.`,
494
+ );
373
495
  }
374
496
 
375
497
  for (const region of [...regions, ...(code === null ? [] : inlineStyleRegions(code))]) {
@@ -378,7 +500,7 @@ function checkFile(file, text, vocab, root) {
378
500
 
379
501
  for (const m of css.matchAll(/var\(\s*(--[a-z0-9-]+)/g)) {
380
502
  const name = m[1];
381
- if (declaredHere.has(name) || vocab.knows(name)) continue;
503
+ if (declaredSites.has(name) || vocab.knows(name)) continue;
382
504
  add(
383
505
  'unknown-token',
384
506
  at(m.index),
package/bin/cli.mjs CHANGED
@@ -20,6 +20,7 @@ import { fileURLToPath } from 'node:url';
20
20
  import process from 'node:process';
21
21
  import { COMPONENT_RULES, COMPONENT_RULE_FIX, checkComponent, discoverComponents, formatReport } from './check-component.mjs';
22
22
  import { PAGE_RULES, checkPages, discoverPages } from './check-page.mjs';
23
+ import { resolvePageTestTargets } from './lib/pageRoutes.mjs';
23
24
  import { describeComponents, describeTokens, formatComponents, formatTokens } from './lib/catalogue.mjs';
24
25
  import { buildReport, formatReport as formatProjectReport } from './lib/report.mjs';
25
26
  import { loadVocabulary } from './lib/tokenVocabulary.mjs';
@@ -74,10 +75,20 @@ Commands:
74
75
  @playwright/test, vitest, and happy-dom; a
75
76
  missing one is a tests-not-installed finding
76
77
  naming the install command
77
- check-page [paths...] Validate pages against the live-tokens-create-page
78
+ check-page [paths...] [--tests]
79
+ Validate pages against the live-tokens-create-page
78
80
  contract: catalogue components only, and every CSS
79
81
  value a design token. Checks every page under src/
80
- when given no paths.
82
+ when given no paths. --tests also opens each
83
+ page's own route in the consumer's own app and
84
+ proves, per shipped rule id, that the cascade
85
+ painted every component from its semantic
86
+ properties, every run of text sits in one shipped
87
+ text style, every text/surface pair meets AA,
88
+ sections sit on the page grid, and nothing
89
+ overflows. Needs @playwright/test, vitest, and
90
+ happy-dom; a missing one is a tests-not-installed
91
+ finding naming the install command
81
92
 
82
93
  check-component and check-page also accept:
83
94
  --json Machine-readable findings, for a skill to iterate
@@ -270,7 +281,18 @@ if (command === 'check-page') {
270
281
  const opts = parseCheckFlags(rest);
271
282
  const targets = opts.rest.length > 0 ? opts.rest : discoverPages(process.cwd());
272
283
  const { findings, checked } = checkPages(targets, { root: process.cwd() });
273
- reportChecks('check-page', findings, checked, PAGE_RULES, opts);
284
+ if (!opts.tests) {
285
+ reportChecks('check-page', findings, checked, PAGE_RULES, opts);
286
+ }
287
+ const { hasHardFailure, runPageTests } = await import('./contractRunner.mjs');
288
+ const pageTargets = resolvePageTestTargets(opts.rest, process.cwd());
289
+ const testOutcome = await runPageTests(pageTargets, { root: process.cwd() });
290
+ const label = 'check-page --tests';
291
+ const allFindings = [...findings, ...testOutcome.findings];
292
+ reportChecks(label, allFindings, Math.max(checked, pageTargets.length), PAGE_RULES, opts, {
293
+ coverage: testOutcome.coverage,
294
+ hardFailure: hasHardFailure(testOutcome.findings),
295
+ });
274
296
  }
275
297
 
276
298
  if (command === 'set-colors') {