@krapwoo/ds-viewer 0.14.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,13 @@
1
- import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
2
- import { AccessibilityInfo, Platform, Pressable, StyleSheet, Text, View, findNodeHandle } 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
@@ -13,6 +15,7 @@ import { hashForId } from './catalogNavigation';
13
15
  import {
14
16
  alsoUsedBySegments,
15
17
  anatomyColumns,
18
+ anatomyLayout,
16
19
  anatomyPinFor,
17
20
  ariaLevelFor,
18
21
  buildPartTree,
@@ -51,6 +54,93 @@ function memberAsSectionDef(member: CatalogMember): SectionDef {
51
54
 
52
55
  const ARIA_HIDDEN = { 'aria-hidden': true } as Record<string, unknown>;
53
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
+
54
144
  /** Fidelity badge (spec §5.2 "Badges"): no existing fidelity/OS badge to reuse, so this is a small
55
145
  * pill on `microLabel`, reusing `SectionBlock`'s own OS-badge visual. `replicated` keeps that
56
146
  * plain pill; `approximated` and `documentation-only` both get the gray/muted treatment the
@@ -139,6 +229,8 @@ function BelowSpecimensLine({
139
229
  source,
140
230
  alsoUsedBy,
141
231
  resolvesAddress,
232
+ navigation,
233
+ sourceMemberId,
142
234
  }: {
143
235
  related?: RelatedSegment[];
144
236
  showRelationship: boolean;
@@ -146,33 +238,39 @@ function BelowSpecimensLine({
146
238
  source: string;
147
239
  alsoUsedBy: AlsoUsedByEntry[];
148
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;
149
245
  }) {
150
246
  const usedBy = alsoUsedBySegments(alsoUsedBy);
151
247
  return (
152
248
  <Text style={styles.belowLine}>
153
249
  {related &&
154
- related.map((segment, i) =>
155
- segment.memberId ? (
156
- <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}>
157
254
  {segment.text}
158
255
  </Text>
159
256
  ) : (
160
257
  <Text key={i}>{segment.text}</Text>
161
- ),
162
- )}
258
+ );
259
+ })}
163
260
  {related && ' · '}
164
261
  {showRelationship && `${relationship} · `}
165
262
  {source}
166
263
  {usedBy.length > 0 && ' · '}
167
- {usedBy.map((segment, i) =>
168
- segment.component && resolvesAddress(segment.component) ? (
169
- <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}>
170
268
  {segment.text}
171
269
  </Text>
172
270
  ) : (
173
271
  <Text key={i}>{segment.text}</Text>
174
- ),
175
- )}
272
+ );
273
+ })}
176
274
  </Text>
177
275
  );
178
276
  }
@@ -186,12 +284,14 @@ function WholeBelowLine({
186
284
  source,
187
285
  alsoUsedBy,
188
286
  resolvesAddress,
287
+ navigation,
189
288
  }: {
190
289
  name: string;
191
290
  fidelity: CatalogMember['fidelity'];
192
291
  source: string;
193
292
  alsoUsedBy: AlsoUsedByEntry[];
194
293
  resolvesAddress: (name: string) => boolean;
294
+ navigation?: ReferenceNavigation;
195
295
  }) {
196
296
  const usedBy = alsoUsedBySegments(alsoUsedBy);
197
297
  return (
@@ -202,17 +302,20 @@ function WholeBelowLine({
202
302
  <Text style={styles.belowLine}> · </Text>
203
303
  <Text style={styles.belowLine}>{source}</Text>
204
304
  {usedBy.length > 0 && <Text style={styles.belowLine}> · </Text>}
205
- {usedBy.map((segment, i) =>
206
- segment.component && resolvesAddress(segment.component) ? (
207
- <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}>
208
311
  {segment.text}
209
312
  </Text>
210
313
  ) : (
211
314
  <Text key={i} style={styles.belowLine}>
212
315
  {segment.text}
213
316
  </Text>
214
- ),
215
- )}
317
+ );
318
+ })}
216
319
  </View>
217
320
  );
218
321
  }
@@ -221,23 +324,36 @@ function WholeBelowLine({
221
324
  * the disclosure, after notes and before Guidance. A name links — the same accent treatment
222
325
  * `BelowSpecimensLine`'s own `related` links use — when it resolves to a real catalog address (a
223
326
  * page, member, or alias); otherwise it shows in `codeSmall`, since there's nothing to link to. */
224
- 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
+ }) {
225
338
  return (
226
339
  <Text style={styles.usesLine}>
227
340
  {'Uses '}
228
- {composedOf.map((entry, i) => (
229
- <Text key={i}>
230
- {resolvesAddress(entry.component) ? (
231
- <Text role="link" style={styles.relatedLink} {...({ href: hashForId(entry.component) } as Record<string, unknown>)}>
232
- {entry.component}
233
- </Text>
234
- ) : (
235
- <Text style={styles.usesCode}>{entry.component}</Text>
236
- )}
237
- {` (${entry.role})`}
238
- {i < composedOf.length - 1 ? ', ' : ''}
239
- </Text>
240
- ))}
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
+ })}
241
357
  </Text>
242
358
  );
243
359
  }
@@ -252,11 +368,13 @@ function DisclosureBody({
252
368
  parts,
253
369
  resolvedProps,
254
370
  resolvesAddress,
371
+ navigation,
255
372
  }: {
256
373
  member: CatalogMember;
257
374
  parts: DisclosurePart[];
258
375
  resolvedProps?: PropDef[];
259
376
  resolvesAddress: (name: string) => boolean;
377
+ navigation?: ReferenceNavigation;
260
378
  }) {
261
379
  return (
262
380
  <View style={styles.disclosureBody}>
@@ -267,7 +385,7 @@ function DisclosureBody({
267
385
  {note}
268
386
  </Text>
269
387
  ))}
270
- {parts.includes('uses') && <UsesLine composedOf={member.composedOf!} resolvesAddress={resolvesAddress} />}
388
+ {parts.includes('uses') && <UsesLine composedOf={member.composedOf!} resolvesAddress={resolvesAddress} navigation={navigation} sourceMemberId={member.id} />}
271
389
  {parts.includes('whenToUse') && (
272
390
  <Text style={styles.disclosureText}>
273
391
  <Text style={styles.disclosureLead}>Guidance </Text>
@@ -290,55 +408,186 @@ function DisclosureBody({
290
408
  );
291
409
  }
292
410
 
293
- /** V2b item 6: the disclosure toggle shows a chevron before its label (`›` closed, `⌄` open, as
294
- * v8 does), on the `icon` role, hidden from screen readers (the toggle's own accessible name is
295
- * its text label — Errata 4's `accessibilityState`/`aria-expanded` already say whether it's
296
- * open). No animation. */
297
- 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() {
298
415
  return (
299
416
  <Text {...ARIA_HIDDEN} style={styles.chevron}>
300
- {open ? '⌄' : '›'}
417
+ ›
301
418
  </Text>
302
419
  );
303
420
  }
304
421
 
305
- /** Slice D1 item 9: "Notes and API" (using `apiLabel`) as a controlled toggle, closed by default —
306
- * shared by the whole and every Part/Related member (never by a folded row, which has no second,
307
- * 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. */
308
545
  function MemberDisclosureToggle({
309
546
  member,
310
547
  apiLabel,
311
548
  resolvesAddress,
549
+ navigation,
312
550
  }: {
313
551
  member: CatalogMember & { props?: PropDef[] };
314
552
  apiLabel: string;
315
553
  resolvesAddress: (name: string) => boolean;
554
+ navigation?: ReferenceNavigation;
316
555
  }) {
317
- const [open, setOpen] = useState(false);
556
+ const panel = useContext(MemberPanelContext);
318
557
  const [focused, setFocused] = useState(false);
319
558
  const parts = disclosureContent(member, member.props);
320
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
+ }
321
568
  const label = disclosureLabel(member, apiLabel, parts.includes('props'));
569
+ const open = panel.activeId === member.id;
322
570
  return (
323
- <>
324
- <Pressable
325
- onPress={() => setOpen((value) => !value)}
326
- onFocus={() => setFocused(true)}
327
- onBlur={() => setFocused(false)}
328
- accessibilityRole="button"
329
- accessibilityState={{ expanded: open }}
330
- {...({ 'aria-expanded': open } as Record<string, unknown>)}
331
- style={[styles.disclosureToggle, focused && styles.focusRing]}
332
- >
333
- <DisclosureChevron open={open} />
334
- <Text style={styles.disclosureToggleLabel}>{label}</Text>
335
- </Pressable>
336
- {open && <DisclosureBody member={member} parts={parts} resolvedProps={member.props} resolvesAddress={resolvesAddress} />}
337
- </>
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>
338
583
  );
339
584
  }
340
585
 
341
- 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[] };
342
591
 
343
592
  interface MemberRenderProps {
344
593
  member: FamilyMember;
@@ -353,6 +602,9 @@ interface MemberRenderProps {
353
602
  /** V2d item 3: whether a name (a `composedOf` entry's `component`) resolves to a real catalog
354
603
  * address — a page, member, or alias — so `UsesLine` knows which names to link. */
355
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;
356
608
  // Pressable forwards its ref to its underlying `View` (react-native's own `Pressable.d.ts`), so
357
609
  // a registered node is always a `View` instance whether it came from a block member's `View`
358
610
  // heading target or a folded row's `Pressable`.
@@ -367,7 +619,13 @@ interface MemberRenderProps {
367
619
  * Related member (no pin, no gutter) — otherwise the same header and body: name, description,
368
620
  * specimens (through `Blocks`, when the member has any), the below-specimens line, and a
369
621
  * closed-by-default disclosure. */
370
- 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 }) {
371
629
  const surface = member.specimenSurface ?? DEFAULT_SPECIMEN_SURFACE;
372
630
  const memberDef = useMemo(() => memberAsSectionDef(member), [member]);
373
631
  const blocks = useMemo(
@@ -399,6 +657,7 @@ function FamilyPart({ member, number, level, allMemberIds, defaultPreviewWidths,
399
657
  />
400
658
  </View>
401
659
  </View>
660
+ {afterNameRow}
402
661
  {/* D2 item A1: `partBody`'s own rhythm gap applies to every member, numbered or not — only
403
662
  the gutter indent (`partBodyIndented`) is conditional — so a Related member (never
404
663
  numbered) still gets the same spacing between its own description/specimens/below-line/
@@ -423,9 +682,11 @@ function FamilyPart({ member, number, level, allMemberIds, defaultPreviewWidths,
423
682
  source={member.source}
424
683
  alsoUsedBy={member.alsoUsedBy ?? []}
425
684
  resolvesAddress={resolvesAddress}
685
+ navigation={navigation}
686
+ sourceMemberId={member.id}
426
687
  />
427
688
  <View style={styles.disclosureSpacer}>
428
- <MemberDisclosureToggle member={member} apiLabel={apiLabel} resolvesAddress={resolvesAddress} />
689
+ <MemberDisclosureToggle member={member} apiLabel={apiLabel} resolvesAddress={resolvesAddress} navigation={navigation} />
429
690
  </View>
430
691
  </View>
431
692
  </View>
@@ -440,10 +701,9 @@ function FamilyPart({ member, number, level, allMemberIds, defaultPreviewWidths,
440
701
  * show specimens (spec's v8 reference keeps every folded-row example documentation-only). Slice
441
702
  * D1: no gutter either way — a folded row's own pin (when it has a number) sits inline with its
442
703
  * name, never offsetting the row's body. */
443
- 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 }) {
444
705
  const [focused, setFocused] = useState(false);
445
706
  const related = member.related ? linkifyRelated(member.related, allMemberIds) : undefined;
446
- const parts = disclosureContent(member, member.props);
447
707
  return (
448
708
  <View style={styles.foldedRow}>
449
709
  <Pressable
@@ -474,10 +734,10 @@ function FoldedRow({ member, number, allMemberIds, resolvesAddress, open, onTogg
474
734
  source={member.source}
475
735
  alsoUsedBy={member.alsoUsedBy ?? []}
476
736
  resolvesAddress={resolvesAddress}
737
+ navigation={navigation}
738
+ sourceMemberId={member.id}
477
739
  />
478
- {parts.length > 0 && (
479
- <DisclosureBody member={member} parts={parts} resolvedProps={member.props} resolvesAddress={resolvesAddress} />
480
- )}
740
+ <MemberDisclosureToggle member={member} apiLabel={apiLabel} resolvesAddress={resolvesAddress} navigation={navigation} />
481
741
  </View>
482
742
  )}
483
743
  </View>
@@ -571,10 +831,75 @@ interface MeasurableNode {
571
831
  measureLayout?: (relativeToNativeNode: number, onSuccess: (left: number, top: number, width: number, height: number) => void, onFail: () => void) => void;
572
832
  }
573
833
 
834
+ /** A node with `focus` (every mounted web host node's real DOM method) — narrower than RN's own
835
+ * `Text`/`View` ref types, the same reason `MeasurableNode` above exists for `measureLayout`. */
836
+ interface FocusableNode {
837
+ focus?: (options?: { preventScroll?: boolean }) => void;
838
+ }
839
+
840
+ /** Anatomy-narrow-layout focus fix: lets a legend row (`AnatomyMemberRow`/`AnatomyOtherRow`)
841
+ * register its own focusable name link and report when it gains focus, without either of them
842
+ * needing to know *why* — `AnatomyBox` is the only thing that reads `legendNodesRef`, after a
843
+ * layout-mode or balanced-column change remounts the row that currently holds focus at a new
844
+ * tree position (switching `anatomyLegend` <-> `anatomyColumns`, or redistributing columns)
845
+ * disconnects it and the browser drops focus to `<body>`. `register`'s `key` is stable across
846
+ * that remount (a member id, or `other:<name>` for an "Other page" row), so the *replacement*
847
+ * node at the same address can be found and refocused. */
848
+ interface LegendFocus {
849
+ register: (key: string, node: FocusableNode | null) => void;
850
+ onFocus: (key: string) => void;
851
+ onBlur: (key: string) => void;
852
+ }
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
+
574
897
  /** One Part in its tree position: rendered through `FamilyPart` as always, wrapped in a `Branch`
575
898
  * only when it has children (a leaf costs nothing extra), with its children nested inside its own
576
- * block (spec: "so the lines don't cross other parts") at one more 52px gutter. */
577
- 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({
578
903
  node,
579
904
  byId,
580
905
  outline,
@@ -582,8 +907,10 @@ function PartTreeBranch({
582
907
  defaultPreviewWidths,
583
908
  apiLabel,
584
909
  resolvesAddress,
910
+ navigation,
585
911
  registerNode,
586
912
  pinNodesRef,
913
+ externalParts,
587
914
  }: {
588
915
  node: PartTreeEntry;
589
916
  byId: ReadonlyMap<string, FamilyMember>;
@@ -592,12 +919,18 @@ function PartTreeBranch({
592
919
  defaultPreviewWidths?: PreviewWidths;
593
920
  apiLabel: string;
594
921
  resolvesAddress: (name: string) => boolean;
922
+ navigation?: ReferenceNavigation;
595
923
  registerNode: (id: string, node: View | null) => void;
596
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'];
597
931
  }) {
598
932
  const member = byId.get(node.id);
599
- if (!member) return null;
600
- const part = (
933
+ const part = member ? (
601
934
  <FamilyPart
602
935
  member={member}
603
936
  number={outline.get(node.id)?.number}
@@ -606,13 +939,20 @@ function PartTreeBranch({
606
939
  defaultPreviewWidths={defaultPreviewWidths}
607
940
  apiLabel={apiLabel}
608
941
  resolvesAddress={resolvesAddress}
942
+ navigation={navigation}
609
943
  registerNode={registerNode}
610
944
  pinRef={(pinNode) => {
611
945
  if (pinNode) pinNodesRef.current.set(node.id, pinNode);
612
946
  else pinNodesRef.current.delete(node.id);
613
947
  }}
614
948
  />
615
- );
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;
616
956
  if (node.children.length === 0) return part;
617
957
  return (
618
958
  <Branch parentId={node.id} childIds={node.children.map((child) => child.id)} pinNodesRef={pinNodesRef}>
@@ -628,8 +968,10 @@ function PartTreeBranch({
628
968
  defaultPreviewWidths={defaultPreviewWidths}
629
969
  apiLabel={apiLabel}
630
970
  resolvesAddress={resolvesAddress}
971
+ navigation={navigation}
631
972
  registerNode={registerNode}
632
973
  pinNodesRef={pinNodesRef}
974
+ externalParts={externalParts}
633
975
  />
634
976
  ))}
635
977
  </View>
@@ -648,9 +990,11 @@ function FamilySectionGroup({
648
990
  defaultPreviewWidths,
649
991
  apiLabel,
650
992
  resolvesAddress,
993
+ navigation,
651
994
  openRows,
652
995
  onToggleRow,
653
996
  registerNode,
997
+ externalParts,
654
998
  }: {
655
999
  section: CatalogMemberSection;
656
1000
  members: FamilyMember[];
@@ -659,14 +1003,22 @@ function FamilySectionGroup({
659
1003
  defaultPreviewWidths?: PreviewWidths;
660
1004
  apiLabel: string;
661
1005
  resolvesAddress: (name: string) => boolean;
1006
+ navigation?: ReferenceNavigation;
662
1007
  openRows: ReadonlySet<string>;
663
1008
  onToggleRow: (id: string) => void;
664
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'];
665
1013
  }) {
666
1014
  const byId = useMemo(() => new Map(members.map((member) => [member.id, member])), [members]);
667
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));
668
1020
  if (section.kind === 'blocks') {
669
- const tree = buildPartTree(members.map((member) => member.id), outline);
1021
+ const tree = buildPartTree(ids, outline);
670
1022
  return (
671
1023
  <View style={styles.blocksSection}>
672
1024
  {tree.map((node) => (
@@ -679,8 +1031,10 @@ function FamilySectionGroup({
679
1031
  defaultPreviewWidths={defaultPreviewWidths}
680
1032
  apiLabel={apiLabel}
681
1033
  resolvesAddress={resolvesAddress}
1034
+ navigation={navigation}
682
1035
  registerNode={registerNode}
683
1036
  pinNodesRef={pinNodesRef}
1037
+ externalParts={externalParts}
684
1038
  />
685
1039
  ))}
686
1040
  </View>
@@ -688,21 +1042,30 @@ function FamilySectionGroup({
688
1042
  }
689
1043
  return (
690
1044
  <View style={styles.foldedList}>
691
- {members.map((member, i) => (
692
- <View key={member.id} style={[styles.foldedListRow, i < members.length - 1 && styles.foldedListRowDivider]}>
693
- <FoldedRow
694
- member={member}
695
- number={outline.get(member.id)?.number}
696
- allMemberIds={allMemberIds}
697
- defaultPreviewWidths={defaultPreviewWidths}
698
- apiLabel={apiLabel}
699
- resolvesAddress={resolvesAddress}
700
- open={openRows.has(member.id)}
701
- onToggle={() => onToggleRow(member.id)}
702
- registerNode={registerNode}
703
- />
704
- </View>
705
- ))}
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
+ })}
706
1069
  </View>
707
1070
  );
708
1071
  }
@@ -711,12 +1074,12 @@ function FamilySectionGroup({
711
1074
  * page's own id as a link when it resolves to a real catalog address, plain text when not — never
712
1075
  * a dead link — then its note, when authored. No specimens, no disclosure, no source line, no
713
1076
  * number, no pin. */
714
- function RelatedPageRow({ entry, resolvesAddress }: { entry: RelatedPage; resolvesAddress: (name: string) => boolean }) {
715
- 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);
716
1079
  return (
717
1080
  <Text style={styles.belowLine}>
718
- {resolved ? (
719
- <Text role="link" style={styles.relatedPageName} {...({ href: hashForId(entry.page) } as Record<string, unknown>)}>
1081
+ {link ? (
1082
+ <Text role="link" style={styles.relatedPageName} {...link}>
720
1083
  {entry.page}
721
1084
  </Text>
722
1085
  ) : (
@@ -727,11 +1090,11 @@ function RelatedPageRow({ entry, resolvesAddress }: { entry: RelatedPage; resolv
727
1090
  );
728
1091
  }
729
1092
 
730
- 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 }) {
731
1094
  return (
732
1095
  <View style={styles.relatedPagesList}>
733
1096
  {items.map((entry, i) => (
734
- <RelatedPageRow key={i} entry={entry} resolvesAddress={resolvesAddress} />
1097
+ <RelatedPageRow key={i} entry={entry} resolvesAddress={resolvesAddress} navigation={navigation} />
735
1098
  ))}
736
1099
  </View>
737
1100
  );
@@ -753,9 +1116,11 @@ function FamilyLabeledGroup({
753
1116
  defaultPreviewWidths,
754
1117
  apiLabel,
755
1118
  resolvesAddress,
1119
+ navigation,
756
1120
  openRows,
757
1121
  onToggleRow,
758
1122
  registerNode,
1123
+ externalParts,
759
1124
  }: {
760
1125
  label: 'Parts' | 'Related';
761
1126
  sections: CatalogMemberSection[];
@@ -767,9 +1132,13 @@ function FamilyLabeledGroup({
767
1132
  defaultPreviewWidths?: PreviewWidths;
768
1133
  apiLabel: string;
769
1134
  resolvesAddress: (name: string) => boolean;
1135
+ navigation?: ReferenceNavigation;
770
1136
  openRows: ReadonlySet<string>;
771
1137
  onToggleRow: (id: string) => void;
772
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'];
773
1142
  }) {
774
1143
  // Field spec item 3: "a page with no relatedPages and no Related sections renders exactly as it
775
1144
  // does today — no heading, no divider, nothing."
@@ -797,21 +1166,37 @@ function FamilyLabeledGroup({
797
1166
  defaultPreviewWidths={defaultPreviewWidths}
798
1167
  apiLabel={apiLabel}
799
1168
  resolvesAddress={resolvesAddress}
1169
+ navigation={navigation}
800
1170
  openRows={openRows}
801
1171
  onToggleRow={onToggleRow}
802
1172
  registerNode={registerNode}
1173
+ externalParts={externalParts}
803
1174
  />
804
1175
  ))}
805
- {relatedPages && relatedPages.length > 0 && <RelatedPagesList items={relatedPages} resolvesAddress={resolvesAddress} />}
1176
+ {relatedPages && relatedPages.length > 0 && <RelatedPagesList items={relatedPages} resolvesAddress={resolvesAddress} navigation={navigation} />}
806
1177
  </View>
807
1178
  </View>
808
1179
  );
809
1180
  }
810
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
+
811
1196
  /** Slice D1 item 5: the family page's own member records and computed outline, read by `AnatomyPin`
812
1197
  * so a page's `anatomy.picture` (author-supplied markup, possibly arbitrarily positioned) never
813
1198
  * hardcodes a number. `undefined` outside a family page's own anatomy box. */
814
- const FamilyAnatomyContext = createContext<{ byId: ReadonlyMap<string, FamilyMember>; outline: FamilyDef['outline'] } | undefined>(undefined);
1199
+ const FamilyAnatomyContext = createContext<{ byId: PinMembers; outline: FamilyDef['outline'] } | undefined>(undefined);
815
1200
 
816
1201
  /** Slice D1 item 5: a pin for use inside `anatomy.picture`. `member` names one of this page's
817
1202
  * members and shows its number (dashed when it has no area of its own), looked up through
@@ -841,8 +1226,12 @@ export function AnatomyPin({ member, other }: { member?: string; other?: string
841
1226
  * when there's a pin to show — a member with no number (outside every `inWhole` section) gets no
842
1227
  * gutter either, so its name starts flush at the row's own left edge instead of under blank
843
1228
  * space. */
844
- function AnatomyMemberRow({ memberId, description, byId, outline }: { memberId: string; description: string; byId: ReadonlyMap<string, FamilyMember>; outline: FamilyDef['outline'] }) {
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 }) {
845
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);
846
1235
  return (
847
1236
  <View style={styles.legendRow}>
848
1237
  {pin && (
@@ -854,9 +1243,18 @@ function AnatomyMemberRow({ memberId, description, byId, outline }: { memberId:
854
1243
  </View>
855
1244
  )}
856
1245
  <View style={styles.legendBody}>
857
- <Text role="link" style={styles.legendName} {...({ href: hashForId(memberId) } as Record<string, unknown>)}>
858
- {memberId}
859
- </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
+ )}
860
1258
  <Text style={styles.legendDesc}>{description}</Text>
861
1259
  </View>
862
1260
  </View>
@@ -869,8 +1267,11 @@ function AnatomyMemberRow({ memberId, description, byId, outline }: { memberId:
869
1267
  * "other page" row always shows its ↗, so the slot is never blank. Keeping it also keeps
870
1268
  * APPROVED.md's "the description ... starts at the name's left edge" — pulling the marker inline
871
1269
  * instead left each description hanging 26px to the left of its own name. */
872
- function AnatomyOtherRow({ name, description, resolvesAddress }: { name: string; description: string; resolvesAddress: (name: string) => boolean }) {
873
- 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);
1272
+ // No member id of its own (it names a component documented elsewhere), so it needs its own
1273
+ // namespace to never collide with a real member id sharing the same text.
1274
+ const focusKey = `other:${name}`;
874
1275
  return (
875
1276
  <View style={styles.legendRow}>
876
1277
  <View style={styles.legendPinSlot} {...ARIA_HIDDEN} testID="legend-pin-slot">
@@ -880,8 +1281,13 @@ function AnatomyOtherRow({ name, description, resolvesAddress }: { name: string;
880
1281
  </View>
881
1282
  <View style={styles.legendBody}>
882
1283
  <View style={styles.legendNameRow}>
883
- {resolved ? (
884
- <Text role="link" style={styles.legendName} {...({ href: hashForId(name) } as Record<string, unknown>)}>
1284
+ {link ? (
1285
+ <Text
1286
+ role="link"
1287
+ style={styles.legendName}
1288
+ ref={(node) => legendFocus.register(focusKey, node as unknown as FocusableNode)}
1289
+ {...({ ...link, onFocus: () => legendFocus.onFocus(focusKey), onBlur: () => legendFocus.onBlur(focusKey) } as Record<string, unknown>)}
1290
+ >
885
1291
  {name}
886
1292
  </Text>
887
1293
  ) : (
@@ -895,69 +1301,200 @@ function AnatomyOtherRow({ name, description, resolvesAddress }: { name: string;
895
1301
  );
896
1302
  }
897
1303
 
898
- function AnatomyGroup({ group, byId, outline, resolvesAddress }: { group: CatalogAnatomyGroup; byId: ReadonlyMap<string, FamilyMember>; outline: FamilyDef['outline']; resolvesAddress: (name: string) => boolean }) {
1304
+ function AnatomyGroup({ group, byId, outline, resolvesAddress, navigation, legendFocus }: { group: CatalogAnatomyGroup; byId: PinMembers; outline: FamilyDef['outline']; resolvesAddress: (name: string) => boolean; navigation?: ReferenceNavigation; legendFocus: LegendFocus }) {
899
1305
  return (
900
1306
  <View>
901
1307
  <Text style={styles.legendGroupLabel}>{group.label}</Text>
902
1308
  {group.items.map((item, i) =>
903
1309
  'member' in item ? (
904
- <AnatomyMemberRow key={i} memberId={item.member} description={item.description} byId={byId} outline={outline} />
1310
+ <AnatomyMemberRow key={i} memberId={item.member} description={item.description} byId={byId} outline={outline} legendFocus={legendFocus} resolvesAddress={resolvesAddress} navigation={navigation} />
905
1311
  ) : (
906
- <AnatomyOtherRow key={i} name={item.other} description={item.description} resolvesAddress={resolvesAddress} />
1312
+ <AnatomyOtherRow key={i} name={item.other} description={item.description} resolvesAddress={resolvesAddress} navigation={navigation} legendFocus={legendFocus} />
907
1313
  ),
908
1314
  )}
909
1315
  </View>
910
1316
  );
911
1317
  }
912
1318
 
913
- /** Slice D1 item 5: the anatomy box — always first (right after the page head), a plain-card
914
- * "Anatomy" label above the picture (the same label-outside-card treatment the specimen grids
915
- * below it use), and a legend beside it (six items or fewer) or below it in up to three balanced
916
- * columns (more than six). */
917
- function AnatomyBox({ anatomy, byId, outline, resolvesAddress }: { anatomy: CatalogAnatomy; byId: ReadonlyMap<string, FamilyMember>; outline: FamilyDef['outline']; resolvesAddress: (name: string) => boolean }) {
1319
+ /** Slice D1 item 5, narrow-layout fix: the anatomy box — always first (right after the page head),
1320
+ * a plain-card "Anatomy" label above the picture (the same label-outside-card treatment the
1321
+ * specimen grids below it use), and a legend beside it or below it, decided by `anatomyLayout`
1322
+ * from the card's own measured width and the picture's own measured intrinsic width (web only —
1323
+ * see `onCardLayout`/`onPictureLayout` below), in up to three balanced columns when stacked.
1324
+ *
1325
+ * The stage (and the picture inside it) renders at the same position in this tree whether the
1326
+ * legend sits beside it or below it — only the wrapper's own `flexDirection` and the legend's own
1327
+ * shape change around it — so toggling between the two never remounts the picture and discards
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 }) {
918
1335
  const totalItems = anatomy.groups.reduce((sum, group) => sum + group.items.length, 0);
919
- const stacked = totalItems > 6;
1336
+ const [cardWidth, setCardWidth] = useState(0);
1337
+ const [pictureWidth, setPictureWidth] = useState(0);
1338
+ // Width-driven layout is a web-only concern (field spec: native keeps its existing item-count
1339
+ // decision) — `onLayout` itself is the same RN-compatible API either platform would use, but
1340
+ // these handlers simply never update state off web, so `anatomyLayout` always sees an
1341
+ // unmeasured (0) width on native and keeps falling back to the original `totalItems > 6` rule.
1342
+ // `onCardLayout` sits on the card's own interior wrapper (below), not the bordered card itself,
1343
+ // so the measured width is the interior content width `anatomyLayout`'s budget already assumes
1344
+ // — the bordered card's own box would overstate it by its own 2px (1px each side) border.
1345
+ const onCardLayout = (event: LayoutChangeEvent) => {
1346
+ if (Platform.OS !== 'web') return;
1347
+ const next = Math.round(event.nativeEvent.layout.width);
1348
+ if (next > 0 && next !== cardWidth) setCardWidth(next);
1349
+ };
1350
+ const onPictureLayout = (event: LayoutChangeEvent) => {
1351
+ if (Platform.OS !== 'web') return;
1352
+ const next = Math.round(event.nativeEvent.layout.width);
1353
+ if (next > 0 && next !== pictureWidth) setPictureWidth(next);
1354
+ };
1355
+ const { stacked, legendColumns } = anatomyLayout(totalItems, cardWidth, pictureWidth);
920
1356
  const contextValue = useMemo(() => ({ byId, outline }), [byId, outline]);
1357
+
1358
+ // Focus fix: a legend row's own focusable name link, keyed by its stable address (a member id,
1359
+ // or `other:<name>`), registered by whichever row currently renders it — see `LegendFocus`'s own
1360
+ // comment above. Refs/plain mutable state, not React state: recording "who last had focus"
1361
+ // must never itself trigger a render.
1362
+ const legendNodesRef = useRef<Map<string, FocusableNode | null>>(new Map());
1363
+ const focusedLegendKeyRef = useRef<string | undefined>(undefined);
1364
+ const legendFocus: LegendFocus = useMemo(
1365
+ () => ({
1366
+ register: (key, node) => {
1367
+ if (node) legendNodesRef.current.set(key, node);
1368
+ else legendNodesRef.current.delete(key);
1369
+ },
1370
+ onFocus: (key) => {
1371
+ focusedLegendKeyRef.current = key;
1372
+ },
1373
+ onBlur: (key) => {
1374
+ if (focusedLegendKeyRef.current === key) focusedLegendKeyRef.current = undefined;
1375
+ },
1376
+ }),
1377
+ [],
1378
+ );
1379
+ // A layout-mode switch (`anatomyLegend` <-> `anatomyColumns`) or a balanced-column count change
1380
+ // can remount the legend row that currently holds focus at a different tree position — the
1381
+ // browser then drops focus to `<body>` (web's own behavior for a removed focused node). Restore
1382
+ // it to the replacement row at the same address, right after that same render commits, the same
1383
+ // web-only `.focus()` convention `scrollAndFocusMember` already uses below. Guarded on
1384
+ // `document.activeElement === document.body` so another control's focus is never overridden.
1385
+ // Explicit blur clears the recorded key, so clicking page background cannot revive stale focus;
1386
+ // disconnecting an active anchor during reflow does not dispatch that blur event.
1387
+ useLayoutEffect(() => {
1388
+ if (Platform.OS !== 'web') return;
1389
+ const key = focusedLegendKeyRef.current;
1390
+ if (!key) return;
1391
+ if (document.activeElement !== document.body) return;
1392
+ legendNodesRef.current.get(key)?.focus?.({ preventScroll: true });
1393
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1394
+ }, [stacked, legendColumns]);
1395
+
1396
+ const stage = (
1397
+ <View style={styles.anatomyStage}>
1398
+ {/* Measures the picture's own intrinsic width, not the stage: the stage stretches to the
1399
+ card's full width when stacked (a plain View's default cross-axis stretch in a column
1400
+ layout), but this wrapper's own `alignItems: 'center'` parent never stretches it in turn,
1401
+ so the picture's measured width stays the same whichever layout is in effect. Web only:
1402
+ this measuring wrapper would otherwise give an authored picture (e.g. one already using
1403
+ `alignSelf: 'stretch'` or percentage sizing) a new containing block it never had before —
1404
+ native keeps receiving `anatomy.picture` directly, as it always did. */}
1405
+ {Platform.OS === 'web' ? (
1406
+ // `testID` exists only so a renderer test can trigger a real resize on exactly this node
1407
+ // without depending on the author's own picture markup — see `legendPinSlot`'s comment.
1408
+ <View onLayout={onPictureLayout} testID="anatomy-picture-measure">
1409
+ {anatomy.picture}
1410
+ </View>
1411
+ ) : (
1412
+ anatomy.picture
1413
+ )}
1414
+ </View>
1415
+ );
1416
+
921
1417
  return (
922
1418
  <FamilyAnatomyContext.Provider value={contextValue}>
923
1419
  <View style={styles.anatomy}>
924
1420
  <Text style={styles.anatomyHeadText}>Anatomy</Text>
925
1421
  <View style={styles.anatomyCard}>
926
- {stacked ? (
927
- <View>
928
- <View style={styles.anatomyStage}>{anatomy.picture}</View>
929
- <View style={styles.anatomyColumns}>
930
- {anatomyColumns(anatomy.groups, 3).map((column, i) => (
931
- <View key={i} style={styles.anatomyColumn}>
932
- {column.map((group, j) => (
933
- <AnatomyGroup key={j} group={group} byId={byId} outline={outline} resolvesAddress={resolvesAddress} />
934
- ))}
935
- </View>
936
- ))}
937
- </View>
938
- </View>
939
- ) : (
940
- <View style={styles.anatomyBody}>
941
- <View style={styles.anatomyStage}>{anatomy.picture}</View>
942
- <View style={styles.anatomyLegend}>
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">
943
1426
  {anatomy.groups.map((group, i) => (
944
- <AnatomyGroup key={i} group={group} byId={byId} outline={outline} resolvesAddress={resolvesAddress} />
1427
+ <AnatomyGroup key={i} group={group} byId={byId} outline={outline} resolvesAddress={resolvesAddress} navigation={navigation} legendFocus={legendFocus} />
945
1428
  ))}
946
1429
  </View>
1430
+ ) : (
1431
+ <View style={stacked ? undefined : styles.anatomyBody} onLayout={onCardLayout} testID="anatomy-body">
1432
+ {stage}
1433
+ {stacked ? (
1434
+ // `testID` exists only so a renderer test can assert which layout rendered without
1435
+ // depending on geometry jsdom can't provide (no ResizeObserver there — `onLayout`
1436
+ // never fires), the same reason `legendPinSlot` carries one above.
1437
+ <View style={styles.anatomyColumns} testID="anatomy-columns">
1438
+ {anatomyColumns(anatomy.groups, legendColumns).map((column, i) => (
1439
+ <View key={i} style={styles.anatomyColumn}>
1440
+ {column.map((group, j) => (
1441
+ <AnatomyGroup key={j} group={group} byId={byId} outline={outline} resolvesAddress={resolvesAddress} navigation={navigation} legendFocus={legendFocus} />
1442
+ ))}
1443
+ </View>
1444
+ ))}
1445
+ </View>
1446
+ ) : (
1447
+ <View style={styles.anatomyLegend} testID="anatomy-legend">
1448
+ {anatomy.groups.map((group, i) => (
1449
+ <AnatomyGroup key={i} group={group} byId={byId} outline={outline} resolvesAddress={resolvesAddress} navigation={navigation} legendFocus={legendFocus} />
1450
+ ))}
1451
+ </View>
1452
+ )}
947
1453
  </View>
948
- )}
1454
+ )}
949
1455
  </View>
950
1456
  </View>
951
1457
  </FamilyAnatomyContext.Provider>
952
1458
  );
953
1459
  }
954
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
+
955
1489
  /** Scrolls a member's node into view and moves focus to it (spec §5.3 "Member address"). The jump
956
1490
  * is always instant, like a normal anchor link: a smooth `scrollIntoView` does not reliably move
957
1491
  * the shell's nested scroll container on web (the same limitation `CatalogShell`'s
958
1492
  * `resetMainScroll` works around), and an instant jump also satisfies Reduce Motion. Native has
959
- * no scroll-into-view of its own; `AccessibilityInfo`'s focus call is enough there. */
960
- 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 {
961
1498
  if (Platform.OS === 'web') {
962
1499
  const dom = node as unknown as { scrollIntoView?: (options: { behavior: 'auto'; block: 'start' }) => void; focus?: (options?: { preventScroll?: boolean }) => void };
963
1500
  dom.scrollIntoView?.({ behavior: 'auto', block: 'start' });
@@ -986,6 +1523,7 @@ export function FamilyPage<TId extends string>({
986
1523
  focusMemberId,
987
1524
  focusNonce,
988
1525
  knownAddressIds,
1526
+ navigation,
989
1527
  }: {
990
1528
  def: SectionDef<TId>;
991
1529
  groupLabel?: string;
@@ -1011,10 +1549,19 @@ export function FamilyPage<TId extends string>({
1011
1549
  * omitted when `FamilyPage` is used standalone (e.g. this very page's own live demo), where
1012
1550
  * only this family's own members can resolve. */
1013
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>;
1014
1560
  }) {
1015
1561
  const family = def.family!;
1016
1562
  const apiLabel = family.apiLabel ?? 'API';
1017
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]);
1018
1565
  // N7: a `whole` that isn't one of its own members (bad data, already reported by
1019
1566
  // `validateFamily`) must degrade to a missing block, never a crash reading an absent member.
1020
1567
  const wholeMember = resolveWhole(byId, family.whole);
@@ -1071,28 +1618,11 @@ export function FamilyPage<TId extends string>({
1071
1618
  }, [focusMemberId, openRows, focusNonce]);
1072
1619
 
1073
1620
  return (
1621
+ <MemberPanelHost>
1074
1622
  <View>
1075
- {groupLabel && <Text style={styles.breadcrumb}>{breadcrumbRoot ? `${breadcrumbRoot} / ${groupLabel}` : groupLabel}</Text>}
1076
- {banner}
1077
- <View style={styles.pageHead}>
1078
- <View style={styles.titlebar}>
1079
- <View style={styles.titleText}>
1080
- <View ref={headingRef} tabIndex={-1} role="heading" {...({ 'aria-level': 1 } as Record<string, unknown>)} style={styles.headingTarget}>
1081
- <Text style={styles.title}>{def.title ?? def.id}</Text>
1082
- </View>
1083
- <Text style={styles.desc}>{def.description}</Text>
1084
- {family.notShown && (
1085
- <Text style={styles.notShown}>
1086
- <Text style={styles.notShownLead}>Not shown in the browser: </Text>
1087
- {family.notShown}
1088
- </Text>
1089
- )}
1090
- </View>
1091
- {pager && <SectionPagerRow pager={pager} />}
1092
- </View>
1093
- </View>
1623
+ <FamilyPageHead kind="family" def={def} groupLabel={groupLabel} breadcrumbRoot={breadcrumbRoot} banner={banner} pager={pager} headingRef={headingRef} notShown={family.notShown} />
1094
1624
 
1095
- {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} />}
1096
1626
 
1097
1627
  {wholeMember && (
1098
1628
  <View style={styles.whole} ref={(node) => registerNode(wholeMember.id, node)} tabIndex={-1}>
@@ -1114,9 +1644,10 @@ export function FamilyPage<TId extends string>({
1114
1644
  source={wholeMember.source}
1115
1645
  alsoUsedBy={wholeMember.alsoUsedBy ?? []}
1116
1646
  resolvesAddress={resolvesAddress}
1647
+ navigation={navigation}
1117
1648
  />
1118
1649
  <View style={styles.disclosureSpacer}>
1119
- <MemberDisclosureToggle member={wholeMember} apiLabel={apiLabel} resolvesAddress={resolvesAddress} />
1650
+ <MemberDisclosureToggle member={wholeMember} apiLabel={apiLabel} resolvesAddress={resolvesAddress} navigation={navigation} />
1120
1651
  </View>
1121
1652
  </>
1122
1653
  );
@@ -1133,9 +1664,11 @@ export function FamilyPage<TId extends string>({
1133
1664
  defaultPreviewWidths={defaultPreviewWidths}
1134
1665
  apiLabel={apiLabel}
1135
1666
  resolvesAddress={resolvesAddress}
1667
+ navigation={navigation}
1136
1668
  openRows={openRows}
1137
1669
  onToggleRow={toggleRow}
1138
1670
  registerNode={registerNode}
1671
+ externalParts={family.externalParts}
1139
1672
  />
1140
1673
  <FamilyLabeledGroup
1141
1674
  label="Related"
@@ -1147,11 +1680,13 @@ export function FamilyPage<TId extends string>({
1147
1680
  defaultPreviewWidths={defaultPreviewWidths}
1148
1681
  apiLabel={apiLabel}
1149
1682
  resolvesAddress={resolvesAddress}
1683
+ navigation={navigation}
1150
1684
  openRows={openRows}
1151
1685
  onToggleRow={toggleRow}
1152
1686
  registerNode={registerNode}
1153
1687
  />
1154
1688
  </View>
1689
+ </MemberPanelHost>
1155
1690
  );
1156
1691
  }
1157
1692
 
@@ -1168,6 +1703,9 @@ const styles = StyleSheet.create({
1168
1703
  title: { ...CATALOG_TEXT.pageTitle, color: CATALOG_COLOR.text, marginBottom: 5 },
1169
1704
  desc: { ...CATALOG_TEXT.body, color: CATALOG_COLOR.textMuted, maxWidth: 700 },
1170
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 },
1171
1709
  notShownLead: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.text },
1172
1710
 
1173
1711
  // Slice D1 item 5: the anatomy box's own "Anatomy" label sits outside, above a plain card —
@@ -1179,6 +1717,7 @@ const styles = StyleSheet.create({
1179
1717
  anatomyBody: { flexDirection: 'row', alignItems: 'flex-start' },
1180
1718
  anatomyStage: { padding: CATALOG_SPACE.xl, alignItems: 'center', justifyContent: 'center' },
1181
1719
  anatomyLegend: { flex: 1, borderLeftWidth: 1, borderLeftColor: CATALOG_COLOR.border, paddingVertical: CATALOG_SPACE.sm },
1720
+ anatomyLegendAlone: { paddingVertical: CATALOG_SPACE.sm },
1182
1721
  anatomyColumns: { flexDirection: 'row', borderTopWidth: 1, borderTopColor: CATALOG_COLOR.border },
1183
1722
  anatomyColumn: { flex: 1, borderLeftWidth: 1, borderLeftColor: CATALOG_COLOR.border, paddingVertical: CATALOG_SPACE.sm },
1184
1723
  // Host-authored text (`anatomy.groups[].label`) — never a transforming role like `microLabel`,
@@ -1283,6 +1822,16 @@ const styles = StyleSheet.create({
1283
1822
  justifyContent: 'center',
1284
1823
  },
1285
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 },
1286
1835
  disclosureToggleLabel: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.accent },
1287
1836
  disclosureBody: { gap: CATALOG_SPACE.sm, paddingTop: CATALOG_SPACE.xs },
1288
1837
  note: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.text },