@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.
- package/README.md +74 -8
- package/dist/cli/doctor.js +92 -5
- package/dist/cli/explain.js +11 -20
- package/dist/cli/main.js +11 -2
- package/dist/cli/render.js +338 -0
- package/dist/cli/staticPage.js +27 -4
- package/dist/native/catalog/comparison.js +69 -30
- package/dist/native/catalog/tokens.js +8 -6
- package/native/catalog/CatalogButton.tsx +47 -0
- package/native/catalog/CatalogShell.tsx +29 -11
- package/native/catalog/CatalogSidebar.tsx +18 -6
- package/native/catalog/ComparisonGrid.tsx +9 -8
- package/native/catalog/ComparisonGroups.tsx +6 -5
- package/native/catalog/ComparisonList.tsx +12 -3
- package/native/catalog/OverlayDemo.tsx +52 -0
- package/native/catalog/PhoneScreen.tsx +51 -0
- package/native/catalog/SectionBlock.tsx +16 -11
- package/native/catalog/SpecimenSurface.tsx +4 -4
- package/native/catalog/TokenLayouts.tsx +14 -3
- package/native/catalog/UpdatePanel.tsx +4 -4
- package/native/catalog/comparison.ts +77 -33
- package/native/catalog/index.ts +4 -1
- package/native/catalog/overlayDemoState.ts +8 -0
- package/native/catalog/pageApi.ts +3 -0
- package/native/catalog/specimenSurfaceStyle.ts +11 -0
- package/native/catalog/tokens.ts +8 -6
- package/native/catalog/types.ts +10 -3
- package/package.json +1 -1
- package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +38 -48
- package/starter-kit/components/Dialog/Dialog.catalog.tsx +16 -16
- package/starter-kit/components/Toast/Toast.catalog.tsx +9 -10
- package/starter-kit/pages/ReportIssue.catalog.tsx +17 -13
- 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
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
package/dist/cli/doctor.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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;
|
package/dist/cli/explain.js
CHANGED
|
@@ -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 `
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
167
|
-
second: toPlacementBlock(blocks[1], secondHeight
|
|
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
|
-
|
|
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.
|