@krapwoo/ds-viewer 0.14.1 → 0.15.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.
@@ -1,5 +1,6 @@
1
1
  import type React from 'react';
2
2
  import type { AlsoUsedByEntry, CatalogAnatomy, CatalogMember, CatalogMemberSection, ComparisonDef, ComposedOfEntry, NavGroup, PreviewWidths, PropDef, RelatedPage, SectionDef, SpecimenSize, SpecimenSurfaceKind, TokenSection, VariantSlot } from './types.ts';
3
+ import type { TokenDefinition, TokenKind } from './tokenDefinition.ts';
3
4
 
4
5
  /** `SectionDef` without the derived fields (`id`/`path`/`props` come from detection and
5
6
  * generated props), plus the three fields design §1 "Page shape" adds. */
@@ -20,6 +21,9 @@ export interface CatalogPageInput<TId extends string = string> {
20
21
  description: string;
21
22
  whenToUse?: string;
22
23
  a11y?: string;
24
+ /** Page-level notes, shown as a "Notes" entry in the page's Quick reference (the page-level
25
+ * counterpart of a family member's `notes`). */
26
+ notes?: string[];
23
27
  variants?: VariantSlot;
24
28
  states?: VariantSlot;
25
29
  render?: () => React.ReactNode;
@@ -27,6 +31,16 @@ export interface CatalogPageInput<TId extends string = string> {
27
31
  tokenSections?: TokenSection[];
28
32
  tokenColumns?: 1 | 2 | 3;
29
33
  fullWidthLabel?: string;
34
+ /** #61: an opt-in structured token definition — real entries (name/value/provenance/source) for
35
+ * one of the eight approved kinds, rendered through the existing token-gallery grammar
36
+ * (`StructuredTokenPage.tsx`, via `SectionBlock.tsx`'s own `tokenSections` block case) instead
37
+ * of a hand-built `render()`/`tokenSections`. Omitting it leaves every existing
38
+ * `tokenGallery`/`tokenSections`/`render` page unchanged. Setting it alongside an explicit
39
+ * `render`/`tokenGallery`/`tokenSections`/`members`, or an explicit `title` that doesn't match
40
+ * the kind's canonical title, is an actionable conflict (`buildCatalogSections` logs it in dev;
41
+ * `cli/doctor.ts` fails CI on it) rather than silent precedence — see
42
+ * `validateTokenDefinitionUsage`. See README's "Token pages" for the authoring shape. */
43
+ tokenDefinition?: TokenDefinition;
30
44
  comparison?: ComparisonDef;
31
45
  specimenSize?: SpecimenSize;
32
46
  specimenSurface?: SpecimenSurfaceKind;
@@ -82,6 +96,13 @@ export interface CatalogPageInput<TId extends string = string> {
82
96
  * issues, and an entry that still fails to resolve at runtime renders its name as plain text
83
97
  * instead of a dead link. Only takes effect on a page with `members`. */
84
98
  relatedPages?: RelatedPage[];
99
+ /** #60 controller decision: opts this page into the "reference" browse layer instead of the
100
+ * default `'browse'` (omitting the field is identical to `'browse'` — no legacy page changes
101
+ * behavior). A reference page keeps its full address, aliases, and (on a family page) every
102
+ * member address — only the browse-facing sidebar/pager/hash-fallback sequence drops it; see
103
+ * `SectionDef.navigation`. A family page's members inherit this same layer; there is no
104
+ * separate per-member opt-in. */
105
+ navigation?: 'browse' | 'reference';
85
106
  }
86
107
 
87
108
  export interface CatalogPage<TId extends string = string> extends CatalogPageInput<TId> {
@@ -112,6 +133,21 @@ export interface GeneratedComponent {
112
133
  inheritedFrom: string[];
113
134
  }
114
135
 
136
+ /** Shared by a family member's own "also used by" (legacy) and, #60, an ordinary page's own —
137
+ * dedupes by `component`, keeping the first role, and drops any user in `excludeIds` (a family
138
+ * member excludes its own family's ids; an ordinary page excludes only itself). */
139
+ function dedupeAlsoUsedBy(
140
+ entries: ReadonlyArray<{ component: string; role: string }>,
141
+ excludeIds: ReadonlySet<string>,
142
+ ): AlsoUsedByEntry[] {
143
+ const deduped = new Map<string, { component: string; role: string }>();
144
+ for (const entry of entries) {
145
+ if (excludeIds.has(entry.component)) continue;
146
+ if (!deduped.has(entry.component)) deduped.set(entry.component, entry);
147
+ }
148
+ return [...deduped.values()].map((entry) => ({ component: entry.component, role: entry.role || undefined }));
149
+ }
150
+
115
151
  function mergePropNotes(component: GeneratedComponent, propNotes: Record<string, string> | undefined): PropDef[] {
116
152
  const own = component.props.map((prop) => ({
117
153
  name: prop.name,
@@ -131,6 +167,99 @@ function mergePropNotes(component: GeneratedComponent, propNotes: Record<string,
131
167
  return [...own, ...inherited];
132
168
  }
133
169
 
170
+ // #61: the same canonical-title map `tokenDefinition.ts`'s own `canonicalTokenTitle` holds —
171
+ // duplicated here, deliberately, rather than value-imported: `pageApi.ts` must stay importable
172
+ // from a plain `node --experimental-strip-types` test (`pageApi.test.ts`) with no JSX/bundler
173
+ // loader, which needs a real resolvable `./tokenDefinition.ts` specifier, while the SAME file is
174
+ // also reachable from `kit-host`'s/`viewer-host`'s own "how the app actually resolves this"
175
+ // `moduleResolution: "bundler"` type-check (`npm run check:types`), which rejects a `.ts`-extension
176
+ // *value* import outright (`allowImportingTsExtensions` is off there, and that tsconfig is read-
177
+ // only). A `import type` (erased entirely, so its extension is never resolved at either runtime)
178
+ // stays fine either way — only the three lines of logic below that would otherwise need a real
179
+ // value import are kept local instead. `tokenDefinition.ts`'s own exported `canonicalTokenTitle`/
180
+ // `validateTokenDefinition`/`validateTokenDefinitionUsage` remain the canonical versions for every
181
+ // other consumer (`StructuredTokenPage.tsx`, `cli/doctor.ts`, the public `index.ts` barrel) — only
182
+ // `pageApi.ts` keeps this one local copy, and only for this one reason.
183
+ const TOKEN_KIND_TITLES: Record<TokenKind, string> = {
184
+ colors: 'Colors', typography: 'Typography', spacing: 'Spacing', radius: 'Radius',
185
+ dimensions: 'Dimensions', shadows: 'Shadows', opacity: 'Opacity', motion: 'Motion',
186
+ };
187
+
188
+ /** #61: resolves `page.tokenDefinition` into the page's real `title`/`tokenGallery`/`tokenSections`
189
+ * — the only three fields `comparison.ts`'s own `presentationBlocks` (not writable here) reads to
190
+ * place a token-gallery block, so an opted-in page needs zero changes anywhere in that layout
191
+ * pipeline. Each derived `TokenSection`'s own `render` is a trivial `() => null` placeholder, never
192
+ * actually called: `SectionBlock.tsx`'s own `tokenSections` block case renders this page's real
193
+ * `tokenDefinition` entries instead whenever it's set (`StructuredTokenPage.tsx`) — keeping every
194
+ * JSX-producing closure at that existing rendering boundary, never inside this plain-TS module.
195
+ * Conflicts (duplicate section/entry ids, a legacy `render`/`tokenGallery`/`tokenSections`/
196
+ * `members` set alongside it, or an explicit title that disagrees with the kind) are logged to the
197
+ * dev console only — the same "soft" runtime posture `validateComposedOf`/`validateFamily` already
198
+ * use; `cli/doctor.ts`'s own static read of the equivalent rules (`tokenDefinition.ts`'s exported
199
+ * `validateTokenDefinition`/`validateTokenDefinitionUsage`) is what actually fails CI on them.
200
+ * Undefined when the page didn't opt in — every other field keeps the author's own values
201
+ * untouched. */
202
+ function deriveTokenDefinition(page: {
203
+ tokenDefinition?: TokenDefinition;
204
+ title?: string;
205
+ render?: () => React.ReactNode;
206
+ tokenGallery?: boolean;
207
+ tokenSections?: TokenSection[];
208
+ members?: unknown[];
209
+ id: string;
210
+ }): { title: string; tokenGallery: true; tokenSections: TokenSection[] } | undefined {
211
+ const def = page.tokenDefinition;
212
+ if (!def) return undefined;
213
+ if ((globalThis as { __DEV__?: boolean }).__DEV__) {
214
+ const issues: string[] = [];
215
+ if (page.render !== undefined) issues.push('tokenDefinition conflicts with this page\'s own "render" — remove one of them.');
216
+ if (page.tokenGallery === true) issues.push('tokenDefinition conflicts with this page\'s own "tokenGallery" — remove one of them.');
217
+ if (page.tokenSections !== undefined && page.tokenSections.length > 0) issues.push('tokenDefinition conflicts with this page\'s own "tokenSections" — remove one of them.');
218
+ if (page.members !== undefined && page.members.length > 0) issues.push('tokenDefinition conflicts with this page\'s own "members" (a family page) — remove one of them.');
219
+ const canonicalTitle = TOKEN_KIND_TITLES[def.kind];
220
+ if (page.title !== undefined && page.title !== canonicalTitle) {
221
+ issues.push(`tokenDefinition: title "${page.title}" conflicts with kind "${def.kind}" (expected "${canonicalTitle}"). Omit "title" to use it automatically.`);
222
+ }
223
+ const duplicateIds = (ids: readonly string[]): string[] => {
224
+ const seen = new Set<string>();
225
+ const duplicates = new Set<string>();
226
+ for (const id of ids) {
227
+ if (seen.has(id)) duplicates.add(id);
228
+ seen.add(id);
229
+ }
230
+ return [...duplicates];
231
+ };
232
+ for (const id of duplicateIds(def.sections.map((s) => s.id))) issues.push(`tokenDefinition has more than one section with id "${id}".`);
233
+ for (const section of def.sections) {
234
+ for (const id of duplicateIds(section.entries.map((e) => e.id))) {
235
+ issues.push(`tokenDefinition section "${section.id}" has more than one entry with id "${id}".`);
236
+ }
237
+ for (const entry of section.entries) {
238
+ if (entry.kind !== def.kind) issues.push(`tokenDefinition section "${section.id}": entry "${entry.id}" has kind "${entry.kind}", but the page's kind is "${def.kind}".`);
239
+ }
240
+ // #64 B1: groups inside a category — the same duplicate-id/kind-mismatch checks, scoped to
241
+ // each group's own entries, mirroring `tokenDefinition.ts`'s own `validateTokenDefinition`.
242
+ for (const id of duplicateIds((section.groups ?? []).map((g) => g.id))) {
243
+ issues.push(`tokenDefinition section "${section.id}" has more than one group with id "${id}".`);
244
+ }
245
+ for (const group of section.groups ?? []) {
246
+ for (const id of duplicateIds(group.entries.map((e) => e.id))) {
247
+ issues.push(`tokenDefinition section "${section.id}" group "${group.id}" has more than one entry with id "${id}".`);
248
+ }
249
+ for (const entry of group.entries) {
250
+ if (entry.kind !== def.kind) issues.push(`tokenDefinition section "${section.id}" group "${group.id}": entry "${entry.id}" has kind "${entry.kind}", but the page's kind is "${def.kind}".`);
251
+ }
252
+ }
253
+ }
254
+ for (const issueMsg of issues) console.error(`[Catalog] ${page.id}: ${issueMsg}`);
255
+ }
256
+ return {
257
+ title: page.title ?? TOKEN_KIND_TITLES[def.kind],
258
+ tokenGallery: true,
259
+ tokenSections: def.sections.map((section) => ({ id: section.id, title: section.title, desc: section.desc, wide: section.wide, render: () => null })),
260
+ };
261
+ }
262
+
134
263
  /** Human-readable problems with a page's (or, V2d, a family member's own) `composedOf`, in
135
264
  * declaration order: one line per entry whose `component` isn't in `knownNames` (every real
136
265
  * component name, documented or not, plus every page id and family member id — the full catalog
@@ -178,12 +307,25 @@ export interface FamilyAnatomyLike {
178
307
  * member in exactly one section; every parent resolving within the page with no cycles; member
179
308
  * and alias ids unique catalog-wide and never colliding with a page id, except the whole (whose
180
309
  * id equals its own page's id). Returns `[]` for a page without `members`. `allPageIds` and
181
- * `otherMemberIds` are the ids used by every *other* page/member in the catalog. */
182
- export function validateFamily(page: FamilyPageLike, allPageIds: ReadonlySet<string>, otherMemberIds: ReadonlySet<string>): string[] {
310
+ * `otherMemberIds` are the ids used by every *other* page/member in the catalog. #63 A1: a page
311
+ * with neither `whole` nor `sections` is a *group* page, not a family — validated instead by
312
+ * `validateGroup`; this function is a no-op for it (its own "both present" behavior and messages
313
+ * are otherwise unchanged). A page with exactly one of the two stays a family-shape error, as
314
+ * before. #63 A5: `externalPartIds` names every id this family's own `inWhole` sections list that
315
+ * was resolved (catalog-wide, by the caller) to a real, validly-parented group-page member —
316
+ * these are exempted from the "not a member of this page" check below; omit it (the default, `[]`)
317
+ * to keep every existing call site's behavior byte-for-byte unchanged. */
318
+ export function validateFamily(
319
+ page: FamilyPageLike,
320
+ allPageIds: ReadonlySet<string>,
321
+ otherMemberIds: ReadonlySet<string>,
322
+ externalPartIds: ReadonlySet<string> = new Set(),
323
+ ): string[] {
183
324
  const { members } = page;
184
325
  if (!members || members.length === 0) return [];
185
326
  const issues: string[] = [];
186
327
 
328
+ if (!page.whole && !page.sections) return issues; // A group page — see `validateGroup`.
187
329
  if (!page.whole || !page.sections) {
188
330
  issues.push(`Family "${page.id}": "members" requires both "whole" and "sections".`);
189
331
  return issues;
@@ -202,10 +344,17 @@ export function validateFamily(page: FamilyPageLike, allPageIds: ReadonlySet<str
202
344
  // here, rather than left to crash the renderer or silently occupy authored order in `numberFor`.
203
345
  // N6: a `list`-section member authored with a specimen — `FoldedRow` never renders one, so this
204
346
  // degrades to a silently dropped specimen rather than a crash, same as the unknown-id case.
347
+ const externalCountById = new Map<string, number>();
205
348
  for (const section of page.sections) {
206
349
  for (const id of section.members) {
207
350
  const resolved = byId.get(id);
208
351
  if (!resolved) {
352
+ // #63 A5: a validly-resolved external part — a group-page member, already checked by the
353
+ // caller against `memberLocations`/parent — is never reported as missing.
354
+ if (section.inWhole && externalPartIds.has(id)) {
355
+ externalCountById.set(id, (externalCountById.get(id) ?? 0) + 1);
356
+ continue;
357
+ }
209
358
  issues.push(`Family "${page.id}": section "${section.label}" lists "${id}", which is not a member of this page.`);
210
359
  continue;
211
360
  }
@@ -215,6 +364,11 @@ export function validateFamily(page: FamilyPageLike, allPageIds: ReadonlySet<str
215
364
  }
216
365
  }
217
366
 
367
+ // An external part rendered twice would duplicate its row, React key and registered node.
368
+ for (const [id, count] of externalCountById) {
369
+ if (count > 1) issues.push(`Family "${page.id}": member "${id}" is in more than one section.`);
370
+ }
371
+
218
372
  const sectionCountById = new Map<string, number>();
219
373
  for (const section of page.sections) {
220
374
  for (const id of section.members) sectionCountById.set(id, (sectionCountById.get(id) ?? 0) + 1);
@@ -286,6 +440,15 @@ export function validateFamily(page: FamilyPageLike, allPageIds: ReadonlySet<str
286
440
  continue;
287
441
  }
288
442
  if (!byId.has(item.member)) {
443
+ // #63 A5: a resolved external part (numbered by the catalog-wide pre-pass, documented on a
444
+ // group page) may be named in the anatomy, exactly as its `inWhole` section may list it.
445
+ if (externalPartIds.has(item.member)) {
446
+ if (seenAnatomyMembers.has(item.member)) {
447
+ issues.push(`Family "${page.id}": anatomy lists member "${item.member}" more than once.`);
448
+ }
449
+ seenAnatomyMembers.add(item.member);
450
+ continue;
451
+ }
289
452
  issues.push(`Family "${page.id}": anatomy item "${item.member}" is not a member of this page.`);
290
453
  continue;
291
454
  }
@@ -399,6 +562,224 @@ export function numberFor(page: FamilyOutlineInput): Map<string, { number: strin
399
562
  return outline;
400
563
  }
401
564
 
565
+ /** #63 A4: numbers one group member's own parts — authored (array) order among siblings, `1`,
566
+ * `2`, then `1.1`, … for children, exactly like `numberFor`'s own child assignment under a
567
+ * family's whole — scoped to `topLevelId`'s own descendants among `members` (a group page has no
568
+ * `sections` to scan, so "authored order" is simply the `members` array's own order). `topLevelId`
569
+ * itself gets no number, the same way a family's whole never does. Lifted, not reimplemented, from
570
+ * `numberFor`'s own `assignChildren`/cycle guard, re-anchored on one top-level member instead of
571
+ * one page-wide whole. */
572
+ export function numberForGroupMember(
573
+ topLevelId: string,
574
+ members: ReadonlyArray<Pick<CatalogMember, 'id' | 'parent'>>,
575
+ ): Map<string, { number: string; level: number }> {
576
+ const outline = new Map<string, { number: string; level: number }>();
577
+ const childrenOf = new Map<string, string[]>();
578
+ for (const m of members) {
579
+ if (m.parent === undefined) continue;
580
+ childrenOf.set(m.parent, [...(childrenOf.get(m.parent) ?? []), m.id]);
581
+ }
582
+ const assignChildren = (parentId: string, parentNumber: string, level: number, visited: Set<string>) => {
583
+ const kids = childrenOf.get(parentId) ?? [];
584
+ kids.forEach((childId, index) => {
585
+ if (visited.has(childId)) return;
586
+ visited.add(childId);
587
+ const number = `${parentNumber}.${index + 1}`;
588
+ outline.set(childId, { number, level: level + 1 });
589
+ assignChildren(childId, number, level + 1, visited);
590
+ });
591
+ };
592
+ const visited = new Set<string>([topLevelId]);
593
+ const kids = childrenOf.get(topLevelId) ?? [];
594
+ kids.forEach((childId, index) => {
595
+ if (visited.has(childId)) return;
596
+ visited.add(childId);
597
+ const number = String(index + 1);
598
+ outline.set(childId, { number, level: 2 });
599
+ assignChildren(childId, number, 2, visited);
600
+ });
601
+ return outline;
602
+ }
603
+
604
+ /** #63 A3/A6: the minimal shape `validateGroup` needs from a group page's own `members`. */
605
+ export interface GroupMemberLike {
606
+ id: string;
607
+ parent?: string;
608
+ aliases?: string[];
609
+ /** #63 A4: only meaningful on a top-level member — see `CatalogMember.anatomy`. */
610
+ anatomy?: FamilyAnatomyLike;
611
+ }
612
+
613
+ export interface GroupPageLike {
614
+ id: string;
615
+ members?: GroupMemberLike[];
616
+ aliases?: string[];
617
+ /** #63 A1: forbidden on a group page — only ever checked for presence here, never for shape
618
+ * (an author who sets a family-shaped `anatomy` object here still gets this one message, not a
619
+ * second, confusing shape error). */
620
+ anatomy?: unknown;
621
+ /** Review F8: a group page has no "Related" layer, so a non-empty list here would silently never
622
+ * render — reported as unused instead. Presence only, never shape. */
623
+ relatedPages?: readonly unknown[];
624
+ }
625
+
626
+ /** One group-page problem with the doctor rule it belongs to, decided where the issue is raised
627
+ * (never by matching its message text): `group-anatomy-member` for an embedded `anatomy` problem
628
+ * (A4), `family-invalid` for every other shape error. */
629
+ export interface GroupIssue {
630
+ rule: 'family-invalid' | 'group-anatomy-member';
631
+ message: string;
632
+ }
633
+
634
+ /** Validates a group page (#63 A1/A3/A4/A6): no page-level `anatomy` and no `relatedPages` (a group page renders no Related list, so it is reported as unused); every `parent` resolves
635
+ * either to a same-page member (checked for cycles, the same guard `validateFamily` already
636
+ * lifts) or — A3's cross-page case — to a member known catalog-wide (`otherMemberIds`), never to
637
+ * neither; member and alias ids unique catalog-wide, with no privileged "whole" exemption (a group
638
+ * has none); and `anatomy` set only on a top-level member (one with no parent, or whose parent
639
+ * isn't one of this page's own members), naming only that member's own descendants on this page.
640
+ * Returns `[]` for a page without `members`. Mirrors `validateFamily`'s own contract and the same
641
+ * `allPageIds`/`otherMemberIds` meaning (every *other* page's/member's id in the catalog). */
642
+ export function validateGroupIssues(page: GroupPageLike, allPageIds: ReadonlySet<string>, otherMemberIds: ReadonlySet<string>): GroupIssue[] {
643
+ const { members } = page;
644
+ if (!members || members.length === 0) return [];
645
+ const issues: GroupIssue[] = [];
646
+ const shapeIssue = (message: string) => issues.push({ rule: 'family-invalid', message });
647
+ const anatomyIssue = (message: string) => issues.push({ rule: 'group-anatomy-member', message });
648
+ const byId = new Map(members.map((m) => [m.id, m]));
649
+
650
+ if (page.anatomy !== undefined) {
651
+ shapeIssue(`Group "${page.id}": page-level "anatomy" is not used on a group page — move it to the relevant top-level member instead.`);
652
+ }
653
+
654
+ if (page.relatedPages && page.relatedPages.length > 0) {
655
+ shapeIssue(`Group "${page.id}": "relatedPages" is not used on a group page (it renders no Related list) — name the related page in a member's "related" text or "composedOf" instead.`);
656
+ }
657
+
658
+ for (const m of members) {
659
+ if (m.parent === undefined) continue;
660
+ if (!byId.has(m.parent) && !otherMemberIds.has(m.parent)) {
661
+ shapeIssue(`Group "${page.id}": member "${m.id}" has an unknown parent "${m.parent}".`);
662
+ continue;
663
+ }
664
+ // Lifted verbatim from `validateFamily`'s own cycle guard: `byId.get(current)?.parent` already
665
+ // degrades to `undefined` (loop ends, no cycle) the moment `current` resolves off this page —
666
+ // exactly A3's cross-page top-level case, so no change is needed for that case here.
667
+ const seen = new Set([m.id]);
668
+ let current: string | undefined = m.parent;
669
+ let cycle = false;
670
+ while (current !== undefined) {
671
+ if (seen.has(current)) {
672
+ cycle = true;
673
+ break;
674
+ }
675
+ seen.add(current);
676
+ current = byId.get(current)?.parent;
677
+ }
678
+ if (cycle) shapeIssue(`Group "${page.id}": member "${m.id}" has a parent cycle.`);
679
+ }
680
+
681
+ const seenInPage = new Set<string>();
682
+ const checkId = (id: string, isMemberOrMemberAlias = true) => {
683
+ // `allPageIds` excludes this page, and `resolveRoute` matches page ids before aliases — so a
684
+ // member (or its alias) named like its own page could never be addressed.
685
+ if (isMemberOrMemberAlias && id === page.id) shapeIssue(`Group "${page.id}": id "${id}" collides with this group page's own id.`);
686
+ if (seenInPage.has(id)) shapeIssue(`Group "${page.id}": id "${id}" is used more than once on this page.`);
687
+ seenInPage.add(id);
688
+ if (allPageIds.has(id)) shapeIssue(`Group "${page.id}": id "${id}" collides with another page's id.`);
689
+ if (otherMemberIds.has(id)) shapeIssue(`Group "${page.id}": id "${id}" collides with another member or alias elsewhere in the catalog.`);
690
+ };
691
+ for (const m of members) {
692
+ checkId(m.id);
693
+ for (const alias of m.aliases ?? []) checkId(alias);
694
+ }
695
+ for (const alias of page.aliases ?? []) checkId(alias, false);
696
+
697
+ // A3/A4: a top-level member has no parent, or a parent not among this page's own members
698
+ // (resolved cross-page above). `anatomy` is only ever meaningful there.
699
+ const isTopLevel = (m: GroupMemberLike) => m.parent === undefined || !byId.has(m.parent);
700
+ const descendantsOf = (topLevelId: string): Set<string> => {
701
+ const result = new Set<string>();
702
+ const stack = [topLevelId];
703
+ while (stack.length > 0) {
704
+ const current = stack.pop()!;
705
+ for (const m of members) {
706
+ if (m.parent === current && m.id !== topLevelId && !result.has(m.id)) {
707
+ result.add(m.id);
708
+ stack.push(m.id);
709
+ }
710
+ }
711
+ }
712
+ return result;
713
+ };
714
+ for (const m of members) {
715
+ if (!m.anatomy) continue;
716
+ if (!isTopLevel(m)) {
717
+ anatomyIssue(`Group "${page.id}": member "${m.id}" has "anatomy", but anatomy is only used on a top-level member.`);
718
+ continue;
719
+ }
720
+ const descendants = descendantsOf(m.id);
721
+ const seenAnatomyMembers = new Set<string>();
722
+ for (const group of m.anatomy.groups) {
723
+ for (const item of group.items) {
724
+ if (!('member' in item)) continue;
725
+ if (item.member === m.id) {
726
+ anatomyIssue(`Group "${page.id}": member "${m.id}": anatomy item "${item.member}" names itself.`);
727
+ continue;
728
+ }
729
+ if (!descendants.has(item.member)) {
730
+ anatomyIssue(`Group "${page.id}": member "${m.id}": anatomy item "${item.member}" is not one of its descendants on this page.`);
731
+ continue;
732
+ }
733
+ if (seenAnatomyMembers.has(item.member)) {
734
+ anatomyIssue(`Group "${page.id}": member "${m.id}": anatomy lists member "${item.member}" more than once.`);
735
+ continue;
736
+ }
737
+ seenAnatomyMembers.add(item.member);
738
+ }
739
+ }
740
+ }
741
+
742
+ return issues;
743
+ }
744
+
745
+ export function validateGroup(page: GroupPageLike, allPageIds: ReadonlySet<string>, otherMemberIds: ReadonlySet<string>): string[] {
746
+ return validateGroupIssues(page, allPageIds, otherMemberIds).map((issue) => issue.message);
747
+ }
748
+
749
+ /** #63 A5/A6: where one catalog member id lives — every page that declares it as one of its own
750
+ * `members` (never an alias), whether that page is itself a group page, and that member's own
751
+ * `parent`. Built catalog-wide, once, by the caller (`buildCatalogSections`'s own pre-pass, or
752
+ * `cli/doctor.ts`'s equivalent over `StaticPage[]`) — never a second, independent registry. */
753
+ export interface MemberLocation {
754
+ pageId: string;
755
+ isGroupPage: boolean;
756
+ parent?: string;
757
+ }
758
+
759
+ /** #63 A5/A6: resolves one family `inWhole` section id that is not one of that family's own
760
+ * `members` — a candidate external part — against `owners`, every catalog location that id
761
+ * resolves to as a real member. Exactly one owner, itself a group page, whose own `parent` equals
762
+ * `familyWhole`, is the only success; anything else is the validation issue A6 requires (zero or
763
+ * multiple owners, an owner that isn't a group page, or a parent that doesn't match). Pure and
764
+ * catalog-shape-agnostic — shared between `buildCatalogSections` (runtime) and `cli/doctor.ts`
765
+ * (static), which each build `owners` from their own page representation. */
766
+ export function resolveExternalPart(
767
+ id: string,
768
+ owners: ReadonlyArray<MemberLocation>,
769
+ familyWhole: string,
770
+ ): { pageId: string } | { issue: string } {
771
+ if (owners.length === 0) return { issue: `"${id}" is not a member of this page, and is not defined on any group page either.` };
772
+ if (owners.length > 1) {
773
+ return { issue: `"${id}" is defined as a member on more than one page (${owners.map((o) => o.pageId).join(', ')}).` };
774
+ }
775
+ const [owner] = owners;
776
+ if (!owner.isGroupPage) return { issue: `"${id}" is a member of "${owner.pageId}", which is not a group page.` };
777
+ if (owner.parent !== familyWhole) {
778
+ return { issue: `"${id}"'s parent on "${owner.pageId}" ("${owner.parent ?? 'none'}") does not equal this family's whole "${familyWhole}".` };
779
+ }
780
+ return { pageId: owner.pageId };
781
+ }
782
+
402
783
  function orderGroups<TId extends string>(groupIds: Map<string, TId[]>, groupOrder: string[] | undefined): NavGroup<TId>[] {
403
784
  const listed = groupOrder ?? [];
404
785
  const labels = [...groupIds.keys()];
@@ -453,12 +834,45 @@ export function buildCatalogSections<TId extends string>(
453
834
  // `FamilyPage`'s own `resolvesAddress`. Without resolving through the same map here, an
454
835
  // alias-named or component-named entry would link fine going forward but derive nothing going
455
836
  // back: a missing "Also used by" line looks identical to "nothing uses this".
837
+ // C1 fix: every page's own id and every member's own id is itself an exact registered address —
838
+ // it must win over any same-named `component` binding elsewhere, including the common case where
839
+ // a page's own id already equals its component name (so it never gets a chance to appear as a
840
+ // "claimant" in the ambiguity pass below). Registering these identities first, before any
841
+ // binding-name guessing runs, is what makes that guessing unable to ever shadow an exact address.
456
842
  const canonicalMemberIdByAlias = new Map<string, string>();
457
843
  for (const page of pages) {
844
+ canonicalMemberIdByAlias.set(page.id, page.id);
458
845
  for (const member of page.members ?? []) {
846
+ canonicalMemberIdByAlias.set(member.id, member.id);
459
847
  for (const alias of member.aliases ?? []) canonicalMemberIdByAlias.set(alias, member.id);
460
- if (member.component && member.component !== member.id) canonicalMemberIdByAlias.set(member.component, member.id);
461
848
  }
849
+ for (const alias of page.aliases ?? []) canonicalMemberIdByAlias.set(alias, page.id);
850
+ }
851
+ // #60/C1: a `composedOf` entry may instead name a component export rather than its page/member's
852
+ // own id or alias — an ordinary page's own `component` (when it differs from its `id`) or a family
853
+ // member's own `component` (when it differs from its `id`) are the same kind of claim, so both
854
+ // feed the one shared ambiguity pass below: a name already owned by an exact address above is
855
+ // never reconsidered, and a name claimed by more than one binding (ordinary-vs-ordinary,
856
+ // member-vs-member, or ordinary-vs-member alike) is never guessed — it is left unresolved, not
857
+ // silently decided by whichever binding happened to be declared first.
858
+ const componentBindingOwners = new Map<string, string[]>();
859
+ const addComponentBindingOwner = (name: string, ownerId: string) => {
860
+ if (canonicalMemberIdByAlias.has(name)) return;
861
+ componentBindingOwners.set(name, [...(componentBindingOwners.get(name) ?? []), ownerId]);
862
+ };
863
+ for (const page of pages) {
864
+ if (page.members && page.members.length > 0) {
865
+ for (const member of page.members) {
866
+ if (member.component && member.component !== member.id) addComponentBindingOwner(member.component, member.id);
867
+ }
868
+ continue;
869
+ }
870
+ const componentName = page.component ?? page.id;
871
+ if (componentName !== page.id) addComponentBindingOwner(componentName, page.id);
872
+ }
873
+ for (const [name, owners] of componentBindingOwners) {
874
+ if (owners.length !== 1) continue;
875
+ canonicalMemberIdByAlias.set(name, owners[0]);
462
876
  }
463
877
  const canonicalUsedByTarget = (name: string) => canonicalMemberIdByAlias.get(name) ?? name;
464
878
 
@@ -487,20 +901,89 @@ export function buildCatalogSections<TId extends string>(
487
901
 
488
902
  // Every id/alias a family page's own `members` and `aliases` use, keyed by page id — gathered up
489
903
  // front so `validateFamily` (above) can check catalog-wide uniqueness against every *other*
490
- // page's ids.
904
+ // page's ids. #64 B5: also a tokenDefinition page's own `aliases` and each of its sections' own
905
+ // `aliases` (extending this same catalog-wide pool to tokenDefinition pages).
491
906
  const allPageIds = new Set(pages.map((page) => page.id));
492
907
  const familyIdsByPage = new Map<TId, Set<string>>();
493
908
  for (const page of pages) {
494
- if (!page.members) continue;
909
+ if (!page.members && !page.tokenDefinition) continue;
495
910
  const ids = new Set<string>();
496
- for (const member of page.members) {
911
+ for (const member of page.members ?? []) {
497
912
  ids.add(member.id);
498
913
  for (const alias of member.aliases ?? []) ids.add(alias);
499
914
  }
500
915
  for (const alias of page.aliases ?? []) ids.add(alias);
916
+ for (const section of page.tokenDefinition?.sections ?? []) for (const alias of section.aliases ?? []) ids.add(alias);
501
917
  familyIdsByPage.set(page.id, ids);
502
918
  }
503
919
 
920
+ // #63 A5/A6: catalog-wide — where every member id lives, so each family's `inWhole` sections can
921
+ // tell an external part (a group-page member) from a genuinely unknown id. Built once, up front,
922
+ // the same way `familyIdsByPage` already is above.
923
+ const memberLocations = new Map<string, MemberLocation[]>();
924
+ for (const p of pages) {
925
+ const pHasMembers = p.members !== undefined && p.members.length > 0;
926
+ const pIsGroup = pHasMembers && p.whole === undefined && p.sections === undefined;
927
+ for (const member of p.members ?? []) {
928
+ memberLocations.set(member.id, [...(memberLocations.get(member.id) ?? []), { pageId: p.id, isGroupPage: pIsGroup, parent: member.parent }]);
929
+ }
930
+ }
931
+
932
+ // #63 A5: resolved once, per valid family, before the per-page pass below assembles any section
933
+ // — never by mutating an already-built section a second time (per the controller's own decision).
934
+ // `outlineByFamily` is this same pre-pass's own `numberFor` call, including a synthetic stub
935
+ // (`parent: page.whole`) per resolved external id so `numberFor`'s existing authored-order scan
936
+ // (which already walks each `inWhole` section's own `members` list) numbers it exactly like an
937
+ // own part, with no change to `numberFor` itself.
938
+ const externalIssues: string[] = [];
939
+ const externalPartsByFamily = new Map<TId, Map<string, { pageId: TId; title: string; fidelity?: CatalogMember['fidelity'] }>>();
940
+ const partOfByMemberId = new Map<string, { pageId: TId; title: string; number: string }>();
941
+ const outlineByFamily = new Map<TId, Map<string, { number: string; level: number }>>();
942
+ for (const page of pages) {
943
+ const pHasMembers = page.members !== undefined && page.members.length > 0;
944
+ if (!pHasMembers || page.whole === undefined || page.sections === undefined) continue; // not a valid family.
945
+ const byIdThisPage = new Set(page.members!.map((m) => m.id));
946
+ const externalResolutions = new Map<string, { pageId: TId }>();
947
+ for (const section of page.sections) {
948
+ if (!section.inWhole) continue;
949
+ for (const id of section.members) {
950
+ if (byIdThisPage.has(id) || externalResolutions.has(id)) continue;
951
+ const owners = memberLocations.get(id);
952
+ if (!owners) continue; // genuinely unknown — `validateFamily`'s own "not a member" error covers it.
953
+ const resolution = resolveExternalPart(id, owners, page.whole);
954
+ if ('issue' in resolution) externalIssues.push(`Family "${page.id}": ${resolution.issue}`);
955
+ else externalResolutions.set(id, { pageId: resolution.pageId as TId });
956
+ }
957
+ }
958
+ const membersForNumbering = [...page.members!, ...[...externalResolutions.keys()].map((id) => ({ id, parent: page.whole }))];
959
+ const outline = numberFor({ ...page, members: membersForNumbering });
960
+ outlineByFamily.set(page.id, outline);
961
+ if (externalResolutions.size === 0) continue;
962
+ const familyTitle = String(page.title ?? page.id);
963
+ const externalMap = new Map<string, { pageId: TId; title: string; fidelity?: CatalogMember['fidelity'] }>();
964
+ for (const [id, resolution] of externalResolutions) {
965
+ const ownerPage = pages.find((p) => p.id === resolution.pageId);
966
+ externalMap.set(id, {
967
+ pageId: resolution.pageId,
968
+ title: String(ownerPage?.title ?? resolution.pageId),
969
+ fidelity: ownerPage?.members?.find((m) => m.id === id)?.fidelity,
970
+ });
971
+ const number = outline.get(id)?.number;
972
+ if (number) partOfByMemberId.set(id, { pageId: page.id, title: familyTitle, number });
973
+ }
974
+ externalPartsByFamily.set(page.id, externalMap);
975
+ }
976
+ if ((globalThis as { __DEV__?: boolean }).__DEV__) {
977
+ for (const issueMsg of externalIssues) console.error(`[Catalog] ${issueMsg}`);
978
+ }
979
+
980
+ // #64 B4: every page whose `tokenDefinition.kind` matches another page's — one page per kind.
981
+ const tokenKindPages = new Map<TokenKind, TId[]>();
982
+ for (const page of pages) {
983
+ if (!page.tokenDefinition) continue;
984
+ tokenKindPages.set(page.tokenDefinition.kind, [...(tokenKindPages.get(page.tokenDefinition.kind) ?? []), page.id]);
985
+ }
986
+
504
987
  // Design §4 "duplicate-page-id" rule's viewer-side equivalent — this is the one place in the
505
988
  // whole viewer that already resolves every page's id to build sections/groups, so it costs
506
989
  // nothing extra to also notice two pages claiming the same one (`sectionsById` in
@@ -524,9 +1007,36 @@ export function buildCatalogSections<TId extends string>(
524
1007
  console.warn(`[Catalog] ${page.id}.${member.id}: ${issue}`);
525
1008
  }
526
1009
  }
1010
+ if (page.tokenDefinition) {
1011
+ // #64 B4: one page per kind — mirrors `cli/doctor.ts`'s `token-definition-duplicate-kind`.
1012
+ const samekind = (tokenKindPages.get(page.tokenDefinition.kind) ?? []).filter((id) => id !== page.id);
1013
+ if (samekind.length > 0) {
1014
+ console.error(`[Catalog] ${page.id}: tokenDefinition kind "${page.tokenDefinition.kind}" is also used by ${samekind.join(', ')}.`);
1015
+ }
1016
+ // #64 B5: a tokenDefinition page's own page-level/section aliases join the same
1017
+ // catalog-wide "one owner" pool a family's own ids/aliases already check against —
1018
+ // mirrors `cli/doctor.ts`'s own `checkTokenDefinitionAliases` (rule id `family-invalid`).
1019
+ const otherPageIdsForTokenDef = new Set<string>([...allPageIds].filter((id) => id !== page.id));
1020
+ const otherIdsForTokenDef = new Set<string>();
1021
+ for (const [id, ids] of familyIdsByPage) if (id !== page.id) for (const x of ids) otherIdsForTokenDef.add(x);
1022
+ const seenAliases = new Set<string>();
1023
+ const checkTokenDefAlias = (alias: string) => {
1024
+ if (seenAliases.has(alias)) console.error(`[Catalog] Page "${page.id}": id "${alias}" is used more than once on this page.`);
1025
+ seenAliases.add(alias);
1026
+ if (otherPageIdsForTokenDef.has(alias)) console.error(`[Catalog] Page "${page.id}": id "${alias}" collides with another page's id.`);
1027
+ if (otherIdsForTokenDef.has(alias)) console.error(`[Catalog] Page "${page.id}": id "${alias}" collides with another member or alias elsewhere in the catalog.`);
1028
+ };
1029
+ for (const alias of page.aliases ?? []) checkTokenDefAlias(alias);
1030
+ for (const section of page.tokenDefinition.sections) for (const alias of section.aliases ?? []) checkTokenDefAlias(alias);
1031
+ }
527
1032
  }
528
- const isFamily = page.members !== undefined && page.members.length > 0;
529
- const componentName = isFamily ? undefined : (page.component ?? page.id);
1033
+ const hasMembers = page.members !== undefined && page.members.length > 0;
1034
+ // #63 A1: a page with neither `whole` nor `sections` is a group, not a family; a page with
1035
+ // exactly one of the two stays the same family-shape error `validateFamily` already reports,
1036
+ // via `isFamily` (unchanged from before this field existed).
1037
+ const isGroup = hasMembers && page.whole === undefined && page.sections === undefined;
1038
+ const isFamily = hasMembers && !isGroup;
1039
+ const componentName = hasMembers ? undefined : (page.component ?? page.id);
530
1040
  const component = componentName ? componentByName.get(componentName) : undefined;
531
1041
  addToGroup(page.group, page.id);
532
1042
 
@@ -541,7 +1051,8 @@ export function buildCatalogSections<TId extends string>(
541
1051
  ...member,
542
1052
  hasSpecimens: Boolean(member.variants?.items?.length || member.states?.items?.length || member.comparison),
543
1053
  }));
544
- const issues = validateFamily({ ...page, members, anatomy: page.anatomy, relatedPages: page.relatedPages }, otherPageIds, otherMemberIds);
1054
+ const externalPartIds = new Set(externalPartsByFamily.get(page.id)?.keys() ?? []);
1055
+ const issues = validateFamily({ ...page, members, anatomy: page.anatomy, relatedPages: page.relatedPages }, otherPageIds, otherMemberIds, externalPartIds);
545
1056
  if ((globalThis as { __DEV__?: boolean }).__DEV__) {
546
1057
  for (const issue of issues) console.error(`[Catalog] ${issue}`);
547
1058
  }
@@ -552,27 +1063,23 @@ export function buildCatalogSections<TId extends string>(
552
1063
  family = {
553
1064
  whole: page.whole!,
554
1065
  sections: page.sections!,
555
- outline: numberFor(page),
1066
+ // #63 A5: the pre-pass above already ran `numberFor` with every valid external part's
1067
+ // synthetic stub included; the "exactly one of whole/sections" invalid shape never reaches
1068
+ // that pre-pass, so it falls back to the same plain `numberFor(page)` call this always was.
1069
+ outline: outlineByFamily.get(page.id) ?? numberFor(page),
556
1070
  notShown: page.notShown,
557
1071
  aliases: page.aliases,
558
1072
  apiLabel: page.apiLabel,
559
1073
  anatomy: page.anatomy,
560
1074
  relatedPages: page.relatedPages,
1075
+ externalParts: externalPartsByFamily.get(page.id),
561
1076
  members: page.members!.map((member) => {
562
1077
  const memberComponent = member.component ? componentByName.get(member.component) : undefined;
563
1078
  // Dedupe by `component`, keeping the first role: page-level and whole-member `composedOf`
564
1079
  // attribute to the same string for a family's whole (`validateFamily` forces
565
1080
  // `whole === page.id`), and `validateComposedOf` has no duplicate check of its own, so the
566
1081
  // same user declared twice would otherwise render "Also used by P and P".
567
- const dedupedUsers = new Map<string, { component: string; role: string }>();
568
- for (const entry of usedByTarget.get(member.id) ?? []) {
569
- if (pageMemberIds.has(entry.component)) continue;
570
- if (!dedupedUsers.has(entry.component)) dedupedUsers.set(entry.component, entry);
571
- }
572
- const alsoUsedBy: AlsoUsedByEntry[] = [...dedupedUsers.values()].map((entry) => ({
573
- component: entry.component,
574
- role: entry.role || undefined,
575
- }));
1082
+ const alsoUsedBy = dedupeAlsoUsedBy(usedByTarget.get(member.id) ?? [], pageMemberIds);
576
1083
  return {
577
1084
  ...member,
578
1085
  props: memberComponent ? mergePropNotes(memberComponent, undefined) : undefined,
@@ -581,26 +1088,79 @@ export function buildCatalogSections<TId extends string>(
581
1088
  };
582
1089
  }),
583
1090
  };
1091
+ } else if (isGroup) {
1092
+ const otherPageIds = new Set([...allPageIds].filter((id) => id !== page.id));
1093
+ const otherMemberIds = new Set<string>();
1094
+ for (const [id, ids] of familyIdsByPage) if (id !== page.id) for (const memberId of ids) otherMemberIds.add(memberId);
1095
+ const groupMembers = page.members!.map((member) => ({
1096
+ ...member,
1097
+ hasSpecimens: Boolean(member.variants?.items?.length || member.states?.items?.length || member.comparison),
1098
+ }));
1099
+ const groupIssues = validateGroup({ id: page.id, members: groupMembers, aliases: page.aliases, anatomy: page.anatomy, relatedPages: page.relatedPages }, otherPageIds, otherMemberIds);
1100
+ if ((globalThis as { __DEV__?: boolean }).__DEV__) {
1101
+ for (const issue of groupIssues) console.error(`[Catalog] ${issue}`);
1102
+ }
1103
+ // #63 A4: one top-level member is any member with no parent, or whose parent isn't one of
1104
+ // this page's own members (A3's cross-page case) — its own parts are numbered independently
1105
+ // of every other top-level member's (`numberForGroupMember`), then merged into one outline
1106
+ // map the same shape a family's own `outline` already has.
1107
+ const byIdThisPage = new Set(page.members!.map((m) => m.id));
1108
+ const groupOutline = new Map<string, { number: string; level: number }>();
1109
+ for (const m of page.members!) {
1110
+ if (m.parent !== undefined && byIdThisPage.has(m.parent)) continue; // not top-level.
1111
+ for (const [id, entry] of numberForGroupMember(m.id, page.members!)) groupOutline.set(id, entry);
1112
+ }
1113
+ const pageMemberIds = new Set(page.members!.map((m) => m.id));
1114
+ family = {
1115
+ kind: 'group',
1116
+ whole: undefined,
1117
+ sections: [],
1118
+ outline: groupOutline,
1119
+ notShown: page.notShown,
1120
+ aliases: page.aliases,
1121
+ apiLabel: page.apiLabel,
1122
+ relatedPages: page.relatedPages,
1123
+ members: page.members!.map((member) => {
1124
+ const memberComponent = member.component ? componentByName.get(member.component) : undefined;
1125
+ const alsoUsedBy = dedupeAlsoUsedBy(usedByTarget.get(member.id) ?? [], pageMemberIds);
1126
+ const partOf = partOfByMemberId.get(member.id);
1127
+ return {
1128
+ ...member,
1129
+ props: memberComponent ? mergePropNotes(memberComponent, undefined) : undefined,
1130
+ path: memberComponent?.file ?? member.source,
1131
+ alsoUsedBy,
1132
+ ...(partOf ? { partOf } : {}),
1133
+ };
1134
+ }),
1135
+ };
584
1136
  }
585
1137
 
586
1138
  // Important finding 4: a standalone page (no matching component record) has no props to show
587
1139
  // and nothing to say "This component takes no props" about — hide the Props box, and fall back
588
1140
  // to the page's own file as its Source instead of an empty path.
1141
+ // #61: resolved once so `title`/`tokenGallery`/`tokenSections` below can each fall back to it.
1142
+ const derivedTokenDefinition = deriveTokenDefinition(page);
589
1143
  return {
590
1144
  id: page.id,
591
- title: page.title,
1145
+ title: derivedTokenDefinition?.title ?? page.title,
592
1146
  osComponent: page.osComponent,
593
1147
  description: page.description,
594
1148
  path: component?.file ?? page.source ?? page.file,
595
1149
  whenToUse: page.whenToUse,
596
1150
  props: component ? mergePropNotes(component, page.propNotes) : undefined,
597
1151
  a11y: page.a11y,
1152
+ notes: page.notes,
598
1153
  variants: page.variants,
599
1154
  states: page.states,
600
1155
  render: page.render,
601
1156
  // `tokenSections` makes a page a token gallery by itself.
602
- tokenGallery: page.tokenGallery || (page.tokenSections !== undefined && page.tokenSections.length > 0) || undefined,
603
- tokenSections: page.tokenSections,
1157
+ tokenGallery: derivedTokenDefinition?.tokenGallery ?? (page.tokenGallery || (page.tokenSections !== undefined && page.tokenSections.length > 0) || undefined),
1158
+ tokenSections: derivedTokenDefinition?.tokenSections ?? page.tokenSections,
1159
+ // #61: the real entry data `SectionBlock.tsx` renders instead of the placeholder sections
1160
+ // above, when set — see `SectionDef.tokenDefinition`'s own doc comment.
1161
+ tokenDefinition: page.tokenDefinition,
1162
+ // #64 B5: extends `page.aliases` to a tokenDefinition page too (see `SectionDef.tokenDefinitionAliases`).
1163
+ tokenDefinitionAliases: page.tokenDefinition ? page.aliases : undefined,
604
1164
  tokenColumns: page.tokenColumns,
605
1165
  fullWidthLabel: page.fullWidthLabel,
606
1166
  comparison: page.comparison,
@@ -613,6 +1173,14 @@ export function buildCatalogSections<TId extends string>(
613
1173
  internalProps: page.internalProps,
614
1174
  intentionalStaticPreview: page.intentionalStaticPreview,
615
1175
  family,
1176
+ navigation: page.navigation,
1177
+ // Family and group pages alike carry their own component binding per member
1178
+ // (`FamilyDef.members[].component` isn't a field — a member names its binding via
1179
+ // `CatalogMember.component` itself); only a normal page's own single binding is surfaced here.
1180
+ component: hasMembers ? undefined : component?.name,
1181
+ // #60: see `SectionDef.alsoUsedBy` (rendered by `ReferenceDetails`'s `AlsoUsedByFact`) — a family or group
1182
+ // page's "also used by" lives per member instead (`family.members[].alsoUsedBy`, above).
1183
+ alsoUsedBy: hasMembers ? undefined : dedupeAlsoUsedBy(usedByTarget.get(page.id) ?? [], new Set([page.id])),
616
1184
  };
617
1185
  });
618
1186
 
@@ -628,7 +1196,11 @@ export function buildCatalogSections<TId extends string>(
628
1196
  }
629
1197
  continue;
630
1198
  }
631
- sections.push({ id, description: 'No examples documented.', path: component.file, props: mergePropNotes(component, undefined) });
1199
+ sections.push({
1200
+ id, description: 'No examples documented.', path: component.file, props: mergePropNotes(component, undefined),
1201
+ component: component.name,
1202
+ alsoUsedBy: dedupeAlsoUsedBy(usedByTarget.get(id) ?? [], new Set([id])),
1203
+ });
632
1204
  addToGroup('Components', id);
633
1205
  }
634
1206