@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
@@ -18,7 +18,18 @@ function useWidth(): [number, (event: LayoutChangeEvent) => void] {
18
18
  * in up to `columns` columns. A `wide` section takes a full row; the page drops to fewer columns
19
19
  * rather than squeeze a section under its minimum width.
20
20
  */
21
- export function TokenSections({ sections, columns, label }: { sections: TokenSection[]; columns: 1 | 2 | 3; label?: string }) {
21
+ export function TokenSections({
22
+ sections, columns, label, onSectionNode,
23
+ }: {
24
+ sections: TokenSection[];
25
+ columns: 1 | 2 | 3;
26
+ label?: string;
27
+ /** #64 B2/B5: called with each section's own outer node (web: the real DOM node) as it mounts,
28
+ * and `undefined` as it unmounts — keyed by the same id/title React already keys the section
29
+ * by. Lets a caller (the jump row, or a section-alias arrival) scroll a given section into view.
30
+ * Omitted (every existing caller) leaves every section's own rendering untouched. */
31
+ onSectionNode?: (key: string, node: unknown) => void;
32
+ }) {
22
33
  const [width, onLayout] = useWidth();
23
34
  const perRow = sectionColumns(width, columns);
24
35
  // Degrading quietly is how a page ends up claiming a column count it never renders: the author
@@ -35,13 +46,23 @@ export function TokenSections({ sections, columns, label }: { sections: TokenSec
35
46
  const columnWidth = perRow === 1 ? width : (width - TOKEN_LAYOUT_GAP * (perRow - 1)) / perRow;
36
47
  return (
37
48
  <View onLayout={onLayout} style={styles.sections}>
38
- {width > 0 && sections.map((section) => (
39
- <View key={section.title} style={{ width: section.wide || perRow === 1 ? width : columnWidth }}>
40
- <Text style={styles.sectionLabel}>{section.title}</Text>
41
- {section.desc ? <Text style={styles.sectionDesc}>{section.desc}</Text> : null}
42
- <View style={styles.card}>{section.render()}</View>
43
- </View>
44
- ))}
49
+ {width > 0 && sections.map((section) => {
50
+ // #61: `section.id` when the author set one (two sections may legitimately share a human
51
+ // `title`, e.g. two scoped "Palette" cards — `TokenSection.id`'s own doc comment) — omitted
52
+ // keeps the prior `title`-keyed behavior exactly as it was.
53
+ const key = section.id ?? section.title;
54
+ return (
55
+ <View
56
+ key={key}
57
+ ref={onSectionNode ? (node: unknown) => onSectionNode(key, node) : undefined}
58
+ style={{ width: section.wide || perRow === 1 ? width : columnWidth }}
59
+ >
60
+ <Text style={styles.sectionLabel}>{section.title}</Text>
61
+ {section.desc ? <Text style={styles.sectionDesc}>{section.desc}</Text> : null}
62
+ <View style={styles.card}>{section.render()}</View>
63
+ </View>
64
+ );
65
+ })}
45
66
  </View>
46
67
  );
47
68
  }
@@ -64,7 +85,7 @@ export function TokenGrid({ children, minTileWidth = 140, maxColumns }: { childr
64
85
  * One token in a `TokenGrid`: its preview, then its name and value, then its role. `note` says
65
86
  * where or why the token is used (from its comments or real usages) — omit it when there is none.
66
87
  */
67
- export function TokenTile({ name, value, note, children }: { name: string; value?: string; note?: string; children: React.ReactNode }) {
88
+ export function TokenTile({ name, value, note, children }: { name: string; value?: string; note?: React.ReactNode; children: React.ReactNode }) {
68
89
  return (
69
90
  <View style={styles.tile}>
70
91
  <View style={styles.tilePreview}>{children}</View>
@@ -157,26 +157,42 @@ export function resolveActiveFromHash<TId extends string>(
157
157
  }
158
158
 
159
159
  /** Where an alias or member id resolves: the family page `pageId`, and — unless it names the
160
- * whole itself — the specific `memberId` within it. Built by `aliasTargets`. */
160
+ * whole itself — the specific `memberId` within it. Built by `aliasTargets`. #64 B5: a
161
+ * tokenDefinition page's section alias instead sets `sectionId` (never together with
162
+ * `memberId`), so the page can scroll that section into view on arrival. */
161
163
  export interface AliasTarget {
162
164
  pageId: string;
163
165
  memberId?: string;
166
+ sectionId?: string;
164
167
  }
165
168
 
166
169
  /** Every member id other than the whole, every member alias, and every family page's own
167
- * (page-level) alias, mapped to where it resolves (spec §5.3 "Old addresses"). Pages without a
168
- * `family` contribute nothing. */
170
+ * (page-level) alias, mapped to where it resolves (spec §5.3 "Old addresses"). #64 B5: also, a
171
+ * tokenDefinition page's own page-level aliases (`tokenDefinitionAliases`) and each of its
172
+ * sections' own `aliases` — the same routing a family page's aliases already get, extended to
173
+ * tokenDefinition pages. A page with neither contributes nothing. */
169
174
  export function aliasTargets<TId extends string>(sections: readonly SectionDef<TId>[]): Map<string, AliasTarget> {
170
175
  const targets = new Map<string, AliasTarget>();
171
176
  for (const section of sections) {
172
177
  const family = section.family;
173
- if (!family) continue;
174
- for (const member of family.members) {
175
- if (member.id === family.whole) continue;
176
- targets.set(member.id, { pageId: section.id, memberId: member.id });
177
- for (const alias of member.aliases ?? []) targets.set(alias, { pageId: section.id, memberId: member.id });
178
+ if (family) {
179
+ for (const member of family.members) {
180
+ if (member.id === family.whole) {
181
+ // C3: the whole's own id already resolves via `order` (it equals the page's own id), but
182
+ // its own aliases were never registered anywhere — routing one to the page, with no member
183
+ // focus (unlike a non-whole member's alias, which focuses that member).
184
+ for (const alias of member.aliases ?? []) targets.set(alias, { pageId: section.id });
185
+ continue;
186
+ }
187
+ targets.set(member.id, { pageId: section.id, memberId: member.id });
188
+ for (const alias of member.aliases ?? []) targets.set(alias, { pageId: section.id, memberId: member.id });
189
+ }
190
+ for (const alias of family.aliases ?? []) targets.set(alias, { pageId: section.id });
191
+ }
192
+ for (const alias of section.tokenDefinitionAliases ?? []) targets.set(alias, { pageId: section.id });
193
+ for (const tdSection of section.tokenDefinition?.sections ?? []) {
194
+ for (const alias of tdSection.aliases ?? []) targets.set(alias, { pageId: section.id, sectionId: tdSection.id });
178
195
  }
179
- for (const alias of family.aliases ?? []) targets.set(alias, { pageId: section.id });
180
196
  }
181
197
  return targets;
182
198
  }
@@ -185,7 +201,10 @@ export function aliasTargets<TId extends string>(sections: readonly SectionDef<T
185
201
  * (kept, unchanged, for existing callers), this never silently falls back to the first page: an
186
202
  * unknown or malformed fragment is reported as `notFound` instead. */
187
203
  export type Route<TId extends string> =
188
- | { kind: 'page'; id: TId }
204
+ // #64 B5: `sectionId` is set only when a tokenDefinition page's own section alias resolved here
205
+ // — the page then scrolls that section into view on arrival. Absent for every other page route,
206
+ // exactly as before.
207
+ | { kind: 'page'; id: TId; sectionId?: string }
189
208
  | { kind: 'member'; pageId: TId; memberId: string }
190
209
  | { kind: 'update' }
191
210
  | { kind: 'notFound'; raw: string }
@@ -209,6 +228,7 @@ export function resolveRoute<TId extends string>(
209
228
  const target = aliases.get(decoded);
210
229
  if (target) {
211
230
  if (target.memberId !== undefined) return { kind: 'member', pageId: target.pageId as TId, memberId: target.memberId };
231
+ if (target.sectionId !== undefined) return { kind: 'page', id: target.pageId as TId, sectionId: target.sectionId };
212
232
  return { kind: 'page', id: target.pageId as TId };
213
233
  }
214
234
  return { kind: 'notFound', raw: decoded };
@@ -0,0 +1,212 @@
1
+ /**
2
+ * #60 "Source reference" layer — pure data/navigation foundation (plan Task 1). No React or React
3
+ * Native imports, so it runs under Node's test runner, the same reasoning `catalogNavigation.ts`
4
+ * documents for itself. Everything here derives from the catalog's own registered `SectionDef[]`
5
+ * (and the ordering/alias primitives `catalogNavigation.ts` already owns) — never a second,
6
+ * hand-maintained registry.
7
+ */
8
+ import type { SectionDef } from './types.ts';
9
+ import type { AliasTarget } from './catalogNavigation.ts';
10
+
11
+ /** True once any page in the catalog opts into the `'reference'` layer — the controller's trigger
12
+ * for showing the separate Source reference affordance at all. A catalog with no such page keeps
13
+ * its present sidebar/page set unchanged. */
14
+ export function hasReferenceNavigation<TId extends string>(sections: readonly SectionDef<TId>[]): boolean {
15
+ return sections.some((section) => section.navigation === 'reference');
16
+ }
17
+
18
+ /** Every page id whose own `navigation` is not `'reference'` (omitted counts as `'browse'`) — the
19
+ * browse-facing subset the sidebar, pager, and hash fallback show. Pass this as `orderedIds`'s own
20
+ * `availableIds` to drop reference-only pages from the browse sequence while leaving the complete
21
+ * routing universe (every id, alias, and member address) untouched — a reference page, and every
22
+ * member of a reference family, stays fully routable; only this browse-facing presentation filter
23
+ * ever excludes it. */
24
+ export function browsableIds<TId extends string>(sections: readonly SectionDef<TId>[]): Set<TId> {
25
+ const ids = new Set<TId>();
26
+ for (const section of sections) if (section.navigation !== 'reference') ids.add(section.id);
27
+ return ids;
28
+ }
29
+
30
+ /** One address the catalog-wide Source reference search can find: a whole page, or (for a family
31
+ * page) one of its non-whole members — the whole is never listed twice (see `aliasTargets`'s own
32
+ * precedent of skipping the whole among member addresses). `title` is the page's own displayed
33
+ * title for a page-level entry, or the member's own id for a member entry (a family member has no
34
+ * separate display title of its own, the same convention `MemberSearchEntry`'s `label` already
35
+ * uses). `component` is set only from an actual detected binding (`SectionDef.component` /
36
+ * `CatalogMember.component`), never guessed. */
37
+ export interface ReferenceEntry<TId extends string = string> {
38
+ pageId: TId;
39
+ /** Present only for a family member other than its own whole. */
40
+ memberId?: string;
41
+ title: string;
42
+ component?: string;
43
+ aliases: readonly string[];
44
+ /** Authored declaration source, when distinct from the detected implementation path. */
45
+ source?: string;
46
+ path: string;
47
+ }
48
+
49
+ /** Builds the catalog-wide reference index from the registered `SectionDef[]` — one entry per
50
+ * page (its own id/title/component/path, plus its family-level aliases, when any), and one per
51
+ * non-whole family member (its own id/component/aliases/path). Never a second, hand-authored
52
+ * registry: every field here already exists on the runtime catalog record. */
53
+ export function buildReferenceIndex<TId extends string>(sections: readonly SectionDef<TId>[]): ReferenceEntry<TId>[] {
54
+ const entries: ReferenceEntry<TId>[] = [];
55
+ for (const section of sections) {
56
+ const family = section.family;
57
+ // Family members carry the implementation binding/path and authored declaration source;
58
+ // section.path may instead point to the catalog page. Keep the whole in the single page entry,
59
+ // retaining both locations when they differ. Documentation-only wholes keep no binding.
60
+ // Merge aliases without mutating either authored array.
61
+ const whole = family?.members.find((member) => member.id === family.whole);
62
+ entries.push({
63
+ pageId: section.id,
64
+ title: section.title ?? section.id,
65
+ component: whole?.component ?? section.component,
66
+ aliases: [...new Set([...(family?.aliases ?? []), ...(whole?.aliases ?? [])])],
67
+ path: whole?.path ?? section.path,
68
+ ...(whole?.source && whole.source !== (whole.path ?? section.path) ? { source: whole.source } : {}),
69
+ });
70
+ if (!family) continue;
71
+ for (const member of family.members) {
72
+ if (member.id === family.whole) continue; // the whole already has its own page-level entry above
73
+ entries.push({
74
+ pageId: section.id,
75
+ memberId: member.id,
76
+ title: member.id,
77
+ component: member.component,
78
+ aliases: member.aliases ?? [],
79
+ path: member.path,
80
+ ...(member.source !== member.path ? { source: member.source } : {}),
81
+ });
82
+ }
83
+ }
84
+ return entries;
85
+ }
86
+
87
+ /** Reference search hits (spec #60's "Search IDs, human titles, component names where known,
88
+ * aliases and source paths"): case-insensitive substring over every one of those fields. Mirrors
89
+ * `catalogNavigation.ts`'s own `searchResults`/`filterGroups` convention — trimmed, empty query
90
+ * returns no hits. */
91
+ export function searchReferenceIndex<TId extends string>(
92
+ entries: readonly ReferenceEntry<TId>[],
93
+ query: string,
94
+ ): ReferenceEntry<TId>[] {
95
+ const q = query.trim().toLowerCase();
96
+ if (!q) return [];
97
+ return entries.filter((entry) =>
98
+ entry.pageId.toLowerCase().includes(q)
99
+ || (entry.memberId?.toLowerCase().includes(q) ?? false)
100
+ || entry.title.toLowerCase().includes(q)
101
+ || (entry.component?.toLowerCase().includes(q) ?? false)
102
+ || entry.aliases.some((alias) => alias.toLowerCase().includes(q))
103
+ || (entry.source?.toLowerCase().includes(q) ?? false)
104
+ || entry.path.toLowerCase().includes(q));
105
+ }
106
+
107
+ /** Where a name (not a URL fragment — see `catalogNavigation.ts`'s own `resolveRoute` for that)
108
+ * resolves, for #60's reference journey: a real address, found to be claimed by more than one
109
+ * entry's component binding (never guessed — the caller must say so rather than silently picking
110
+ * one), or not found at all. */
111
+ export type ReferenceResolution<TId extends string> =
112
+ | { kind: 'address'; pageId: TId; memberId?: string }
113
+ | { kind: 'ambiguous' }
114
+ | { kind: 'unresolved' };
115
+
116
+ /** Resolves `name` to its one catalog address: an exact registered page id or member/page alias
117
+ * always wins first (reusing the same `order`/`aliases` the viewer's own routing already derives
118
+ * — see `catalogNavigation.ts`'s `orderedIds`/`aliasTargets`, never duplicated here), falling back
119
+ * to the one unambiguous reference-index entry whose own `component` binding names it. Lets a
120
+ * normal page be found by its component export even when that name differs from the page's own
121
+ * id — but never invents a new global URL alias from it: a component name shared by more than one
122
+ * entry resolves to `'ambiguous'`, not a guess. */
123
+ export function resolveReference<TId extends string>(
124
+ name: string,
125
+ order: readonly TId[],
126
+ aliases: ReadonlyMap<string, AliasTarget>,
127
+ entries: readonly ReferenceEntry<TId>[],
128
+ ): ReferenceResolution<TId> {
129
+ if ((order as readonly string[]).includes(name)) return { kind: 'address', pageId: name as TId };
130
+ const aliasTarget = aliases.get(name);
131
+ if (aliasTarget) return { kind: 'address', pageId: aliasTarget.pageId as TId, memberId: aliasTarget.memberId };
132
+ const matches = entries.filter((entry) => entry.component === name);
133
+ if (matches.length === 0) return { kind: 'unresolved' };
134
+ if (matches.length > 1) return { kind: 'ambiguous' };
135
+ const [match] = matches;
136
+ return { kind: 'address', pageId: match.pageId, memberId: match.memberId };
137
+ }
138
+
139
+ /** Task 2B2: the smallest shared contract `CatalogShell` hands its own ordinary/family renderers
140
+ * (`SectionBlock` → `ReferenceDetails`, `FamilyPage`) so an existing reference fact (a Composition
141
+ * name, an "also used by" user, a `related`/anatomy name) can become a real, canonical,
142
+ * activatable link — through the one accepted resolver above, never a second lookup. Explicit
143
+ * props, not a global context: a standalone demo renderer (this very framework's own catalog
144
+ * pages included) never receives one unless its own caller passes it, so it can never accidentally
145
+ * inherit an outer catalog's registry. Omitted everywhere it's optional — a caller with no
146
+ * `navigation` keeps its own prior resolution/activation default untouched (see each call site's
147
+ * own doc comment, e.g. `FamilyPage`'s `resolvesAddress`). */
148
+ export interface ReferenceNavigation<TId extends string = string> {
149
+ /** Resolves `name` to its one real catalog address through `resolveReference`'s own order —
150
+ * `undefined` for `'ambiguous'`/`'unresolved'` (never a guess). Method syntax (not a
151
+ * property-typed arrow function), deliberately, so a `ReferenceNavigation<TId>` built with the
152
+ * catalog's own real union type stays assignable to the plain `ReferenceNavigation` (its default
153
+ * `TId = string`) every non-generic internal renderer below this file declares — `FamilyPage.tsx`
154
+ * already keeps the same page/member ids as plain `string` once past its own top-level props. */
155
+ resolve(name: string): { pageId: TId; memberId?: string } | undefined;
156
+ /** Opens the resolved address through the shell's own real activation, recording the real
157
+ * page/member origin and focusing the destination exactly like every other navigation.
158
+ * `sourceMemberId`, when supplied, names the family member (or whole) that actually owns the
159
+ * activated reference (e.g. a Part's own Uses/related/"also used by" line) — the shell validates
160
+ * it against the current page's own family before trusting it, so the recorded origin is the
161
+ * part itself rather than just the page. Omitted (a page-level reference, e.g. `relatedPages` or
162
+ * an anatomy "other page" entry): the origin is the page alone. */
163
+ open(pageId: TId, memberId?: string, sourceMemberId?: string): void;
164
+ }
165
+
166
+ /** `name`'s resolved activation — its real target address plus the activation handler — or
167
+ * `undefined` when `navigation` is omitted (the caller's own prior default; never fabricate one)
168
+ * or `name` doesn't resolve (honest plain text). One path for every call site that today repeats
169
+ * its own "resolve, then build a link" — `ReferenceDetails.tsx`'s Composition facts, and
170
+ * `FamilyPage.tsx`'s below-specimens/whole/uses/related-page/anatomy links.
171
+ *
172
+ * Deliberately returns the target, not a ready-made `href` string: this file's own cross-module
173
+ * imports (`catalogNavigation.ts`) stay type-only (see the `.ts`-suffixed `import type` above),
174
+ * which is what lets it run both under Node's direct `--experimental-strip-types` test execution
175
+ * (which requires that suffix on a relative import) and under the published package's own
176
+ * consumer-facing bundler typecheck (which forbids a *value* import with that same suffix,
177
+ * `allowImportingTsExtensions` being off) — a plain value import of `hashForId` here could only
178
+ * ever satisfy one of the two. Every caller is already a `.tsx` file with its own ordinary,
179
+ * extension-less `hashForId` import, so building the real `href` (`hashForId(memberId ??
180
+ * pageId)`) stays one line at each call site rather than a second, duplicated implementation. */
181
+ /** A modified click (open in a new tab/window) or a non-primary button must reach the browser's
182
+ * own anchor handling untouched — the same check `SourceReferencePicker.tsx`'s own `ResultRow`
183
+ * already makes for its result links, duplicated here (rather than exported from there) because
184
+ * that file is a React component this pure module never imports. */
185
+ function isModifiedPress(event: unknown): boolean {
186
+ const e = event as { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; button?: number } | null | undefined;
187
+ return Boolean(e?.metaKey || e?.ctrlKey || e?.shiftKey || (typeof e?.button === 'number' && e.button !== 0));
188
+ }
189
+
190
+ export function referenceLinkProps<TId extends string>(
191
+ navigation: ReferenceNavigation<TId> | undefined,
192
+ name: string,
193
+ /** The family member (or whole) that owns this particular reference, when the call site is
194
+ * scoped to one — see `ReferenceNavigation.open`'s own doc comment. */
195
+ sourceMemberId?: string,
196
+ ): { pageId: TId; memberId?: string; onPress: (event?: { preventDefault?: () => void; metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; button?: number }) => void } | undefined {
197
+ if (!navigation) return undefined;
198
+ const target = navigation.resolve(name);
199
+ if (!target) return undefined;
200
+ return {
201
+ pageId: target.pageId,
202
+ memberId: target.memberId,
203
+ onPress: (event) => {
204
+ if (isModifiedPress(event)) return; // let the browser open it in a new tab/window
205
+ // The shell owns normal activation and its own URL synchronization — the native anchor
206
+ // default must never race it, embedded or not (an embedded shell additionally never lets
207
+ // that synchronization touch the host document's own address).
208
+ event?.preventDefault?.();
209
+ navigation.open(target.pageId, target.memberId, sourceMemberId);
210
+ },
211
+ };
212
+ }
@@ -53,9 +53,13 @@ export function anatomyPinFor(
53
53
  outline: ReadonlyMap<string, { number: string }>,
54
54
  ): { number: string; dashed: boolean } | undefined {
55
55
  const member = members.get(memberId);
56
- if (!member) return undefined;
57
56
  const entry = outline.get(memberId);
58
57
  if (!entry) return undefined;
58
+ // #63: an external part is numbered in this family's outline but documented on a group page, so
59
+ // it isn't in `members`; it keeps its number. Callers that know the owner's fidelity pass it in
60
+ // `members` (`FamilyDef.externalParts`), so the pin is dashed exactly as an own part's would be;
61
+ // with none, the pin is the plain solid one.
62
+ if (!member) return { number: entry.number, dashed: false };
59
63
  return { number: entry.number, dashed: member.fidelity === 'documentation-only' };
60
64
  }
61
65
 
@@ -171,9 +175,11 @@ export function sectionFor(sections: readonly CatalogMemberSection[], memberId:
171
175
  * bad data (a `whole` that isn't one of its own members, already reported by `validateFamily`),
172
176
  * so `FamilyPage` can skip rendering the whole block instead of crashing on an absent member.
173
177
  * Generic over the member's own shape so `FamilyPage` keeps its resolved `props`/`path` fields
174
- * (`FamilyDef.members`'s own type) through the lookup. */
175
- export function resolveWhole<T>(members: ReadonlyMap<string, T>, whole: string): T | undefined {
176
- return members.get(whole);
178
+ * (`FamilyDef.members`'s own type) through the lookup. #63: `whole` is `undefined` for a group
179
+ * page's own `FamilyDef` — degrades the same way, `undefined`, so a group page never renders a
180
+ * whole block either (`FamilyPage` itself still never dispatches to a group page in this slice). */
181
+ export function resolveWhole<T>(members: ReadonlyMap<string, T>, whole: string | undefined): T | undefined {
182
+ return whole === undefined ? undefined : members.get(whole);
177
183
  }
178
184
 
179
185
  /** V2c item 1 (one-shot jump) + item 11 (repeat activation): in-flight state for a member-address
@@ -18,6 +18,9 @@ export { CatalogSearchInput } from './CatalogSearchInput';
18
18
  export { SectionBlock, PagerButton, SectionPagerRow } from './SectionBlock';
19
19
  export type { SectionPager } from './SectionBlock';
20
20
  export { FamilyPage, AnatomyPin } from './FamilyPage';
21
+ export { GroupPage } from './GroupPage';
22
+ export { JumpRow, JumpChip } from './JumpRow';
23
+ export type { JumpItem } from './JumpRow';
21
24
  export { ComparisonGrid } from './ComparisonGrid';
22
25
  export { grid, axisItems, axisProp } from './comparison';
23
26
  export { ComparisonGroups } from './ComparisonGroups';
@@ -30,12 +33,31 @@ export { MotionSpecimen } from './MotionSpecimen';
30
33
  export type { MotionSpecimenProps, MotionSpecimenSpringConfig } from './MotionSpecimen';
31
34
  export { SpecimenSurface } from './SpecimenSurface';
32
35
  export { TokenSections, TokenGrid, TokenTile } from './TokenLayouts';
36
+ export { StructuredTokenPage, tokenDefinitionSections } from './StructuredTokenPage';
37
+ export {
38
+ TOKEN_KINDS, isTokenKind, canonicalTokenTitle, findDuplicateIds, tokenEntryAddress,
39
+ validateTokenDefinition, validateTokenDefinitionUsage,
40
+ } from './tokenDefinition';
41
+ export type {
42
+ TokenKind, TokenProvenance, TokenResolution, TokenValueInfo, TokenEntryBase, TokenEntry,
43
+ ColorTokenValue, ColorTokenEntry, DimensionTokenValue, SpacingTokenEntry, RadiusTokenEntry,
44
+ DimensionsTokenEntry, OpacityTokenValue, OpacityTokenEntry, TypographyTokenValue, TypographyTokenEntry,
45
+ ShadowTokenValue, ShadowTokenEntry, MotionTimingValue, MotionSpringValue, MotionNativeExpressionValue,
46
+ MotionTokenValue, MotionTokenEntry, TokenDefinitionSection, TokenDefinition, TokenDefinitionPageUsage,
47
+ } from './tokenDefinition';
33
48
  export { DividedStack } from './DividedStack';
34
49
  export { Swatch } from './Swatch';
35
50
  export { PhoneFrame } from './PhoneFrame';
36
51
  export { PhoneScreen } from './PhoneScreen';
37
52
  export { OverlayDemo } from './OverlayDemo';
38
53
  export { CatalogButton } from './CatalogButton';
54
+ export { SourceReferencePicker } from './SourceReferencePicker';
55
+ export type { SourceReferencePickerProps } from './SourceReferencePicker';
56
+ // #60: only the shape a caller needs to pass real (or, for the self-catalog demo, sample)
57
+ // entries into the picker — `buildReferenceIndex`/`searchReferenceIndex`/`resolveReference` stay
58
+ // internal plumbing for `CatalogShell.tsx`'s own Task 2B wiring, the same precedent Task 1 already
59
+ // established for `catalogReferences.ts`'s other exports.
60
+ export type { ReferenceEntry } from './catalogReferences';
39
61
  export { BoundedOverlayViewport } from './BoundedOverlayViewport';
40
62
  export type { BoundedOverlayViewportProps } from './BoundedOverlayViewport';
41
63
  export { comparisonCellItemKey } from './overlayViewport';