@krapwoo/ds-viewer 0.4.9 → 0.6.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 (33) hide show
  1. package/README.md +74 -8
  2. package/dist/cli/doctor.js +92 -5
  3. package/dist/cli/explain.js +11 -20
  4. package/dist/cli/main.js +11 -2
  5. package/dist/cli/render.js +338 -0
  6. package/dist/cli/staticPage.js +27 -4
  7. package/dist/native/catalog/comparison.js +69 -30
  8. package/dist/native/catalog/tokens.js +8 -6
  9. package/native/catalog/CatalogButton.tsx +47 -0
  10. package/native/catalog/CatalogShell.tsx +29 -11
  11. package/native/catalog/CatalogSidebar.tsx +18 -6
  12. package/native/catalog/ComparisonGrid.tsx +9 -8
  13. package/native/catalog/ComparisonGroups.tsx +6 -5
  14. package/native/catalog/ComparisonList.tsx +12 -3
  15. package/native/catalog/OverlayDemo.tsx +52 -0
  16. package/native/catalog/PhoneScreen.tsx +51 -0
  17. package/native/catalog/SectionBlock.tsx +16 -11
  18. package/native/catalog/SpecimenSurface.tsx +4 -4
  19. package/native/catalog/TokenLayouts.tsx +14 -3
  20. package/native/catalog/UpdatePanel.tsx +4 -4
  21. package/native/catalog/comparison.ts +77 -33
  22. package/native/catalog/index.ts +4 -1
  23. package/native/catalog/overlayDemoState.ts +8 -0
  24. package/native/catalog/pageApi.ts +3 -0
  25. package/native/catalog/specimenSurfaceStyle.ts +11 -0
  26. package/native/catalog/tokens.ts +8 -6
  27. package/native/catalog/types.ts +10 -3
  28. package/package.json +1 -1
  29. package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +38 -48
  30. package/starter-kit/components/Dialog/Dialog.catalog.tsx +16 -16
  31. package/starter-kit/components/Toast/Toast.catalog.tsx +9 -10
  32. package/starter-kit/pages/ReportIssue.catalog.tsx +17 -13
  33. package/starter-kit/pages/SavedTrips.catalog.tsx +114 -119
package/README.md CHANGED
@@ -175,13 +175,18 @@ when the card is too narrow even for two. The specimens use one of four layouts:
175
175
  - **Preview** — `render()` content. Component previews render at phone width (402px), optionally
176
176
  with more widths such as a 320px small phone; token galleries and catalog chrome stay full width.
177
177
 
178
- When a page has two blocks, the catalog places them side by side if that makes the page at least
179
- 120px shorter (e.g. Dropdown), and stacks them otherwise. A block with nothing to show is omitted.
180
-
181
- Grid columns and list cells are at most 402px wide with 16px padding. A page's `specimenSize`
182
- (`compact` 160px, `regular` 240px, `wide` 402px) sets the minimum width; full-width (`itemsFill`)
183
- slots default to `wide`. The catalog's own look comes from `native/catalog/tokens.ts`, never from
184
- the host app's tokens.
178
+ When a page has two blocks, the catalog places them side by side — the row split into two equal
179
+ columns, each block filling its own — if both keep their minimum width there and it makes the page
180
+ at least 120px shorter (e.g. Dropdown); otherwise they stack, each filling the whole row. A block
181
+ with nothing to show is omitted.
182
+
183
+ Every card fills the column it is given, and its columns or cells share that width equally: a
184
+ two-column grid is as wide as a five-column one, and a two-item list is as wide as a six-item one.
185
+ Phone width (402px, 370px of content) caps the specimen inside a cell, not the cell. A page's
186
+ `specimenSize` (`compact` 160px, `regular` 240px, `wide` 402px) sets the minimum column width — a
187
+ card narrower than its minimum scrolls inside itself rather than squeezing its cells. Full-width
188
+ (`itemsFill`) slots default to `wide`. The catalog's own look comes from `native/catalog/tokens.ts`,
189
+ never from the host app's tokens.
185
190
 
186
191
  ## Adding a component (the recipe)
187
192
 
@@ -201,7 +206,9 @@ actually apply:
201
206
  combination does not exist. Never multiply `variants` by `states` to fill it. Keep the column axis
202
207
  within what fits a 1280px laptop: 5 compact, 3 regular, or 2 wide columns. `states` items whose
203
208
  key matches a row or column key are not repeated below the grid.
204
- - `specimenSize: 'compact' | 'regular' | 'wide'` — the page's specimen width class.
209
+ - `specimenSize: 'compact' | 'regular' | 'wide' | 'page'` — the page's specimen width class. `'page'`
210
+ is for components wider than a phone (a whole screen layout, a catalog page): cells are at least
211
+ 512px and share the row, with no 402px cap.
205
212
  - `specimenSurface: 'auto' | 'neutral' | 'white' | 'dark' | 'transparent'` — the fill of each
206
213
  example's cell (list, grouped and grid cells, and a non-token `render()` preview). Defaults to
207
214
  `'auto'`: after the page draws, a cell turns light gray only when its own example would vanish
@@ -272,6 +279,28 @@ actually apply:
272
279
  in an iPhone SE viewport (375 × 667 points), scaled down only when its cell is narrower. In the
273
280
  browser viewer it opens that example in its own device-sized document, so overlays that portal
274
281
  to the page (React Native's `Modal`) open inside the phone, not over the catalog.
282
+ - **`OverlayDemo`** — a phone with a catalog button that opens an overlay. Children get
283
+ `{ open, close }`; keep the overlay mounted and drive its own `visible` from `open`, so its
284
+ animations play. `keepTrigger` makes the button a toggle; `triggerLabel` can follow the state;
285
+ `initiallyOpen` starts it open.
286
+ ```tsx
287
+ <OverlayDemo triggerLabel="Open sheet">
288
+ {({ open, close }) => (
289
+ <BottomSheet visible={open} onDismiss={close} footer={<Dock><Button label="Confirm" onPress={close} /></Dock>}>
290
+ …
291
+ </BottomSheet>
292
+ )}
293
+ </OverlayDemo>
294
+ ```
295
+ - **`PhoneScreen`** — a whole app screen inside a `PhoneFrame`: `header` stays at the top,
296
+ `footer` at the phone's bottom, and the children scroll between them instead of pushing the
297
+ footer off the screen. `floating` is a layer over the top for toasts (position its content
298
+ yourself); `bodyWrapper` puts your own surface around the scrolling body.
299
+ ```tsx
300
+ <PhoneFrame>
301
+ <PhoneScreen header={<TopNav title="Saved trips" />} footer={<Dock>…</Dock>}>…</PhoneScreen>
302
+ </PhoneFrame>
303
+ ```
275
304
  - Full-width (`itemsFill`) variants and states wrap at most **3** across; set `maxColumns` to
276
305
  change that. A filled example that keeps its own fixed width is centred in its cell.
277
306
  Default to one preview; add a second width only when a narrower (or wider) viewport actually
@@ -459,10 +488,47 @@ Button
459
488
  or `x`/`onXChange`) but every example that renders it passes a fixed value with no working
460
489
  callback. Add `intentionalStaticPreview: { reason: '...' }` to the page to suppress it when the
461
490
  static examples are deliberate.
491
+ - `preview-only-component-page` — a component page's only examples are a free-form `render()`
492
+ preview, so nothing is named and the checks above have no example to read. Give the page
493
+ `variants` (and `states`, where they differ) naming each real configuration. Token galleries
494
+ and a declared `intentionalStaticPreview` are exempt; `render()` stays right for a token
495
+ gallery or a whole-page layout.
496
+ - `internal-prop-unknown` — `internalProps` names something that is not a prop of the component,
497
+ so the exemption it was written for is not in effect. Correct the spelling or delete the entry.
498
+ A page's `internalProps: { size: 'why' }` exempts a named prop from `option-not-covered` — for
499
+ plumbing a parent passes (a layout size, an internal surface kind) rather than a value the
500
+ reader picks. It needs a reason per prop, so the exemption is a visible declaration in the page;
501
+ when the reader really does choose the value, tag an example with `props` instead.
462
502
  The analyzer follows supported aliases and resolved exports, but uncertain wrappers or
463
503
  dynamically-constructed references remain uncheckable rather than being guessed at. A
464
504
  declared `intentionalStaticPreview` is taken as the author's own call, not independently
465
505
  verified; only a real browser check confirms actual behavior.
506
+ - `overlay-without-device-frame` (warning) — the component imports React Native's `Modal`, which
507
+ opens over the whole catalog page on the web, but its page imports no `PhoneFrame`,
508
+ `OverlayDemo`, `PhoneScreen` or `BoundedOverlayViewport`. Conservative: it can't see
509
+ `import * as RN`, a re-exported `Modal`, or a Modal opened by a sub-component.
510
+
511
+ ### `doctor --render`
512
+
513
+ `npx ds-viewer doctor --render` also starts the viewer, opens every page in a headless browser,
514
+ and adds what only a real render shows:
515
+
516
+ - `render-error` / `render-no-heading` (errors): a console error, or a page that didn't render.
517
+ - `render-layout` (warning): an example wider than its cell (the viewer's own check), with the
518
+ page and file. `render-catalog-warning` (warning): any other `[Catalog]` warning, reported once.
519
+ - `identical-variants` (warning): two examples in the same card whose specimens render identically,
520
+ so the difference their names claim isn't visible — usually a cell too narrow to show it, or a
521
+ prop that was never wired up. The fingerprint reads each specimen's whole subtree (including a
522
+ PhoneFrame's child document and a field's value) but not the caption carrying the variant's own
523
+ name, so two examples differing only in colour are not reported.
524
+ - `render-unavailable` / `render-failed` (errors): no browser, or the catalog didn't start.
525
+
526
+ It needs `puppeteer` in your project (`npm install --save-dev puppeteer`); ds-viewer doesn't
527
+ install a browser. It takes about two minutes for 50 pages and works with `--json` (progress
528
+ goes to stderr), `--ci` and `doctor.strict`, so CI can run `npx ds-viewer doctor --render --ci`.
529
+ It stops the viewer it started, including on Ctrl-C (exit codes 130, 143 or 129 for SIGINT,
530
+ SIGTERM or SIGHUP). On Windows it stops the viewer with `taskkill`; that path is unit-tested, not
531
+ run on a real Windows machine.
466
532
 
467
533
  ## Known gaps
468
534
 
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { discoverAllPageFiles, resolvePageId } from "./pageIndex.js";
5
5
  import { readHostTsconfigOptions } from "./props.js";
6
- import { readStaticPages } from "./staticPage.js";
6
+ import { importedNames, readStaticPages } from "./staticPage.js";
7
7
  import { resolveGlob } from "./glob.js";
8
8
  import { sync } from "./sync.js";
9
9
  import { gridColumnLimit } from "../native/catalog/comparison.js";
@@ -208,6 +208,43 @@ function checkMissingComposition(pages, components) {
208
208
  }
209
209
  return issues;
210
210
  }
211
+ /** The viewer exports that frame an example as a phone (or another bounded document), so an
212
+ * overlay that portals to the document body opens inside it instead of over the catalog. */
213
+ const DEVICE_FRAME_EXPORTS = ['PhoneFrame', 'OverlayDemo', 'PhoneScreen', 'BoundedOverlayViewport'];
214
+ /** Whether a component's own source imports React Native's `Modal` (by its imported name, so an
215
+ * alias still counts and an unrelated local `Modal` doesn't). Conservative by design: it can't see
216
+ * `import * as RN`, a re-exported `Modal`, or a Modal opened by a sub-component. */
217
+ function importsModal(file) {
218
+ try {
219
+ const source = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.ES2022, true, ts.ScriptKind.TSX);
220
+ return [...importedNames(source, 'react-native').values()].includes('Modal');
221
+ }
222
+ catch {
223
+ return false;
224
+ }
225
+ }
226
+ /** `overlay-without-device-frame`: a page whose component opens a React Native `Modal` (which
227
+ * portals to the document body on the web, covering the whole catalog) but whose page imports no
228
+ * device frame. A page whose imports couldn't be read is skipped, never assumed unframed. */
229
+ export function checkOverlaysFramed(pages, components, projectRoot) {
230
+ const byName = new Map(components.map((c) => [c.name, c]));
231
+ const issues = [];
232
+ for (const page of pages) {
233
+ if (!page.component || page.viewerImports === undefined)
234
+ continue;
235
+ if (page.viewerImports.some((name) => DEVICE_FRAME_EXPORTS.includes(name)))
236
+ continue;
237
+ const component = byName.get(page.component);
238
+ if (!component || !importsModal(path.resolve(projectRoot, component.file)))
239
+ continue;
240
+ issues.push(issue({
241
+ id: 'overlay-without-device-frame', severity: 'warning', ...pageRef(page),
242
+ message: `"${component.name}" opens a React Native Modal, which covers the whole catalog page unless its example is inside a phone.`,
243
+ fix: 'Wrap the example in <PhoneFrame> (or use <OverlayDemo>), so it opens inside the phone.',
244
+ }));
245
+ }
246
+ return issues;
247
+ }
211
248
  /** A function-typed prop's printed type always contains `=>` (`cli/props.ts`'s own displayed type
212
249
  * strings — "(value: string) => void", "() => void", …); a plain data prop's never does. */
213
250
  function isFunctionPropType(type) {
@@ -242,6 +279,22 @@ function findControlledPair(component) {
242
279
  * - no example directly renders the component at all, or any direct example's value/callback is
243
280
  * `'unknown'` — genuinely uncheckable, and "uncheckable" must never be reported as "inert".
244
281
  * Never a newly-fatal gate: always a warning, same as every other advisory in this file. */
282
+ /** A component page whose only examples are a free-form `render()` preview: nothing is named, so
283
+ * the reader sees one picture instead of the component's real variants and states, and several
284
+ * other rules here (`missing-working-preview`, the option-coverage rules) are skipped because
285
+ * they have no named example to read. A token gallery is exempt — raw token data has no variants
286
+ * — and so is a page that says `intentionalStaticPreview` with a reason. */
287
+ function checkPreviewOnlyPages(pages) {
288
+ return pages
289
+ .filter((page) => page.component !== undefined && page.checkable && page.hasRender === true && !page.tokenGallery
290
+ && !page.intentionalStaticPreviewReason
291
+ && !page.comparison && !page.variantsItems?.length && !page.statesItems?.length && !page.tokenSectionTitles?.length)
292
+ .map((page) => issue({
293
+ id: 'preview-only-component-page', severity: 'warning', ...pageRef(page),
294
+ message: `"${page.component}" is documented by a render() preview alone, with no named variants, states or comparison.`,
295
+ fix: 'Give the page variants (and states, where they differ) naming each real configuration, so every example has a caption and the other checks can read them. Keep render() for token galleries and whole-page layouts.',
296
+ }));
297
+ }
245
298
  function checkMissingWorkingPreview(pages, components) {
246
299
  const componentsByName = new Map(components.map((c) => [c.name, c]));
247
300
  const issues = [];
@@ -372,8 +425,25 @@ function checkComponentPages(pages, components) {
372
425
  }));
373
426
  }
374
427
  }
428
+ // Props the page declares as plumbing the reader never picks (with a reason) are not expected
429
+ // to have an example per option; see `internalProps`.
430
+ const internalProps = new Set(page.internalProps ?? []);
431
+ // A misspelled key silently exempts nothing, so the author sees the warnings it was meant to
432
+ // settle and no reason why — the same reasoning as propNotes' own key check.
433
+ for (const name of internalProps) {
434
+ // `component.props` deliberately omits a node_modules-declared prop (cli/props.ts), so an
435
+ // inherited name such as `autoCapitalize` is expected to be absent rather than misspelled —
436
+ // the same exemption the tagged-item check above makes.
437
+ if (!propsByName.has(name) && component.inheritedFrom.length === 0) {
438
+ issues.push(issue({
439
+ id: 'internal-prop-unknown', severity: 'warning', ...ref,
440
+ message: `internalProps names "${name}", which is not a prop of ${component.name}.`,
441
+ fix: `Correct the spelling, or delete the "${name}" entry — it exempts nothing as written.`,
442
+ }));
443
+ }
444
+ }
375
445
  for (const prop of component.props) {
376
- if (!prop.options)
446
+ if (!prop.options || internalProps.has(prop.name))
377
447
  continue;
378
448
  const covered = new Set();
379
449
  if (page.comparison) {
@@ -419,10 +489,15 @@ function checkComponentPages(pages, components) {
419
489
  fix: 'Add a one-sentence whenToUse note disambiguating this component from its closest look-alike(s), or omit it only when there genuinely is none.',
420
490
  }));
421
491
  }
422
- if (!page.tokenGallery && page.hasA11y === false && !page.hidesAccessibility) {
492
+ // `hide: { accessibility: true }` is a display choice, not an exemption from documenting the
493
+ // component: hiding the section only removes the visible placeholder, so without this the gap
494
+ // becomes invisible to both the reader and the doctor.
495
+ if (!page.tokenGallery && page.hasA11y === false) {
423
496
  issues.push(issue({
424
497
  id: 'missing-guidance-or-a11y', severity: 'warning', ...ref,
425
- message: 'No "a11y" notes documented.',
498
+ message: page.hidesAccessibility
499
+ ? 'No "a11y" notes documented, and the page hides the accessibility section.'
500
+ : 'No "a11y" notes documented.',
426
501
  fix: 'Add a one-sentence a11y note, grounded in the component\'s real behavior — even "no explicit handling beyond defaults" is better than leaving it blank.',
427
502
  }));
428
503
  }
@@ -443,6 +518,7 @@ export function collectIssues(pages, components) {
443
518
  ...checkComponentPages(pages, components),
444
519
  ...checkMissingComposition(pages, components),
445
520
  ...checkMissingWorkingPreview(pages, components),
521
+ ...checkPreviewOnlyPages(pages),
446
522
  ];
447
523
  }
448
524
  /** Issues that aren't tied to one page: `sync`'s own "Could not read" warnings, and a configured
@@ -509,6 +585,15 @@ export function checkPageTypeErrors(pages, options = {}) {
509
585
  }
510
586
  return issues;
511
587
  }
588
+ /** Adds findings to a finished result: applies `doctor.strict`, recounts errors and warnings, and
589
+ * resets the exit code. */
590
+ export function withExtraIssues(result, extra, strict) {
591
+ const added = strict ? extra.map((i) => (i.severity === 'warning' ? { ...i, severity: 'error' } : i)) : extra;
592
+ const issues = [...result.issues, ...added];
593
+ const errors = issues.filter((i) => i.severity === 'error').length;
594
+ const summary = { ...result.summary, errors, warnings: issues.length - errors };
595
+ return { ...result, issues, summary, exitCode: errors > 0 ? 1 : 0 };
596
+ }
512
597
  /** "With examples" means what `checkComponentNoExamples` (Task 4) checks the *absence* of: at
513
598
  * least one checkable page with a real example, not merely "has a page" (an `init`-drafted
514
599
  * "Needs examples" page has one, with none). */
@@ -572,6 +657,7 @@ export function runDoctor(config) {
572
657
  let issues = [
573
658
  ...collectIssues(pages, components),
574
659
  ...checkPageTypeErrors(pages, { fallbackPaths, hostRoot: config.projectRoot }),
660
+ ...checkOverlaysFramed(pages, components, config.projectRoot),
575
661
  ...collectGlobalIssues({ syncWarnings: syncResult.warnings, tokenFileCount }),
576
662
  ];
577
663
  // Design's own `--json` example gives a project-relative `file`; `StaticPage.file` is absolute
@@ -588,7 +674,8 @@ export function runDoctor(config) {
588
674
  withExamples: countWithExamples(pages, components),
589
675
  unboundExamples: countUnboundExamples(pages),
590
676
  };
591
- return { version: 1, summary, update: null, issues, exitCode: summary.errors > 0 ? 1 : 0 };
677
+ const pageFileById = new Map(pages.map((p) => [resolvePageId(p, p.file), path.relative(config.projectRoot, p.file)]));
678
+ return { version: 1, summary, update: null, issues, exitCode: summary.errors > 0 ? 1 : 0, pageFiles: pageFileById };
592
679
  }
593
680
  export function toDoctorJson(result) {
594
681
  const { version, summary, update, issues } = result;
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import { readStaticPages } from "./staticPage.js";
3
3
  import { discoverAllPageFiles, resolvePageId } from "./pageIndex.js";
4
4
  import { resolveGlob } from "./glob.js";
5
- import { axisItems, choosePlacement, gridColumnLimit, listGeometry, presentationBlocks, MATRIX_LAYOUT, } from "../native/catalog/comparison.js";
5
+ import { axisItems, blockMinWidth, choosePlacement, gridColumnLimit, listGeometry, presentationBlocks, MATRIX_LAYOUT, } from "../native/catalog/comparison.js";
6
6
  import { CATALOG_LAYOUT } from "../native/catalog/tokens.js";
7
7
  import { sectionColumns } from "../native/catalog/tokenLayout.js";
8
8
  function placeholderItem(item) {
@@ -113,23 +113,14 @@ function explainBlock(block) {
113
113
  }
114
114
  return { kind: 'empty', title: block.title, reason: block.message };
115
115
  }
116
- /** Mirrors `native/catalog/SectionBlock.tsx`'s own `blockWidth` helper: a list block's width is its
117
- * own natural, content-hugging width (`listGeometry`'s `containerWidth`) — the real viewer only
118
- * ever moves a *second* block beside a *first* one when that first block doesn't already fill the
119
- * whole row, so `choosePlacement` only reads `first.width` (never `second.width`) — but it's
120
- * computed uniformly here for either role, since setting it on `second` is simply unused, not wrong. */
121
- function toPlacementBlock(block, height, available) {
122
- if (block.kind === 'list') {
123
- return {
124
- kind: 'list',
125
- height,
126
- itemCount: block.items.length,
127
- size: block.size,
128
- maxColumns: block.maxColumns,
129
- width: listGeometry(block.items.length, available, block.size, block.maxColumns).containerWidth,
130
- };
131
- }
132
- return { kind: block.kind, height };
116
+ /** Mirrors `native/catalog/SectionBlock.tsx`'s own `placementBlock` helper: side by side splits the
117
+ * row in two and each block fills its half, so the decision needs each block's narrowest width
118
+ * plus, for a list, the geometry that predicts its height in half a row. */
119
+ function toPlacementBlock(block, height) {
120
+ const base = { kind: block.kind, height, minWidth: blockMinWidth(block) };
121
+ return block.kind === 'list'
122
+ ? { ...base, itemCount: block.items.length, size: block.size, maxColumns: block.maxColumns }
123
+ : base;
133
124
  }
134
125
  /** Finds `pageId` among every page `cli/doctor.ts`'s own `discoverAllPageFiles` would find, builds
135
126
  * its throwaway `SectionDef`, and asks the viewer's own pure layout functions for each block's
@@ -163,8 +154,8 @@ export function explainPage(config, pageId, options = {}) {
163
154
  const decision = choosePlacement({
164
155
  available: MATRIX_LAYOUT.laptopContentWidth,
165
156
  gap: CATALOG_LAYOUT.blockGap,
166
- first: toPlacementBlock(blocks[0], firstHeight, MATRIX_LAYOUT.laptopContentWidth),
167
- second: toPlacementBlock(blocks[1], secondHeight, MATRIX_LAYOUT.laptopContentWidth),
157
+ first: toPlacementBlock(blocks[0], firstHeight),
158
+ second: toPlacementBlock(blocks[1], secondHeight),
168
159
  });
169
160
  placement = {
170
161
  decision,
package/dist/cli/main.js CHANGED
@@ -5,7 +5,8 @@ import { resolveConfig } from "./config.js";
5
5
  import { initExistingProject, initNewProject, promptInitMode } from "./init.js";
6
6
  import { dev } from "./dev.js";
7
7
  import { sync } from "./sync.js";
8
- import { formatHuman, runDoctor, toDoctorJson } from "./doctor.js";
8
+ import { formatHuman, runDoctor, toDoctorJson, withExtraIssues } from "./doctor.js";
9
+ import { renderCheck } from "./render.js";
9
10
  import { explainPage, formatExplain, parseHeights } from "./explain.js";
10
11
  import { readOwnVersion } from "./packageVersion.js";
11
12
  import { checkForUpdate, defaultPreferencesDir, readPersonalAutoCheck, resolveAutoCheck } from "./updateCheck.js";
@@ -27,6 +28,7 @@ Commands:
27
28
  doctor Check every page for drift and coverage gaps
28
29
  --json print the machine-readable report instead
29
30
  --ci exit 1 when any error was found (never on a warning alone)
31
+ --render also open every page in a headless browser (needs puppeteer)
30
32
  explain <Page>
31
33
  Print why a page's specimens are laid out the way they are
32
34
  --heights <first>,<second> check side-by-side placement with these measured heights
@@ -116,7 +118,14 @@ export async function runInit(projectRoot, rest, options = {}) {
116
118
  * plain `doctor` (no `--ci`) always returns 0 here regardless of errors found. */
117
119
  export async function runDoctorCommand(projectRoot, rest, options = {}) {
118
120
  const config = resolveConfig(projectRoot);
119
- const result = runDoctor(config);
121
+ let result = runDoctor(config);
122
+ // `--render`: open every page in a headless browser and add what only a real render shows.
123
+ // Progress goes to stderr, so `--json` output stays pure JSON.
124
+ if (rest.includes('--render')) {
125
+ const files = result.pageFiles;
126
+ const rendered = await (options.renderCheck ?? renderCheck)(config.projectRoot, { files, log: (line) => process.stderr.write(`${line}\n`) });
127
+ result = withExtraIssues(result, rendered, Boolean(config.doctor?.strict));
128
+ }
120
129
  // Design §4 "`--ci`: ... no update check." — `--ci` skips this whole block even when
121
130
  // `updateCheck` is otherwise enabled; `result.update` stays `null`.
122
131
  // Same precedence as the viewer: CI's environment variable, your own switch, the project config.