@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,11 +1,13 @@
1
- import React, { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
2
- import { AccessibilityInfo, Platform, Pressable, StyleSheet, Text, View, findNodeHandle, type LayoutChangeEvent } from 'react-native';
1
+ import React, { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
2
+ import { AccessibilityInfo, Modal, Platform, Pressable, ScrollView, StyleSheet, Text, View, findNodeHandle, useWindowDimensions, type LayoutChangeEvent } from 'react-native';
3
+ import { CatalogButton } from './CatalogButton';
3
4
  import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TEXT } from './tokens';
4
5
  import { Blocks, SectionPagerRow, type SectionPager } from './SectionBlock';
5
6
  import { PropsTable } from './PropsTable';
6
7
  import { presentationBlocks } from './comparison';
7
8
  import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
8
9
  import { hashForId } from './catalogNavigation';
10
+ import { referenceLinkProps, type ReferenceNavigation } from './catalogReferences';
9
11
  // A plain extensionless import — the same one every other `.tsx` file here already uses for its
10
12
  // own pure-module siblings (e.g. `CatalogShell.tsx` → `./catalogNavigation`): this file is a React
11
13
  // component with no test of its own that imports it directly under Node, so it only ever goes
@@ -52,6 +54,93 @@ function memberAsSectionDef(member: CatalogMember): SectionDef {
52
54
 
53
55
  const ARIA_HIDDEN = { 'aria-hidden': true } as Record<string, unknown>;
54
56
 
57
+ /** The one muted line under a family/group page's description: what kind of page this is and how
58
+ * it differs from a page of variants. The same wording on every page of that kind. */
59
+ const PAGE_KIND_EXPLAINER = {
60
+ group: {
61
+ lead: 'Group page.',
62
+ text: 'These are separate components shown together because they’re used in the same place. Each has its own API, unlike variants, which are one component changed by its props.',
63
+ },
64
+ family: {
65
+ lead: 'Family page.',
66
+ text: 'One component and the parts it’s built from. Each part is its own component, unlike variants, which are one component changed by its props.',
67
+ },
68
+ } as const;
69
+
70
+ /** #63 R2: the shared page head both `FamilyPage` and `GroupPage` render — breadcrumb, banner,
71
+ * title/description (plus an optional "Not shown in the browser" note) and the pager. Pulled out
72
+ * of `FamilyPage`'s own top-level render rather than duplicated, per the contract's "factor the
73
+ * head into a small shared piece rather than duplicating markup". */
74
+ export function FamilyPageHead<TId extends string>({
75
+ def, kind, groupLabel, breadcrumbRoot, banner, pager, headingRef, notShown,
76
+ }: {
77
+ /** Which page kind this head introduces — adds that kind's one-line explainer under the
78
+ * description (`PAGE_KIND_EXPLAINER`). */
79
+ kind: 'family' | 'group';
80
+ def: Pick<SectionDef<TId>, 'id' | 'title' | 'description'>;
81
+ groupLabel?: string;
82
+ breadcrumbRoot?: string;
83
+ banner?: React.ReactNode;
84
+ pager?: SectionPager<TId>;
85
+ headingRef?: React.Ref<View>;
86
+ /** See `FamilyDef.notShown`. */
87
+ notShown?: string;
88
+ }) {
89
+ return (
90
+ <>
91
+ {groupLabel && <Text style={styles.breadcrumb}>{breadcrumbRoot ? `${breadcrumbRoot} / ${groupLabel}` : groupLabel}</Text>}
92
+ {banner}
93
+ <View style={styles.pageHead}>
94
+ <View style={styles.titlebar}>
95
+ <View style={styles.titleText}>
96
+ <View ref={headingRef} tabIndex={-1} role="heading" {...({ 'aria-level': 1 } as Record<string, unknown>)} style={styles.headingTarget}>
97
+ <Text style={styles.title}>{def.title ?? def.id}</Text>
98
+ </View>
99
+ <Text style={styles.desc}>{def.description}</Text>
100
+ <View style={styles.kindNote}>
101
+ <Text style={styles.kindNoteText}>
102
+ <Text style={styles.kindNoteLead}>{PAGE_KIND_EXPLAINER[kind].lead} </Text>
103
+ {PAGE_KIND_EXPLAINER[kind].text}
104
+ </Text>
105
+ </View>
106
+ {notShown && (
107
+ <Text style={styles.notShown}>
108
+ <Text style={styles.notShownLead}>Not shown in the browser: </Text>
109
+ {notShown}
110
+ </Text>
111
+ )}
112
+ </View>
113
+ {pager && <SectionPagerRow pager={pager} />}
114
+ </View>
115
+ </View>
116
+ </>
117
+ );
118
+ }
119
+
120
+ /** Task 2B2: one path for every one of this file's own link producers (below-specimens/whole
121
+ * lines, Uses, related pages, anatomy legend) — `navigation` supplied: resolve+activate through
122
+ * the shell's own real `CatalogShell` journey (`referenceLinkProps`), the canonical address, even
123
+ * when it differs from `name` (an alias, or a component binding). `navigation` omitted (a
124
+ * standalone-mounted `FamilyPage`, this framework's own catalog demo included): keep the prior
125
+ * plain-`href`-only behavior, gated by the member's own `resolvesAddress` (sibling member ids,
126
+ * plus whatever `knownAddressIds` the caller passed) — never a dead link, same as before. */
127
+ function familyLink(
128
+ navigation: ReferenceNavigation | undefined,
129
+ resolvesAddress: (name: string) => boolean,
130
+ name: string,
131
+ /** Task 2B2 correction: the Part/whole that actually owns this call site (its Uses/related/
132
+ * "also used by" line) — see `ReferenceNavigation.open`'s own doc comment. Omitted for a
133
+ * page-level link producer (`relatedPages`, an anatomy "other page" entry, or the anatomy
134
+ * legend's own member row, which already names its destination). */
135
+ sourceMemberId?: string,
136
+ ): { href: string; onPress?: (event?: { preventDefault?: () => void }) => void } | undefined {
137
+ if (navigation) {
138
+ const activation = referenceLinkProps(navigation, name, sourceMemberId);
139
+ return activation ? { href: hashForId(activation.memberId ?? activation.pageId), onPress: activation.onPress } : undefined;
140
+ }
141
+ return resolvesAddress(name) ? { href: hashForId(name) } : undefined;
142
+ }
143
+
55
144
  /** Fidelity badge (spec §5.2 "Badges"): no existing fidelity/OS badge to reuse, so this is a small
56
145
  * pill on `microLabel`, reusing `SectionBlock`'s own OS-badge visual. `replicated` keeps that
57
146
  * plain pill; `approximated` and `documentation-only` both get the gray/muted treatment the
@@ -140,6 +229,8 @@ function BelowSpecimensLine({
140
229
  source,
141
230
  alsoUsedBy,
142
231
  resolvesAddress,
232
+ navigation,
233
+ sourceMemberId,
143
234
  }: {
144
235
  related?: RelatedSegment[];
145
236
  showRelationship: boolean;
@@ -147,33 +238,39 @@ function BelowSpecimensLine({
147
238
  source: string;
148
239
  alsoUsedBy: AlsoUsedByEntry[];
149
240
  resolvesAddress: (name: string) => boolean;
241
+ navigation?: ReferenceNavigation;
242
+ /** Task 2B2 correction: this line's own owning member — threaded into every link it renders, so
243
+ * a Back return lands on this member, not just the page (`familyLink`'s own doc comment). */
244
+ sourceMemberId?: string;
150
245
  }) {
151
246
  const usedBy = alsoUsedBySegments(alsoUsedBy);
152
247
  return (
153
248
  <Text style={styles.belowLine}>
154
249
  {related &&
155
- related.map((segment, i) =>
156
- segment.memberId ? (
157
- <Text key={i} role="link" style={styles.relatedLink} {...({ href: hashForId(segment.memberId) } as Record<string, unknown>)}>
250
+ related.map((segment, i) => {
251
+ const link = segment.memberId ? familyLink(navigation, resolvesAddress, segment.memberId, sourceMemberId) : undefined;
252
+ return link ? (
253
+ <Text key={i} role="link" style={styles.relatedLink} {...link}>
158
254
  {segment.text}
159
255
  </Text>
160
256
  ) : (
161
257
  <Text key={i}>{segment.text}</Text>
162
- ),
163
- )}
258
+ );
259
+ })}
164
260
  {related && ' · '}
165
261
  {showRelationship && `${relationship} · `}
166
262
  {source}
167
263
  {usedBy.length > 0 && ' · '}
168
- {usedBy.map((segment, i) =>
169
- segment.component && resolvesAddress(segment.component) ? (
170
- <Text key={i} role="link" style={styles.relatedLink} {...({ href: hashForId(segment.component) } as Record<string, unknown>)}>
264
+ {usedBy.map((segment, i) => {
265
+ const link = segment.component ? familyLink(navigation, resolvesAddress, segment.component, sourceMemberId) : undefined;
266
+ return link ? (
267
+ <Text key={i} role="link" style={styles.relatedLink} {...link}>
171
268
  {segment.text}
172
269
  </Text>
173
270
  ) : (
174
271
  <Text key={i}>{segment.text}</Text>
175
- ),
176
- )}
272
+ );
273
+ })}
177
274
  </Text>
178
275
  );
179
276
  }
@@ -187,12 +284,14 @@ function WholeBelowLine({
187
284
  source,
188
285
  alsoUsedBy,
189
286
  resolvesAddress,
287
+ navigation,
190
288
  }: {
191
289
  name: string;
192
290
  fidelity: CatalogMember['fidelity'];
193
291
  source: string;
194
292
  alsoUsedBy: AlsoUsedByEntry[];
195
293
  resolvesAddress: (name: string) => boolean;
294
+ navigation?: ReferenceNavigation;
196
295
  }) {
197
296
  const usedBy = alsoUsedBySegments(alsoUsedBy);
198
297
  return (
@@ -203,17 +302,20 @@ function WholeBelowLine({
203
302
  <Text style={styles.belowLine}> · </Text>
204
303
  <Text style={styles.belowLine}>{source}</Text>
205
304
  {usedBy.length > 0 && <Text style={styles.belowLine}> · </Text>}
206
- {usedBy.map((segment, i) =>
207
- segment.component && resolvesAddress(segment.component) ? (
208
- <Text key={i} role="link" style={styles.relatedLink} {...({ href: hashForId(segment.component) } as Record<string, unknown>)}>
305
+ {usedBy.map((segment, i) => {
306
+ // The whole's own inverse use returns to the whole wrapper, not just the page — `name` is
307
+ // this very member's own id (the whole is a member like any other).
308
+ const link = segment.component ? familyLink(navigation, resolvesAddress, segment.component, name) : undefined;
309
+ return link ? (
310
+ <Text key={i} role="link" style={styles.relatedLink} {...link}>
209
311
  {segment.text}
210
312
  </Text>
211
313
  ) : (
212
314
  <Text key={i} style={styles.belowLine}>
213
315
  {segment.text}
214
316
  </Text>
215
- ),
216
- )}
317
+ );
318
+ })}
217
319
  </View>
218
320
  );
219
321
  }
@@ -222,23 +324,36 @@ function WholeBelowLine({
222
324
  * the disclosure, after notes and before Guidance. A name links — the same accent treatment
223
325
  * `BelowSpecimensLine`'s own `related` links use — when it resolves to a real catalog address (a
224
326
  * page, member, or alias); otherwise it shows in `codeSmall`, since there's nothing to link to. */
225
- function UsesLine({ composedOf, resolvesAddress }: { composedOf: ComposedOfEntry[]; resolvesAddress: (name: string) => boolean }) {
327
+ function UsesLine({
328
+ composedOf,
329
+ resolvesAddress,
330
+ navigation,
331
+ sourceMemberId,
332
+ }: {
333
+ composedOf: ComposedOfEntry[];
334
+ resolvesAddress: (name: string) => boolean;
335
+ navigation?: ReferenceNavigation;
336
+ sourceMemberId?: string;
337
+ }) {
226
338
  return (
227
339
  <Text style={styles.usesLine}>
228
340
  {'Uses '}
229
- {composedOf.map((entry, i) => (
230
- <Text key={i}>
231
- {resolvesAddress(entry.component) ? (
232
- <Text role="link" style={styles.relatedLink} {...({ href: hashForId(entry.component) } as Record<string, unknown>)}>
233
- {entry.component}
234
- </Text>
235
- ) : (
236
- <Text style={styles.usesCode}>{entry.component}</Text>
237
- )}
238
- {` (${entry.role})`}
239
- {i < composedOf.length - 1 ? ', ' : ''}
240
- </Text>
241
- ))}
341
+ {composedOf.map((entry, i) => {
342
+ const link = familyLink(navigation, resolvesAddress, entry.component, sourceMemberId);
343
+ return (
344
+ <Text key={i}>
345
+ {link ? (
346
+ <Text role="link" style={styles.relatedLink} {...link}>
347
+ {entry.component}
348
+ </Text>
349
+ ) : (
350
+ <Text style={styles.usesCode}>{entry.component}</Text>
351
+ )}
352
+ {` (${entry.role})`}
353
+ {i < composedOf.length - 1 ? ', ' : ''}
354
+ </Text>
355
+ );
356
+ })}
242
357
  </Text>
243
358
  );
244
359
  }
@@ -253,11 +368,13 @@ function DisclosureBody({
253
368
  parts,
254
369
  resolvedProps,
255
370
  resolvesAddress,
371
+ navigation,
256
372
  }: {
257
373
  member: CatalogMember;
258
374
  parts: DisclosurePart[];
259
375
  resolvedProps?: PropDef[];
260
376
  resolvesAddress: (name: string) => boolean;
377
+ navigation?: ReferenceNavigation;
261
378
  }) {
262
379
  return (
263
380
  <View style={styles.disclosureBody}>
@@ -268,7 +385,7 @@ function DisclosureBody({
268
385
  {note}
269
386
  </Text>
270
387
  ))}
271
- {parts.includes('uses') && <UsesLine composedOf={member.composedOf!} resolvesAddress={resolvesAddress} />}
388
+ {parts.includes('uses') && <UsesLine composedOf={member.composedOf!} resolvesAddress={resolvesAddress} navigation={navigation} sourceMemberId={member.id} />}
272
389
  {parts.includes('whenToUse') && (
273
390
  <Text style={styles.disclosureText}>
274
391
  <Text style={styles.disclosureLead}>Guidance </Text>
@@ -291,55 +408,186 @@ function DisclosureBody({
291
408
  );
292
409
  }
293
410
 
294
- /** V2b item 6: the disclosure toggle shows a chevron before its label (`›` closed, `⌄` open, as
295
- * v8 does), on the `icon` role, hidden from screen readers (the toggle's own accessible name is
296
- * its text label — Errata 4's `accessibilityState`/`aria-expanded` already say whether it's
297
- * open). No animation. */
298
- function DisclosureChevron({ open }: { open: boolean }) {
411
+ /** The disclosure trigger's trailing chevron (`›`), on the `icon` role, hidden from screen readers
412
+ * (the trigger's own accessible name is its text label). Always `›`: the content opens in a side
413
+ * panel, never expands in place. */
414
+ function DisclosureChevron() {
299
415
  return (
300
416
  <Text {...ARIA_HIDDEN} style={styles.chevron}>
301
- {open ? '⌄' : '›'}
417
+ ›
302
418
  </Text>
303
419
  );
304
420
  }
305
421
 
306
- /** Slice D1 item 9: "Notes and API" (using `apiLabel`) as a controlled toggle, closed by default —
307
- * shared by the whole and every Part/Related member (never by a folded row, which has no second,
308
- * nested toggle of its own: its own open state already decides whether its disclosure body shows). */
422
+ /** What the one shared side panel shows: the member's disclosure, headed by its trigger's label. */
423
+ interface MemberPanelRequest {
424
+ member: CatalogMember & { props?: PropDef[] };
425
+ parts: DisclosurePart[];
426
+ label: string;
427
+ resolvesAddress: (name: string) => boolean;
428
+ navigation?: ReferenceNavigation;
429
+ }
430
+
431
+ interface MemberPanelControl {
432
+ /** The member whose panel is open, if any — a trigger's `aria-expanded`. */
433
+ activeId?: string;
434
+ show: (request: MemberPanelRequest) => void;
435
+ hide: () => void;
436
+ }
437
+
438
+ const MemberPanelContext = createContext<MemberPanelControl | null>(null);
439
+
440
+ const PANEL_WIDTH = 440;
441
+ /** At or below this window width the panel takes the full width, as the approved design shows at
442
+ * 768 (the viewer has no shared narrow breakpoint constant to reuse). */
443
+ const PANEL_FULL_WIDTH_AT_OR_BELOW = 768;
444
+ /** The light scrim behind the panel — no `CATALOG_COLOR` is a translucent overlay. */
445
+ const PANEL_SCRIM = 'rgba(20,20,20,.28)';
446
+
447
+ /** The member side panel: `role="dialog"`, `aria-modal`, named by the member, one at a time, no
448
+ * animation. On web, `Modal` (transparent) supplies the dialog role, Esc to close, Tab
449
+ * containment, and returning focus to the trigger on close. React Native's own `Modal` does none
450
+ * of those on native (only `onRequestClose` for the hardware back button), so on native the panel
451
+ * moves screen-reader focus to the × itself on open (`AccessibilityInfo.setAccessibilityFocus`);
452
+ * it does not return focus to the trigger on close — that is left to the platform. Following a
453
+ * link inside it closes it first (`navigation` is wrapped), then navigates. */
454
+ function MemberPanel({ request, onClose }: { request: MemberPanelRequest; onClose: () => void }) {
455
+ const { width } = useWindowDimensions();
456
+ const headingId = useId();
457
+ const closeRef = useRef<View>(null);
458
+ const focusClose = useCallback(() => {
459
+ const node = closeRef.current;
460
+ if (!node) return;
461
+ if (Platform.OS === 'web') {
462
+ node.focus?.();
463
+ return;
464
+ }
465
+ const handle = findNodeHandle(node);
466
+ if (handle != null) AccessibilityInfo.setAccessibilityFocus(handle);
467
+ }, []);
468
+ const { member, parts, label, resolvesAddress, navigation } = request;
469
+ const closingNavigation = useMemo<ReferenceNavigation | undefined>(
470
+ () =>
471
+ navigation && {
472
+ resolve: (name) => navigation.resolve(name),
473
+ open: (pageId, memberId, sourceMemberId) => {
474
+ onClose();
475
+ navigation.open(pageId, memberId, sourceMemberId);
476
+ },
477
+ },
478
+ [navigation, onClose],
479
+ );
480
+ // Locks the document's own scroll while the panel is open, for hosts whose body scrolls. The
481
+ // shell's own pane needs nothing: `Modal` portals outside it, so wheel chaining from the scrim
482
+ // never reaches it. Restores the previous overflow on close and on unmount. (iOS Safari ignores
483
+ // body overflow for touch scrolling.)
484
+ useEffect(() => {
485
+ if (Platform.OS !== 'web' || typeof document === 'undefined') return;
486
+ const { body } = document;
487
+ const previous = body.style.overflow;
488
+ body.style.overflow = 'hidden';
489
+ return () => { body.style.overflow = previous; };
490
+ }, []);
491
+ // A plain-`href` link (no shell navigation) just changes the hash — close the panel on that too.
492
+ useEffect(() => {
493
+ if (Platform.OS !== 'web' || typeof window === 'undefined') return;
494
+ window.addEventListener('hashchange', onClose);
495
+ return () => window.removeEventListener('hashchange', onClose);
496
+ }, [onClose]);
497
+ return (
498
+ <Modal visible transparent animationType="none" onRequestClose={onClose} onShow={focusClose} {...({ 'aria-labelledby': headingId } as Record<string, unknown>)}>
499
+ <View style={styles.panelRoot}>
500
+ {/* On web, a plain View, not a Pressable: the Modal's focus trap `focus()`es descendants in turn, and
501
+ even a tabindex=-1 scrim would take focus — an unnamed, ringless stop. Press-to-close there is a
502
+ click handler only (the × and Esc are the keyboard paths). */}
503
+ {Platform.OS === 'web' ? (
504
+ <View testID="member-panel-scrim" style={styles.panelScrim} {...({ onClick: onClose } as Record<string, unknown>)} />
505
+ ) : (
506
+ // Native has no DOM focus trap, so a Pressable is safe there and gives tap-to-close.
507
+ <Pressable testID="member-panel-scrim" style={styles.panelScrim} onPress={onClose} accessible={false} />
508
+ )}
509
+ <View style={[styles.panel, width <= PANEL_FULL_WIDTH_AT_OR_BELOW && styles.panelFull]}>
510
+ <View style={styles.panelHead}>
511
+ <View style={styles.panelTitle}>
512
+ <Text style={styles.panelKicker}>{label}</Text>
513
+ <Text nativeID={headingId} role="heading" {...({ 'aria-level': 2 } as Record<string, unknown>)} style={styles.panelName}>
514
+ {member.id}
515
+ </Text>
516
+ </View>
517
+ <CatalogButton ref={closeRef} label="×" accessibilityLabel="Close" variant="secondary" onPress={onClose} />
518
+ </View>
519
+ <ScrollView style={styles.panelBody} contentContainerStyle={styles.panelBodyContent}>
520
+ <DisclosureBody member={member} parts={parts} resolvedProps={member.props} resolvesAddress={resolvesAddress} navigation={closingNavigation} />
521
+ </ScrollView>
522
+ </View>
523
+ </View>
524
+ </Modal>
525
+ );
526
+ }
527
+
528
+ /** Owns the one shared side panel for a page: every member trigger below it opens it, a second
529
+ * trigger swaps its content, and only one is ever open. */
530
+ export function MemberPanelHost({ children }: { children: React.ReactNode }) {
531
+ const [request, setRequest] = useState<MemberPanelRequest | null>(null);
532
+ const hide = useCallback(() => setRequest(null), []);
533
+ const control = useMemo<MemberPanelControl>(() => ({ activeId: request?.member.id, show: setRequest, hide }), [request?.member.id, hide]);
534
+ return (
535
+ <MemberPanelContext.Provider value={control}>
536
+ {children}
537
+ {request && <MemberPanel request={request} onClose={hide} />}
538
+ </MemberPanelContext.Provider>
539
+ );
540
+ }
541
+
542
+ /** Slice D1 item 9: "Notes and API" (using `apiLabel`) as a link-style trigger that opens the
543
+ * member's side panel — shared by the whole, every Part/Related member, and an open folded row.
544
+ * Renders nothing for a member with nothing to disclose. */
309
545
  function MemberDisclosureToggle({
310
546
  member,
311
547
  apiLabel,
312
548
  resolvesAddress,
549
+ navigation,
313
550
  }: {
314
551
  member: CatalogMember & { props?: PropDef[] };
315
552
  apiLabel: string;
316
553
  resolvesAddress: (name: string) => boolean;
554
+ navigation?: ReferenceNavigation;
317
555
  }) {
318
- const [open, setOpen] = useState(false);
556
+ const panel = useContext(MemberPanelContext);
319
557
  const [focused, setFocused] = useState(false);
320
558
  const parts = disclosureContent(member, member.props);
321
559
  if (parts.length === 0) return null;
560
+ // A member rendered outside a page's own host still gets a working panel.
561
+ if (!panel) {
562
+ return (
563
+ <MemberPanelHost>
564
+ <MemberDisclosureToggle member={member} apiLabel={apiLabel} resolvesAddress={resolvesAddress} navigation={navigation} />
565
+ </MemberPanelHost>
566
+ );
567
+ }
322
568
  const label = disclosureLabel(member, apiLabel, parts.includes('props'));
569
+ const open = panel.activeId === member.id;
323
570
  return (
324
- <>
325
- <Pressable
326
- onPress={() => setOpen((value) => !value)}
327
- onFocus={() => setFocused(true)}
328
- onBlur={() => setFocused(false)}
329
- accessibilityRole="button"
330
- accessibilityState={{ expanded: open }}
331
- {...({ 'aria-expanded': open } as Record<string, unknown>)}
332
- style={[styles.disclosureToggle, focused && styles.focusRing]}
333
- >
334
- <DisclosureChevron open={open} />
335
- <Text style={styles.disclosureToggleLabel}>{label}</Text>
336
- </Pressable>
337
- {open && <DisclosureBody member={member} parts={parts} resolvedProps={member.props} resolvesAddress={resolvesAddress} />}
338
- </>
571
+ <Pressable
572
+ onPress={() => panel.show({ member, parts, label, resolvesAddress, navigation })}
573
+ onFocus={() => setFocused(true)}
574
+ onBlur={() => setFocused(false)}
575
+ accessibilityRole="button"
576
+ accessibilityState={{ expanded: open }}
577
+ {...({ 'aria-expanded': open, 'aria-haspopup': 'dialog' } as Record<string, unknown>)}
578
+ style={[styles.disclosureToggle, focused && styles.focusRing]}
579
+ >
580
+ <Text style={styles.disclosureToggleLabel}>{label}</Text>
581
+ <DisclosureChevron />
582
+ </Pressable>
339
583
  );
340
584
  }
341
585
 
342
- type FamilyMember = CatalogMember & { props?: PropDef[]; path: string; alsoUsedBy?: AlsoUsedByEntry[] };
586
+ /** #63 R2: exported so `GroupPage.tsx` can type its own `byId`/member records the same way
587
+ * `FamilyPage`'s internals already do — a group's own `FamilyDef.members` carries the same shape
588
+ * plus an optional `partOf` (`types.ts`), which this type simply doesn't need to name to stay
589
+ * assignable. */
590
+ export type FamilyMember = CatalogMember & { props?: PropDef[]; path: string; alsoUsedBy?: AlsoUsedByEntry[] };
343
591
 
344
592
  interface MemberRenderProps {
345
593
  member: FamilyMember;
@@ -354,6 +602,9 @@ interface MemberRenderProps {
354
602
  /** V2d item 3: whether a name (a `composedOf` entry's `component`) resolves to a real catalog
355
603
  * address — a page, member, or alias — so `UsesLine` knows which names to link. */
356
604
  resolvesAddress: (name: string) => boolean;
605
+ /** Task 2B2: `CatalogShell`'s own shared reference navigation — see `familyLink`'s own doc
606
+ * comment above for how it and `resolvesAddress` combine. */
607
+ navigation?: ReferenceNavigation;
357
608
  // Pressable forwards its ref to its underlying `View` (react-native's own `Pressable.d.ts`), so
358
609
  // a registered node is always a `View` instance whether it came from a block member's `View`
359
610
  // heading target or a folded row's `Pressable`.
@@ -368,7 +619,13 @@ interface MemberRenderProps {
368
619
  * Related member (no pin, no gutter) — otherwise the same header and body: name, description,
369
620
  * specimens (through `Blocks`, when the member has any), the below-specimens line, and a
370
621
  * closed-by-default disclosure. */
371
- function FamilyPart({ member, number, level, allMemberIds, defaultPreviewWidths, apiLabel, resolvesAddress, registerNode, pinRef }: MemberRenderProps & { level: number }) {
622
+ /** #63 R2: exported so `GroupPage.tsx` can render a top-level group member through the exact same
623
+ * piece a family's own Part/Related member uses, with no second copy of its name row/specimens/
624
+ * below-line/disclosure rendering. `afterNameRow` is additive (`undefined` elsewhere, unchanged
625
+ * behavior for every existing family-page call site) — a group's own "Part N of <Family>" link
626
+ * (`PartOfLine`, below) renders there, right under the member's own name row and before its
627
+ * description, per the contract's own ordering. */
628
+ export function FamilyPart({ member, number, level, allMemberIds, defaultPreviewWidths, apiLabel, resolvesAddress, navigation, registerNode, pinRef, afterNameRow }: MemberRenderProps & { level: number; afterNameRow?: React.ReactNode }) {
372
629
  const surface = member.specimenSurface ?? DEFAULT_SPECIMEN_SURFACE;
373
630
  const memberDef = useMemo(() => memberAsSectionDef(member), [member]);
374
631
  const blocks = useMemo(
@@ -400,6 +657,7 @@ function FamilyPart({ member, number, level, allMemberIds, defaultPreviewWidths,
400
657
  />
401
658
  </View>
402
659
  </View>
660
+ {afterNameRow}
403
661
  {/* D2 item A1: `partBody`'s own rhythm gap applies to every member, numbered or not — only
404
662
  the gutter indent (`partBodyIndented`) is conditional — so a Related member (never
405
663
  numbered) still gets the same spacing between its own description/specimens/below-line/
@@ -424,9 +682,11 @@ function FamilyPart({ member, number, level, allMemberIds, defaultPreviewWidths,
424
682
  source={member.source}
425
683
  alsoUsedBy={member.alsoUsedBy ?? []}
426
684
  resolvesAddress={resolvesAddress}
685
+ navigation={navigation}
686
+ sourceMemberId={member.id}
427
687
  />
428
688
  <View style={styles.disclosureSpacer}>
429
- <MemberDisclosureToggle member={member} apiLabel={apiLabel} resolvesAddress={resolvesAddress} />
689
+ <MemberDisclosureToggle member={member} apiLabel={apiLabel} resolvesAddress={resolvesAddress} navigation={navigation} />
430
690
  </View>
431
691
  </View>
432
692
  </View>
@@ -441,10 +701,9 @@ function FamilyPart({ member, number, level, allMemberIds, defaultPreviewWidths,
441
701
  * show specimens (spec's v8 reference keeps every folded-row example documentation-only). Slice
442
702
  * D1: no gutter either way — a folded row's own pin (when it has a number) sits inline with its
443
703
  * name, never offsetting the row's body. */
444
- function FoldedRow({ member, number, allMemberIds, resolvesAddress, open, onToggle, registerNode }: MemberRenderProps & { open: boolean; onToggle: () => void }) {
704
+ function FoldedRow({ member, number, allMemberIds, apiLabel, resolvesAddress, navigation, open, onToggle, registerNode }: MemberRenderProps & { open: boolean; onToggle: () => void }) {
445
705
  const [focused, setFocused] = useState(false);
446
706
  const related = member.related ? linkifyRelated(member.related, allMemberIds) : undefined;
447
- const parts = disclosureContent(member, member.props);
448
707
  return (
449
708
  <View style={styles.foldedRow}>
450
709
  <Pressable
@@ -475,10 +734,10 @@ function FoldedRow({ member, number, allMemberIds, resolvesAddress, open, onTogg
475
734
  source={member.source}
476
735
  alsoUsedBy={member.alsoUsedBy ?? []}
477
736
  resolvesAddress={resolvesAddress}
737
+ navigation={navigation}
738
+ sourceMemberId={member.id}
478
739
  />
479
- {parts.length > 0 && (
480
- <DisclosureBody member={member} parts={parts} resolvedProps={member.props} resolvesAddress={resolvesAddress} />
481
- )}
740
+ <MemberDisclosureToggle member={member} apiLabel={apiLabel} resolvesAddress={resolvesAddress} navigation={navigation} />
482
741
  </View>
483
742
  )}
484
743
  </View>
@@ -592,10 +851,55 @@ interface LegendFocus {
592
851
  onBlur: (key: string) => void;
593
852
  }
594
853
 
854
+ /** #63 R3: a family's own stub row for an *external part* — a member defined on a group page
855
+ * rather than this family's own `members` (`FamilyDef.externalParts`). Same pin/gutter as a real
856
+ * `FamilyPart`, but no specimens, description, or disclosure: only the name and a "Details on
857
+ * <group title>" link to that member's own address (`familyLink`, the same resolve-and-activate
858
+ * convention every other canonical link here uses — plain text, never a dead link, when it
859
+ * doesn't resolve). */
860
+ function ExternalPartRow({
861
+ memberId, number, groupTitle, resolvesAddress, navigation,
862
+ }: {
863
+ memberId: string;
864
+ number?: string;
865
+ groupTitle: string;
866
+ resolvesAddress: (name: string) => boolean;
867
+ navigation?: ReferenceNavigation;
868
+ }) {
869
+ const link = familyLink(navigation, resolvesAddress, memberId);
870
+ return (
871
+ <View>
872
+ <View style={styles.partHeadRow}>
873
+ {number !== undefined && (
874
+ <View style={styles.pinBox}>
875
+ <Pin number={number} />
876
+ </View>
877
+ )}
878
+ <View style={styles.partHeadMain}>
879
+ <Text style={[CATALOG_TEXT.heading2, styles.memberName]}>{memberId}</Text>
880
+ </View>
881
+ </View>
882
+ <View style={[styles.partBody, number !== undefined && styles.partBodyIndented]}>
883
+ <Text style={styles.belowLine}>
884
+ {link ? (
885
+ <Text role="link" style={styles.relatedLink} {...link}>
886
+ {`Details on ${groupTitle}`}
887
+ </Text>
888
+ ) : (
889
+ <Text>{`Details on ${groupTitle}`}</Text>
890
+ )}
891
+ </Text>
892
+ </View>
893
+ </View>
894
+ );
895
+ }
896
+
595
897
  /** One Part in its tree position: rendered through `FamilyPart` as always, wrapped in a `Branch`
596
898
  * only when it has children (a leaf costs nothing extra), with its children nested inside its own
597
- * block (spec: "so the lines don't cross other parts") at one more 52px gutter. */
598
- function PartTreeBranch({
899
+ * block (spec: "so the lines don't cross other parts") at one more 52px gutter. #63 R2: exported
900
+ * so `GroupPage.tsx` can nest a top-level group member's own parts underneath it, the same tree
901
+ * (pins, tree lines, numbering) a family's own Parts section already renders. */
902
+ export function PartTreeBranch({
599
903
  node,
600
904
  byId,
601
905
  outline,
@@ -603,8 +907,10 @@ function PartTreeBranch({
603
907
  defaultPreviewWidths,
604
908
  apiLabel,
605
909
  resolvesAddress,
910
+ navigation,
606
911
  registerNode,
607
912
  pinNodesRef,
913
+ externalParts,
608
914
  }: {
609
915
  node: PartTreeEntry;
610
916
  byId: ReadonlyMap<string, FamilyMember>;
@@ -613,12 +919,18 @@ function PartTreeBranch({
613
919
  defaultPreviewWidths?: PreviewWidths;
614
920
  apiLabel: string;
615
921
  resolvesAddress: (name: string) => boolean;
922
+ navigation?: ReferenceNavigation;
616
923
  registerNode: (id: string, node: View | null) => void;
617
924
  pinNodesRef: React.RefObject<Map<string, View | null>>;
925
+ /** #63 R3: every id this family's own `inWhole` sections list that is actually a group-page
926
+ * member rather than one of `byId` above — rendered as `ExternalPartRow` instead of
927
+ * `FamilyPart`. Omitted (the default) outside a family page's own Parts tree — `GroupPage.tsx`'s
928
+ * own use of this same component never passes one, since a group never has external parts of
929
+ * its own. */
930
+ externalParts?: FamilyDef['externalParts'];
618
931
  }) {
619
932
  const member = byId.get(node.id);
620
- if (!member) return null;
621
- const part = (
933
+ const part = member ? (
622
934
  <FamilyPart
623
935
  member={member}
624
936
  number={outline.get(node.id)?.number}
@@ -627,13 +939,20 @@ function PartTreeBranch({
627
939
  defaultPreviewWidths={defaultPreviewWidths}
628
940
  apiLabel={apiLabel}
629
941
  resolvesAddress={resolvesAddress}
942
+ navigation={navigation}
630
943
  registerNode={registerNode}
631
944
  pinRef={(pinNode) => {
632
945
  if (pinNode) pinNodesRef.current.set(node.id, pinNode);
633
946
  else pinNodesRef.current.delete(node.id);
634
947
  }}
635
948
  />
636
- );
949
+ ) : (() => {
950
+ const external = externalParts?.get(node.id);
951
+ return external ? (
952
+ <ExternalPartRow memberId={node.id} number={outline.get(node.id)?.number} groupTitle={external.title} resolvesAddress={resolvesAddress} navigation={navigation} />
953
+ ) : null;
954
+ })();
955
+ if (part === null) return null;
637
956
  if (node.children.length === 0) return part;
638
957
  return (
639
958
  <Branch parentId={node.id} childIds={node.children.map((child) => child.id)} pinNodesRef={pinNodesRef}>
@@ -649,8 +968,10 @@ function PartTreeBranch({
649
968
  defaultPreviewWidths={defaultPreviewWidths}
650
969
  apiLabel={apiLabel}
651
970
  resolvesAddress={resolvesAddress}
971
+ navigation={navigation}
652
972
  registerNode={registerNode}
653
973
  pinNodesRef={pinNodesRef}
974
+ externalParts={externalParts}
654
975
  />
655
976
  ))}
656
977
  </View>
@@ -669,9 +990,11 @@ function FamilySectionGroup({
669
990
  defaultPreviewWidths,
670
991
  apiLabel,
671
992
  resolvesAddress,
993
+ navigation,
672
994
  openRows,
673
995
  onToggleRow,
674
996
  registerNode,
997
+ externalParts,
675
998
  }: {
676
999
  section: CatalogMemberSection;
677
1000
  members: FamilyMember[];
@@ -680,14 +1003,22 @@ function FamilySectionGroup({
680
1003
  defaultPreviewWidths?: PreviewWidths;
681
1004
  apiLabel: string;
682
1005
  resolvesAddress: (name: string) => boolean;
1006
+ navigation?: ReferenceNavigation;
683
1007
  openRows: ReadonlySet<string>;
684
1008
  onToggleRow: (id: string) => void;
685
1009
  registerNode: (id: string, node: View | null) => void;
1010
+ /** #63 R3: see `PartTreeBranch`'s own doc comment. Omitted for the "Related" bucket — A5 only
1011
+ * ever applies to an `inWhole` section. */
1012
+ externalParts?: FamilyDef['externalParts'];
686
1013
  }) {
687
1014
  const byId = useMemo(() => new Map(members.map((member) => [member.id, member])), [members]);
688
1015
  const pinNodesRef = useRef<Map<string, View | null>>(new Map());
1016
+ // #63 R3: `section.members` may list an external part — present in `externalParts` but never in
1017
+ // `byId` (it isn't one of this family's own `members`) — kept here (unlike a genuinely unknown
1018
+ // id, still dropped) so it gets its own `ExternalPartRow` stub below.
1019
+ const ids = section.members.filter((id) => byId.has(id) || externalParts?.has(id));
689
1020
  if (section.kind === 'blocks') {
690
- const tree = buildPartTree(members.map((member) => member.id), outline);
1021
+ const tree = buildPartTree(ids, outline);
691
1022
  return (
692
1023
  <View style={styles.blocksSection}>
693
1024
  {tree.map((node) => (
@@ -700,8 +1031,10 @@ function FamilySectionGroup({
700
1031
  defaultPreviewWidths={defaultPreviewWidths}
701
1032
  apiLabel={apiLabel}
702
1033
  resolvesAddress={resolvesAddress}
1034
+ navigation={navigation}
703
1035
  registerNode={registerNode}
704
1036
  pinNodesRef={pinNodesRef}
1037
+ externalParts={externalParts}
705
1038
  />
706
1039
  ))}
707
1040
  </View>
@@ -709,21 +1042,30 @@ function FamilySectionGroup({
709
1042
  }
710
1043
  return (
711
1044
  <View style={styles.foldedList}>
712
- {members.map((member, i) => (
713
- <View key={member.id} style={[styles.foldedListRow, i < members.length - 1 && styles.foldedListRowDivider]}>
714
- <FoldedRow
715
- member={member}
716
- number={outline.get(member.id)?.number}
717
- allMemberIds={allMemberIds}
718
- defaultPreviewWidths={defaultPreviewWidths}
719
- apiLabel={apiLabel}
720
- resolvesAddress={resolvesAddress}
721
- open={openRows.has(member.id)}
722
- onToggle={() => onToggleRow(member.id)}
723
- registerNode={registerNode}
724
- />
725
- </View>
726
- ))}
1045
+ {ids.map((id, i) => {
1046
+ const member = byId.get(id);
1047
+ const external = member ? undefined : externalParts?.get(id);
1048
+ return (
1049
+ <View key={id} style={[styles.foldedListRow, i < ids.length - 1 && styles.foldedListRowDivider]}>
1050
+ {member ? (
1051
+ <FoldedRow
1052
+ member={member}
1053
+ number={outline.get(id)?.number}
1054
+ allMemberIds={allMemberIds}
1055
+ defaultPreviewWidths={defaultPreviewWidths}
1056
+ apiLabel={apiLabel}
1057
+ resolvesAddress={resolvesAddress}
1058
+ navigation={navigation}
1059
+ open={openRows.has(id)}
1060
+ onToggle={() => onToggleRow(id)}
1061
+ registerNode={registerNode}
1062
+ />
1063
+ ) : external ? (
1064
+ <ExternalPartRow memberId={id} number={outline.get(id)?.number} groupTitle={external.title} resolvesAddress={resolvesAddress} navigation={navigation} />
1065
+ ) : null}
1066
+ </View>
1067
+ );
1068
+ })}
727
1069
  </View>
728
1070
  );
729
1071
  }
@@ -732,12 +1074,12 @@ function FamilySectionGroup({
732
1074
  * page's own id as a link when it resolves to a real catalog address, plain text when not — never
733
1075
  * a dead link — then its note, when authored. No specimens, no disclosure, no source line, no
734
1076
  * number, no pin. */
735
- function RelatedPageRow({ entry, resolvesAddress }: { entry: RelatedPage; resolvesAddress: (name: string) => boolean }) {
736
- const resolved = resolvesAddress(entry.page);
1077
+ function RelatedPageRow({ entry, resolvesAddress, navigation }: { entry: RelatedPage; resolvesAddress: (name: string) => boolean; navigation?: ReferenceNavigation }) {
1078
+ const link = familyLink(navigation, resolvesAddress, entry.page);
737
1079
  return (
738
1080
  <Text style={styles.belowLine}>
739
- {resolved ? (
740
- <Text role="link" style={styles.relatedPageName} {...({ href: hashForId(entry.page) } as Record<string, unknown>)}>
1081
+ {link ? (
1082
+ <Text role="link" style={styles.relatedPageName} {...link}>
741
1083
  {entry.page}
742
1084
  </Text>
743
1085
  ) : (
@@ -748,11 +1090,11 @@ function RelatedPageRow({ entry, resolvesAddress }: { entry: RelatedPage; resolv
748
1090
  );
749
1091
  }
750
1092
 
751
- function RelatedPagesList({ items, resolvesAddress }: { items: readonly RelatedPage[]; resolvesAddress: (name: string) => boolean }) {
1093
+ function RelatedPagesList({ items, resolvesAddress, navigation }: { items: readonly RelatedPage[]; resolvesAddress: (name: string) => boolean; navigation?: ReferenceNavigation }) {
752
1094
  return (
753
1095
  <View style={styles.relatedPagesList}>
754
1096
  {items.map((entry, i) => (
755
- <RelatedPageRow key={i} entry={entry} resolvesAddress={resolvesAddress} />
1097
+ <RelatedPageRow key={i} entry={entry} resolvesAddress={resolvesAddress} navigation={navigation} />
756
1098
  ))}
757
1099
  </View>
758
1100
  );
@@ -774,9 +1116,11 @@ function FamilyLabeledGroup({
774
1116
  defaultPreviewWidths,
775
1117
  apiLabel,
776
1118
  resolvesAddress,
1119
+ navigation,
777
1120
  openRows,
778
1121
  onToggleRow,
779
1122
  registerNode,
1123
+ externalParts,
780
1124
  }: {
781
1125
  label: 'Parts' | 'Related';
782
1126
  sections: CatalogMemberSection[];
@@ -788,9 +1132,13 @@ function FamilyLabeledGroup({
788
1132
  defaultPreviewWidths?: PreviewWidths;
789
1133
  apiLabel: string;
790
1134
  resolvesAddress: (name: string) => boolean;
1135
+ navigation?: ReferenceNavigation;
791
1136
  openRows: ReadonlySet<string>;
792
1137
  onToggleRow: (id: string) => void;
793
1138
  registerNode: (id: string, node: View | null) => void;
1139
+ /** #63 R3: see `PartTreeBranch`'s own doc comment — only ever passed for the "Parts" group
1140
+ * (A5 only applies to an `inWhole` section). */
1141
+ externalParts?: FamilyDef['externalParts'];
794
1142
  }) {
795
1143
  // Field spec item 3: "a page with no relatedPages and no Related sections renders exactly as it
796
1144
  // does today — no heading, no divider, nothing."
@@ -818,21 +1166,37 @@ function FamilyLabeledGroup({
818
1166
  defaultPreviewWidths={defaultPreviewWidths}
819
1167
  apiLabel={apiLabel}
820
1168
  resolvesAddress={resolvesAddress}
1169
+ navigation={navigation}
821
1170
  openRows={openRows}
822
1171
  onToggleRow={onToggleRow}
823
1172
  registerNode={registerNode}
1173
+ externalParts={externalParts}
824
1174
  />
825
1175
  ))}
826
- {relatedPages && relatedPages.length > 0 && <RelatedPagesList items={relatedPages} resolvesAddress={resolvesAddress} />}
1176
+ {relatedPages && relatedPages.length > 0 && <RelatedPagesList items={relatedPages} resolvesAddress={resolvesAddress} navigation={navigation} />}
827
1177
  </View>
828
1178
  </View>
829
1179
  );
830
1180
  }
831
1181
 
1182
+ /** What an anatomy pin looks up by member id: only the `fidelity` (dashed or solid). An own member
1183
+ * satisfies it as is; an external part is represented by a stub carrying its owner's fidelity
1184
+ * (`pinMembersWithExternal`). */
1185
+ type PinMembers = ReadonlyMap<string, Pick<FamilyMember, 'fidelity'>>;
1186
+
1187
+ /** `byId` plus one fidelity-only entry per external part (a member documented on a group page), so
1188
+ * its anatomy pin is dashed or solid by its owner's fidelity, exactly like an own part's. */
1189
+ function pinMembersWithExternal(byId: ReadonlyMap<string, FamilyMember>, externalParts: FamilyDef['externalParts']): PinMembers {
1190
+ if (!externalParts || externalParts.size === 0) return byId;
1191
+ const merged = new Map<string, Pick<FamilyMember, 'fidelity'>>(byId);
1192
+ for (const [id, external] of externalParts) if (!merged.has(id) && external.fidelity) merged.set(id, { fidelity: external.fidelity });
1193
+ return merged;
1194
+ }
1195
+
832
1196
  /** Slice D1 item 5: the family page's own member records and computed outline, read by `AnatomyPin`
833
1197
  * so a page's `anatomy.picture` (author-supplied markup, possibly arbitrarily positioned) never
834
1198
  * hardcodes a number. `undefined` outside a family page's own anatomy box. */
835
- const FamilyAnatomyContext = createContext<{ byId: ReadonlyMap<string, FamilyMember>; outline: FamilyDef['outline'] } | undefined>(undefined);
1199
+ const FamilyAnatomyContext = createContext<{ byId: PinMembers; outline: FamilyDef['outline'] } | undefined>(undefined);
836
1200
 
837
1201
  /** Slice D1 item 5: a pin for use inside `anatomy.picture`. `member` names one of this page's
838
1202
  * members and shows its number (dashed when it has no area of its own), looked up through
@@ -862,8 +1226,12 @@ export function AnatomyPin({ member, other }: { member?: string; other?: string
862
1226
  * when there's a pin to show — a member with no number (outside every `inWhole` section) gets no
863
1227
  * gutter either, so its name starts flush at the row's own left edge instead of under blank
864
1228
  * space. */
865
- function AnatomyMemberRow({ memberId, description, byId, outline, legendFocus }: { memberId: string; description: string; byId: ReadonlyMap<string, FamilyMember>; outline: FamilyDef['outline']; legendFocus: LegendFocus }) {
1229
+ function AnatomyMemberRow({ memberId, description, byId, outline, legendFocus, resolvesAddress, navigation }: { memberId: string; description: string; byId: PinMembers; outline: FamilyDef['outline']; legendFocus: LegendFocus; resolvesAddress: (name: string) => boolean; navigation?: ReferenceNavigation }) {
866
1230
  const pin = anatomyPinFor(memberId, byId, outline);
1231
+ // `familyLink` already resolves this row's own member (`resolvesAddress`'s sibling ids, at
1232
+ // minimum, cover it) — an explicitly supplied `navigation` that still can't resolve it must stay
1233
+ // honest plain text, never a raw `hashForId(memberId)` escape hatch around that refusal.
1234
+ const link = familyLink(navigation, resolvesAddress, memberId);
867
1235
  return (
868
1236
  <View style={styles.legendRow}>
869
1237
  {pin && (
@@ -875,14 +1243,18 @@ function AnatomyMemberRow({ memberId, description, byId, outline, legendFocus }:
875
1243
  </View>
876
1244
  )}
877
1245
  <View style={styles.legendBody}>
878
- <Text
879
- role="link"
880
- style={styles.legendName}
881
- ref={(node) => legendFocus.register(memberId, node as unknown as FocusableNode)}
882
- {...({ href: hashForId(memberId), onFocus: () => legendFocus.onFocus(memberId), onBlur: () => legendFocus.onBlur(memberId) } as Record<string, unknown>)}
883
- >
884
- {memberId}
885
- </Text>
1246
+ {link ? (
1247
+ <Text
1248
+ role="link"
1249
+ style={styles.legendName}
1250
+ ref={(node) => legendFocus.register(memberId, node as unknown as FocusableNode)}
1251
+ {...({ ...link, onFocus: () => legendFocus.onFocus(memberId), onBlur: () => legendFocus.onBlur(memberId) } as Record<string, unknown>)}
1252
+ >
1253
+ {memberId}
1254
+ </Text>
1255
+ ) : (
1256
+ <Text style={styles.legendName}>{memberId}</Text>
1257
+ )}
886
1258
  <Text style={styles.legendDesc}>{description}</Text>
887
1259
  </View>
888
1260
  </View>
@@ -895,8 +1267,8 @@ function AnatomyMemberRow({ memberId, description, byId, outline, legendFocus }:
895
1267
  * "other page" row always shows its ↗, so the slot is never blank. Keeping it also keeps
896
1268
  * APPROVED.md's "the description ... starts at the name's left edge" — pulling the marker inline
897
1269
  * instead left each description hanging 26px to the left of its own name. */
898
- function AnatomyOtherRow({ name, description, resolvesAddress, legendFocus }: { name: string; description: string; resolvesAddress: (name: string) => boolean; legendFocus: LegendFocus }) {
899
- const resolved = resolvesAddress(name);
1270
+ function AnatomyOtherRow({ name, description, resolvesAddress, navigation, legendFocus }: { name: string; description: string; resolvesAddress: (name: string) => boolean; navigation?: ReferenceNavigation; legendFocus: LegendFocus }) {
1271
+ const link = familyLink(navigation, resolvesAddress, name);
900
1272
  // No member id of its own (it names a component documented elsewhere), so it needs its own
901
1273
  // namespace to never collide with a real member id sharing the same text.
902
1274
  const focusKey = `other:${name}`;
@@ -909,12 +1281,12 @@ function AnatomyOtherRow({ name, description, resolvesAddress, legendFocus }: {
909
1281
  </View>
910
1282
  <View style={styles.legendBody}>
911
1283
  <View style={styles.legendNameRow}>
912
- {resolved ? (
1284
+ {link ? (
913
1285
  <Text
914
1286
  role="link"
915
1287
  style={styles.legendName}
916
1288
  ref={(node) => legendFocus.register(focusKey, node as unknown as FocusableNode)}
917
- {...({ href: hashForId(name), onFocus: () => legendFocus.onFocus(focusKey), onBlur: () => legendFocus.onBlur(focusKey) } as Record<string, unknown>)}
1289
+ {...({ ...link, onFocus: () => legendFocus.onFocus(focusKey), onBlur: () => legendFocus.onBlur(focusKey) } as Record<string, unknown>)}
918
1290
  >
919
1291
  {name}
920
1292
  </Text>
@@ -929,15 +1301,15 @@ function AnatomyOtherRow({ name, description, resolvesAddress, legendFocus }: {
929
1301
  );
930
1302
  }
931
1303
 
932
- function AnatomyGroup({ group, byId, outline, resolvesAddress, legendFocus }: { group: CatalogAnatomyGroup; byId: ReadonlyMap<string, FamilyMember>; outline: FamilyDef['outline']; resolvesAddress: (name: string) => boolean; legendFocus: LegendFocus }) {
1304
+ function AnatomyGroup({ group, byId, outline, resolvesAddress, navigation, legendFocus }: { group: CatalogAnatomyGroup; byId: PinMembers; outline: FamilyDef['outline']; resolvesAddress: (name: string) => boolean; navigation?: ReferenceNavigation; legendFocus: LegendFocus }) {
933
1305
  return (
934
1306
  <View>
935
1307
  <Text style={styles.legendGroupLabel}>{group.label}</Text>
936
1308
  {group.items.map((item, i) =>
937
1309
  'member' in item ? (
938
- <AnatomyMemberRow key={i} memberId={item.member} description={item.description} byId={byId} outline={outline} legendFocus={legendFocus} />
1310
+ <AnatomyMemberRow key={i} memberId={item.member} description={item.description} byId={byId} outline={outline} legendFocus={legendFocus} resolvesAddress={resolvesAddress} navigation={navigation} />
939
1311
  ) : (
940
- <AnatomyOtherRow key={i} name={item.other} description={item.description} resolvesAddress={resolvesAddress} legendFocus={legendFocus} />
1312
+ <AnatomyOtherRow key={i} name={item.other} description={item.description} resolvesAddress={resolvesAddress} navigation={navigation} legendFocus={legendFocus} />
941
1313
  ),
942
1314
  )}
943
1315
  </View>
@@ -953,8 +1325,13 @@ function AnatomyGroup({ group, byId, outline, resolvesAddress, legendFocus }: {
953
1325
  * The stage (and the picture inside it) renders at the same position in this tree whether the
954
1326
  * legend sits beside it or below it — only the wrapper's own `flexDirection` and the legend's own
955
1327
  * shape change around it — so toggling between the two never remounts the picture and discards
956
- * its own authored state or focus. */
957
- function AnatomyBox({ anatomy, byId, outline, resolvesAddress }: { anatomy: CatalogAnatomy; byId: ReadonlyMap<string, FamilyMember>; outline: FamilyDef['outline']; resolvesAddress: (name: string) => boolean }) {
1328
+ * its own authored state or focus.
1329
+ *
1330
+ * #63 R2: exported so `GroupPage.tsx` can render an embedded family's own anatomy box — several
1331
+ * may exist on one group page (one per top-level member that has one), each its own independent
1332
+ * instance of this component (its own `FamilyAnatomyContext`/measurement state), not a
1333
+ * page-singleton. */
1334
+ export function AnatomyBox({ anatomy, byId, outline, resolvesAddress, navigation }: { anatomy: CatalogAnatomy; byId: PinMembers; outline: FamilyDef['outline']; resolvesAddress: (name: string) => boolean; navigation?: ReferenceNavigation }) {
958
1335
  const totalItems = anatomy.groups.reduce((sum, group) => sum + group.items.length, 0);
959
1336
  const [cardWidth, setCardWidth] = useState(0);
960
1337
  const [pictureWidth, setPictureWidth] = useState(0);
@@ -1042,6 +1419,15 @@ function AnatomyBox({ anatomy, byId, outline, resolvesAddress }: { anatomy: Cata
1042
1419
  <View style={styles.anatomy}>
1043
1420
  <Text style={styles.anatomyHeadText}>Anatomy</Text>
1044
1421
  <View style={styles.anatomyCard}>
1422
+ {anatomy.picture == null ? (
1423
+ // No picture (e.g. mutually exclusive parts that never share one frame): the legend
1424
+ // takes the card's full width instead of sitting beside an empty picture column.
1425
+ <View style={styles.anatomyLegendAlone} testID="anatomy-legend">
1426
+ {anatomy.groups.map((group, i) => (
1427
+ <AnatomyGroup key={i} group={group} byId={byId} outline={outline} resolvesAddress={resolvesAddress} navigation={navigation} legendFocus={legendFocus} />
1428
+ ))}
1429
+ </View>
1430
+ ) : (
1045
1431
  <View style={stacked ? undefined : styles.anatomyBody} onLayout={onCardLayout} testID="anatomy-body">
1046
1432
  {stage}
1047
1433
  {stacked ? (
@@ -1052,7 +1438,7 @@ function AnatomyBox({ anatomy, byId, outline, resolvesAddress }: { anatomy: Cata
1052
1438
  {anatomyColumns(anatomy.groups, legendColumns).map((column, i) => (
1053
1439
  <View key={i} style={styles.anatomyColumn}>
1054
1440
  {column.map((group, j) => (
1055
- <AnatomyGroup key={j} group={group} byId={byId} outline={outline} resolvesAddress={resolvesAddress} legendFocus={legendFocus} />
1441
+ <AnatomyGroup key={j} group={group} byId={byId} outline={outline} resolvesAddress={resolvesAddress} navigation={navigation} legendFocus={legendFocus} />
1056
1442
  ))}
1057
1443
  </View>
1058
1444
  ))}
@@ -1060,23 +1446,55 @@ function AnatomyBox({ anatomy, byId, outline, resolvesAddress }: { anatomy: Cata
1060
1446
  ) : (
1061
1447
  <View style={styles.anatomyLegend} testID="anatomy-legend">
1062
1448
  {anatomy.groups.map((group, i) => (
1063
- <AnatomyGroup key={i} group={group} byId={byId} outline={outline} resolvesAddress={resolvesAddress} legendFocus={legendFocus} />
1449
+ <AnatomyGroup key={i} group={group} byId={byId} outline={outline} resolvesAddress={resolvesAddress} navigation={navigation} legendFocus={legendFocus} />
1064
1450
  ))}
1065
1451
  </View>
1066
1452
  )}
1067
1453
  </View>
1454
+ )}
1068
1455
  </View>
1069
1456
  </View>
1070
1457
  </FamilyAnatomyContext.Provider>
1071
1458
  );
1072
1459
  }
1073
1460
 
1461
+ /** #63 R2: a group member that is also an external part of some family's own outline — a plain
1462
+ * link line, "Part N of <Family>", to the family page (never to a specific member address — the
1463
+ * family page has no separate address for one of its own parts). Reuses this file's own
1464
+ * `belowLine`/`relatedLink` styles rather than a new visual; renders through `familyLink` so it
1465
+ * resolves and activates exactly like every other canonical link `FamilyPage`'s own pieces
1466
+ * produce (plain text, never a dead link, when it doesn't resolve). */
1467
+ export function PartOfLine({
1468
+ partOf, resolvesAddress, navigation,
1469
+ }: {
1470
+ partOf: { pageId: string; title: string; number: string };
1471
+ resolvesAddress: (name: string) => boolean;
1472
+ navigation?: ReferenceNavigation;
1473
+ }) {
1474
+ const link = familyLink(navigation, resolvesAddress, partOf.pageId);
1475
+ return (
1476
+ <Text style={styles.belowLine}>
1477
+ {`Part ${partOf.number} of `}
1478
+ {link ? (
1479
+ <Text role="link" style={styles.relatedLink} {...link}>
1480
+ {partOf.title}
1481
+ </Text>
1482
+ ) : (
1483
+ <Text>{partOf.title}</Text>
1484
+ )}
1485
+ </Text>
1486
+ );
1487
+ }
1488
+
1074
1489
  /** Scrolls a member's node into view and moves focus to it (spec §5.3 "Member address"). The jump
1075
1490
  * is always instant, like a normal anchor link: a smooth `scrollIntoView` does not reliably move
1076
1491
  * the shell's nested scroll container on web (the same limitation `CatalogShell`'s
1077
1492
  * `resetMainScroll` works around), and an instant jump also satisfies Reduce Motion. Native has
1078
- * no scroll-into-view of its own; `AccessibilityInfo`'s focus call is enough there. */
1079
- function scrollAndFocusMember(node: View): void {
1493
+ * no scroll-into-view of its own; `AccessibilityInfo`'s focus call is enough there.
1494
+ *
1495
+ * #63 R2: exported so `GroupPage.tsx` reuses the exact same jump mechanics for its own member
1496
+ * addresses, rather than a second implementation. */
1497
+ export function scrollAndFocusMember(node: View): void {
1080
1498
  if (Platform.OS === 'web') {
1081
1499
  const dom = node as unknown as { scrollIntoView?: (options: { behavior: 'auto'; block: 'start' }) => void; focus?: (options?: { preventScroll?: boolean }) => void };
1082
1500
  dom.scrollIntoView?.({ behavior: 'auto', block: 'start' });
@@ -1105,6 +1523,7 @@ export function FamilyPage<TId extends string>({
1105
1523
  focusMemberId,
1106
1524
  focusNonce,
1107
1525
  knownAddressIds,
1526
+ navigation,
1108
1527
  }: {
1109
1528
  def: SectionDef<TId>;
1110
1529
  groupLabel?: string;
@@ -1130,10 +1549,19 @@ export function FamilyPage<TId extends string>({
1130
1549
  * omitted when `FamilyPage` is used standalone (e.g. this very page's own live demo), where
1131
1550
  * only this family's own members can resolve. */
1132
1551
  knownAddressIds?: ReadonlySet<string>;
1552
+ /** Task 2B2: `CatalogShell`'s own shared reference navigation (see `catalogReferences.ts`'s
1553
+ * `ReferenceNavigation`/`referenceLinkProps`) — every one of this page's own link producers
1554
+ * (below-specimens/whole lines, Uses, related pages, anatomy legend) resolves and activates
1555
+ * through it when supplied, retaining this family's own sibling-member addresses exactly as
1556
+ * `resolvesAddress` below already does. Omitted (a standalone-mounted `FamilyPage`, this
1557
+ * framework's own catalog demo included) keeps every one of them at its prior plain-`href`-only
1558
+ * behavior, unchanged. */
1559
+ navigation?: ReferenceNavigation<TId>;
1133
1560
  }) {
1134
1561
  const family = def.family!;
1135
1562
  const apiLabel = family.apiLabel ?? 'API';
1136
1563
  const byId = useMemo(() => new Map(family.members.map((member) => [member.id, member])), [family.members]);
1564
+ const anatomyById = useMemo(() => pinMembersWithExternal(byId, family.externalParts), [byId, family.externalParts]);
1137
1565
  // N7: a `whole` that isn't one of its own members (bad data, already reported by
1138
1566
  // `validateFamily`) must degrade to a missing block, never a crash reading an absent member.
1139
1567
  const wholeMember = resolveWhole(byId, family.whole);
@@ -1190,28 +1618,11 @@ export function FamilyPage<TId extends string>({
1190
1618
  }, [focusMemberId, openRows, focusNonce]);
1191
1619
 
1192
1620
  return (
1621
+ <MemberPanelHost>
1193
1622
  <View>
1194
- {groupLabel && <Text style={styles.breadcrumb}>{breadcrumbRoot ? `${breadcrumbRoot} / ${groupLabel}` : groupLabel}</Text>}
1195
- {banner}
1196
- <View style={styles.pageHead}>
1197
- <View style={styles.titlebar}>
1198
- <View style={styles.titleText}>
1199
- <View ref={headingRef} tabIndex={-1} role="heading" {...({ 'aria-level': 1 } as Record<string, unknown>)} style={styles.headingTarget}>
1200
- <Text style={styles.title}>{def.title ?? def.id}</Text>
1201
- </View>
1202
- <Text style={styles.desc}>{def.description}</Text>
1203
- {family.notShown && (
1204
- <Text style={styles.notShown}>
1205
- <Text style={styles.notShownLead}>Not shown in the browser: </Text>
1206
- {family.notShown}
1207
- </Text>
1208
- )}
1209
- </View>
1210
- {pager && <SectionPagerRow pager={pager} />}
1211
- </View>
1212
- </View>
1623
+ <FamilyPageHead kind="family" def={def} groupLabel={groupLabel} breadcrumbRoot={breadcrumbRoot} banner={banner} pager={pager} headingRef={headingRef} notShown={family.notShown} />
1213
1624
 
1214
- {family.anatomy && <AnatomyBox anatomy={family.anatomy} byId={byId} outline={family.outline} resolvesAddress={resolvesAddress} />}
1625
+ {family.anatomy && <AnatomyBox anatomy={family.anatomy} byId={anatomyById} outline={family.outline} resolvesAddress={resolvesAddress} navigation={navigation} />}
1215
1626
 
1216
1627
  {wholeMember && (
1217
1628
  <View style={styles.whole} ref={(node) => registerNode(wholeMember.id, node)} tabIndex={-1}>
@@ -1233,9 +1644,10 @@ export function FamilyPage<TId extends string>({
1233
1644
  source={wholeMember.source}
1234
1645
  alsoUsedBy={wholeMember.alsoUsedBy ?? []}
1235
1646
  resolvesAddress={resolvesAddress}
1647
+ navigation={navigation}
1236
1648
  />
1237
1649
  <View style={styles.disclosureSpacer}>
1238
- <MemberDisclosureToggle member={wholeMember} apiLabel={apiLabel} resolvesAddress={resolvesAddress} />
1650
+ <MemberDisclosureToggle member={wholeMember} apiLabel={apiLabel} resolvesAddress={resolvesAddress} navigation={navigation} />
1239
1651
  </View>
1240
1652
  </>
1241
1653
  );
@@ -1252,9 +1664,11 @@ export function FamilyPage<TId extends string>({
1252
1664
  defaultPreviewWidths={defaultPreviewWidths}
1253
1665
  apiLabel={apiLabel}
1254
1666
  resolvesAddress={resolvesAddress}
1667
+ navigation={navigation}
1255
1668
  openRows={openRows}
1256
1669
  onToggleRow={toggleRow}
1257
1670
  registerNode={registerNode}
1671
+ externalParts={family.externalParts}
1258
1672
  />
1259
1673
  <FamilyLabeledGroup
1260
1674
  label="Related"
@@ -1266,11 +1680,13 @@ export function FamilyPage<TId extends string>({
1266
1680
  defaultPreviewWidths={defaultPreviewWidths}
1267
1681
  apiLabel={apiLabel}
1268
1682
  resolvesAddress={resolvesAddress}
1683
+ navigation={navigation}
1269
1684
  openRows={openRows}
1270
1685
  onToggleRow={toggleRow}
1271
1686
  registerNode={registerNode}
1272
1687
  />
1273
1688
  </View>
1689
+ </MemberPanelHost>
1274
1690
  );
1275
1691
  }
1276
1692
 
@@ -1287,6 +1703,9 @@ const styles = StyleSheet.create({
1287
1703
  title: { ...CATALOG_TEXT.pageTitle, color: CATALOG_COLOR.text, marginBottom: 5 },
1288
1704
  desc: { ...CATALOG_TEXT.body, color: CATALOG_COLOR.textMuted, maxWidth: 700 },
1289
1705
  notShown: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted, maxWidth: 700, marginTop: CATALOG_SPACE.sm },
1706
+ kindNote: { maxWidth: 700, marginTop: CATALOG_SPACE.sm, borderLeftWidth: 2, borderLeftColor: CATALOG_COLOR.borderStrong, paddingLeft: CATALOG_SPACE.md, paddingVertical: 2 },
1707
+ kindNoteText: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted },
1708
+ kindNoteLead: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.text },
1290
1709
  notShownLead: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.text },
1291
1710
 
1292
1711
  // Slice D1 item 5: the anatomy box's own "Anatomy" label sits outside, above a plain card —
@@ -1298,6 +1717,7 @@ const styles = StyleSheet.create({
1298
1717
  anatomyBody: { flexDirection: 'row', alignItems: 'flex-start' },
1299
1718
  anatomyStage: { padding: CATALOG_SPACE.xl, alignItems: 'center', justifyContent: 'center' },
1300
1719
  anatomyLegend: { flex: 1, borderLeftWidth: 1, borderLeftColor: CATALOG_COLOR.border, paddingVertical: CATALOG_SPACE.sm },
1720
+ anatomyLegendAlone: { paddingVertical: CATALOG_SPACE.sm },
1301
1721
  anatomyColumns: { flexDirection: 'row', borderTopWidth: 1, borderTopColor: CATALOG_COLOR.border },
1302
1722
  anatomyColumn: { flex: 1, borderLeftWidth: 1, borderLeftColor: CATALOG_COLOR.border, paddingVertical: CATALOG_SPACE.sm },
1303
1723
  // Host-authored text (`anatomy.groups[].label`) — never a transforming role like `microLabel`,
@@ -1402,6 +1822,16 @@ const styles = StyleSheet.create({
1402
1822
  justifyContent: 'center',
1403
1823
  },
1404
1824
  chevron: { ...CATALOG_TEXT.icon, color: CATALOG_COLOR.accent },
1825
+ panelRoot: { flex: 1, flexDirection: 'row', justifyContent: 'flex-end' },
1826
+ panelScrim: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, backgroundColor: PANEL_SCRIM },
1827
+ panel: { width: PANEL_WIDTH, maxWidth: '100%', height: '100%', backgroundColor: CATALOG_COLOR.surface, borderLeftWidth: 1, borderLeftColor: CATALOG_COLOR.borderStrong },
1828
+ panelFull: { width: '100%' },
1829
+ panelHead: { flexDirection: 'row', alignItems: 'flex-start', gap: CATALOG_SPACE.md, padding: CATALOG_SPACE.lg, borderBottomWidth: 1, borderBottomColor: CATALOG_COLOR.border },
1830
+ panelTitle: { flex: 1, gap: 2 },
1831
+ panelKicker: { ...CATALOG_TEXT.microLabel, color: CATALOG_COLOR.textMuted },
1832
+ panelName: { ...CATALOG_TEXT.heading3, color: CATALOG_COLOR.text },
1833
+ panelBody: { flex: 1 },
1834
+ panelBodyContent: { padding: CATALOG_SPACE.lg },
1405
1835
  disclosureToggleLabel: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.accent },
1406
1836
  disclosureBody: { gap: CATALOG_SPACE.sm, paddingTop: CATALOG_SPACE.xs },
1407
1837
  note: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.text },