@krapwoo/ds-viewer 0.14.2 → 0.16.0

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