@krapwoo/ds-viewer 0.12.1 → 0.13.1

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
@@ -167,6 +167,13 @@ npm run viewer:dev # opens viewer-host/, the DS Viewer internals catalog (
167
167
 
168
168
  `check:catalog`, `check:doctor` and `check:types` cover both catalogs.
169
169
 
170
+ Three checks cover three different things, and none substitutes for another: `npm test` covers
171
+ pure logic and source assertions; `npm run test:render` renders catalog components into jsdom
172
+ through react-native-web and covers component structure (which element wraps what); `npm run
173
+ check:catalog` is the only one that sees geometry, because jsdom has no layout engine —
174
+ `getBoundingClientRect()` returns zeros there. A layout or alignment claim must be verified in a
175
+ browser, with `check:catalog`, never in a renderer test.
176
+
170
177
  ## Contributing
171
178
 
172
179
  Found a bug or an improvement while using ds-viewer in your app? Open an issue or a pull request:
@@ -459,6 +466,7 @@ export default defineCatalogPage({
459
466
  whole: 'Toast',
460
467
  sections: [
461
468
  { label: 'Parts', kind: 'blocks', inWhole: true, members: ['ToastBanner'] },
469
+ // deprecated shape — don't copy this; name a related component in `relatedPages` instead (below).
462
470
  { label: 'Related', kind: 'blocks', inWhole: false, members: ['ToastStandaloneBanner'] },
463
471
  ],
464
472
  members: [
@@ -484,7 +492,18 @@ export default defineCatalogPage({
484
492
  "Parts" label; the rest render under its one "Related" label — the only two section labels the
485
493
  page ever shows; each section's own `label` stays in the data (for the anatomy legend and your
486
494
  own reference) but is never rendered. Numbering follows each member's own `parent` (below), not
487
- this ordering.
495
+ this ordering. A non-`inWhole` section is **deprecated**: it still works, but `doctor`'s
496
+ `deprecated-related-section` now warns about it — give the member its own catalog page and name
497
+ it in `relatedPages` instead of documenting it inline here. Under `doctor.strict`, this warning
498
+ becomes an error, so a consumer with existing non-`inWhole` sections sees red CI on upgrade until
499
+ they migrate.
500
+ - **`relatedPages`** (`{ page, note? }[]`) names other catalog pages at the bottom of this page,
501
+ under the same "Related" heading, after any deprecated sections. Each row is the named page's own
502
+ id, linked when it resolves to a real catalog address — a page id, another family's member id, or
503
+ a family page's own alias or one of its members' aliases — and shown as plain text, never a dead link, when it doesn't
504
+ (`doctor` reports an unresolvable or self-naming entry either way), then its `note`, when
505
+ authored — no specimens, disclosure, source line, number, or pin. A page with neither
506
+ `relatedPages` nor a non-`inWhole` section renders no "Related" heading at all.
488
507
  - **`parent`** nests a member under another (e.g. a subview under its view). A top-level part (an
489
508
  `inWhole` member whose `parent` is the whole, or who has no `parent` at all) is numbered `1`,
490
509
  `2`, `3`… in the order it's first listed across `sections`; a child takes its parent's number
@@ -493,8 +512,10 @@ export default defineCatalogPage({
493
512
  - **`anatomy`** (page-level) is the first box after the page head, labeled “Anatomy” like the
494
513
  specimen grids: `{ picture, groups }`, where each group is
495
514
  `{ 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
515
+ of this page's own members (not the whole); it shows that member's Part number, or no pin at all
516
+ when the member sits outside every `inWhole` section — such a row reserves no gutter for the pin
517
+ it doesn't have, its name starting flush at the row's own left edge; an `other` item names a
518
+ component documented elsewhere, shows an unnumbered ↗ pin, and links only when the name
498
519
  resolves to a real catalog address. With six items or fewer the legend sits beside `picture`;
499
520
  with more it goes below it in up to three columns, each group placed in the currently shortest
500
521
  column so the columns end nearly level (a group never splits). Mark `picture` with
@@ -642,6 +663,10 @@ Button
642
663
  gallery or a whole-page layout.
643
664
  - `internal-prop-unknown` — `internalProps` names something that is not a prop of the component,
644
665
  so the exemption it was written for is not in effect. Correct the spelling or delete the entry.
666
+ - `deprecated-related-section` — a family page still documents one or more members inline in a
667
+ non-`inWhole` section, naming every one of them so you can act without opening the file. Give
668
+ each its own catalog page and name it in the family's `relatedPages` list instead; the section
669
+ keeps working until you do.
645
670
 
646
671
  The catalog also warns at render time when a page under-declares the width its examples need.
647
672
  A `PhoneFrame` given a cell narrower than the 375px device scales the whole phone down, and its
@@ -168,7 +168,12 @@ function checkFamilyValidity(pages) {
168
168
  const familyPages = pages.filter((p) => p.members && p.members.length > 0 && p.checkable);
169
169
  const allPageIds = new Set(pages.map((p) => resolvePageId(p, p.file)));
170
170
  const familyIdsByPageId = new Map();
171
- for (const page of familyPages) {
171
+ // I-B (independent review, confirmation pass): built from every page with members, not only
172
+ // checkable ones — the same precedent `checkMemberComposedOf` above already follows for
173
+ // `knownNames` (no `checkable` filter). A non-checkable family page still contributes a working
174
+ // link at runtime (`pageApi.ts`'s `otherMemberIds` has no `checkable` concept either), so
175
+ // excluding it here turned a valid `relatedPages` entry into a false CI-failing error.
176
+ for (const page of pages.filter((p) => p.members && p.members.length > 0)) {
172
177
  const id = resolvePageId(page, page.file);
173
178
  const ids = new Set();
174
179
  for (const member of page.members ?? []) {
@@ -192,10 +197,19 @@ function checkFamilyValidity(pages) {
192
197
  const pageLike = {
193
198
  id,
194
199
  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 })),
200
+ // `validateFamily` does read `inWhole` now (`pageApi.ts:277`, C1's narrowing).
201
+ // `StaticFamilySection.inWhole` is optional (unlike the real `CatalogMemberSection`) only so
202
+ // hand-built test fixtures don't all need one, so this defaults a missing one to `false` —
203
+ // the lenient direction (an unreadable `inWhole` must never manufacture a false "has no
204
+ // number" the day `anatomy` joins the static read) rather than passing `undefined` where
205
+ // `validateFamily`'s own type expects a boolean.
206
+ sections: page.sections?.map((s) => ({ ...s, inWhole: s.inWhole ?? false })),
197
207
  members: (page.members ?? []).map((m) => ({ id: m.id, parent: m.parent, aliases: m.aliases, hasSpecimens: m.hasExamples })),
198
208
  aliases: page.aliases,
209
+ // I2: passed through so an unresolvable or self-naming `relatedPages` entry is reported here
210
+ // too, the same precedent `checkMemberComposedOf` below already follows for `composedOf` —
211
+ // not only ever silently warned in a browser console (`npm run check:catalog`).
212
+ relatedPages: page.relatedPages,
199
213
  };
200
214
  for (const message of validateFamily(pageLike, otherPageIds, otherMemberIds)) {
201
215
  issues.push(issue({ id: 'family-invalid', severity: 'error', ...pageRef(page), message, fix: 'Fix the family\'s members/whole/sections to satisfy the rule above.' }));
@@ -203,6 +217,31 @@ function checkFamilyValidity(pages) {
203
217
  }
204
218
  return issues;
205
219
  }
220
+ /** Deprecation (`relatedPages`): a family page still using one or more non-`inWhole` sections to
221
+ * document a merely-related component inline, instead of giving it its own page and naming it in
222
+ * the page's `relatedPages` list. One finding per page (never per section), naming every member
223
+ * still parked in a deprecated section so the author can act without opening the file. Always a
224
+ * warning — `inWhole: false` keeps working. */
225
+ function checkDeprecatedRelatedSections(pages) {
226
+ const issues = [];
227
+ for (const page of pages) {
228
+ if (!page.members || page.members.length === 0 || !page.checkable)
229
+ continue; // V2c item 6's own predicate.
230
+ // Strictly `false`, never `!s.inWhole` — a test fixture (or any other caller) that omits
231
+ // `inWhole` entirely means "not testing this", not "deprecated".
232
+ const deprecated = (page.sections ?? []).filter((s) => s.inWhole === false);
233
+ if (deprecated.length === 0)
234
+ continue;
235
+ const memberIds = deprecated.flatMap((s) => s.members);
236
+ const id = resolvePageId(page, page.file);
237
+ issues.push(issue({
238
+ id: 'deprecated-related-section', severity: 'warning', ...pageRef(page),
239
+ message: `Family "${id}" documents ${memberIds.length} member${memberIds.length === 1 ? '' : 's'} (${memberIds.join(', ')}) in a deprecated non-"inWhole" section.`,
240
+ fix: `Give each of these members its own catalog page, drop the non-"inWhole" section, and name its new page in "${id}"'s relatedPages instead.`,
241
+ }));
242
+ }
243
+ return issues;
244
+ }
206
245
  /** V2d item 5: resolves each family member's own `composedOf` the same way `buildCatalogSections`
207
246
  * resolves it at runtime (`native/catalog/pageApi.ts`'s own `validateComposedOf`, imported
208
247
  * directly — the subpath-import boundary that once made importing that file from `dist/cli/`
@@ -698,6 +737,7 @@ export function collectIssues(pages, components) {
698
737
  ...checkFamilyMembers(pages, components),
699
738
  ...checkFamilyValidity(pages),
700
739
  ...checkMemberComposedOf(pages, components),
740
+ ...checkDeprecatedRelatedSections(pages),
701
741
  ];
702
742
  }
703
743
  /** Issues that aren't tied to one page: `sync`'s own "Could not read" warnings, and a configured
@@ -232,6 +232,9 @@ function readStaticPage(file) {
232
232
  else
233
233
  page.aliases = aliases;
234
234
  }
235
+ const relatedPagesProp = findProp(pageObject, 'relatedPages');
236
+ if (relatedPagesProp)
237
+ page.relatedPages = readRelatedPages(relatedPagesProp.initializer, consts);
235
238
  return page;
236
239
  }
237
240
  /** A literal array of string literals (or same-file `const` references to one) — undefined for
@@ -308,7 +311,10 @@ function readFamilyMembers(node, consts, gridNames, sourceFile) {
308
311
  /** Plan V3 / N3: `CatalogPageInput.sections`, read down to `label`/`kind`/`members` — undefined
309
312
  * (not checkable) for anything that isn't itself a literal array, or whose element isn't an
310
313
  * object literal with a literal `label`, a literal `kind` of `'blocks'`/`'list'`, and a literal
311
- * `members` array. */
314
+ * `members` array. I1: `inWhole` is read too, but — like `composedOf` — a non-literal `inWhole`
315
+ * never costs the section's `label`/`kind`/`members`, so the page stays checkable; it only means
316
+ * `checkDeprecatedRelatedSections` has nothing to check for that section (`StaticFamilySection`'s
317
+ * own `inWhole?` already reflects this: present only when it was itself a literal). */
312
318
  function readSections(node, consts) {
313
319
  const resolved = resolve(node, consts, new Set());
314
320
  if (!ts.isArrayLiteralExpression(resolved))
@@ -322,12 +328,14 @@ function readSections(node, consts) {
322
328
  const kindProp = findProp(resolvedElement, 'kind');
323
329
  const kind = kindProp ? stringLiteral(resolve(kindProp.initializer, consts, new Set())) : undefined;
324
330
  const membersProp = findProp(resolvedElement, 'members');
331
+ const inWholeProp = findProp(resolvedElement, 'inWhole');
332
+ const inWhole = inWholeProp ? literalBooleanOrUnknown(inWholeProp.initializer, consts) : undefined;
325
333
  if (label === undefined || (kind !== 'blocks' && kind !== 'list') || !membersProp)
326
334
  return undefined;
327
335
  const members = readStringLiteralArray(membersProp.initializer, consts);
328
336
  if (!members)
329
337
  return undefined;
330
- sections.push({ label, kind, members });
338
+ sections.push({ label, kind, members, inWhole });
331
339
  }
332
340
  return sections;
333
341
  }
@@ -352,6 +360,27 @@ function readComposedOf(node, consts) {
352
360
  }
353
361
  return entries;
354
362
  }
363
+ /** A literal array of `RelatedPage`-shaped object literals, read down to just `page` (design
364
+ * parity with `readComposedOf`: `note` is authored prose `doctor` never needs). Undefined — not
365
+ * an empty array — for anything that isn't itself a literal array, or whose element isn't an
366
+ * object literal with a literal `page`: `doctor` must never mistake "couldn't read this" for
367
+ * "author declared no related pages here". */
368
+ function readRelatedPages(node, consts) {
369
+ const resolved = resolve(node, consts, new Set());
370
+ if (!ts.isArrayLiteralExpression(resolved))
371
+ return undefined;
372
+ const entries = [];
373
+ for (const element of resolved.elements) {
374
+ const resolvedElement = resolve(element, consts, new Set());
375
+ if (!ts.isObjectLiteralExpression(resolvedElement))
376
+ return undefined;
377
+ const page = propertyStringLiteral(resolvedElement, 'page', consts);
378
+ if (page === undefined)
379
+ return undefined;
380
+ entries.push({ page });
381
+ }
382
+ return entries;
383
+ }
355
384
  /** Local name -> imported name, for every named import from `moduleSpecifier`. */
356
385
  export function importedNames(sourceFile, moduleSpecifier) {
357
386
  const names = new Map();
@@ -130,11 +130,20 @@ export function validateFamily(page, allPageIds, otherMemberIds) {
130
130
  checkId(alias, false);
131
131
  // Slice D1 item 3: an anatomy `member` item must name a real, non-whole member of this page, and
132
132
  // each member may be pinned to the picture's legend at most once.
133
- // D2 item A2: a member item whose member has no number — outside every `inWhole` section, or
134
- // under a parent that isn't itself numbered — must also be reported; left unchecked, it renders
135
- // a blank 34px pin slot in the legend and nothing at all from `AnatomyPin` in the picture.
133
+ // D2 item A2, narrowed per review C1: a member item is reported only when it sits inside an
134
+ // `inWhole` section but still has no number — i.e. its parent isn't itself numbered. Left
135
+ // unchecked, such a part renders indistinguishably from a Related row and its `AnatomyPin`
136
+ // renders nothing at all. A member outside every `inWhole` section (Related) is legitimately
137
+ // unnumbered by design (APPROVED.md: related members have no number) and must not be reported
138
+ // here.
136
139
  if (page.anatomy) {
137
140
  const numbered = numberFor(page);
141
+ const inWholeMemberIds = new Set();
142
+ for (const section of page.sections ?? []) {
143
+ if (section.inWhole)
144
+ for (const id of section.members)
145
+ inWholeMemberIds.add(id);
146
+ }
138
147
  const seenAnatomyMembers = new Set();
139
148
  for (const group of page.anatomy.groups) {
140
149
  for (const item of group.items) {
@@ -153,12 +162,39 @@ export function validateFamily(page, allPageIds, otherMemberIds) {
153
162
  continue;
154
163
  }
155
164
  seenAnatomyMembers.add(item.member);
156
- if (!numbered.has(item.member)) {
165
+ if (inWholeMemberIds.has(item.member) && !numbered.has(item.member)) {
157
166
  issues.push(`Family "${page.id}": anatomy item "${item.member}" has no number.`);
158
167
  }
159
168
  }
160
169
  }
161
170
  }
171
+ // `relatedPages`: each entry must name a real catalog page, other than this one, and the list
172
+ // must not repeat a page. An unresolvable or self-naming entry still renders (plain text, never
173
+ // a dead link), but is always reported here so `doctor` fails in CI. N8: `allPageIds` ∪
174
+ // `otherMemberIds` is the same universe `FamilyPage`'s own `resolvesAddress` accepts as a
175
+ // working link — page ids, member ids, and both page-level and member aliases — so an entry
176
+ // naming another page's alias or another family's member address is accepted too.
177
+ if (page.relatedPages) {
178
+ const seenRelatedPages = new Set();
179
+ for (const entry of page.relatedPages) {
180
+ if (seenRelatedPages.has(entry.page)) {
181
+ issues.push(`Family "${page.id}": relatedPages lists "${entry.page}" more than once.`);
182
+ continue;
183
+ }
184
+ seenRelatedPages.add(entry.page);
185
+ if (entry.page === page.id) {
186
+ issues.push(`Family "${page.id}": relatedPages entry "${entry.page}" names this page itself.`);
187
+ continue;
188
+ }
189
+ if (byId.has(entry.page)) {
190
+ issues.push(`Family "${page.id}": relatedPages entry "${entry.page}" is a member of this page, not another page.`);
191
+ continue;
192
+ }
193
+ if (!allPageIds.has(entry.page) && !otherMemberIds.has(entry.page)) {
194
+ issues.push(`Family "${page.id}": relatedPages entry "${entry.page}" is not a catalog page.`);
195
+ }
196
+ }
197
+ }
162
198
  return issues;
163
199
  }
164
200
  /** Slice D1 item 2: anatomy-first numbering — the only numbering rule for every family page (not
@@ -334,7 +370,7 @@ pages, components, groupOrder) {
334
370
  ...member,
335
371
  hasSpecimens: Boolean(member.variants?.items?.length || member.states?.items?.length || member.comparison),
336
372
  }));
337
- const issues = validateFamily({ ...page, members, anatomy: page.anatomy }, otherPageIds, otherMemberIds);
373
+ const issues = validateFamily({ ...page, members, anatomy: page.anatomy, relatedPages: page.relatedPages }, otherPageIds, otherMemberIds);
338
374
  if (globalThis.__DEV__) {
339
375
  for (const issue of issues)
340
376
  console.error(`[Catalog] ${issue}`);
@@ -347,6 +383,7 @@ pages, components, groupOrder) {
347
383
  aliases: page.aliases,
348
384
  apiLabel: page.apiLabel,
349
385
  anatomy: page.anatomy,
386
+ relatedPages: page.relatedPages,
350
387
  members: page.members.map((member) => {
351
388
  const memberComponent = member.component ? componentByName.get(member.component) : undefined;
352
389
  return {
@@ -26,7 +26,7 @@ import {
26
26
  type PartTreeEntry,
27
27
  type RelatedSegment,
28
28
  } from './familyModel';
29
- import type { CatalogAnatomy, CatalogAnatomyGroup, CatalogMember, CatalogMemberSection, ComposedOfEntry, FamilyDef, PreviewWidths, PropDef, SectionDef } from './types';
29
+ import type { CatalogAnatomy, CatalogAnatomyGroup, CatalogMember, CatalogMemberSection, ComposedOfEntry, FamilyDef, PreviewWidths, PropDef, RelatedPage, SectionDef } from './types';
30
30
 
31
31
  /** `SectionDef`'s parts `presentationBlocks`/`Blocks` actually read, built from one family member
32
32
  * so its own specimens render through the exact same renderers (`VariantGroup`, `ComparisonGrid`,
@@ -654,12 +654,46 @@ function FamilySectionGroup({
654
654
  );
655
655
  }
656
656
 
657
+ /** One row of the bottom-of-page `relatedPages` list (field spec, item 3 "Rendering"): the named
658
+ * page's own id as a link when it resolves to a real catalog address, plain text when not — never
659
+ * a dead link — then its note, when authored. No specimens, no disclosure, no source line, no
660
+ * number, no pin. */
661
+ function RelatedPageRow({ entry, resolvesAddress }: { entry: RelatedPage; resolvesAddress: (name: string) => boolean }) {
662
+ const resolved = resolvesAddress(entry.page);
663
+ return (
664
+ <Text style={styles.belowLine}>
665
+ {resolved ? (
666
+ <Text role="link" style={styles.relatedPageName} {...({ href: hashForId(entry.page) } as Record<string, unknown>)}>
667
+ {entry.page}
668
+ </Text>
669
+ ) : (
670
+ <Text style={[styles.relatedPageName, styles.relatedPageNameUnresolved]}>{entry.page}</Text>
671
+ )}
672
+ {entry.note ? ` — ${entry.note}` : null}
673
+ </Text>
674
+ );
675
+ }
676
+
677
+ function RelatedPagesList({ items, resolvesAddress }: { items: readonly RelatedPage[]; resolvesAddress: (name: string) => boolean }) {
678
+ return (
679
+ <View style={styles.relatedPagesList}>
680
+ {items.map((entry, i) => (
681
+ <RelatedPageRow key={i} entry={entry} resolvesAddress={resolvesAddress} />
682
+ ))}
683
+ </View>
684
+ );
685
+ }
686
+
657
687
  /** Slice D1 item 4: the two umbrella labels — "Parts" groups every `inWhole` section, "Related"
658
- * every other one. A divider sits above each, and nothing else renders between the authored
659
- * sections it covers (item 10: authored `section.label`s stay in the data, never on screen). */
688
+ * every other one (plus, on "Related" only, the page's own `relatedPages` list, rendered after
689
+ * every deprecated section — field spec item 3: "render the deprecated sections first and the
690
+ * links after, under the single Related heading"). A divider sits above each, and nothing else
691
+ * renders between the authored sections it covers (item 10: authored `section.label`s stay in the
692
+ * data, never on screen). */
660
693
  function FamilyLabeledGroup({
661
694
  label,
662
695
  sections,
696
+ relatedPages,
663
697
  outline,
664
698
  byId,
665
699
  allMemberIds,
@@ -672,6 +706,8 @@ function FamilyLabeledGroup({
672
706
  }: {
673
707
  label: 'Parts' | 'Related';
674
708
  sections: CatalogMemberSection[];
709
+ /** Only ever set for the "Related" group — "Parts" has no equivalent. */
710
+ relatedPages?: RelatedPage[];
675
711
  outline: FamilyDef['outline'];
676
712
  byId: ReadonlyMap<string, FamilyMember>;
677
713
  allMemberIds: readonly string[];
@@ -682,7 +718,9 @@ function FamilyLabeledGroup({
682
718
  onToggleRow: (id: string) => void;
683
719
  registerNode: (id: string, node: View | null) => void;
684
720
  }) {
685
- if (sections.length === 0) return null;
721
+ // Field spec item 3: "a page with no relatedPages and no Related sections renders exactly as it
722
+ // does today — no heading, no divider, nothing."
723
+ if (sections.length === 0 && (!relatedPages || relatedPages.length === 0)) return null;
686
724
  return (
687
725
  <View style={styles.labeledGroup}>
688
726
  {/* Spec §5.2 "Heading semantics": "Parts"/"Related" are aria-level 2 headings — one level
@@ -711,6 +749,7 @@ function FamilyLabeledGroup({
711
749
  registerNode={registerNode}
712
750
  />
713
751
  ))}
752
+ {relatedPages && relatedPages.length > 0 && <RelatedPagesList items={relatedPages} resolvesAddress={resolvesAddress} />}
714
753
  </View>
715
754
  </View>
716
755
  );
@@ -723,8 +762,10 @@ const FamilyAnatomyContext = createContext<{ byId: ReadonlyMap<string, FamilyMem
723
762
 
724
763
  /** Slice D1 item 5: a pin for use inside `anatomy.picture`. `member` names one of this page's
725
764
  * members and shows its number (dashed when it has no area of its own), looked up through
726
- * `FamilyAnatomyContext` — renders nothing for an unknown member, one with no number (D2 item A2:
727
- * already a `validateFamily` issue), or outside a family page's own anatomy box (no context).
765
+ * `FamilyAnatomyContext` — renders nothing for an unknown member, one outside a family page's own
766
+ * anatomy box (no context), or one with no number — the last is a `validateFamily` issue only
767
+ * when the member sits inside an `inWhole` section (D2 item A2, narrowed per review C1); for a
768
+ * member outside every `inWhole` section it's legal, deliberately silent, and reported by nobody.
728
769
  * `other` marks a component documented on another page with the unnumbered ↗ pin the legend
729
770
  * uses, and always renders it — it needs no context, with or without a family page in scope. */
730
771
  export function AnatomyPin({ member, other }: { member?: string; other?: string }) {
@@ -742,12 +783,23 @@ export function AnatomyPin({ member, other }: { member?: string; other?: string
742
783
  }
743
784
 
744
785
  /** Slice D1 item 5: one legend row naming a member of this page — a pin (looked up the same way
745
- * `AnatomyPin` does), its name linked to the member's address, and the row's own description. */
786
+ * `AnatomyPin` does), its name linked to the member's address, and the row's own description.
787
+ * Defect fix (APPROVED.md "the legend is not indented"): the pin's 34px gutter is only reserved
788
+ * when there's a pin to show — a member with no number (outside every `inWhole` section) gets no
789
+ * gutter either, so its name starts flush at the row's own left edge instead of under blank
790
+ * space. */
746
791
  function AnatomyMemberRow({ memberId, description, byId, outline }: { memberId: string; description: string; byId: ReadonlyMap<string, FamilyMember>; outline: FamilyDef['outline'] }) {
747
792
  const pin = anatomyPinFor(memberId, byId, outline);
748
793
  return (
749
794
  <View style={styles.legendRow}>
750
- <View style={styles.legendPinSlot}>{pin && <Pin number={pin.number} dashed={pin.dashed} />}</View>
795
+ {pin && (
796
+ // `testID` exists only so a renderer test can assert the slot's presence without
797
+ // depending on class names — react-native-web renders it to the DOM as `data-testid`;
798
+ // native ignores it.
799
+ <View style={styles.legendPinSlot} testID="legend-pin-slot">
800
+ <Pin number={pin.number} dashed={pin.dashed} />
801
+ </View>
802
+ )}
751
803
  <View style={styles.legendBody}>
752
804
  <Text role="link" style={styles.legendName} {...({ href: hashForId(memberId) } as Record<string, unknown>)}>
753
805
  {memberId}
@@ -759,12 +811,16 @@ function AnatomyMemberRow({ memberId, description, byId, outline }: { memberId:
759
811
  }
760
812
 
761
813
  /** Slice D1 item 5: one legend row naming a component documented elsewhere — never numbered; its
762
- * own name links only when it resolves to a real catalog address. */
814
+ * own name links only when it resolves to a real catalog address. The ↗ marker keeps its own
815
+ * gutter: the defect this slice fixes is a gutter reserved for a pin that *isn't there*, and an
816
+ * "other page" row always shows its ↗, so the slot is never blank. Keeping it also keeps
817
+ * APPROVED.md's "the description ... starts at the name's left edge" — pulling the marker inline
818
+ * instead left each description hanging 26px to the left of its own name. */
763
819
  function AnatomyOtherRow({ name, description, resolvesAddress }: { name: string; description: string; resolvesAddress: (name: string) => boolean }) {
764
820
  const resolved = resolvesAddress(name);
765
821
  return (
766
822
  <View style={styles.legendRow}>
767
- <View style={styles.legendPinSlot} {...ARIA_HIDDEN}>
823
+ <View style={styles.legendPinSlot} {...ARIA_HIDDEN} testID="legend-pin-slot">
768
824
  <View style={styles.extPin}>
769
825
  <Text style={styles.extPinText}>↗</Text>
770
826
  </View>
@@ -1025,6 +1081,7 @@ export function FamilyPage<TId extends string>({
1025
1081
  <FamilyLabeledGroup
1026
1082
  label="Related"
1027
1083
  sections={relatedSections}
1084
+ relatedPages={family.relatedPages}
1028
1085
  outline={family.outline}
1029
1086
  byId={byId}
1030
1087
  allMemberIds={allMemberIds}
@@ -1114,6 +1171,10 @@ const styles = StyleSheet.create({
1114
1171
  memberSpecimens: { marginTop: CATALOG_SPACE.sm, marginBottom: CATALOG_SPACE.lg - CATALOG_SPACE.sm },
1115
1172
  belowLine: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted, maxWidth: 700 },
1116
1173
  relatedLink: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.accent },
1174
+ // relatedPages (field spec item 3 "Rendering"): one row per entry, under the Related heading.
1175
+ relatedPagesList: { gap: CATALOG_SPACE.sm },
1176
+ relatedPageName: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.accent },
1177
+ relatedPageNameUnresolved: { color: CATALOG_COLOR.text },
1117
1178
  wholeBelowRow: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.xs, marginTop: CATALOG_SPACE.sm, flexWrap: 'wrap' },
1118
1179
  wholeName: { ...CATALOG_TEXT.codeSmallStrong, color: CATALOG_COLOR.text },
1119
1180
  // V2d item 3: v8's own `.uses` line, inside the disclosure.
@@ -49,7 +49,7 @@ export type { CatalogPage, CatalogPageInput, GeneratedComponent, GeneratedPropRe
49
49
  export type {
50
50
  PropDef, SectionDef, NavGroup, SpecimenSize, SpecimenSurfaceKind, ComparisonDef, ComparisonCell, ComparisonAxisItem, GridAxis,
51
51
  PreviewWidths, TokenSection, ComposedOfEntry, CatalogMember, CatalogMemberSection, FamilyDef,
52
- CatalogAnatomy, CatalogAnatomyGroup, CatalogAnatomyItem,
52
+ CatalogAnatomy, CatalogAnatomyGroup, CatalogAnatomyItem, RelatedPage,
53
53
  } from './types';
54
54
  export { UpdatePanel } from './UpdatePanel';
55
55
  export { UPDATE_PAGE_ID } from './catalogNavigation';
@@ -1,5 +1,5 @@
1
1
  import type React from 'react';
2
- import type { CatalogAnatomy, CatalogMember, CatalogMemberSection, ComparisonDef, ComposedOfEntry, NavGroup, PreviewWidths, PropDef, SectionDef, SpecimenSize, SpecimenSurfaceKind, TokenSection, VariantSlot } from './types.ts';
2
+ import type { CatalogAnatomy, CatalogMember, CatalogMemberSection, ComparisonDef, ComposedOfEntry, NavGroup, PreviewWidths, PropDef, RelatedPage, SectionDef, SpecimenSize, SpecimenSurfaceKind, TokenSection, VariantSlot } from './types.ts';
3
3
 
4
4
  /** `SectionDef` without the derived fields (`id`/`path`/`props` come from detection and
5
5
  * generated props), plus the three fields design §1 "Page shape" adds. */
@@ -75,6 +75,13 @@ export interface CatalogPageInput<TId extends string = string> {
75
75
  * a picture of the whole with numbered pins and a legend. Only takes effect on a page with
76
76
  * `members`. */
77
77
  anatomy?: CatalogAnatomy;
78
+ /** Other catalog pages worth naming at the bottom of this page, under the "Related" heading,
79
+ * rendered after any deprecated non-`inWhole` sections — a component merely related to the
80
+ * whole owns its own page rather than being documented inline here. Each entry's `page` must
81
+ * resolve to a real catalog page, never this page's own id; `validateFamily` reports both as
82
+ * issues, and an entry that still fails to resolve at runtime renders its name as plain text
83
+ * instead of a dead link. Only takes effect on a page with `members`. */
84
+ relatedPages?: RelatedPage[];
78
85
  }
79
86
 
80
87
  export interface CatalogPage<TId extends string = string> extends CatalogPageInput<TId> {
@@ -158,6 +165,8 @@ export interface FamilyPageLike extends FamilyOutlineInput {
158
165
  * decoupled from the full `CatalogAnatomy` type (which carries a `picture` React node this pure
159
166
  * module never touches). */
160
167
  anatomy?: FamilyAnatomyLike;
168
+ /** See `CatalogPageInput.relatedPages`. */
169
+ relatedPages?: RelatedPage[];
161
170
  }
162
171
 
163
172
  /** Slice D1 item 3. */
@@ -256,11 +265,18 @@ export function validateFamily(page: FamilyPageLike, allPageIds: ReadonlySet<str
256
265
 
257
266
  // Slice D1 item 3: an anatomy `member` item must name a real, non-whole member of this page, and
258
267
  // each member may be pinned to the picture's legend at most once.
259
- // D2 item A2: a member item whose member has no number — outside every `inWhole` section, or
260
- // under a parent that isn't itself numbered — must also be reported; left unchecked, it renders
261
- // a blank 34px pin slot in the legend and nothing at all from `AnatomyPin` in the picture.
268
+ // D2 item A2, narrowed per review C1: a member item is reported only when it sits inside an
269
+ // `inWhole` section but still has no number — i.e. its parent isn't itself numbered. Left
270
+ // unchecked, such a part renders indistinguishably from a Related row and its `AnatomyPin`
271
+ // renders nothing at all. A member outside every `inWhole` section (Related) is legitimately
272
+ // unnumbered by design (APPROVED.md: related members have no number) and must not be reported
273
+ // here.
262
274
  if (page.anatomy) {
263
275
  const numbered = numberFor(page);
276
+ const inWholeMemberIds = new Set<string>();
277
+ for (const section of page.sections ?? []) {
278
+ if (section.inWhole) for (const id of section.members) inWholeMemberIds.add(id);
279
+ }
264
280
  const seenAnatomyMembers = new Set<string>();
265
281
  for (const group of page.anatomy.groups) {
266
282
  for (const item of group.items) {
@@ -278,13 +294,41 @@ export function validateFamily(page: FamilyPageLike, allPageIds: ReadonlySet<str
278
294
  continue;
279
295
  }
280
296
  seenAnatomyMembers.add(item.member);
281
- if (!numbered.has(item.member)) {
297
+ if (inWholeMemberIds.has(item.member) && !numbered.has(item.member)) {
282
298
  issues.push(`Family "${page.id}": anatomy item "${item.member}" has no number.`);
283
299
  }
284
300
  }
285
301
  }
286
302
  }
287
303
 
304
+ // `relatedPages`: each entry must name a real catalog page, other than this one, and the list
305
+ // must not repeat a page. An unresolvable or self-naming entry still renders (plain text, never
306
+ // a dead link), but is always reported here so `doctor` fails in CI. N8: `allPageIds` ∪
307
+ // `otherMemberIds` is the same universe `FamilyPage`'s own `resolvesAddress` accepts as a
308
+ // working link — page ids, member ids, and both page-level and member aliases — so an entry
309
+ // naming another page's alias or another family's member address is accepted too.
310
+ if (page.relatedPages) {
311
+ const seenRelatedPages = new Set<string>();
312
+ for (const entry of page.relatedPages) {
313
+ if (seenRelatedPages.has(entry.page)) {
314
+ issues.push(`Family "${page.id}": relatedPages lists "${entry.page}" more than once.`);
315
+ continue;
316
+ }
317
+ seenRelatedPages.add(entry.page);
318
+ if (entry.page === page.id) {
319
+ issues.push(`Family "${page.id}": relatedPages entry "${entry.page}" names this page itself.`);
320
+ continue;
321
+ }
322
+ if (byId.has(entry.page)) {
323
+ issues.push(`Family "${page.id}": relatedPages entry "${entry.page}" is a member of this page, not another page.`);
324
+ continue;
325
+ }
326
+ if (!allPageIds.has(entry.page) && !otherMemberIds.has(entry.page)) {
327
+ issues.push(`Family "${page.id}": relatedPages entry "${entry.page}" is not a catalog page.`);
328
+ }
329
+ }
330
+ }
331
+
288
332
  return issues;
289
333
  }
290
334
 
@@ -459,7 +503,7 @@ export function buildCatalogSections<TId extends string>(
459
503
  ...member,
460
504
  hasSpecimens: Boolean(member.variants?.items?.length || member.states?.items?.length || member.comparison),
461
505
  }));
462
- const issues = validateFamily({ ...page, members, anatomy: page.anatomy }, otherPageIds, otherMemberIds);
506
+ const issues = validateFamily({ ...page, members, anatomy: page.anatomy, relatedPages: page.relatedPages }, otherPageIds, otherMemberIds);
463
507
  if ((globalThis as { __DEV__?: boolean }).__DEV__) {
464
508
  for (const issue of issues) console.error(`[Catalog] ${issue}`);
465
509
  }
@@ -471,6 +515,7 @@ export function buildCatalogSections<TId extends string>(
471
515
  aliases: page.aliases,
472
516
  apiLabel: page.apiLabel,
473
517
  anatomy: page.anatomy,
518
+ relatedPages: page.relatedPages,
474
519
  members: page.members!.map((member) => {
475
520
  const memberComponent = member.component ? componentByName.get(member.component) : undefined;
476
521
  return {
@@ -401,6 +401,14 @@ export interface CatalogAnatomy {
401
401
  groups: CatalogAnatomyGroup[];
402
402
  }
403
403
 
404
+ /** One other catalog page worth naming at the bottom of a family page: a component merely
405
+ * related to the whole owns its own page, and the family page links to it rather than
406
+ * documenting it inside. `note` is one short sentence saying why a reader would go there. */
407
+ export interface RelatedPage {
408
+ page: string;
409
+ note?: string;
410
+ }
411
+
404
412
  /** A family page's members with resolved props and paths, the whole, sections, and the computed
405
413
  * outline — set on `SectionDef.family` by `buildCatalogSections` when a page has `members`. */
406
414
  export interface FamilyDef {
@@ -417,6 +425,9 @@ export interface FamilyDef {
417
425
  apiLabel?: string;
418
426
  /** Slice D1 item 1. See `CatalogPageInput.anatomy`. */
419
427
  anatomy?: CatalogAnatomy;
428
+ /** Other catalog pages named at the bottom of this page, under the "Related" heading, after any
429
+ * deprecated non-`inWhole` sections. See `CatalogPageInput.relatedPages`. */
430
+ relatedPages?: RelatedPage[];
420
431
  }
421
432
 
422
433
  /** One titled card on a token page (`SectionDef.tokenSections`). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krapwoo/ds-viewer",
3
- "version": "0.12.1",
3
+ "version": "0.13.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A browsable, always-current catalog for an Expo / React Native design system.",
@@ -55,14 +55,18 @@
55
55
  "typescript": "^6.0.0"
56
56
  },
57
57
  "devDependencies": {
58
+ "@types/jsdom": "^21.1.7",
58
59
  "@types/node": "^22.0.0",
59
60
  "@types/react": "^19.0.0",
61
+ "esbuild": "^0.25.12",
62
+ "jsdom": "^26.1.0",
60
63
  "puppeteer": "^25.12.0"
61
64
  },
62
65
  "scripts": {
63
66
  "build": "tsc -p tsconfig.json",
64
67
  "prepack": "npm run build",
65
68
  "test": "node --experimental-strip-types --test 'cli/**/*.test.ts' 'config/**/*.test.ts' 'native/catalog/__tests__/*.test.ts'",
69
+ "test:render": "node --import ./test/register.mjs --test 'test/__tests__/*.test.ts' 'native/catalog/__tests__/*.test.tsx'",
66
70
  "typecheck": "tsc -p tsconfig.json --noEmit",
67
71
  "kit:dev": "npm run build && cd kit-host && node node_modules/@krapwoo/ds-viewer/dist/cli/main.js dev",
68
72
  "viewer:dev": "npm run build && cd viewer-host && node node_modules/@krapwoo/ds-viewer/dist/cli/main.js dev",