@krapwoo/ds-viewer 0.10.0 → 0.12.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 CHANGED
@@ -445,6 +445,88 @@ path. For anything beyond a single list of tokens, use the token-page layouts be
445
445
  declared in another component's folder, such as a shared `IconName`, is that component's
446
446
  options, so a Banner page never needs one example per icon.
447
447
 
448
+ ### Family pages
449
+
450
+ A page can document a family of related declarations — a view and its internal parts, or a
451
+ pattern and the pieces it's built from — on one page instead of scattering them across separate
452
+ catalog pages, each member getting its own address and search entry. Give it `members`, `whole`
453
+ and `sections` instead of `component`/`whenToUse`/`a11y`/specimens, which live on the members (the
454
+ whole is a member too):
455
+
456
+ ```tsx
457
+ export default defineCatalogPage({
458
+ id: 'Toast', group: 'Patterns', description: 'How a Toast is assembled from a Banner.',
459
+ whole: 'Toast',
460
+ sections: [
461
+ { label: 'Parts', kind: 'blocks', inWhole: true, members: ['ToastBanner'] },
462
+ { label: 'Related', kind: 'blocks', inWhole: false, members: ['ToastStandaloneBanner'] },
463
+ ],
464
+ members: [
465
+ { id: 'Toast', source: 'components/Toast/Toast.tsx:1', fidelity: 'documentation-only', relationship: 'Main pattern', description: '...' },
466
+ {
467
+ id: 'ToastBanner', component: 'Banner', parent: 'Toast',
468
+ source: 'components/Banner/Banner.tsx:28', fidelity: 'replicated', relationship: 'Anatomy', description: '...',
469
+ whenToUse: '...', a11y: '...', variants: { items: [/* ... */] },
470
+ },
471
+ {
472
+ id: 'ToastStandaloneBanner', source: 'components/Banner/Banner.tsx:28', fidelity: 'documentation-only', relationship: 'Related',
473
+ related: 'Wraps ToastBanner directly when a notice should stay until dismissed.', description: '...', aliases: ['BannerStandalone'],
474
+ },
475
+ ],
476
+ });
477
+ ```
478
+
479
+ - **`whole`** is a member id, and must equal the page's own `id`. It renders first, with no header
480
+ of its own (the page title already names it) and no number.
481
+ - **`sections`** (`{ label, kind: 'blocks' | 'list', inWhole, members }`) group the rest: `blocks`
482
+ renders each member as its own full block (specimens and all); `list` renders one card of folded
483
+ rows, each a toggle that expands in place. `inWhole` sections render under the page's one
484
+ "Parts" label; the rest render under its one "Related" label — the only two section labels the
485
+ page ever shows; each section's own `label` stays in the data (for the anatomy legend and your
486
+ own reference) but is never rendered. Numbering follows each member's own `parent` (below), not
487
+ this ordering.
488
+ - **`parent`** nests a member under another (e.g. a subview under its view). A top-level part (an
489
+ `inWhole` member whose `parent` is the whole, or who has no `parent` at all) is numbered `1`,
490
+ `2`, `3`… in the order it's first listed across `sections`; a child takes its parent's number
491
+ plus `.1`, `.2`, … the same way. A Related member (outside every `inWhole` section) gets no
492
+ number at all.
493
+ - **`anatomy`** (page-level) is the first box after the page head, labeled “Anatomy” like the
494
+ specimen grids: `{ picture, groups }`, where each group is
495
+ `{ label, items: [{ member, description } | { other, description }] }`. A `member` item names one
496
+ of this page's own members (not the whole) and is numbered to match its Part; an `other` item
497
+ names a component documented elsewhere, shows an unnumbered ↗ pin, and links only when the name
498
+ resolves to a real catalog address. With six items or fewer the legend sits beside `picture`;
499
+ with more it goes below it in up to three columns, each group placed in the currently shortest
500
+ column so the columns end nearly level (a group never splits). Mark `picture` with
501
+ `<AnatomyPin member="ThatMemberId" />` (its number and style come from the page, so `picture`
502
+ never hardcodes one) or `<AnatomyPin other="ThatComponent" />`.
503
+ - **`axis`** on a `variants` or `states` slot names the one property that grid varies (e.g.
504
+ `'Expression'`) and shows as the grid's label. Family pages hide the generic block labels, so
505
+ give each grid an `axis` there.
506
+ - **`fidelity`** (`'replicated' | 'approximated' | 'documentation-only'`) shows as a small badge.
507
+ Use `'documentation-only'` for a member with no rendered specimen (no native counterpart to show,
508
+ or nothing meaningfully different to render).
509
+ - **`related`** is a short sentence for a member outside the whole (e.g. "Wraps X directly …");
510
+ another member's name inside it becomes a link to that member's own address. **`aliases`** (on a
511
+ member, or on the page itself) resolve old/retired ids to the right address, for both routing and
512
+ search — on the page itself, this only takes effect when the page also has `members`; it's
513
+ accepted but unused on a normal (single-component) page.
514
+ - **`composedOf`** on a member — same shape and meaning as the page-level field above — shows as
515
+ one "Uses …" line inside the member's own disclosure, after any notes. A name links when it
516
+ resolves to a real catalog address (a page, another member, or an alias — in this family or any
517
+ other), otherwise it shows in code; an unknown name logs a `[Catalog]` warning the same way the
518
+ page-level field's does.
519
+ - **`notShown`** (page-level) is a short "Not shown in the browser: …" line under the page head,
520
+ for behavior a web catalog can't demonstrate (a timer, a native gesture). A member's own
521
+ `notShown` works the same way, under its specimens. Like `aliases`, the page-level field only
522
+ takes effect on a page with `members`.
523
+ - **`apiLabel`** (default `'API'`) renames the disclosure's reference-excerpt part for a host app
524
+ that generates one (e.g. `'Swift API'`) — never hardcode a specific language's wording. Also
525
+ only takes effect on a page with `members`.
526
+ - Every component-bound member (one with `component` set) wants a real `whenToUse`, a real `a11y`,
527
+ and at least one specimen, the same as a normal page's component — `doctor` checks each one.
528
+ See `viewer-host/viewer-pages/FamilyPage.catalog.tsx` in this repo for a complete, worked example.
529
+
448
530
  ### Token pages
449
531
 
450
532
  A token page shows each token at its real value with its role. Pick the layout by the token's
@@ -613,10 +695,22 @@ and adds what only a real render shows:
613
695
  cells are not visited at all. Usually fixed with a wider `specimenSize`, by stacking the blocks so
614
696
  the example gets the full row, or by dropping `itemsFill` from a small instance that should sit
615
697
  centered.
698
+ - `page-head-overlap` (error): something is painting on top of a page's own heading. This is the
699
+ one check measured at **800x600** — Puppeteer's default viewport, and so the size a smoke check
700
+ judges your catalog at — because the overlap it catches does not appear at 1280 at all: a page
701
+ title that is a single unbroken identifier (a long Swift type name, say) has no break
702
+ opportunity for the browser to use, so it keeps its full intrinsic width and paints past its own
703
+ column into the previous/next buttons. It measures every leaf element carrying text, clips each
704
+ rect to any ancestor that scrolls or clips (so something scrolled out of view is not reported as
705
+ covering anything), and reports a pair only when it overlaps the heading by more than 1px in
706
+ both directions, so a shared edge between neighbours is not a finding. Only the heading is
707
+ checked: specimens legitimately stack text, and reporting those would bury the one overlap that
708
+ is always a defect.
616
709
  - `render-unavailable` / `render-failed` (errors): no browser, or the catalog didn't start.
617
710
 
618
711
  It needs `puppeteer` in your project (`npm install --save-dev puppeteer`); ds-viewer doesn't
619
- install a browser. It takes about two minutes for 50 pages and works with `--json` (progress
712
+ install a browser. It takes about two minutes for 50 pages — plus a moment per page for the
713
+ narrow-viewport heading pass — and works with `--json` (progress
620
714
  goes to stderr), `--ci` and `doctor.strict`, so CI can run `npx ds-viewer doctor --render --ci`.
621
715
 
622
716
  It starts its own catalog on the first free port from 5181. `--port <n>` pins it instead, which
@@ -7,6 +7,7 @@ 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";
10
+ import { validateComposedOf, validateFamily } from "../native/catalog/pageApi.js";
10
11
  function issue(fields) {
11
12
  return fields;
12
13
  }
@@ -18,9 +19,14 @@ function pageRef(page) {
18
19
  * own file (design §2; the viewer already applies this same default via `page.component ??
19
20
  * page.id`, and the generated page index already defaults a missing `id` to the file stem). A
20
21
  * page from a standalone `pages` glob is never touched — `componentFolders` only ever contains
21
- * folders resolved from `config.components`, so a standalone page's own folder is never a member. */
22
+ * folders resolved from `config.components`, so a standalone page's own folder is never a member.
23
+ * A family page (non-empty `members`) is also never touched — same exemption the runtime applies
24
+ * at `pageApi.ts`'s `buildCatalogSections` (`page.members && page.members.length > 0` skips the
25
+ * `page.component ?? page.id` fallback entirely): a family's own guidance/examples always live on
26
+ * its members, never on the page itself, so a documentation-only whole (its file stem naming no
27
+ * real component export) must never be assigned one here either. */
22
28
  export function applyComponentDefaults(pages, componentFolders) {
23
- return pages.map((page) => page.component === undefined && componentFolders.has(path.dirname(page.file))
29
+ return pages.map((page) => page.component === undefined && componentFolders.has(path.dirname(page.file)) && !(page.members && page.members.length > 0)
24
30
  ? { ...page, component: resolvePageId(page, page.file) }
25
31
  : page);
26
32
  }
@@ -59,12 +65,22 @@ function pageHasExamples(page) {
59
65
  }
60
66
  function checkComponentNoExamples(pages, components) {
61
67
  const byComponent = new Map();
68
+ // Plan V3: "a component documented only as a member counts as documented" — any family member
69
+ // naming a component (regardless of which page, or whether that specific member has its own
70
+ // examples — a member with none gets its own `component-no-examples` from `checkFamilyMembers`
71
+ // instead) is enough to clear the component-wide "no catalog page at all" half of this rule.
72
+ const documentedByMember = new Set();
62
73
  for (const page of pages) {
63
74
  if (page.component)
64
75
  byComponent.set(page.component, [...(byComponent.get(page.component) ?? []), page]);
76
+ for (const member of page.members ?? [])
77
+ if (member.component)
78
+ documentedByMember.add(member.component);
65
79
  }
66
80
  return components
67
81
  .filter((c) => {
82
+ if (documentedByMember.has(c.name))
83
+ return false;
68
84
  const componentPages = byComponent.get(c.name) ?? [];
69
85
  // A not-statically-checkable page is given the benefit of the doubt here — it might have
70
86
  // real examples this reader simply couldn't read (Global Constraint: never a partial read;
@@ -83,6 +99,152 @@ function checkComponentNoExamples(pages, components) {
83
99
  });
84
100
  });
85
101
  }
102
+ /** Plan V3: the same two advisories `checkComponentPages` gives a page-level `component` —
103
+ * missing guidance/accessibility, and no examples — evaluated per component-bound member of a
104
+ * family page instead, with the member id in the message. A documentation-only member (no
105
+ * `component`) is exempt from all three. V2c item 9: a member whose `component` no longer
106
+ * resolves to a real export gets its own `component-export-removed` error here (below), naming
107
+ * the member, instead of being silently exempted. */
108
+ function checkFamilyMembers(pages, components) {
109
+ const componentsByName = new Map(components.map((c) => [c.name, c]));
110
+ const issues = [];
111
+ for (const page of pages) {
112
+ // V2c item 6: the same "is this really a family page" predicate the runtime uses
113
+ // (`pageApi.ts`'s `page.members && page.members.length > 0`) — an empty `members` array is a
114
+ // normal, single-component page, never a family.
115
+ if (!page.members || page.members.length === 0 || !page.checkable)
116
+ continue; // never a partial read — see checkGridAxisKeys's own note.
117
+ const ref = pageRef(page);
118
+ // Members in a `list` section render as folded rows without specimens, so asking them for
119
+ // examples would contradict `family-invalid`'s list-section rule.
120
+ const listMemberIds = new Set((page.sections ?? []).filter((s) => s.kind === 'list').flatMap((s) => s.members));
121
+ for (const member of page.members) {
122
+ if (!member.component)
123
+ continue; // documentation-only — exempt.
124
+ // V2c item 9: a member naming a component that no longer exists — reported here rather than
125
+ // silently exempted from every advisory (its props would also silently vanish at runtime).
126
+ if (!componentsByName.has(member.component)) {
127
+ issues.push(issue({
128
+ id: 'component-export-removed', severity: 'error', ...ref,
129
+ message: `Member "${member.id}" documents "${member.component}", which no longer exists as a component export.`,
130
+ fix: `Delete this member, or update its "component" to the export's new name.`,
131
+ }));
132
+ continue;
133
+ }
134
+ if (!member.hasWhenToUse) {
135
+ issues.push(issue({
136
+ id: 'missing-guidance-or-a11y', severity: 'warning', ...ref,
137
+ message: `Member "${member.id}": no "whenToUse" guidance documented.`,
138
+ fix: `Add a one-sentence whenToUse note for "${member.id}", or omit it only when there genuinely is none.`,
139
+ }));
140
+ }
141
+ if (!member.hasA11y) {
142
+ issues.push(issue({
143
+ id: 'missing-guidance-or-a11y', severity: 'warning', ...ref,
144
+ message: `Member "${member.id}": no "a11y" notes documented.`,
145
+ fix: `Add a one-sentence a11y note for "${member.id}", grounded in its real behavior.`,
146
+ }));
147
+ }
148
+ if (!member.hasExamples && !listMemberIds.has(member.id)) {
149
+ issues.push(issue({
150
+ id: 'component-no-examples', severity: 'warning', ...ref,
151
+ message: `Member "${member.id}" ("${member.component}") has no variants, states, or comparison — it will show no specimen.`,
152
+ fix: `Add variants/states/comparison to the "${member.id}" member.`,
153
+ }));
154
+ }
155
+ }
156
+ }
157
+ return issues;
158
+ }
159
+ /** Plan V3's `family-invalid`: reuses `native/catalog/pageApi.ts`'s own `validateFamily` —
160
+ * the exact same rule `pageApi.ts`'s `buildCatalogSections` runs at runtime — against every
161
+ * checkable family page, with the same catalog-wide "every other page/member id" contract that
162
+ * function already takes. N3: `StaticPage.sections` already carries each section's real
163
+ * `label`/`kind`, so `validateFamily`'s messages name the real section (never an empty label),
164
+ * and its N6 list-section rule can tell a `list` section from a `blocks` one. */
165
+ function checkFamilyValidity(pages) {
166
+ // V2c item 6: `members.length > 0`, the same predicate the runtime uses — an empty array is not
167
+ // a family page.
168
+ const familyPages = pages.filter((p) => p.members && p.members.length > 0 && p.checkable);
169
+ const allPageIds = new Set(pages.map((p) => resolvePageId(p, p.file)));
170
+ const familyIdsByPageId = new Map();
171
+ for (const page of familyPages) {
172
+ const id = resolvePageId(page, page.file);
173
+ const ids = new Set();
174
+ for (const member of page.members ?? []) {
175
+ ids.add(member.id);
176
+ for (const alias of member.aliases ?? [])
177
+ ids.add(alias);
178
+ }
179
+ for (const alias of page.aliases ?? [])
180
+ ids.add(alias);
181
+ familyIdsByPageId.set(id, ids);
182
+ }
183
+ const issues = [];
184
+ for (const page of familyPages) {
185
+ const id = resolvePageId(page, page.file);
186
+ const otherPageIds = new Set([...allPageIds].filter((pageId) => pageId !== id));
187
+ const otherMemberIds = new Set();
188
+ for (const [pageId, ids] of familyIdsByPageId)
189
+ if (pageId !== id)
190
+ for (const memberId of ids)
191
+ otherMemberIds.add(memberId);
192
+ const pageLike = {
193
+ id,
194
+ whole: page.whole,
195
+ // `inWhole` is never read by `validateFamily` — only `label`/`kind`/`members` matter here.
196
+ sections: page.sections?.map((s) => ({ ...s, inWhole: true })),
197
+ members: (page.members ?? []).map((m) => ({ id: m.id, parent: m.parent, aliases: m.aliases, hasSpecimens: m.hasExamples })),
198
+ aliases: page.aliases,
199
+ };
200
+ for (const message of validateFamily(pageLike, otherPageIds, otherMemberIds)) {
201
+ issues.push(issue({ id: 'family-invalid', severity: 'error', ...pageRef(page), message, fix: 'Fix the family\'s members/whole/sections to satisfy the rule above.' }));
202
+ }
203
+ }
204
+ return issues;
205
+ }
206
+ /** V2d item 5: resolves each family member's own `composedOf` the same way `buildCatalogSections`
207
+ * resolves it at runtime (`native/catalog/pageApi.ts`'s own `validateComposedOf`, imported
208
+ * directly — the subpath-import boundary that once made importing that file from `dist/cli/`
209
+ * unsafe is gone), against the same known-name universe `checkFamilyValidity` already builds for
210
+ * member ids — every real component name, every page id, and every member id across the whole
211
+ * catalog — so an unknown name is reported here too, not only ever silently warned in a browser
212
+ * console (`npm run check:catalog`). A member whose `composedOf` wasn't itself literally readable
213
+ * (`undefined`) has nothing to check — the same "never a partial read" leniency the page-level
214
+ * field already gets. Always a warning, the same severity the runtime's own console warning
215
+ * carries, never an error. N8: also accepts a member alias and a page-level alias — the same
216
+ * universe `FamilyPage`'s own link resolution (`aliasTargets`) already accepts. */
217
+ function checkMemberComposedOf(pages, components) {
218
+ const knownNames = new Set(components.map((c) => c.name));
219
+ for (const page of pages)
220
+ knownNames.add(resolvePageId(page, page.file));
221
+ for (const page of pages)
222
+ for (const member of page.members ?? [])
223
+ knownNames.add(member.id);
224
+ for (const page of pages)
225
+ for (const member of page.members ?? [])
226
+ for (const alias of member.aliases ?? [])
227
+ knownNames.add(alias);
228
+ for (const page of pages)
229
+ for (const alias of page.aliases ?? [])
230
+ knownNames.add(alias);
231
+ const issues = [];
232
+ for (const page of pages) {
233
+ if (!page.members || page.members.length === 0 || !page.checkable)
234
+ continue; // V2c item 6's own predicate.
235
+ const ref = pageRef(page);
236
+ for (const member of page.members) {
237
+ for (const message of validateComposedOf(member.composedOf, knownNames)) {
238
+ issues.push(issue({
239
+ id: 'composed-of-unknown', severity: 'warning', ...ref,
240
+ message: `Member "${member.id}": ${message}`,
241
+ fix: 'Correct the name, or add { component: \'...\', role: \'...\', relationship: \'...\' } for a component/page/member that really does exist.',
242
+ }));
243
+ }
244
+ }
245
+ }
246
+ return issues;
247
+ }
86
248
  function checkDuplicatePageId(pages) {
87
249
  const byId = new Map();
88
250
  for (const page of pages) {
@@ -225,7 +387,14 @@ function importsModal(file) {
225
387
  }
226
388
  /** `overlay-without-device-frame`: a page whose component opens a React Native `Modal` (which
227
389
  * 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. */
390
+ * device frame. A page whose imports couldn't be read is skipped, never assumed unframed.
391
+ * V2c item 3 (CLI side): deliberately exempts a family member's own `component` — this check
392
+ * keys off `page.component`/`page.viewerImports` (the page file's own top-level imports), neither
393
+ * of which a family page sets; a member naming a Modal-opening component would need its OWN
394
+ * device-frame-import evidence, which `cli/staticPage.ts`'s member reader doesn't currently carry.
395
+ * Left as a gap rather than guessed at — a family member wrapping a Modal in a device frame is
396
+ * rare enough (and the resulting miss only an advisory, never an error) that this is the smallest
397
+ * honest fix here. */
229
398
  export function checkOverlaysFramed(pages, components, projectRoot) {
230
399
  const byName = new Map(components.map((c) => [c.name, c]));
231
400
  const issues = [];
@@ -342,6 +511,13 @@ function checkComponentPages(pages, components) {
342
511
  for (const page of pages) {
343
512
  if (!page.component)
344
513
  continue;
514
+ // Plan V3: a family page's own guidance always lives on its members, never on the page
515
+ // itself — even if `applyComponentDefaults` (or, in principle, the author) ends up giving it
516
+ // a page-level `component` matching a real export, this page-level advisory must still defer
517
+ // entirely to `checkFamilyMembers`'s own per-member one. V2c item 6: `members.length > 0`,
518
+ // the same predicate the runtime uses — an empty array is not a family page.
519
+ if (page.members && page.members.length > 0)
520
+ continue;
345
521
  if (!page.checkable)
346
522
  continue; // never a partial read — see checkGridAxisKeys's own note.
347
523
  const component = componentsByName.get(page.component);
@@ -519,6 +695,9 @@ export function collectIssues(pages, components) {
519
695
  ...checkMissingComposition(pages, components),
520
696
  ...checkMissingWorkingPreview(pages, components),
521
697
  ...checkPreviewOnlyPages(pages),
698
+ ...checkFamilyMembers(pages, components),
699
+ ...checkFamilyValidity(pages),
700
+ ...checkMemberComposedOf(pages, components),
522
701
  ];
523
702
  }
524
703
  /** Issues that aren't tied to one page: `sync`'s own "Could not read" warnings, and a configured
@@ -121,6 +121,83 @@ export function identicalVariantIssues(page, pairs) {
121
121
  fix: "Make the difference visible — a wider specimenSize when the cell is too narrow to show it, or the prop the name claims — or drop one of the two examples.",
122
122
  }));
123
123
  }
124
+ /**
125
+ * The width the heading check measures at. 800x600 is Puppeteer's own default viewport, which is
126
+ * what a consumer's smoke check uses unless it says otherwise, so it is the width their catalog is
127
+ * judged at. Every other check here runs at 1280x800, where this class of overlap does not appear
128
+ * at all — which is exactly why issue #51 reached a release.
129
+ */
130
+ export const NARROW_VIEWPORT = { width: 800, height: 600 };
131
+ /**
132
+ * Runs in the browser: finds anything painting on top of the page's own heading.
133
+ *
134
+ * The page head puts the title in a `flex: 1` column beside a non-shrinking pager row. A title
135
+ * that is one unbroken identifier — a Swift type name, say — has no space for the browser's
136
+ * default line breaking to use, so without an explicit break opportunity it keeps its full
137
+ * intrinsic width and paints straight through the column into the pager (issue #51:
138
+ * `TranscriptMediaUnavailableChip` covered the `←` button by 14x22 at an 800px window). The
139
+ * reader sees the title and the control drawn over each other, and the title is the page's own
140
+ * heading, so it is the one thing on the page that must stay readable.
141
+ *
142
+ * Measured the way a consumer's own smoke check measures it: every leaf element that carries
143
+ * text, its rect clipped to each ancestor that handles its own overflow (so a scrolled-away rect
144
+ * is not reported as covering anything), and a pair counted only when it overlaps by more than
145
+ * 1px in BOTH directions — a shared edge or a single-pixel seam between neighbours is not an
146
+ * overlap.
147
+ *
148
+ * Only the heading is checked, not every pair on the page. Host specimens legitimately stack text
149
+ * — a badge over a thumbnail, a caption over a surface — and reporting those would bury the one
150
+ * overlap that is always a defect. The heading's own descendants are skipped for the same reason:
151
+ * a heading's `<Text>` child sits inside its `<View>` by construction.
152
+ */
153
+ export const HEAD_OVERLAP_SCRIPT = `(() => {
154
+ const doc = document;
155
+ const CONTAINS = /^(auto|scroll|hidden|clip)$/;
156
+ const heading = doc.querySelector('[role="heading"]');
157
+ if (!heading) return [];
158
+ const clip = (el) => {
159
+ const r = el.getBoundingClientRect();
160
+ let x1 = r.left, y1 = r.top, x2 = r.right, y2 = r.bottom;
161
+ for (let n = el.parentElement; n; n = n.parentElement) {
162
+ const cs = getComputedStyle(n);
163
+ if (CONTAINS.test(cs.overflowX) || CONTAINS.test(cs.overflowY)) {
164
+ const b = n.getBoundingClientRect();
165
+ if (b.left > x1) x1 = b.left;
166
+ if (b.top > y1) y1 = b.top;
167
+ if (b.right < x2) x2 = b.right;
168
+ if (b.bottom < y2) y2 = b.bottom;
169
+ }
170
+ }
171
+ return { x1: x1, y1: y1, x2: x2, y2: y2 };
172
+ };
173
+ const head = clip(heading);
174
+ if (head.x2 - head.x1 <= 0 || head.y2 - head.y1 <= 0) return [];
175
+ const out = [];
176
+ for (const el of doc.querySelectorAll('*')) {
177
+ if (el.children.length) continue;
178
+ if (el === heading || heading.contains(el)) continue;
179
+ const text = (el.textContent || '').trim();
180
+ if (!text) continue;
181
+ const r = el.getBoundingClientRect();
182
+ if (r.width <= 0 || r.height <= 0) continue;
183
+ const b = clip(el);
184
+ if (b.x2 - b.x1 <= 0 || b.y2 - b.y1 <= 0) continue;
185
+ const w = Math.min(head.x2, b.x2) - Math.max(head.x1, b.x1);
186
+ const h = Math.min(head.y2, b.y2) - Math.max(head.y1, b.y1);
187
+ if (w > 1 && h > 1) out.push({ other: text.slice(0, 40), w: Math.round(w), h: Math.round(h) });
188
+ }
189
+ return out;
190
+ })()`;
191
+ export function headOverlapIssues(ref, overlaps) {
192
+ return overlaps.map((o) => ({
193
+ id: 'page-head-overlap',
194
+ severity: 'error',
195
+ page: ref.id,
196
+ ...(ref.file ? { file: ref.file } : {}),
197
+ message: `At ${NARROW_VIEWPORT.width}px the page heading is covered by "${o.other}" over ${o.w}x${o.h}px.`,
198
+ fix: 'Let the heading wrap or truncate inside its own column, or move or clip whatever paints over it.',
199
+ }));
200
+ }
124
201
  /**
125
202
  * Runs in the browser: finds specimen content that paints outside its cell and cannot be reached.
126
203
  *
@@ -213,6 +290,8 @@ const DEV_START_TIMEOUT_MS = 120_000;
213
290
  const FIRST_LOAD_TIMEOUT_MS = 300_000;
214
291
  const PAGE_TIMEOUT_MS = 120_000;
215
292
  const SETTLE_MS = 900;
293
+ /** After a viewport change, long enough for react-native-web to reflow before anything is measured. */
294
+ const RESIZE_SETTLE_MS = 250;
216
295
  /** Stops `dev` and everything it started (Expo/Metro): the whole process tree on Windows, the
217
296
  * whole process group elsewhere (Expo stops through dev's own signal handler). Releases the pipes
218
297
  * so this process can exit. `os` is injectable for tests. */
@@ -436,6 +515,20 @@ export async function renderCheck(projectRoot, options = {}) {
436
515
  }
437
516
  if (Array.isArray(clipped))
438
517
  issues.push(...clippedSpecimenIssues({ id, file: options.files?.get(id) }, clipped));
518
+ // Last, because it is the only check that changes the viewport: the overlap it guards
519
+ // appears only at a narrow width, and every check above is calibrated at 1280. Advisory
520
+ // like the two passes above — a failing script must not cost this page its other findings.
521
+ let headOverlaps;
522
+ try {
523
+ await page.setViewport({ width: NARROW_VIEWPORT.width, height: NARROW_VIEWPORT.height });
524
+ await new Promise((r) => setTimeout(r, RESIZE_SETTLE_MS));
525
+ headOverlaps = (await page.evaluate(HEAD_OVERLAP_SCRIPT));
526
+ }
527
+ catch {
528
+ headOverlaps = undefined;
529
+ }
530
+ if (Array.isArray(headOverlaps))
531
+ issues.push(...headOverlapIssues({ id, file: options.files?.get(id) }, headOverlaps));
439
532
  await page.close();
440
533
  }
441
534
  catch (error) {
@@ -200,8 +200,137 @@ function readStaticPage(file) {
200
200
  page.internalProps = names;
201
201
  }
202
202
  }
203
+ const membersProp = findProp(pageObject, 'members');
204
+ if (membersProp) {
205
+ const members = readFamilyMembers(membersProp.initializer, consts, gridNames, sourceFile);
206
+ if (!members)
207
+ page.checkable = false;
208
+ else
209
+ page.members = members;
210
+ }
211
+ const wholeProp = findProp(pageObject, 'whole');
212
+ if (wholeProp) {
213
+ const whole = stringLiteral(resolve(wholeProp.initializer, consts, new Set()));
214
+ if (whole === undefined)
215
+ page.checkable = false;
216
+ else
217
+ page.whole = whole;
218
+ }
219
+ const sectionsProp = findProp(pageObject, 'sections');
220
+ if (sectionsProp) {
221
+ const sectionsRead = readSections(sectionsProp.initializer, consts);
222
+ if (!sectionsRead)
223
+ page.checkable = false;
224
+ else
225
+ page.sections = sectionsRead;
226
+ }
227
+ const aliasesProp = findProp(pageObject, 'aliases');
228
+ if (aliasesProp) {
229
+ const aliases = readStringLiteralArray(aliasesProp.initializer, consts);
230
+ if (!aliases)
231
+ page.checkable = false;
232
+ else
233
+ page.aliases = aliases;
234
+ }
203
235
  return page;
204
236
  }
237
+ /** A literal array of string literals (or same-file `const` references to one) — undefined for
238
+ * anything else, including an array containing even one non-literal element. */
239
+ function readStringLiteralArray(node, consts) {
240
+ const resolved = resolve(node, consts, new Set());
241
+ if (!ts.isArrayLiteralExpression(resolved))
242
+ return undefined;
243
+ const items = [];
244
+ for (const element of resolved.elements) {
245
+ const value = stringLiteral(resolve(element, consts, new Set()));
246
+ if (value === undefined)
247
+ return undefined;
248
+ items.push(value);
249
+ }
250
+ return items;
251
+ }
252
+ /** Plan V3 / V2c item 5: a literal array of `CatalogMember`-shaped object literals, down to just
253
+ * the five fields `cli/doctor.ts` needs (`StaticMember`). Undefined for anything that isn't
254
+ * itself a literal array, or whose element isn't an object literal with a literal `id` — the
255
+ * same "never a partial read" contract every other array reader here already follows.
256
+ * `hasExamples` reuses the same item-reading helpers (`readSlotItems`/`readComparison`) the
257
+ * page level uses, rather than a presence-only check — a member authored with `variants: {
258
+ * items: [] }` has no real specimen to show, the same way a page-level empty list doesn't.
259
+ * N1: a member's own `variants`/`states`/`comparison` follows the exact same "never a partial
260
+ * read" contract the page level already does (`readStaticPage`'s own `page.checkable = false`
261
+ * for the same three fields) — a non-literal slot or an unreadable comparison makes the whole
262
+ * members array (and so the whole page) not checkable, rather than silently reading as "no
263
+ * specimen" and firing a confidently false `component-no-examples`. */
264
+ function readFamilyMembers(node, consts, gridNames, sourceFile) {
265
+ const resolved = resolve(node, consts, new Set());
266
+ if (!ts.isArrayLiteralExpression(resolved))
267
+ return undefined;
268
+ const members = [];
269
+ for (const element of resolved.elements) {
270
+ const resolvedElement = resolve(element, consts, new Set());
271
+ if (!ts.isObjectLiteralExpression(resolvedElement))
272
+ return undefined;
273
+ const id = propertyStringLiteral(resolvedElement, 'id', consts);
274
+ if (id === undefined)
275
+ return undefined;
276
+ const component = propertyStringLiteral(resolvedElement, 'component', consts);
277
+ const parent = propertyStringLiteral(resolvedElement, 'parent', consts);
278
+ const aliasesProp = findProp(resolvedElement, 'aliases');
279
+ const aliases = aliasesProp ? readStringLiteralArray(aliasesProp.initializer, consts) : undefined;
280
+ const composedOfProp = findProp(resolvedElement, 'composedOf');
281
+ const composedOf = composedOfProp ? readComposedOf(composedOfProp.initializer, consts) : undefined;
282
+ const variants = readSlotItems(findProp(resolvedElement, 'variants'), consts, component, sourceFile);
283
+ if (!variants.checkable)
284
+ return undefined;
285
+ const states = readSlotItems(findProp(resolvedElement, 'states'), consts, component, sourceFile);
286
+ if (!states.checkable)
287
+ return undefined;
288
+ const comparisonProp = findProp(resolvedElement, 'comparison');
289
+ let comparison;
290
+ if (comparisonProp) {
291
+ comparison = readComparison(comparisonProp.initializer, consts, gridNames);
292
+ if (!comparison)
293
+ return undefined;
294
+ }
295
+ members.push({
296
+ id,
297
+ component,
298
+ parent,
299
+ aliases,
300
+ composedOf,
301
+ hasWhenToUse: findProp(resolvedElement, 'whenToUse') !== undefined,
302
+ hasA11y: findProp(resolvedElement, 'a11y') !== undefined,
303
+ hasExamples: Boolean(variants.items?.length) || Boolean(states.items?.length) || Boolean(comparison),
304
+ });
305
+ }
306
+ return members;
307
+ }
308
+ /** Plan V3 / N3: `CatalogPageInput.sections`, read down to `label`/`kind`/`members` — undefined
309
+ * (not checkable) for anything that isn't itself a literal array, or whose element isn't an
310
+ * object literal with a literal `label`, a literal `kind` of `'blocks'`/`'list'`, and a literal
311
+ * `members` array. */
312
+ function readSections(node, consts) {
313
+ const resolved = resolve(node, consts, new Set());
314
+ if (!ts.isArrayLiteralExpression(resolved))
315
+ return undefined;
316
+ const sections = [];
317
+ for (const element of resolved.elements) {
318
+ const resolvedElement = resolve(element, consts, new Set());
319
+ if (!ts.isObjectLiteralExpression(resolvedElement))
320
+ return undefined;
321
+ const label = propertyStringLiteral(resolvedElement, 'label', consts);
322
+ const kindProp = findProp(resolvedElement, 'kind');
323
+ const kind = kindProp ? stringLiteral(resolve(kindProp.initializer, consts, new Set())) : undefined;
324
+ const membersProp = findProp(resolvedElement, 'members');
325
+ if (label === undefined || (kind !== 'blocks' && kind !== 'list') || !membersProp)
326
+ return undefined;
327
+ const members = readStringLiteralArray(membersProp.initializer, consts);
328
+ if (!members)
329
+ return undefined;
330
+ sections.push({ label, kind, members });
331
+ }
332
+ return sections;
333
+ }
205
334
  /** A literal array of `ComposedOfEntry`-shaped object literals, read down to just `component`
206
335
  * (design §4: role/relationship are authored prose `doctor` never needs for its missing-
207
336
  * composition comparison). Undefined — not an empty array — for anything that isn't itself a