@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,6 +1,19 @@
1
1
  export function defineCatalogPage(page) {
2
2
  return { ...page, __dsViewerPage: true };
3
3
  }
4
+ /** Shared by a family member's own "also used by" (legacy) and, #60, an ordinary page's own —
5
+ * dedupes by `component`, keeping the first role, and drops any user in `excludeIds` (a family
6
+ * member excludes its own family's ids; an ordinary page excludes only itself). */
7
+ function dedupeAlsoUsedBy(entries, excludeIds) {
8
+ const deduped = new Map();
9
+ for (const entry of entries) {
10
+ if (excludeIds.has(entry.component))
11
+ continue;
12
+ if (!deduped.has(entry.component))
13
+ deduped.set(entry.component, entry);
14
+ }
15
+ return [...deduped.values()].map((entry) => ({ component: entry.component, role: entry.role || undefined }));
16
+ }
4
17
  function mergePropNotes(component, propNotes) {
5
18
  const own = component.props.map((prop) => ({
6
19
  name: prop.name,
@@ -19,6 +32,99 @@ function mergePropNotes(component, propNotes) {
19
32
  }));
20
33
  return [...own, ...inherited];
21
34
  }
35
+ // #61: the same canonical-title map `tokenDefinition.ts`'s own `canonicalTokenTitle` holds —
36
+ // duplicated here, deliberately, rather than value-imported: `pageApi.ts` must stay importable
37
+ // from a plain `node --experimental-strip-types` test (`pageApi.test.ts`) with no JSX/bundler
38
+ // loader, which needs a real resolvable `./tokenDefinition.ts` specifier, while the SAME file is
39
+ // also reachable from `kit-host`'s/`viewer-host`'s own "how the app actually resolves this"
40
+ // `moduleResolution: "bundler"` type-check (`npm run check:types`), which rejects a `.ts`-extension
41
+ // *value* import outright (`allowImportingTsExtensions` is off there, and that tsconfig is read-
42
+ // only). A `import type` (erased entirely, so its extension is never resolved at either runtime)
43
+ // stays fine either way — only the three lines of logic below that would otherwise need a real
44
+ // value import are kept local instead. `tokenDefinition.ts`'s own exported `canonicalTokenTitle`/
45
+ // `validateTokenDefinition`/`validateTokenDefinitionUsage` remain the canonical versions for every
46
+ // other consumer (`StructuredTokenPage.tsx`, `cli/doctor.ts`, the public `index.ts` barrel) — only
47
+ // `pageApi.ts` keeps this one local copy, and only for this one reason.
48
+ const TOKEN_KIND_TITLES = {
49
+ colors: 'Colors', typography: 'Typography', spacing: 'Spacing', radius: 'Radius',
50
+ dimensions: 'Dimensions', shadows: 'Shadows', opacity: 'Opacity', motion: 'Motion',
51
+ };
52
+ /** #61: resolves `page.tokenDefinition` into the page's real `title`/`tokenGallery`/`tokenSections`
53
+ * — the only three fields `comparison.ts`'s own `presentationBlocks` (not writable here) reads to
54
+ * place a token-gallery block, so an opted-in page needs zero changes anywhere in that layout
55
+ * pipeline. Each derived `TokenSection`'s own `render` is a trivial `() => null` placeholder, never
56
+ * actually called: `SectionBlock.tsx`'s own `tokenSections` block case renders this page's real
57
+ * `tokenDefinition` entries instead whenever it's set (`StructuredTokenPage.tsx`) — keeping every
58
+ * JSX-producing closure at that existing rendering boundary, never inside this plain-TS module.
59
+ * Conflicts (duplicate section/entry ids, a legacy `render`/`tokenGallery`/`tokenSections`/
60
+ * `members` set alongside it, or an explicit title that disagrees with the kind) are logged to the
61
+ * dev console only — the same "soft" runtime posture `validateComposedOf`/`validateFamily` already
62
+ * use; `cli/doctor.ts`'s own static read of the equivalent rules (`tokenDefinition.ts`'s exported
63
+ * `validateTokenDefinition`/`validateTokenDefinitionUsage`) is what actually fails CI on them.
64
+ * Undefined when the page didn't opt in — every other field keeps the author's own values
65
+ * untouched. */
66
+ function deriveTokenDefinition(page) {
67
+ const def = page.tokenDefinition;
68
+ if (!def)
69
+ return undefined;
70
+ if (globalThis.__DEV__) {
71
+ const issues = [];
72
+ if (page.render !== undefined)
73
+ issues.push('tokenDefinition conflicts with this page\'s own "render" — remove one of them.');
74
+ if (page.tokenGallery === true)
75
+ issues.push('tokenDefinition conflicts with this page\'s own "tokenGallery" — remove one of them.');
76
+ if (page.tokenSections !== undefined && page.tokenSections.length > 0)
77
+ issues.push('tokenDefinition conflicts with this page\'s own "tokenSections" — remove one of them.');
78
+ if (page.members !== undefined && page.members.length > 0)
79
+ issues.push('tokenDefinition conflicts with this page\'s own "members" (a family page) — remove one of them.');
80
+ const canonicalTitle = TOKEN_KIND_TITLES[def.kind];
81
+ if (page.title !== undefined && page.title !== canonicalTitle) {
82
+ issues.push(`tokenDefinition: title "${page.title}" conflicts with kind "${def.kind}" (expected "${canonicalTitle}"). Omit "title" to use it automatically.`);
83
+ }
84
+ const duplicateIds = (ids) => {
85
+ const seen = new Set();
86
+ const duplicates = new Set();
87
+ for (const id of ids) {
88
+ if (seen.has(id))
89
+ duplicates.add(id);
90
+ seen.add(id);
91
+ }
92
+ return [...duplicates];
93
+ };
94
+ for (const id of duplicateIds(def.sections.map((s) => s.id)))
95
+ issues.push(`tokenDefinition has more than one section with id "${id}".`);
96
+ for (const section of def.sections) {
97
+ for (const id of duplicateIds(section.entries.map((e) => e.id))) {
98
+ issues.push(`tokenDefinition section "${section.id}" has more than one entry with id "${id}".`);
99
+ }
100
+ for (const entry of section.entries) {
101
+ if (entry.kind !== def.kind)
102
+ issues.push(`tokenDefinition section "${section.id}": entry "${entry.id}" has kind "${entry.kind}", but the page's kind is "${def.kind}".`);
103
+ }
104
+ // #64 B1: groups inside a category — the same duplicate-id/kind-mismatch checks, scoped to
105
+ // each group's own entries, mirroring `tokenDefinition.ts`'s own `validateTokenDefinition`.
106
+ for (const id of duplicateIds((section.groups ?? []).map((g) => g.id))) {
107
+ issues.push(`tokenDefinition section "${section.id}" has more than one group with id "${id}".`);
108
+ }
109
+ for (const group of section.groups ?? []) {
110
+ for (const id of duplicateIds(group.entries.map((e) => e.id))) {
111
+ issues.push(`tokenDefinition section "${section.id}" group "${group.id}" has more than one entry with id "${id}".`);
112
+ }
113
+ for (const entry of group.entries) {
114
+ if (entry.kind !== def.kind)
115
+ issues.push(`tokenDefinition section "${section.id}" group "${group.id}": entry "${entry.id}" has kind "${entry.kind}", but the page's kind is "${def.kind}".`);
116
+ }
117
+ }
118
+ }
119
+ for (const issueMsg of issues)
120
+ console.error(`[Catalog] ${page.id}: ${issueMsg}`);
121
+ }
122
+ return {
123
+ title: page.title ?? TOKEN_KIND_TITLES[def.kind],
124
+ tokenGallery: true,
125
+ tokenSections: def.sections.map((section) => ({ id: section.id, title: section.title, desc: section.desc, wide: section.wide, render: () => null })),
126
+ };
127
+ }
22
128
  /** Human-readable problems with a page's (or, V2d, a family member's own) `composedOf`, in
23
129
  * declaration order: one line per entry whose `component` isn't in `knownNames` (every real
24
130
  * component name, documented or not, plus every page id and family member id — the full catalog
@@ -37,12 +143,21 @@ export function validateComposedOf(composedOf, knownNames) {
37
143
  * member in exactly one section; every parent resolving within the page with no cycles; member
38
144
  * and alias ids unique catalog-wide and never colliding with a page id, except the whole (whose
39
145
  * id equals its own page's id). Returns `[]` for a page without `members`. `allPageIds` and
40
- * `otherMemberIds` are the ids used by every *other* page/member in the catalog. */
41
- export function validateFamily(page, allPageIds, otherMemberIds) {
146
+ * `otherMemberIds` are the ids used by every *other* page/member in the catalog. #63 A1: a page
147
+ * with neither `whole` nor `sections` is a *group* page, not a family — validated instead by
148
+ * `validateGroup`; this function is a no-op for it (its own "both present" behavior and messages
149
+ * are otherwise unchanged). A page with exactly one of the two stays a family-shape error, as
150
+ * before. #63 A5: `externalPartIds` names every id this family's own `inWhole` sections list that
151
+ * was resolved (catalog-wide, by the caller) to a real, validly-parented group-page member —
152
+ * these are exempted from the "not a member of this page" check below; omit it (the default, `[]`)
153
+ * to keep every existing call site's behavior byte-for-byte unchanged. */
154
+ export function validateFamily(page, allPageIds, otherMemberIds, externalPartIds = new Set()) {
42
155
  const { members } = page;
43
156
  if (!members || members.length === 0)
44
157
  return [];
45
158
  const issues = [];
159
+ if (!page.whole && !page.sections)
160
+ return issues; // A group page — see `validateGroup`.
46
161
  if (!page.whole || !page.sections) {
47
162
  issues.push(`Family "${page.id}": "members" requires both "whole" and "sections".`);
48
163
  return issues;
@@ -59,10 +174,17 @@ export function validateFamily(page, allPageIds, otherMemberIds) {
59
174
  // here, rather than left to crash the renderer or silently occupy authored order in `numberFor`.
60
175
  // N6: a `list`-section member authored with a specimen — `FoldedRow` never renders one, so this
61
176
  // degrades to a silently dropped specimen rather than a crash, same as the unknown-id case.
177
+ const externalCountById = new Map();
62
178
  for (const section of page.sections) {
63
179
  for (const id of section.members) {
64
180
  const resolved = byId.get(id);
65
181
  if (!resolved) {
182
+ // #63 A5: a validly-resolved external part — a group-page member, already checked by the
183
+ // caller against `memberLocations`/parent — is never reported as missing.
184
+ if (section.inWhole && externalPartIds.has(id)) {
185
+ externalCountById.set(id, (externalCountById.get(id) ?? 0) + 1);
186
+ continue;
187
+ }
66
188
  issues.push(`Family "${page.id}": section "${section.label}" lists "${id}", which is not a member of this page.`);
67
189
  continue;
68
190
  }
@@ -71,6 +193,11 @@ export function validateFamily(page, allPageIds, otherMemberIds) {
71
193
  }
72
194
  }
73
195
  }
196
+ // An external part rendered twice would duplicate its row, React key and registered node.
197
+ for (const [id, count] of externalCountById) {
198
+ if (count > 1)
199
+ issues.push(`Family "${page.id}": member "${id}" is in more than one section.`);
200
+ }
74
201
  const sectionCountById = new Map();
75
202
  for (const section of page.sections) {
76
203
  for (const id of section.members)
@@ -154,6 +281,15 @@ export function validateFamily(page, allPageIds, otherMemberIds) {
154
281
  continue;
155
282
  }
156
283
  if (!byId.has(item.member)) {
284
+ // #63 A5: a resolved external part (numbered by the catalog-wide pre-pass, documented on a
285
+ // group page) may be named in the anatomy, exactly as its `inWhole` section may list it.
286
+ if (externalPartIds.has(item.member)) {
287
+ if (seenAnatomyMembers.has(item.member)) {
288
+ issues.push(`Family "${page.id}": anatomy lists member "${item.member}" more than once.`);
289
+ }
290
+ seenAnatomyMembers.add(item.member);
291
+ continue;
292
+ }
157
293
  issues.push(`Family "${page.id}": anatomy item "${item.member}" is not a member of this page.`);
158
294
  continue;
159
295
  }
@@ -263,6 +399,183 @@ export function numberFor(page) {
263
399
  }
264
400
  return outline;
265
401
  }
402
+ /** #63 A4: numbers one group member's own parts — authored (array) order among siblings, `1`,
403
+ * `2`, then `1.1`, … for children, exactly like `numberFor`'s own child assignment under a
404
+ * family's whole — scoped to `topLevelId`'s own descendants among `members` (a group page has no
405
+ * `sections` to scan, so "authored order" is simply the `members` array's own order). `topLevelId`
406
+ * itself gets no number, the same way a family's whole never does. Lifted, not reimplemented, from
407
+ * `numberFor`'s own `assignChildren`/cycle guard, re-anchored on one top-level member instead of
408
+ * one page-wide whole. */
409
+ export function numberForGroupMember(topLevelId, members) {
410
+ const outline = new Map();
411
+ const childrenOf = new Map();
412
+ for (const m of members) {
413
+ if (m.parent === undefined)
414
+ continue;
415
+ childrenOf.set(m.parent, [...(childrenOf.get(m.parent) ?? []), m.id]);
416
+ }
417
+ const assignChildren = (parentId, parentNumber, level, visited) => {
418
+ const kids = childrenOf.get(parentId) ?? [];
419
+ kids.forEach((childId, index) => {
420
+ if (visited.has(childId))
421
+ return;
422
+ visited.add(childId);
423
+ const number = `${parentNumber}.${index + 1}`;
424
+ outline.set(childId, { number, level: level + 1 });
425
+ assignChildren(childId, number, level + 1, visited);
426
+ });
427
+ };
428
+ const visited = new Set([topLevelId]);
429
+ const kids = childrenOf.get(topLevelId) ?? [];
430
+ kids.forEach((childId, index) => {
431
+ if (visited.has(childId))
432
+ return;
433
+ visited.add(childId);
434
+ const number = String(index + 1);
435
+ outline.set(childId, { number, level: 2 });
436
+ assignChildren(childId, number, 2, visited);
437
+ });
438
+ return outline;
439
+ }
440
+ /** 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
441
+ * either to a same-page member (checked for cycles, the same guard `validateFamily` already
442
+ * lifts) or — A3's cross-page case — to a member known catalog-wide (`otherMemberIds`), never to
443
+ * neither; member and alias ids unique catalog-wide, with no privileged "whole" exemption (a group
444
+ * has none); and `anatomy` set only on a top-level member (one with no parent, or whose parent
445
+ * isn't one of this page's own members), naming only that member's own descendants on this page.
446
+ * Returns `[]` for a page without `members`. Mirrors `validateFamily`'s own contract and the same
447
+ * `allPageIds`/`otherMemberIds` meaning (every *other* page's/member's id in the catalog). */
448
+ export function validateGroupIssues(page, allPageIds, otherMemberIds) {
449
+ const { members } = page;
450
+ if (!members || members.length === 0)
451
+ return [];
452
+ const issues = [];
453
+ const shapeIssue = (message) => issues.push({ rule: 'family-invalid', message });
454
+ const anatomyIssue = (message) => issues.push({ rule: 'group-anatomy-member', message });
455
+ const byId = new Map(members.map((m) => [m.id, m]));
456
+ if (page.anatomy !== undefined) {
457
+ shapeIssue(`Group "${page.id}": page-level "anatomy" is not used on a group page — move it to the relevant top-level member instead.`);
458
+ }
459
+ if (page.relatedPages && page.relatedPages.length > 0) {
460
+ 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.`);
461
+ }
462
+ for (const m of members) {
463
+ if (m.parent === undefined)
464
+ continue;
465
+ if (!byId.has(m.parent) && !otherMemberIds.has(m.parent)) {
466
+ shapeIssue(`Group "${page.id}": member "${m.id}" has an unknown parent "${m.parent}".`);
467
+ continue;
468
+ }
469
+ // Lifted verbatim from `validateFamily`'s own cycle guard: `byId.get(current)?.parent` already
470
+ // degrades to `undefined` (loop ends, no cycle) the moment `current` resolves off this page —
471
+ // exactly A3's cross-page top-level case, so no change is needed for that case here.
472
+ const seen = new Set([m.id]);
473
+ let current = m.parent;
474
+ let cycle = false;
475
+ while (current !== undefined) {
476
+ if (seen.has(current)) {
477
+ cycle = true;
478
+ break;
479
+ }
480
+ seen.add(current);
481
+ current = byId.get(current)?.parent;
482
+ }
483
+ if (cycle)
484
+ shapeIssue(`Group "${page.id}": member "${m.id}" has a parent cycle.`);
485
+ }
486
+ const seenInPage = new Set();
487
+ const checkId = (id, isMemberOrMemberAlias = true) => {
488
+ // `allPageIds` excludes this page, and `resolveRoute` matches page ids before aliases — so a
489
+ // member (or its alias) named like its own page could never be addressed.
490
+ if (isMemberOrMemberAlias && id === page.id)
491
+ shapeIssue(`Group "${page.id}": id "${id}" collides with this group page's own id.`);
492
+ if (seenInPage.has(id))
493
+ shapeIssue(`Group "${page.id}": id "${id}" is used more than once on this page.`);
494
+ seenInPage.add(id);
495
+ if (allPageIds.has(id))
496
+ shapeIssue(`Group "${page.id}": id "${id}" collides with another page's id.`);
497
+ if (otherMemberIds.has(id))
498
+ shapeIssue(`Group "${page.id}": id "${id}" collides with another member or alias elsewhere in the catalog.`);
499
+ };
500
+ for (const m of members) {
501
+ checkId(m.id);
502
+ for (const alias of m.aliases ?? [])
503
+ checkId(alias);
504
+ }
505
+ for (const alias of page.aliases ?? [])
506
+ checkId(alias, false);
507
+ // A3/A4: a top-level member has no parent, or a parent not among this page's own members
508
+ // (resolved cross-page above). `anatomy` is only ever meaningful there.
509
+ const isTopLevel = (m) => m.parent === undefined || !byId.has(m.parent);
510
+ const descendantsOf = (topLevelId) => {
511
+ const result = new Set();
512
+ const stack = [topLevelId];
513
+ while (stack.length > 0) {
514
+ const current = stack.pop();
515
+ for (const m of members) {
516
+ if (m.parent === current && m.id !== topLevelId && !result.has(m.id)) {
517
+ result.add(m.id);
518
+ stack.push(m.id);
519
+ }
520
+ }
521
+ }
522
+ return result;
523
+ };
524
+ for (const m of members) {
525
+ if (!m.anatomy)
526
+ continue;
527
+ if (!isTopLevel(m)) {
528
+ anatomyIssue(`Group "${page.id}": member "${m.id}" has "anatomy", but anatomy is only used on a top-level member.`);
529
+ continue;
530
+ }
531
+ const descendants = descendantsOf(m.id);
532
+ const seenAnatomyMembers = new Set();
533
+ for (const group of m.anatomy.groups) {
534
+ for (const item of group.items) {
535
+ if (!('member' in item))
536
+ continue;
537
+ if (item.member === m.id) {
538
+ anatomyIssue(`Group "${page.id}": member "${m.id}": anatomy item "${item.member}" names itself.`);
539
+ continue;
540
+ }
541
+ if (!descendants.has(item.member)) {
542
+ anatomyIssue(`Group "${page.id}": member "${m.id}": anatomy item "${item.member}" is not one of its descendants on this page.`);
543
+ continue;
544
+ }
545
+ if (seenAnatomyMembers.has(item.member)) {
546
+ anatomyIssue(`Group "${page.id}": member "${m.id}": anatomy lists member "${item.member}" more than once.`);
547
+ continue;
548
+ }
549
+ seenAnatomyMembers.add(item.member);
550
+ }
551
+ }
552
+ }
553
+ return issues;
554
+ }
555
+ export function validateGroup(page, allPageIds, otherMemberIds) {
556
+ return validateGroupIssues(page, allPageIds, otherMemberIds).map((issue) => issue.message);
557
+ }
558
+ /** #63 A5/A6: resolves one family `inWhole` section id that is not one of that family's own
559
+ * `members` — a candidate external part — against `owners`, every catalog location that id
560
+ * resolves to as a real member. Exactly one owner, itself a group page, whose own `parent` equals
561
+ * `familyWhole`, is the only success; anything else is the validation issue A6 requires (zero or
562
+ * multiple owners, an owner that isn't a group page, or a parent that doesn't match). Pure and
563
+ * catalog-shape-agnostic — shared between `buildCatalogSections` (runtime) and `cli/doctor.ts`
564
+ * (static), which each build `owners` from their own page representation. */
565
+ export function resolveExternalPart(id, owners, familyWhole) {
566
+ if (owners.length === 0)
567
+ return { issue: `"${id}" is not a member of this page, and is not defined on any group page either.` };
568
+ if (owners.length > 1) {
569
+ return { issue: `"${id}" is defined as a member on more than one page (${owners.map((o) => o.pageId).join(', ')}).` };
570
+ }
571
+ const [owner] = owners;
572
+ if (!owner.isGroupPage)
573
+ return { issue: `"${id}" is a member of "${owner.pageId}", which is not a group page.` };
574
+ if (owner.parent !== familyWhole) {
575
+ return { issue: `"${id}"'s parent on "${owner.pageId}" ("${owner.parent ?? 'none'}") does not equal this family's whole "${familyWhole}".` };
576
+ }
577
+ return { pageId: owner.pageId };
578
+ }
266
579
  function orderGroups(groupIds, groupOrder) {
267
580
  const listed = groupOrder ?? [];
268
581
  const labels = [...groupIds.keys()];
@@ -315,14 +628,51 @@ pages, components, groupOrder) {
315
628
  // `FamilyPage`'s own `resolvesAddress`. Without resolving through the same map here, an
316
629
  // alias-named or component-named entry would link fine going forward but derive nothing going
317
630
  // back: a missing "Also used by" line looks identical to "nothing uses this".
631
+ // C1 fix: every page's own id and every member's own id is itself an exact registered address —
632
+ // it must win over any same-named `component` binding elsewhere, including the common case where
633
+ // a page's own id already equals its component name (so it never gets a chance to appear as a
634
+ // "claimant" in the ambiguity pass below). Registering these identities first, before any
635
+ // binding-name guessing runs, is what makes that guessing unable to ever shadow an exact address.
318
636
  const canonicalMemberIdByAlias = new Map();
319
637
  for (const page of pages) {
638
+ canonicalMemberIdByAlias.set(page.id, page.id);
320
639
  for (const member of page.members ?? []) {
640
+ canonicalMemberIdByAlias.set(member.id, member.id);
321
641
  for (const alias of member.aliases ?? [])
322
642
  canonicalMemberIdByAlias.set(alias, member.id);
323
- if (member.component && member.component !== member.id)
324
- canonicalMemberIdByAlias.set(member.component, member.id);
325
643
  }
644
+ for (const alias of page.aliases ?? [])
645
+ canonicalMemberIdByAlias.set(alias, page.id);
646
+ }
647
+ // #60/C1: a `composedOf` entry may instead name a component export rather than its page/member's
648
+ // own id or alias — an ordinary page's own `component` (when it differs from its `id`) or a family
649
+ // member's own `component` (when it differs from its `id`) are the same kind of claim, so both
650
+ // feed the one shared ambiguity pass below: a name already owned by an exact address above is
651
+ // never reconsidered, and a name claimed by more than one binding (ordinary-vs-ordinary,
652
+ // member-vs-member, or ordinary-vs-member alike) is never guessed — it is left unresolved, not
653
+ // silently decided by whichever binding happened to be declared first.
654
+ const componentBindingOwners = new Map();
655
+ const addComponentBindingOwner = (name, ownerId) => {
656
+ if (canonicalMemberIdByAlias.has(name))
657
+ return;
658
+ componentBindingOwners.set(name, [...(componentBindingOwners.get(name) ?? []), ownerId]);
659
+ };
660
+ for (const page of pages) {
661
+ if (page.members && page.members.length > 0) {
662
+ for (const member of page.members) {
663
+ if (member.component && member.component !== member.id)
664
+ addComponentBindingOwner(member.component, member.id);
665
+ }
666
+ continue;
667
+ }
668
+ const componentName = page.component ?? page.id;
669
+ if (componentName !== page.id)
670
+ addComponentBindingOwner(componentName, page.id);
671
+ }
672
+ for (const [name, owners] of componentBindingOwners) {
673
+ if (owners.length !== 1)
674
+ continue;
675
+ canonicalMemberIdByAlias.set(name, owners[0]);
326
676
  }
327
677
  const canonicalUsedByTarget = (name) => canonicalMemberIdByAlias.get(name) ?? name;
328
678
  // "Also used by" (derived, never authored): the inverse of every `composedOf` entry in the
@@ -353,22 +703,100 @@ pages, components, groupOrder) {
353
703
  }
354
704
  // Every id/alias a family page's own `members` and `aliases` use, keyed by page id — gathered up
355
705
  // front so `validateFamily` (above) can check catalog-wide uniqueness against every *other*
356
- // page's ids.
706
+ // page's ids. #64 B5: also a tokenDefinition page's own `aliases` and each of its sections' own
707
+ // `aliases` (extending this same catalog-wide pool to tokenDefinition pages).
357
708
  const allPageIds = new Set(pages.map((page) => page.id));
358
709
  const familyIdsByPage = new Map();
359
710
  for (const page of pages) {
360
- if (!page.members)
711
+ if (!page.members && !page.tokenDefinition)
361
712
  continue;
362
713
  const ids = new Set();
363
- for (const member of page.members) {
714
+ for (const member of page.members ?? []) {
364
715
  ids.add(member.id);
365
716
  for (const alias of member.aliases ?? [])
366
717
  ids.add(alias);
367
718
  }
368
719
  for (const alias of page.aliases ?? [])
369
720
  ids.add(alias);
721
+ for (const section of page.tokenDefinition?.sections ?? [])
722
+ for (const alias of section.aliases ?? [])
723
+ ids.add(alias);
370
724
  familyIdsByPage.set(page.id, ids);
371
725
  }
726
+ // #63 A5/A6: catalog-wide — where every member id lives, so each family's `inWhole` sections can
727
+ // tell an external part (a group-page member) from a genuinely unknown id. Built once, up front,
728
+ // the same way `familyIdsByPage` already is above.
729
+ const memberLocations = new Map();
730
+ for (const p of pages) {
731
+ const pHasMembers = p.members !== undefined && p.members.length > 0;
732
+ const pIsGroup = pHasMembers && p.whole === undefined && p.sections === undefined;
733
+ for (const member of p.members ?? []) {
734
+ memberLocations.set(member.id, [...(memberLocations.get(member.id) ?? []), { pageId: p.id, isGroupPage: pIsGroup, parent: member.parent }]);
735
+ }
736
+ }
737
+ // #63 A5: resolved once, per valid family, before the per-page pass below assembles any section
738
+ // — never by mutating an already-built section a second time (per the controller's own decision).
739
+ // `outlineByFamily` is this same pre-pass's own `numberFor` call, including a synthetic stub
740
+ // (`parent: page.whole`) per resolved external id so `numberFor`'s existing authored-order scan
741
+ // (which already walks each `inWhole` section's own `members` list) numbers it exactly like an
742
+ // own part, with no change to `numberFor` itself.
743
+ const externalIssues = [];
744
+ const externalPartsByFamily = new Map();
745
+ const partOfByMemberId = new Map();
746
+ const outlineByFamily = new Map();
747
+ for (const page of pages) {
748
+ const pHasMembers = page.members !== undefined && page.members.length > 0;
749
+ if (!pHasMembers || page.whole === undefined || page.sections === undefined)
750
+ continue; // not a valid family.
751
+ const byIdThisPage = new Set(page.members.map((m) => m.id));
752
+ const externalResolutions = new Map();
753
+ for (const section of page.sections) {
754
+ if (!section.inWhole)
755
+ continue;
756
+ for (const id of section.members) {
757
+ if (byIdThisPage.has(id) || externalResolutions.has(id))
758
+ continue;
759
+ const owners = memberLocations.get(id);
760
+ if (!owners)
761
+ continue; // genuinely unknown — `validateFamily`'s own "not a member" error covers it.
762
+ const resolution = resolveExternalPart(id, owners, page.whole);
763
+ if ('issue' in resolution)
764
+ externalIssues.push(`Family "${page.id}": ${resolution.issue}`);
765
+ else
766
+ externalResolutions.set(id, { pageId: resolution.pageId });
767
+ }
768
+ }
769
+ const membersForNumbering = [...page.members, ...[...externalResolutions.keys()].map((id) => ({ id, parent: page.whole }))];
770
+ const outline = numberFor({ ...page, members: membersForNumbering });
771
+ outlineByFamily.set(page.id, outline);
772
+ if (externalResolutions.size === 0)
773
+ continue;
774
+ const familyTitle = String(page.title ?? page.id);
775
+ const externalMap = new Map();
776
+ for (const [id, resolution] of externalResolutions) {
777
+ const ownerPage = pages.find((p) => p.id === resolution.pageId);
778
+ externalMap.set(id, {
779
+ pageId: resolution.pageId,
780
+ title: String(ownerPage?.title ?? resolution.pageId),
781
+ fidelity: ownerPage?.members?.find((m) => m.id === id)?.fidelity,
782
+ });
783
+ const number = outline.get(id)?.number;
784
+ if (number)
785
+ partOfByMemberId.set(id, { pageId: page.id, title: familyTitle, number });
786
+ }
787
+ externalPartsByFamily.set(page.id, externalMap);
788
+ }
789
+ if (globalThis.__DEV__) {
790
+ for (const issueMsg of externalIssues)
791
+ console.error(`[Catalog] ${issueMsg}`);
792
+ }
793
+ // #64 B4: every page whose `tokenDefinition.kind` matches another page's — one page per kind.
794
+ const tokenKindPages = new Map();
795
+ for (const page of pages) {
796
+ if (!page.tokenDefinition)
797
+ continue;
798
+ tokenKindPages.set(page.tokenDefinition.kind, [...(tokenKindPages.get(page.tokenDefinition.kind) ?? []), page.id]);
799
+ }
372
800
  // Design §4 "duplicate-page-id" rule's viewer-side equivalent — this is the one place in the
373
801
  // whole viewer that already resolves every page's id to build sections/groups, so it costs
374
802
  // nothing extra to also notice two pages claiming the same one (`sectionsById` in
@@ -393,9 +821,45 @@ pages, components, groupOrder) {
393
821
  console.warn(`[Catalog] ${page.id}.${member.id}: ${issue}`);
394
822
  }
395
823
  }
824
+ if (page.tokenDefinition) {
825
+ // #64 B4: one page per kind — mirrors `cli/doctor.ts`'s `token-definition-duplicate-kind`.
826
+ const samekind = (tokenKindPages.get(page.tokenDefinition.kind) ?? []).filter((id) => id !== page.id);
827
+ if (samekind.length > 0) {
828
+ console.error(`[Catalog] ${page.id}: tokenDefinition kind "${page.tokenDefinition.kind}" is also used by ${samekind.join(', ')}.`);
829
+ }
830
+ // #64 B5: a tokenDefinition page's own page-level/section aliases join the same
831
+ // catalog-wide "one owner" pool a family's own ids/aliases already check against —
832
+ // mirrors `cli/doctor.ts`'s own `checkTokenDefinitionAliases` (rule id `family-invalid`).
833
+ const otherPageIdsForTokenDef = new Set([...allPageIds].filter((id) => id !== page.id));
834
+ const otherIdsForTokenDef = new Set();
835
+ for (const [id, ids] of familyIdsByPage)
836
+ if (id !== page.id)
837
+ for (const x of ids)
838
+ otherIdsForTokenDef.add(x);
839
+ const seenAliases = new Set();
840
+ const checkTokenDefAlias = (alias) => {
841
+ if (seenAliases.has(alias))
842
+ console.error(`[Catalog] Page "${page.id}": id "${alias}" is used more than once on this page.`);
843
+ seenAliases.add(alias);
844
+ if (otherPageIdsForTokenDef.has(alias))
845
+ console.error(`[Catalog] Page "${page.id}": id "${alias}" collides with another page's id.`);
846
+ if (otherIdsForTokenDef.has(alias))
847
+ console.error(`[Catalog] Page "${page.id}": id "${alias}" collides with another member or alias elsewhere in the catalog.`);
848
+ };
849
+ for (const alias of page.aliases ?? [])
850
+ checkTokenDefAlias(alias);
851
+ for (const section of page.tokenDefinition.sections)
852
+ for (const alias of section.aliases ?? [])
853
+ checkTokenDefAlias(alias);
854
+ }
396
855
  }
397
- const isFamily = page.members !== undefined && page.members.length > 0;
398
- const componentName = isFamily ? undefined : (page.component ?? page.id);
856
+ const hasMembers = page.members !== undefined && page.members.length > 0;
857
+ // #63 A1: a page with neither `whole` nor `sections` is a group, not a family; a page with
858
+ // exactly one of the two stays the same family-shape error `validateFamily` already reports,
859
+ // via `isFamily` (unchanged from before this field existed).
860
+ const isGroup = hasMembers && page.whole === undefined && page.sections === undefined;
861
+ const isFamily = hasMembers && !isGroup;
862
+ const componentName = hasMembers ? undefined : (page.component ?? page.id);
399
863
  const component = componentName ? componentByName.get(componentName) : undefined;
400
864
  addToGroup(page.group, page.id);
401
865
  let family;
@@ -412,7 +876,8 @@ pages, components, groupOrder) {
412
876
  ...member,
413
877
  hasSpecimens: Boolean(member.variants?.items?.length || member.states?.items?.length || member.comparison),
414
878
  }));
415
- const issues = validateFamily({ ...page, members, anatomy: page.anatomy, relatedPages: page.relatedPages }, otherPageIds, otherMemberIds);
879
+ const externalPartIds = new Set(externalPartsByFamily.get(page.id)?.keys() ?? []);
880
+ const issues = validateFamily({ ...page, members, anatomy: page.anatomy, relatedPages: page.relatedPages }, otherPageIds, otherMemberIds, externalPartIds);
416
881
  if (globalThis.__DEV__) {
417
882
  for (const issue of issues)
418
883
  console.error(`[Catalog] ${issue}`);
@@ -424,34 +889,80 @@ pages, components, groupOrder) {
424
889
  family = {
425
890
  whole: page.whole,
426
891
  sections: page.sections,
427
- outline: numberFor(page),
892
+ // #63 A5: the pre-pass above already ran `numberFor` with every valid external part's
893
+ // synthetic stub included; the "exactly one of whole/sections" invalid shape never reaches
894
+ // that pre-pass, so it falls back to the same plain `numberFor(page)` call this always was.
895
+ outline: outlineByFamily.get(page.id) ?? numberFor(page),
428
896
  notShown: page.notShown,
429
897
  aliases: page.aliases,
430
898
  apiLabel: page.apiLabel,
431
899
  anatomy: page.anatomy,
432
900
  relatedPages: page.relatedPages,
901
+ externalParts: externalPartsByFamily.get(page.id),
433
902
  members: page.members.map((member) => {
434
903
  const memberComponent = member.component ? componentByName.get(member.component) : undefined;
435
904
  // Dedupe by `component`, keeping the first role: page-level and whole-member `composedOf`
436
905
  // attribute to the same string for a family's whole (`validateFamily` forces
437
906
  // `whole === page.id`), and `validateComposedOf` has no duplicate check of its own, so the
438
907
  // same user declared twice would otherwise render "Also used by P and P".
439
- const dedupedUsers = new Map();
440
- for (const entry of usedByTarget.get(member.id) ?? []) {
441
- if (pageMemberIds.has(entry.component))
442
- continue;
443
- if (!dedupedUsers.has(entry.component))
444
- dedupedUsers.set(entry.component, entry);
445
- }
446
- const alsoUsedBy = [...dedupedUsers.values()].map((entry) => ({
447
- component: entry.component,
448
- role: entry.role || undefined,
449
- }));
908
+ const alsoUsedBy = dedupeAlsoUsedBy(usedByTarget.get(member.id) ?? [], pageMemberIds);
909
+ return {
910
+ ...member,
911
+ props: memberComponent ? mergePropNotes(memberComponent, undefined) : undefined,
912
+ path: memberComponent?.file ?? member.source,
913
+ alsoUsedBy,
914
+ };
915
+ }),
916
+ };
917
+ }
918
+ else if (isGroup) {
919
+ const otherPageIds = new Set([...allPageIds].filter((id) => id !== page.id));
920
+ const otherMemberIds = new Set();
921
+ for (const [id, ids] of familyIdsByPage)
922
+ if (id !== page.id)
923
+ for (const memberId of ids)
924
+ otherMemberIds.add(memberId);
925
+ const groupMembers = page.members.map((member) => ({
926
+ ...member,
927
+ hasSpecimens: Boolean(member.variants?.items?.length || member.states?.items?.length || member.comparison),
928
+ }));
929
+ const groupIssues = validateGroup({ id: page.id, members: groupMembers, aliases: page.aliases, anatomy: page.anatomy, relatedPages: page.relatedPages }, otherPageIds, otherMemberIds);
930
+ if (globalThis.__DEV__) {
931
+ for (const issue of groupIssues)
932
+ console.error(`[Catalog] ${issue}`);
933
+ }
934
+ // #63 A4: one top-level member is any member with no parent, or whose parent isn't one of
935
+ // this page's own members (A3's cross-page case) — its own parts are numbered independently
936
+ // of every other top-level member's (`numberForGroupMember`), then merged into one outline
937
+ // map the same shape a family's own `outline` already has.
938
+ const byIdThisPage = new Set(page.members.map((m) => m.id));
939
+ const groupOutline = new Map();
940
+ for (const m of page.members) {
941
+ if (m.parent !== undefined && byIdThisPage.has(m.parent))
942
+ continue; // not top-level.
943
+ for (const [id, entry] of numberForGroupMember(m.id, page.members))
944
+ groupOutline.set(id, entry);
945
+ }
946
+ const pageMemberIds = new Set(page.members.map((m) => m.id));
947
+ family = {
948
+ kind: 'group',
949
+ whole: undefined,
950
+ sections: [],
951
+ outline: groupOutline,
952
+ notShown: page.notShown,
953
+ aliases: page.aliases,
954
+ apiLabel: page.apiLabel,
955
+ relatedPages: page.relatedPages,
956
+ members: page.members.map((member) => {
957
+ const memberComponent = member.component ? componentByName.get(member.component) : undefined;
958
+ const alsoUsedBy = dedupeAlsoUsedBy(usedByTarget.get(member.id) ?? [], pageMemberIds);
959
+ const partOf = partOfByMemberId.get(member.id);
450
960
  return {
451
961
  ...member,
452
962
  props: memberComponent ? mergePropNotes(memberComponent, undefined) : undefined,
453
963
  path: memberComponent?.file ?? member.source,
454
964
  alsoUsedBy,
965
+ ...(partOf ? { partOf } : {}),
455
966
  };
456
967
  }),
457
968
  };
@@ -459,21 +970,29 @@ pages, components, groupOrder) {
459
970
  // Important finding 4: a standalone page (no matching component record) has no props to show
460
971
  // and nothing to say "This component takes no props" about — hide the Props box, and fall back
461
972
  // to the page's own file as its Source instead of an empty path.
973
+ // #61: resolved once so `title`/`tokenGallery`/`tokenSections` below can each fall back to it.
974
+ const derivedTokenDefinition = deriveTokenDefinition(page);
462
975
  return {
463
976
  id: page.id,
464
- title: page.title,
977
+ title: derivedTokenDefinition?.title ?? page.title,
465
978
  osComponent: page.osComponent,
466
979
  description: page.description,
467
980
  path: component?.file ?? page.source ?? page.file,
468
981
  whenToUse: page.whenToUse,
469
982
  props: component ? mergePropNotes(component, page.propNotes) : undefined,
470
983
  a11y: page.a11y,
984
+ notes: page.notes,
471
985
  variants: page.variants,
472
986
  states: page.states,
473
987
  render: page.render,
474
988
  // `tokenSections` makes a page a token gallery by itself.
475
- tokenGallery: page.tokenGallery || (page.tokenSections !== undefined && page.tokenSections.length > 0) || undefined,
476
- tokenSections: page.tokenSections,
989
+ tokenGallery: derivedTokenDefinition?.tokenGallery ?? (page.tokenGallery || (page.tokenSections !== undefined && page.tokenSections.length > 0) || undefined),
990
+ tokenSections: derivedTokenDefinition?.tokenSections ?? page.tokenSections,
991
+ // #61: the real entry data `SectionBlock.tsx` renders instead of the placeholder sections
992
+ // above, when set — see `SectionDef.tokenDefinition`'s own doc comment.
993
+ tokenDefinition: page.tokenDefinition,
994
+ // #64 B5: extends `page.aliases` to a tokenDefinition page too (see `SectionDef.tokenDefinitionAliases`).
995
+ tokenDefinitionAliases: page.tokenDefinition ? page.aliases : undefined,
477
996
  tokenColumns: page.tokenColumns,
478
997
  fullWidthLabel: page.fullWidthLabel,
479
998
  comparison: page.comparison,
@@ -486,6 +1005,14 @@ pages, components, groupOrder) {
486
1005
  internalProps: page.internalProps,
487
1006
  intentionalStaticPreview: page.intentionalStaticPreview,
488
1007
  family,
1008
+ navigation: page.navigation,
1009
+ // Family and group pages alike carry their own component binding per member
1010
+ // (`FamilyDef.members[].component` isn't a field — a member names its binding via
1011
+ // `CatalogMember.component` itself); only a normal page's own single binding is surfaced here.
1012
+ component: hasMembers ? undefined : component?.name,
1013
+ // #60: see `SectionDef.alsoUsedBy` (rendered by `ReferenceDetails`'s `AlsoUsedByFact`) — a family or group
1014
+ // page's "also used by" lives per member instead (`family.members[].alsoUsedBy`, above).
1015
+ alsoUsedBy: hasMembers ? undefined : dedupeAlsoUsedBy(usedByTarget.get(page.id) ?? [], new Set([page.id])),
489
1016
  };
490
1017
  });
491
1018
  const undocumented = components.filter((component) => !documented.has(component.name)).sort((a, b) => a.name.localeCompare(b.name));
@@ -500,7 +1027,11 @@ pages, components, groupOrder) {
500
1027
  }
501
1028
  continue;
502
1029
  }
503
- sections.push({ id, description: 'No examples documented.', path: component.file, props: mergePropNotes(component, undefined) });
1030
+ sections.push({
1031
+ id, description: 'No examples documented.', path: component.file, props: mergePropNotes(component, undefined),
1032
+ component: component.name,
1033
+ alsoUsedBy: dedupeAlsoUsedBy(usedByTarget.get(id) ?? [], new Set([id])),
1034
+ });
504
1035
  addToGroup('Components', id);
505
1036
  }
506
1037
  return { sections, groups: orderGroups(groupIds, groupOrder) };