@krapwoo/ds-viewer 0.10.0 → 0.12.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.
@@ -0,0 +1,1188 @@
1
+ import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { AccessibilityInfo, Platform, Pressable, StyleSheet, Text, View, findNodeHandle } from 'react-native';
3
+ import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TEXT } from './tokens';
4
+ import { Blocks, SectionPagerRow, type SectionPager } from './SectionBlock';
5
+ import { PropsTable } from './PropsTable';
6
+ import { presentationBlocks } from './comparison';
7
+ import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
8
+ import { hashForId } from './catalogNavigation';
9
+ // A plain extensionless import — the same one every other `.tsx` file here already uses for its
10
+ // own pure-module siblings (e.g. `CatalogShell.tsx` → `./catalogNavigation`): this file is a React
11
+ // component with no test of its own that imports it directly under Node, so it only ever goes
12
+ // through the bundler.
13
+ import {
14
+ anatomyColumns,
15
+ anatomyPinFor,
16
+ ariaLevelFor,
17
+ buildPartTree,
18
+ disclosureContent,
19
+ disclosureLabel,
20
+ linkifyRelated,
21
+ resolveWhole,
22
+ sectionFor,
23
+ shouldRunMemberJump,
24
+ type DisclosurePart,
25
+ type MemberJumpState,
26
+ type PartTreeEntry,
27
+ type RelatedSegment,
28
+ } from './familyModel';
29
+ import type { CatalogAnatomy, CatalogAnatomyGroup, CatalogMember, CatalogMemberSection, ComposedOfEntry, FamilyDef, PreviewWidths, PropDef, SectionDef } from './types';
30
+
31
+ /** `SectionDef`'s parts `presentationBlocks`/`Blocks` actually read, built from one family member
32
+ * so its own specimens render through the exact same renderers (`VariantGroup`, `ComparisonGrid`,
33
+ * `ComparisonList`, `SpecimenSurface`) a normal page's do, with no second copy of that logic (spec
34
+ * §5.2 "Member block"). A member's `hide` only ever hides `accessibility`/`props` (never
35
+ * `variants`/`states`), so this never sets `hide` — a member's specimens are never hidden by it. */
36
+ function memberAsSectionDef(member: CatalogMember): SectionDef {
37
+ return {
38
+ id: member.id,
39
+ description: member.description,
40
+ path: member.source,
41
+ variants: member.variants,
42
+ states: member.states,
43
+ comparison: member.comparison,
44
+ specimenSize: member.specimenSize,
45
+ specimenSurface: member.specimenSurface,
46
+ previewWidths: member.previewWidths,
47
+ previewLayout: member.previewLayout,
48
+ };
49
+ }
50
+
51
+ const ARIA_HIDDEN = { 'aria-hidden': true } as Record<string, unknown>;
52
+
53
+ /** Fidelity badge (spec §5.2 "Badges"): no existing fidelity/OS badge to reuse, so this is a small
54
+ * pill on `microLabel`, reusing `SectionBlock`'s own OS-badge visual. `replicated` keeps that
55
+ * plain pill; `approximated` and `documentation-only` both get the gray/muted treatment the
56
+ * approved v8 bytes use for both (its final `.fidelity-approximated`/`.fidelity-doc-only` rules
57
+ * are identical). */
58
+ function FidelityBadge({ fidelity }: { fidelity: CatalogMember['fidelity'] }) {
59
+ const muted = fidelity !== 'replicated';
60
+ const label = fidelity === 'documentation-only' ? 'Documentation only' : fidelity === 'approximated' ? 'Approximated' : 'Replicated';
61
+ return (
62
+ <View style={[styles.badge, muted && styles.badgeMuted]}>
63
+ <Text style={[styles.badgeText, muted && styles.badgeTextMuted]}>{label}</Text>
64
+ </View>
65
+ );
66
+ }
67
+
68
+ /** V2c item 8: a member's own OS-component marker, passed through to the member header — reuses
69
+ * `SectionBlock`'s own badge visual (`osBadge`/`osBadgeText`) so a member documented as a native
70
+ * control reads the same way a whole page's does. */
71
+ function OsBadge({ osComponent }: { osComponent: NonNullable<CatalogMember['osComponent']> }) {
72
+ return (
73
+ <View style={styles.osBadge}>
74
+ <Text style={styles.osBadgeText}>{osComponent === 'full' ? 'OS component' : 'Partly OS component'}</Text>
75
+ </View>
76
+ );
77
+ }
78
+
79
+ /** Slice D1 item 7: a numbered pin, a filled accent pill — used both by a Part's own header and by
80
+ * the anatomy legend/picture (`AnatomyPin`, which also allows the dashed, no-area-of-their-own
81
+ * style spec §"Pins" describes). Decorative: the member's own name (not the number) is what
82
+ * assistive tech announces. */
83
+ function Pin({ number, dashed }: { number: string; dashed?: boolean }) {
84
+ return (
85
+ <View style={[styles.pin, dashed && styles.pinDashed]} {...ARIA_HIDDEN}>
86
+ <Text style={[styles.pinText, dashed && styles.pinTextDashed]}>{number}</Text>
87
+ </View>
88
+ );
89
+ }
90
+
91
+ /** Slice D1 items 7–8: the name + badges row. Every part uses the same "18/24 bold" role
92
+ * regardless of nesting depth (item 10 removes the old per-depth header ladder) — only
93
+ * `aria-level` still reflects the member's real nesting depth, so assistive tech keeps an
94
+ * accurate outline even though the visual size no longer shrinks with it. */
95
+ function MemberNameRow({
96
+ name,
97
+ fidelity,
98
+ osComponent,
99
+ heading,
100
+ ariaLevel,
101
+ headingRef,
102
+ }: {
103
+ name: string;
104
+ fidelity: CatalogMember['fidelity'];
105
+ osComponent?: CatalogMember['osComponent'];
106
+ /** Whether this name sits inside a real heading (a block member) or inert text inside a folded
107
+ * row's own toggle button. */
108
+ heading: boolean;
109
+ ariaLevel?: number;
110
+ headingRef?: React.Ref<View>;
111
+ }) {
112
+ const nameNode = <Text style={[CATALOG_TEXT.heading2, styles.memberName]}>{name}</Text>;
113
+ return (
114
+ <View style={styles.memberHead}>
115
+ {heading ? (
116
+ <View ref={headingRef} tabIndex={-1} role="heading" {...({ 'aria-level': ariaLevel } as Record<string, unknown>)} style={styles.headingTarget}>
117
+ {nameNode}
118
+ </View>
119
+ ) : (
120
+ nameNode
121
+ )}
122
+ {osComponent && <OsBadge osComponent={osComponent} />}
123
+ <FidelityBadge fidelity={fidelity} />
124
+ </View>
125
+ );
126
+ }
127
+
128
+ /** `[related ·] [relationship ·] source path:line` (spec §5.2 "Below the specimens"). A member
129
+ * name inside `related` is a link to that member's address — a real `href`, like every other
130
+ * in-app link, so clicking it goes through the same `hashchange` routing as typing the address or
131
+ * using search (`CatalogShell`). */
132
+ function BelowSpecimensLine({
133
+ related,
134
+ showRelationship,
135
+ relationship,
136
+ source,
137
+ }: {
138
+ related?: RelatedSegment[];
139
+ showRelationship: boolean;
140
+ relationship: string;
141
+ source: string;
142
+ }) {
143
+ return (
144
+ <Text style={styles.belowLine}>
145
+ {related &&
146
+ related.map((segment, i) =>
147
+ segment.memberId ? (
148
+ <Text key={i} role="link" style={styles.relatedLink} {...({ href: hashForId(segment.memberId) } as Record<string, unknown>)}>
149
+ {segment.text}
150
+ </Text>
151
+ ) : (
152
+ <Text key={i}>{segment.text}</Text>
153
+ ),
154
+ )}
155
+ {related && ' · '}
156
+ {showRelationship && `${relationship} · `}
157
+ {source}
158
+ </Text>
159
+ );
160
+ }
161
+
162
+ /** Slice D1 item 6: the whole's own one-line summary — `<name> · <fidelity badge> · <source>` — in
163
+ * place of the below-specimens line (the whole has no `relationship`/`related` to show). */
164
+ function WholeBelowLine({ name, fidelity, source }: { name: string; fidelity: CatalogMember['fidelity']; source: string }) {
165
+ return (
166
+ <View style={styles.wholeBelowRow}>
167
+ <Text style={styles.wholeName}>{name}</Text>
168
+ <Text style={styles.belowLine}> · </Text>
169
+ <FidelityBadge fidelity={fidelity} />
170
+ <Text style={styles.belowLine}> · </Text>
171
+ <Text style={styles.belowLine}>{source}</Text>
172
+ </View>
173
+ );
174
+ }
175
+
176
+ /** "Uses X (role), Y (role2)" (v8's own `.uses` CSS class; V2d item 3): one caption line inside
177
+ * the disclosure, after notes and before Guidance. A name links — the same accent treatment
178
+ * `BelowSpecimensLine`'s own `related` links use — when it resolves to a real catalog address (a
179
+ * page, member, or alias); otherwise it shows in `codeSmall`, since there's nothing to link to. */
180
+ function UsesLine({ composedOf, resolvesAddress }: { composedOf: ComposedOfEntry[]; resolvesAddress: (name: string) => boolean }) {
181
+ return (
182
+ <Text style={styles.usesLine}>
183
+ {'Uses '}
184
+ {composedOf.map((entry, i) => (
185
+ <Text key={i}>
186
+ {resolvesAddress(entry.component) ? (
187
+ <Text role="link" style={styles.relatedLink} {...({ href: hashForId(entry.component) } as Record<string, unknown>)}>
188
+ {entry.component}
189
+ </Text>
190
+ ) : (
191
+ <Text style={styles.usesCode}>{entry.component}</Text>
192
+ )}
193
+ {` (${entry.role})`}
194
+ {i < composedOf.length - 1 ? ', ' : ''}
195
+ </Text>
196
+ ))}
197
+ </Text>
198
+ );
199
+ }
200
+
201
+ /** The disclosure's body (spec §5.2 "Disclosure"): notes, then Uses (V2d item 2), then Guidance,
202
+ * then Accessibility, then generated Props (through the existing `PropsTable`), then the Swift
203
+ * excerpt — in the order `disclosureContent` already decided; this only ever renders the parts it
204
+ * lists. V2b item 10: the Guidance/Accessibility paragraphs sit under the same short
205
+ * `captionStrong` leads `ReferenceDetails.tsx` already uses for `whenToUse`/`a11y`. */
206
+ function DisclosureBody({
207
+ member,
208
+ parts,
209
+ resolvedProps,
210
+ resolvesAddress,
211
+ }: {
212
+ member: CatalogMember;
213
+ parts: DisclosurePart[];
214
+ resolvedProps?: PropDef[];
215
+ resolvesAddress: (name: string) => boolean;
216
+ }) {
217
+ return (
218
+ <View style={styles.disclosureBody}>
219
+ {parts.includes('notes') &&
220
+ member.notes!.map((note, i) => (
221
+ <Text key={i} style={styles.note}>
222
+ {'• '}
223
+ {note}
224
+ </Text>
225
+ ))}
226
+ {parts.includes('uses') && <UsesLine composedOf={member.composedOf!} resolvesAddress={resolvesAddress} />}
227
+ {parts.includes('whenToUse') && (
228
+ <Text style={styles.disclosureText}>
229
+ <Text style={styles.disclosureLead}>Guidance </Text>
230
+ {member.whenToUse}
231
+ </Text>
232
+ )}
233
+ {parts.includes('a11y') && (
234
+ <Text style={styles.disclosureText}>
235
+ <Text style={styles.disclosureLead}>Accessibility </Text>
236
+ {member.a11y}
237
+ </Text>
238
+ )}
239
+ {parts.includes('props') && resolvedProps && <PropsTable props={resolvedProps} />}
240
+ {parts.includes('api') && (
241
+ <View style={styles.apiBlock}>
242
+ <Text style={styles.apiText}>{member.api}</Text>
243
+ </View>
244
+ )}
245
+ </View>
246
+ );
247
+ }
248
+
249
+ /** V2b item 6: the disclosure toggle shows a chevron before its label (`›` closed, `⌄` open, as
250
+ * v8 does), on the `icon` role, hidden from screen readers (the toggle's own accessible name is
251
+ * its text label — Errata 4's `accessibilityState`/`aria-expanded` already say whether it's
252
+ * open). No animation. */
253
+ function DisclosureChevron({ open }: { open: boolean }) {
254
+ return (
255
+ <Text {...ARIA_HIDDEN} style={styles.chevron}>
256
+ {open ? '⌄' : '›'}
257
+ </Text>
258
+ );
259
+ }
260
+
261
+ /** Slice D1 item 9: "Notes and API" (using `apiLabel`) as a controlled toggle, closed by default —
262
+ * shared by the whole and every Part/Related member (never by a folded row, which has no second,
263
+ * nested toggle of its own: its own open state already decides whether its disclosure body shows). */
264
+ function MemberDisclosureToggle({
265
+ member,
266
+ apiLabel,
267
+ resolvesAddress,
268
+ }: {
269
+ member: CatalogMember & { props?: PropDef[] };
270
+ apiLabel: string;
271
+ resolvesAddress: (name: string) => boolean;
272
+ }) {
273
+ const [open, setOpen] = useState(false);
274
+ const [focused, setFocused] = useState(false);
275
+ const parts = disclosureContent(member, member.props);
276
+ if (parts.length === 0) return null;
277
+ const label = disclosureLabel(member, apiLabel, parts.includes('props'));
278
+ return (
279
+ <>
280
+ <Pressable
281
+ onPress={() => setOpen((value) => !value)}
282
+ onFocus={() => setFocused(true)}
283
+ onBlur={() => setFocused(false)}
284
+ accessibilityRole="button"
285
+ accessibilityState={{ expanded: open }}
286
+ {...({ 'aria-expanded': open } as Record<string, unknown>)}
287
+ style={[styles.disclosureToggle, focused && styles.focusRing]}
288
+ >
289
+ <DisclosureChevron open={open} />
290
+ <Text style={styles.disclosureToggleLabel}>{label}</Text>
291
+ </Pressable>
292
+ {open && <DisclosureBody member={member} parts={parts} resolvedProps={member.props} resolvesAddress={resolvesAddress} />}
293
+ </>
294
+ );
295
+ }
296
+
297
+ type FamilyMember = CatalogMember & { props?: PropDef[]; path: string };
298
+
299
+ interface MemberRenderProps {
300
+ member: FamilyMember;
301
+ /** Undefined for a Related member (spec §"Related": no pin, no gutter) — `numberFor` never
302
+ * assigns a number outside an `inWhole` section. */
303
+ number?: string;
304
+ allMemberIds: readonly string[];
305
+ defaultPreviewWidths?: PreviewWidths;
306
+ /** V2b item 9: the page's own label for the disclosure's API part (`FamilyDef.apiLabel`,
307
+ * default `'API'`). */
308
+ apiLabel: string;
309
+ /** V2d item 3: whether a name (a `composedOf` entry's `component`) resolves to a real catalog
310
+ * address — a page, member, or alias — so `UsesLine` knows which names to link. */
311
+ resolvesAddress: (name: string) => boolean;
312
+ // Pressable forwards its ref to its underlying `View` (react-native's own `Pressable.d.ts`), so
313
+ // a registered node is always a `View` instance whether it came from a block member's `View`
314
+ // heading target or a folded row's `Pressable`.
315
+ registerNode: (id: string, node: View | null) => void;
316
+ /** Slice D1 item 7 "Tree line": reports this Part's pin node, the same callback-ref pattern as
317
+ * `registerNode`, so a `Branch` can measure where to end its line without a `RefObject` having
318
+ * to be allocated and threaded down from an ancestor that doesn't yet know this part exists. */
319
+ pinRef?: (node: View | null) => void;
320
+ }
321
+
322
+ /** Slice D1 items 7–8: a Part (numbered, with a 52px gutter for its pin and tree line) or a
323
+ * Related member (no pin, no gutter) — otherwise the same header and body: name, description,
324
+ * specimens (through `Blocks`, when the member has any), the below-specimens line, and a
325
+ * closed-by-default disclosure. */
326
+ function FamilyPart({ member, number, level, allMemberIds, defaultPreviewWidths, apiLabel, resolvesAddress, registerNode, pinRef }: MemberRenderProps & { level: number }) {
327
+ const surface = member.specimenSurface ?? DEFAULT_SPECIMEN_SURFACE;
328
+ const memberDef = useMemo(() => memberAsSectionDef(member), [member]);
329
+ const blocks = useMemo(
330
+ () => presentationBlocks(memberDef, { defaultPreviewWidths }).filter((block) => block.kind !== 'empty'),
331
+ [memberDef, defaultPreviewWidths],
332
+ );
333
+ const related = member.related ? linkifyRelated(member.related, allMemberIds) : undefined;
334
+ const numbered = number !== undefined;
335
+
336
+ return (
337
+ <View>
338
+ <View style={styles.partHeadRow}>
339
+ {numbered && (
340
+ <View style={styles.pinBox}>
341
+ {/* The tree line measures this wrapper, so it hugs the pin itself, not the gutter. */}
342
+ <View ref={pinRef ?? undefined}>
343
+ <Pin number={number} />
344
+ </View>
345
+ </View>
346
+ )}
347
+ <View style={styles.partHeadMain}>
348
+ <MemberNameRow
349
+ name={member.id}
350
+ fidelity={member.fidelity}
351
+ osComponent={member.osComponent}
352
+ heading
353
+ ariaLevel={ariaLevelFor(level)}
354
+ headingRef={(node) => registerNode(member.id, node)}
355
+ />
356
+ </View>
357
+ </View>
358
+ {/* D2 item A1: `partBody`'s own rhythm gap applies to every member, numbered or not — only
359
+ the gutter indent (`partBodyIndented`) is conditional — so a Related member (never
360
+ numbered) still gets the same spacing between its own description/specimens/below-line/
361
+ disclosure as a numbered Part does. */}
362
+ <View style={[styles.partBody, numbered && styles.partBodyIndented]}>
363
+ <Text style={styles.memberDesc}>{member.description}</Text>
364
+ {blocks.length > 0 && (
365
+ // V2b item 5: a member's own specimen card sits CATALOG_SPACE.lg above the line below it
366
+ // — this wrapper supplies the extra margin beyond `partBody`'s own uniform CATALOG_SPACE.sm
367
+ // gap, instead of double-stacking with `Blocks`' own page-level marginBottom
368
+ // (`trimOuterMargin`), which together measured ~44px (v8: 16px).
369
+ <View style={styles.memberSpecimens}>
370
+ {/* V2b item 4: no uppercase "Variants"/"States" block label inside a member — v8 has none. */}
371
+ <Blocks blocks={blocks} def={memberDef} surface={surface} showLabels={false} trimOuterMargin />
372
+ </View>
373
+ )}
374
+ {member.notShown && <Text style={styles.belowLine}>{member.notShown}</Text>}
375
+ <BelowSpecimensLine
376
+ related={related}
377
+ showRelationship={false}
378
+ relationship={member.relationship}
379
+ source={member.source}
380
+ />
381
+ <View style={styles.disclosureSpacer}>
382
+ <MemberDisclosureToggle member={member} apiLabel={apiLabel} resolvesAddress={resolvesAddress} />
383
+ </View>
384
+ </View>
385
+ </View>
386
+ );
387
+ }
388
+
389
+ /** One row of a folded `list` section (spec §5.2 "Folded row"): the row's header — pin, name and
390
+ * description — is the toggle, and the open body is a sibling *outside* that button, so a related
391
+ * link or the PropsTable never sits inside a `role="button"` and never bubbles its own click back
392
+ * into the toggle. Closed, it shows only the header and description; open, it adds the below-the-
393
+ * specimens line and the disclosure body directly (no nested second toggle). Folded rows never
394
+ * show specimens (spec's v8 reference keeps every folded-row example documentation-only). Slice
395
+ * D1: no gutter either way — a folded row's own pin (when it has a number) sits inline with its
396
+ * name, never offsetting the row's body. */
397
+ function FoldedRow({ member, number, allMemberIds, resolvesAddress, open, onToggle, registerNode }: MemberRenderProps & { open: boolean; onToggle: () => void }) {
398
+ const [focused, setFocused] = useState(false);
399
+ const related = member.related ? linkifyRelated(member.related, allMemberIds) : undefined;
400
+ const parts = disclosureContent(member, member.props);
401
+ return (
402
+ <View style={styles.foldedRow}>
403
+ <Pressable
404
+ ref={(node) => registerNode(member.id, node)}
405
+ onPress={onToggle}
406
+ onFocus={() => setFocused(true)}
407
+ onBlur={() => setFocused(false)}
408
+ accessibilityRole="button"
409
+ accessibilityState={{ expanded: open }}
410
+ {...({ 'aria-expanded': open } as Record<string, unknown>)}
411
+ style={[styles.foldedRowToggle, focused && styles.focusRing]}
412
+ >
413
+ <View style={styles.foldedRowHead}>
414
+ {number !== undefined && <Pin number={number} />}
415
+ <MemberNameRow name={member.id} fidelity={member.fidelity} osComponent={member.osComponent} heading={false} />
416
+ </View>
417
+ <Text style={styles.memberDesc}>{member.description}</Text>
418
+ </Pressable>
419
+ {/* Sibling of the toggle `Pressable`, same pattern `MemberDisclosureToggle` uses above — a
420
+ related link (or the PropsTable) here must never sit inside a `role="button"`, and on
421
+ web must never bubble its own click into this row's own press handler and close it. */}
422
+ {open && (
423
+ <View style={styles.foldedRowBody}>
424
+ <BelowSpecimensLine related={related} showRelationship={false} relationship={member.relationship} source={member.source} />
425
+ {parts.length > 0 && (
426
+ <DisclosureBody member={member} parts={parts} resolvedProps={member.props} resolvesAddress={resolvesAddress} />
427
+ )}
428
+ </View>
429
+ )}
430
+ </View>
431
+ );
432
+ }
433
+
434
+ /** Slice D1 item 7 "Tree line": the elbow from a parent's pin down to each direct child's pin —
435
+ * measured (`View.measureLayout`, so it keeps working after wrapping/resizing) rather than
436
+ * computed from styling constants, the same technique `CatalogShell`'s own DOM-only escape
437
+ * hatches use for layout that CSS alone can't express. A border-left + border-bottom elbow with a
438
+ * 24px radius turn, in the approved reference's own subtle-accent line color. Decorative: hidden
439
+ * from accessibility. */
440
+ function Branch({
441
+ parentId,
442
+ childIds,
443
+ pinNodesRef,
444
+ children,
445
+ }: {
446
+ parentId: string;
447
+ childIds: readonly string[];
448
+ pinNodesRef: React.RefObject<Map<string, View | null>>;
449
+ children: React.ReactNode;
450
+ }) {
451
+ const containerRef = useRef<View>(null);
452
+ const [lines, setLines] = useState<Array<{ left: number; top: number; width: number; height: number }>>([]);
453
+
454
+ const measure = () => {
455
+ const container = containerRef.current;
456
+ // Web: measure from the rendered boxes, so the vertical line sits exactly under the centre of
457
+ // the parent's pin (`measureLayout` reports offsets from the nearest positioned ancestor there,
458
+ // which put the line to the right of the pin). The line is 2px wide, so it starts 1px left.
459
+ if (Platform.OS === 'web') {
460
+ type Box = { getBoundingClientRect?: () => { left: number; top: number; width: number; height: number } };
461
+ const box = (node: unknown) => (node as Box | null | undefined)?.getBoundingClientRect?.();
462
+ const c = box(container), p = box(pinNodesRef.current.get(parentId));
463
+ if (!c || !p || childIds.length === 0) return;
464
+ const x = p.left + p.width / 2 - c.left - 1;
465
+ const top = p.top + p.height - c.top + 4;
466
+ const next: Array<{ left: number; top: number; width: number; height: number }> = [];
467
+ for (const childId of childIds) {
468
+ const k = box(pinNodesRef.current.get(childId));
469
+ if (!k) return;
470
+ const y = k.top + k.height / 2 - c.top + 1;
471
+ next.push({ left: x, top, width: Math.max(0, k.left - c.left - x - 4), height: Math.max(0, y - top) });
472
+ }
473
+ setLines(next);
474
+ return;
475
+ }
476
+ const parentPin = pinNodesRef.current.get(parentId) as unknown as MeasurableNode | undefined;
477
+ if (!container || !parentPin?.measureLayout || childIds.length === 0) return;
478
+ parentPin.measureLayout(
479
+ container as unknown as number,
480
+ (px: number, py: number, pw: number, ph: number) => {
481
+ const x = px + pw / 2;
482
+ const top = py + ph + 4;
483
+ const next: Array<{ left: number; top: number; width: number; height: number }> = [];
484
+ for (const childId of childIds) {
485
+ const childPin = pinNodesRef.current.get(childId) as unknown as MeasurableNode | undefined;
486
+ childPin?.measureLayout?.(
487
+ container as unknown as number,
488
+ (cx: number, cy: number, _cw: number, ch: number) => {
489
+ const y = cy + ch / 2;
490
+ next.push({ left: x, top, width: Math.max(0, cx - x), height: Math.max(0, y - top) });
491
+ if (next.length === childIds.length) setLines(next);
492
+ },
493
+ () => {},
494
+ );
495
+ }
496
+ },
497
+ () => {},
498
+ );
499
+ };
500
+
501
+ // eslint-disable-next-line react-hooks/exhaustive-deps
502
+ useEffect(measure, []);
503
+
504
+ return (
505
+ <View ref={containerRef} style={styles.branch} onLayout={measure}>
506
+ {children}
507
+ {lines.map((line, i) => (
508
+ <View key={i} {...ARIA_HIDDEN} style={[styles.treeLine, line]} />
509
+ ))}
510
+ </View>
511
+ );
512
+ }
513
+
514
+ /** A node with `measureLayout` (every mounted `View`'s native handle) — narrower than RN's own
515
+ * `View` type, which doesn't expose this method in its public TypeScript surface even though
516
+ * every platform implements it. */
517
+ interface MeasurableNode {
518
+ measureLayout?: (relativeToNativeNode: number, onSuccess: (left: number, top: number, width: number, height: number) => void, onFail: () => void) => void;
519
+ }
520
+
521
+ /** One Part in its tree position: rendered through `FamilyPart` as always, wrapped in a `Branch`
522
+ * only when it has children (a leaf costs nothing extra), with its children nested inside its own
523
+ * block (spec: "so the lines don't cross other parts") at one more 52px gutter. */
524
+ function PartTreeBranch({
525
+ node,
526
+ byId,
527
+ outline,
528
+ allMemberIds,
529
+ defaultPreviewWidths,
530
+ apiLabel,
531
+ resolvesAddress,
532
+ registerNode,
533
+ pinNodesRef,
534
+ }: {
535
+ node: PartTreeEntry;
536
+ byId: ReadonlyMap<string, FamilyMember>;
537
+ outline: FamilyDef['outline'];
538
+ allMemberIds: readonly string[];
539
+ defaultPreviewWidths?: PreviewWidths;
540
+ apiLabel: string;
541
+ resolvesAddress: (name: string) => boolean;
542
+ registerNode: (id: string, node: View | null) => void;
543
+ pinNodesRef: React.RefObject<Map<string, View | null>>;
544
+ }) {
545
+ const member = byId.get(node.id);
546
+ if (!member) return null;
547
+ const part = (
548
+ <FamilyPart
549
+ member={member}
550
+ number={outline.get(node.id)?.number}
551
+ level={outline.get(node.id)?.level ?? 2}
552
+ allMemberIds={allMemberIds}
553
+ defaultPreviewWidths={defaultPreviewWidths}
554
+ apiLabel={apiLabel}
555
+ resolvesAddress={resolvesAddress}
556
+ registerNode={registerNode}
557
+ pinRef={(pinNode) => {
558
+ if (pinNode) pinNodesRef.current.set(node.id, pinNode);
559
+ else pinNodesRef.current.delete(node.id);
560
+ }}
561
+ />
562
+ );
563
+ if (node.children.length === 0) return part;
564
+ return (
565
+ <Branch parentId={node.id} childIds={node.children.map((child) => child.id)} pinNodesRef={pinNodesRef}>
566
+ {part}
567
+ <View style={styles.subparts}>
568
+ {node.children.map((child) => (
569
+ <PartTreeBranch
570
+ key={child.id}
571
+ node={child}
572
+ byId={byId}
573
+ outline={outline}
574
+ allMemberIds={allMemberIds}
575
+ defaultPreviewWidths={defaultPreviewWidths}
576
+ apiLabel={apiLabel}
577
+ resolvesAddress={resolvesAddress}
578
+ registerNode={registerNode}
579
+ pinNodesRef={pinNodesRef}
580
+ />
581
+ ))}
582
+ </View>
583
+ </Branch>
584
+ );
585
+ }
586
+
587
+ /** Slice D1 item 4: one `inWhole` or Related section's members, rendered without its own authored
588
+ * label (only the umbrella "Parts"/"Related" label shows — `FamilyPage` renders that once per
589
+ * bucket). `kind` alone still decides full blocks versus one folded list card. */
590
+ function FamilySectionGroup({
591
+ section,
592
+ members,
593
+ outline,
594
+ allMemberIds,
595
+ defaultPreviewWidths,
596
+ apiLabel,
597
+ resolvesAddress,
598
+ openRows,
599
+ onToggleRow,
600
+ registerNode,
601
+ }: {
602
+ section: CatalogMemberSection;
603
+ members: FamilyMember[];
604
+ outline: FamilyDef['outline'];
605
+ allMemberIds: readonly string[];
606
+ defaultPreviewWidths?: PreviewWidths;
607
+ apiLabel: string;
608
+ resolvesAddress: (name: string) => boolean;
609
+ openRows: ReadonlySet<string>;
610
+ onToggleRow: (id: string) => void;
611
+ registerNode: (id: string, node: View | null) => void;
612
+ }) {
613
+ const byId = useMemo(() => new Map(members.map((member) => [member.id, member])), [members]);
614
+ const pinNodesRef = useRef<Map<string, View | null>>(new Map());
615
+ if (section.kind === 'blocks') {
616
+ const tree = buildPartTree(members.map((member) => member.id), outline);
617
+ return (
618
+ <View style={styles.blocksSection}>
619
+ {tree.map((node) => (
620
+ <PartTreeBranch
621
+ key={node.id}
622
+ node={node}
623
+ byId={byId}
624
+ outline={outline}
625
+ allMemberIds={allMemberIds}
626
+ defaultPreviewWidths={defaultPreviewWidths}
627
+ apiLabel={apiLabel}
628
+ resolvesAddress={resolvesAddress}
629
+ registerNode={registerNode}
630
+ pinNodesRef={pinNodesRef}
631
+ />
632
+ ))}
633
+ </View>
634
+ );
635
+ }
636
+ return (
637
+ <View style={styles.foldedList}>
638
+ {members.map((member, i) => (
639
+ <View key={member.id} style={[styles.foldedListRow, i < members.length - 1 && styles.foldedListRowDivider]}>
640
+ <FoldedRow
641
+ member={member}
642
+ number={outline.get(member.id)?.number}
643
+ allMemberIds={allMemberIds}
644
+ defaultPreviewWidths={defaultPreviewWidths}
645
+ apiLabel={apiLabel}
646
+ resolvesAddress={resolvesAddress}
647
+ open={openRows.has(member.id)}
648
+ onToggle={() => onToggleRow(member.id)}
649
+ registerNode={registerNode}
650
+ />
651
+ </View>
652
+ ))}
653
+ </View>
654
+ );
655
+ }
656
+
657
+ /** Slice D1 item 4: the two umbrella labels — "Parts" groups every `inWhole` section, "Related"
658
+ * every other one. A divider sits above each, and nothing else renders between the authored
659
+ * sections it covers (item 10: authored `section.label`s stay in the data, never on screen). */
660
+ function FamilyLabeledGroup({
661
+ label,
662
+ sections,
663
+ outline,
664
+ byId,
665
+ allMemberIds,
666
+ defaultPreviewWidths,
667
+ apiLabel,
668
+ resolvesAddress,
669
+ openRows,
670
+ onToggleRow,
671
+ registerNode,
672
+ }: {
673
+ label: 'Parts' | 'Related';
674
+ sections: CatalogMemberSection[];
675
+ outline: FamilyDef['outline'];
676
+ byId: ReadonlyMap<string, FamilyMember>;
677
+ allMemberIds: readonly string[];
678
+ defaultPreviewWidths?: PreviewWidths;
679
+ apiLabel: string;
680
+ resolvesAddress: (name: string) => boolean;
681
+ openRows: ReadonlySet<string>;
682
+ onToggleRow: (id: string) => void;
683
+ registerNode: (id: string, node: View | null) => void;
684
+ }) {
685
+ if (sections.length === 0) return null;
686
+ return (
687
+ <View style={styles.labeledGroup}>
688
+ {/* Spec §5.2 "Heading semantics": "Parts"/"Related" are aria-level 2 headings — one level
689
+ under the page title (1). Slice D1 removed the whole's own heading, so this level is no
690
+ longer shared with it. */}
691
+ <Text role="heading" {...({ 'aria-level': 2 } as Record<string, unknown>)} style={styles.sectionLabel}>
692
+ {label}
693
+ </Text>
694
+ <View style={styles.labeledGroupBody}>
695
+ {sections.map((section, index) => (
696
+ // V2c item 14: keyed by index, not the section's own label — two sections sharing a
697
+ // label (an already-reported, degenerate shape) must never collide as React keys.
698
+ <FamilySectionGroup
699
+ key={index}
700
+ section={section}
701
+ // V2c item 2: a section listing an id that isn't a real member (already reported by
702
+ // `validateFamily`) must degrade to a missing block, never a crash reading `byId.get(id)!`.
703
+ members={section.members.flatMap((id) => { const member = byId.get(id); return member ? [member] : []; })}
704
+ outline={outline}
705
+ allMemberIds={allMemberIds}
706
+ defaultPreviewWidths={defaultPreviewWidths}
707
+ apiLabel={apiLabel}
708
+ resolvesAddress={resolvesAddress}
709
+ openRows={openRows}
710
+ onToggleRow={onToggleRow}
711
+ registerNode={registerNode}
712
+ />
713
+ ))}
714
+ </View>
715
+ </View>
716
+ );
717
+ }
718
+
719
+ /** Slice D1 item 5: the family page's own member records and computed outline, read by `AnatomyPin`
720
+ * so a page's `anatomy.picture` (author-supplied markup, possibly arbitrarily positioned) never
721
+ * hardcodes a number. `undefined` outside a family page's own anatomy box. */
722
+ const FamilyAnatomyContext = createContext<{ byId: ReadonlyMap<string, FamilyMember>; outline: FamilyDef['outline'] } | undefined>(undefined);
723
+
724
+ /** Slice D1 item 5: a pin for use inside `anatomy.picture`. `member` names one of this page's
725
+ * members and shows its number (dashed when it has no area of its own), looked up through
726
+ * `FamilyAnatomyContext` — renders nothing for an unknown member, one with no number (D2 item A2:
727
+ * already a `validateFamily` issue), or outside a family page's own anatomy box (no context).
728
+ * `other` marks a component documented on another page with the unnumbered ↗ pin the legend
729
+ * uses, and always renders it — it needs no context, with or without a family page in scope. */
730
+ export function AnatomyPin({ member, other }: { member?: string; other?: string }) {
731
+ const context = useContext(FamilyAnatomyContext);
732
+ if (other !== undefined) {
733
+ return (
734
+ <View style={styles.extPin} {...ARIA_HIDDEN}>
735
+ <Text style={styles.extPinText}>↗</Text>
736
+ </View>
737
+ );
738
+ }
739
+ const pin = context && member !== undefined ? anatomyPinFor(member, context.byId, context.outline) : undefined;
740
+ if (!pin) return null;
741
+ return <Pin number={pin.number} dashed={pin.dashed} />;
742
+ }
743
+
744
+ /** Slice D1 item 5: one legend row naming a member of this page — a pin (looked up the same way
745
+ * `AnatomyPin` does), its name linked to the member's address, and the row's own description. */
746
+ function AnatomyMemberRow({ memberId, description, byId, outline }: { memberId: string; description: string; byId: ReadonlyMap<string, FamilyMember>; outline: FamilyDef['outline'] }) {
747
+ const pin = anatomyPinFor(memberId, byId, outline);
748
+ return (
749
+ <View style={styles.legendRow}>
750
+ <View style={styles.legendPinSlot}>{pin && <Pin number={pin.number} dashed={pin.dashed} />}</View>
751
+ <View style={styles.legendBody}>
752
+ <Text role="link" style={styles.legendName} {...({ href: hashForId(memberId) } as Record<string, unknown>)}>
753
+ {memberId}
754
+ </Text>
755
+ <Text style={styles.legendDesc}>{description}</Text>
756
+ </View>
757
+ </View>
758
+ );
759
+ }
760
+
761
+ /** Slice D1 item 5: one legend row naming a component documented elsewhere — never numbered; its
762
+ * own name links only when it resolves to a real catalog address. */
763
+ function AnatomyOtherRow({ name, description, resolvesAddress }: { name: string; description: string; resolvesAddress: (name: string) => boolean }) {
764
+ const resolved = resolvesAddress(name);
765
+ return (
766
+ <View style={styles.legendRow}>
767
+ <View style={styles.legendPinSlot} {...ARIA_HIDDEN}>
768
+ <View style={styles.extPin}>
769
+ <Text style={styles.extPinText}>↗</Text>
770
+ </View>
771
+ </View>
772
+ <View style={styles.legendBody}>
773
+ <View style={styles.legendNameRow}>
774
+ {resolved ? (
775
+ <Text role="link" style={styles.legendName} {...({ href: hashForId(name) } as Record<string, unknown>)}>
776
+ {name}
777
+ </Text>
778
+ ) : (
779
+ <Text style={styles.legendName}>{name}</Text>
780
+ )}
781
+ <Text style={styles.legendExt}>Other page</Text>
782
+ </View>
783
+ <Text style={styles.legendDesc}>{description}</Text>
784
+ </View>
785
+ </View>
786
+ );
787
+ }
788
+
789
+ function AnatomyGroup({ group, byId, outline, resolvesAddress }: { group: CatalogAnatomyGroup; byId: ReadonlyMap<string, FamilyMember>; outline: FamilyDef['outline']; resolvesAddress: (name: string) => boolean }) {
790
+ return (
791
+ <View>
792
+ <Text style={styles.legendGroupLabel}>{group.label}</Text>
793
+ {group.items.map((item, i) =>
794
+ 'member' in item ? (
795
+ <AnatomyMemberRow key={i} memberId={item.member} description={item.description} byId={byId} outline={outline} />
796
+ ) : (
797
+ <AnatomyOtherRow key={i} name={item.other} description={item.description} resolvesAddress={resolvesAddress} />
798
+ ),
799
+ )}
800
+ </View>
801
+ );
802
+ }
803
+
804
+ /** Slice D1 item 5: the anatomy box — always first (right after the page head), a plain-card
805
+ * "Anatomy" label above the picture (the same label-outside-card treatment the specimen grids
806
+ * below it use), and a legend beside it (six items or fewer) or below it in up to three balanced
807
+ * columns (more than six). */
808
+ function AnatomyBox({ anatomy, byId, outline, resolvesAddress }: { anatomy: CatalogAnatomy; byId: ReadonlyMap<string, FamilyMember>; outline: FamilyDef['outline']; resolvesAddress: (name: string) => boolean }) {
809
+ const totalItems = anatomy.groups.reduce((sum, group) => sum + group.items.length, 0);
810
+ const stacked = totalItems > 6;
811
+ const contextValue = useMemo(() => ({ byId, outline }), [byId, outline]);
812
+ return (
813
+ <FamilyAnatomyContext.Provider value={contextValue}>
814
+ <View style={styles.anatomy}>
815
+ <Text style={styles.anatomyHeadText}>Anatomy</Text>
816
+ <View style={styles.anatomyCard}>
817
+ {stacked ? (
818
+ <View>
819
+ <View style={styles.anatomyStage}>{anatomy.picture}</View>
820
+ <View style={styles.anatomyColumns}>
821
+ {anatomyColumns(anatomy.groups, 3).map((column, i) => (
822
+ <View key={i} style={styles.anatomyColumn}>
823
+ {column.map((group, j) => (
824
+ <AnatomyGroup key={j} group={group} byId={byId} outline={outline} resolvesAddress={resolvesAddress} />
825
+ ))}
826
+ </View>
827
+ ))}
828
+ </View>
829
+ </View>
830
+ ) : (
831
+ <View style={styles.anatomyBody}>
832
+ <View style={styles.anatomyStage}>{anatomy.picture}</View>
833
+ <View style={styles.anatomyLegend}>
834
+ {anatomy.groups.map((group, i) => (
835
+ <AnatomyGroup key={i} group={group} byId={byId} outline={outline} resolvesAddress={resolvesAddress} />
836
+ ))}
837
+ </View>
838
+ </View>
839
+ )}
840
+ </View>
841
+ </View>
842
+ </FamilyAnatomyContext.Provider>
843
+ );
844
+ }
845
+
846
+ /** Scrolls a member's node into view and moves focus to it (spec §5.3 "Member address"). The jump
847
+ * is always instant, like a normal anchor link: a smooth `scrollIntoView` does not reliably move
848
+ * the shell's nested scroll container on web (the same limitation `CatalogShell`'s
849
+ * `resetMainScroll` works around), and an instant jump also satisfies Reduce Motion. Native has
850
+ * no scroll-into-view of its own; `AccessibilityInfo`'s focus call is enough there. */
851
+ function scrollAndFocusMember(node: View): void {
852
+ if (Platform.OS === 'web') {
853
+ const dom = node as unknown as { scrollIntoView?: (options: { behavior: 'auto'; block: 'start' }) => void; focus?: (options?: { preventScroll?: boolean }) => void };
854
+ dom.scrollIntoView?.({ behavior: 'auto', block: 'start' });
855
+ dom.focus?.({ preventScroll: true });
856
+ return;
857
+ }
858
+ const handle = findNodeHandle(node as unknown as View);
859
+ if (handle != null) AccessibilityInfo.setAccessibilityFocus(handle);
860
+ }
861
+
862
+ /**
863
+ * A family page (spec §5, Slice D1): the page head, the anatomy box (if `anatomy` is set), the
864
+ * whole's own specimens with no header of its own, then Parts and Related — the only two section
865
+ * labels (item 10; authored `section.label`s stay in the data, never on screen). Renders exactly
866
+ * like a normal page's head (breadcrumb, title, summary, pager) when `def.family` is set, in place
867
+ * of `SectionBlock`'s usual single-component layout (`CatalogShell`).
868
+ */
869
+ export function FamilyPage<TId extends string>({
870
+ def,
871
+ groupLabel,
872
+ breadcrumbRoot,
873
+ banner,
874
+ pager,
875
+ headingRef,
876
+ defaultPreviewWidths,
877
+ focusMemberId,
878
+ focusNonce,
879
+ knownAddressIds,
880
+ }: {
881
+ def: SectionDef<TId>;
882
+ groupLabel?: string;
883
+ breadcrumbRoot?: string;
884
+ banner?: React.ReactNode;
885
+ pager?: SectionPager<TId>;
886
+ headingRef?: React.Ref<View>;
887
+ defaultPreviewWidths?: PreviewWidths;
888
+ /** Spec §5.3 "Member address": the member to scroll to, open (if it's a folded row) and focus
889
+ * once this page has rendered — set by `CatalogShell` from `resolveRoute`'s `member` route.
890
+ * `undefined` for a plain family-address visit, which focuses the page head instead, like any
891
+ * other page. */
892
+ focusMemberId?: string;
893
+ /** V2c item 11: bumped by `CatalogShell` on every activation of `focusMemberId`, even a repeat
894
+ * one — a plain string/value comparison would otherwise never re-trigger the jump for
895
+ * re-selecting the same member address twice in a row. */
896
+ focusNonce?: number;
897
+ /** V2d item 3: every real catalog address outside this family — every other page's own id, plus
898
+ * every member id/alias `catalogNavigation.ts`'s `aliasTargets` already resolves — so a
899
+ * member's `composedOf` can link to a name belonging to a different family, or a plain
900
+ * component/page, not only to a sibling member of this same family (`allMemberIds`, always
901
+ * included below regardless of this prop). `CatalogShell` passes its own catalog-wide set;
902
+ * omitted when `FamilyPage` is used standalone (e.g. this very page's own live demo), where
903
+ * only this family's own members can resolve. */
904
+ knownAddressIds?: ReadonlySet<string>;
905
+ }) {
906
+ const family = def.family!;
907
+ const apiLabel = family.apiLabel ?? 'API';
908
+ const byId = useMemo(() => new Map(family.members.map((member) => [member.id, member])), [family.members]);
909
+ // N7: a `whole` that isn't one of its own members (bad data, already reported by
910
+ // `validateFamily`) must degrade to a missing block, never a crash reading an absent member.
911
+ const wholeMember = resolveWhole(byId, family.whole);
912
+ const partSections = useMemo(() => family.sections.filter((section) => section.inWhole), [family.sections]);
913
+ const relatedSections = useMemo(() => family.sections.filter((section) => !section.inWhole), [family.sections]);
914
+ const allMemberIds = useMemo(() => family.members.map((member) => member.id), [family.members]);
915
+ const resolvesAddress = useMemo(() => {
916
+ const ids = new Set<string>([...(knownAddressIds ?? []), ...allMemberIds]);
917
+ return (name: string) => ids.has(name);
918
+ }, [knownAddressIds, allMemberIds]);
919
+
920
+ const [openRows, setOpenRows] = useState<ReadonlySet<string>>(() => new Set());
921
+ const toggleRow = (id: string) => setOpenRows((prev) => (prev.has(id) ? new Set([...prev].filter((x) => x !== id)) : new Set(prev).add(id)));
922
+
923
+ const nodesRef = useRef<Map<string, View | null>>(new Map());
924
+ const registerNode = (id: string, node: View | null) => {
925
+ if (node) nodesRef.current.set(id, node);
926
+ else nodesRef.current.delete(id);
927
+ };
928
+
929
+ // V2c item 1 (one-shot jump) + item 11 (repeat activation): `jumpRef` tracks how many scroll
930
+ // passes are still owed to the CURRENT activation of `focusMemberId` (keyed by `focusNonce`, so
931
+ // re-selecting the same member again still counts as a fresh activation). Opening a folded row
932
+ // (so it has something to scroll to) is kept separate from the scroll/focus effect below (V2b
933
+ // item 8) so a row that needs opening first gets a *second* run of that effect, after React has
934
+ // committed its now-open (and taller) layout, rather than scrolling against the still-closed one.
935
+ const jumpRef = useRef<MemberJumpState | undefined>(undefined);
936
+ useEffect(() => {
937
+ if (!focusMemberId) return;
938
+ const owningSection = sectionFor(family.sections, focusMemberId);
939
+ // Two passes only when this activation actually opens a currently-closed row (so its layout
940
+ // really does change between them) — a repeat activation (V2c item 11) of an already-open row,
941
+ // or any block member, only ever needs the one pass, so a later, unrelated toggle never finds
942
+ // a stray second pass still owed.
943
+ const needsOpen = owningSection?.kind === 'list' && !openRows.has(focusMemberId);
944
+ jumpRef.current = { memberId: focusMemberId, passesLeft: needsOpen ? 2 : 1 };
945
+ if (owningSection?.kind === 'list') setOpenRows((prev) => new Set(prev).add(focusMemberId));
946
+ // eslint-disable-next-line react-hooks/exhaustive-deps
947
+ }, [focusMemberId, focusNonce]);
948
+
949
+ useEffect(() => {
950
+ if (!focusMemberId || !shouldRunMemberJump(jumpRef.current, focusMemberId)) return;
951
+ const node = nodesRef.current.get(focusMemberId);
952
+ if (node) scrollAndFocusMember(node);
953
+ // Re-running on `openRows` is what makes the re-run above matter: the first pass (the row
954
+ // still closed) and the second (now open) both scroll, and only the second's measurement —
955
+ // taken from the committed, open layout — is the one that actually lands near the top.
956
+ // `focusNonce` (V2c item 11) is also a dependency so a repeat activation of a block member (or
957
+ // an already-open row — see the previous effect's `needsOpen`) still re-runs this even when
958
+ // `openRows` itself never changes. Once every owed pass has run, `shouldRunMemberJump` stops
959
+ // this from firing again — toggling any row afterwards must never scroll or move focus again.
960
+ jumpRef.current = { memberId: focusMemberId, passesLeft: jumpRef.current!.passesLeft - 1 };
961
+ // eslint-disable-next-line react-hooks/exhaustive-deps
962
+ }, [focusMemberId, openRows, focusNonce]);
963
+
964
+ return (
965
+ <View>
966
+ {groupLabel && <Text style={styles.breadcrumb}>{breadcrumbRoot ? `${breadcrumbRoot} / ${groupLabel}` : groupLabel}</Text>}
967
+ {banner}
968
+ <View style={styles.pageHead}>
969
+ <View style={styles.titlebar}>
970
+ <View style={styles.titleText}>
971
+ <View ref={headingRef} tabIndex={-1} role="heading" {...({ 'aria-level': 1 } as Record<string, unknown>)} style={styles.headingTarget}>
972
+ <Text style={styles.title}>{def.title ?? def.id}</Text>
973
+ </View>
974
+ <Text style={styles.desc}>{def.description}</Text>
975
+ {family.notShown && (
976
+ <Text style={styles.notShown}>
977
+ <Text style={styles.notShownLead}>Not shown in the browser: </Text>
978
+ {family.notShown}
979
+ </Text>
980
+ )}
981
+ </View>
982
+ {pager && <SectionPagerRow pager={pager} />}
983
+ </View>
984
+ </View>
985
+
986
+ {family.anatomy && <AnatomyBox anatomy={family.anatomy} byId={byId} outline={family.outline} resolvesAddress={resolvesAddress} />}
987
+
988
+ {wholeMember && (
989
+ <View style={styles.whole} ref={(node) => registerNode(wholeMember.id, node)} tabIndex={-1}>
990
+ {(() => {
991
+ const surface = wholeMember.specimenSurface ?? DEFAULT_SPECIMEN_SURFACE;
992
+ const wholeDef = memberAsSectionDef(wholeMember);
993
+ const blocks = presentationBlocks(wholeDef, { defaultPreviewWidths }).filter((block) => block.kind !== 'empty');
994
+ return (
995
+ <>
996
+ {blocks.length > 0 && (
997
+ <View style={styles.memberSpecimens}>
998
+ <Blocks blocks={blocks} def={wholeDef} surface={surface} showLabels={false} trimOuterMargin />
999
+ </View>
1000
+ )}
1001
+ {wholeMember.notShown && <Text style={styles.belowLine}>{wholeMember.notShown}</Text>}
1002
+ <WholeBelowLine name={wholeMember.id} fidelity={wholeMember.fidelity} source={wholeMember.source} />
1003
+ <View style={styles.disclosureSpacer}>
1004
+ <MemberDisclosureToggle member={wholeMember} apiLabel={apiLabel} resolvesAddress={resolvesAddress} />
1005
+ </View>
1006
+ </>
1007
+ );
1008
+ })()}
1009
+ </View>
1010
+ )}
1011
+
1012
+ <FamilyLabeledGroup
1013
+ label="Parts"
1014
+ sections={partSections}
1015
+ outline={family.outline}
1016
+ byId={byId}
1017
+ allMemberIds={allMemberIds}
1018
+ defaultPreviewWidths={defaultPreviewWidths}
1019
+ apiLabel={apiLabel}
1020
+ resolvesAddress={resolvesAddress}
1021
+ openRows={openRows}
1022
+ onToggleRow={toggleRow}
1023
+ registerNode={registerNode}
1024
+ />
1025
+ <FamilyLabeledGroup
1026
+ label="Related"
1027
+ sections={relatedSections}
1028
+ outline={family.outline}
1029
+ byId={byId}
1030
+ allMemberIds={allMemberIds}
1031
+ defaultPreviewWidths={defaultPreviewWidths}
1032
+ apiLabel={apiLabel}
1033
+ resolvesAddress={resolvesAddress}
1034
+ openRows={openRows}
1035
+ onToggleRow={toggleRow}
1036
+ registerNode={registerNode}
1037
+ />
1038
+ </View>
1039
+ );
1040
+ }
1041
+
1042
+ const PART_GAP = 64;
1043
+ const LABEL_GAP = 80;
1044
+ const PIN_GUTTER = 52;
1045
+
1046
+ const styles = StyleSheet.create({
1047
+ breadcrumb: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted },
1048
+ pageHead: { paddingBottom: CATALOG_SPACE.xl, marginBottom: CATALOG_SPACE['2xl'], borderBottomWidth: 1, borderBottomColor: CATALOG_COLOR.borderStrong },
1049
+ titlebar: { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', gap: CATALOG_SPACE.xl, marginTop: 14 },
1050
+ titleText: { flex: 1 },
1051
+ headingTarget: { alignSelf: 'flex-start' },
1052
+ title: { ...CATALOG_TEXT.pageTitle, color: CATALOG_COLOR.text, marginBottom: 5 },
1053
+ desc: { ...CATALOG_TEXT.body, color: CATALOG_COLOR.textMuted, maxWidth: 700 },
1054
+ notShown: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted, maxWidth: 700, marginTop: CATALOG_SPACE.sm },
1055
+ notShownLead: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.text },
1056
+
1057
+ // Slice D1 item 5: the anatomy box's own "Anatomy" label sits outside, above a plain card —
1058
+ // the same treatment the specimen grids below it use (SectionBlock's blockLabel and
1059
+ // ComparisonList's card).
1060
+ anatomy: { marginTop: CATALOG_SPACE.xl, marginBottom: CATALOG_LAYOUT.blockGap },
1061
+ anatomyCard: { backgroundColor: CATALOG_COLOR.surface, borderWidth: 1, borderColor: CATALOG_COLOR.borderStrong, borderRadius: CATALOG_RADIUS.card, overflow: 'hidden' },
1062
+ anatomyHeadText: { ...CATALOG_TEXT.label, color: CATALOG_COLOR.text, marginBottom: CATALOG_LAYOUT.blockLabelGap },
1063
+ anatomyBody: { flexDirection: 'row', alignItems: 'flex-start' },
1064
+ anatomyStage: { padding: CATALOG_SPACE.xl, alignItems: 'center', justifyContent: 'center' },
1065
+ anatomyLegend: { flex: 1, borderLeftWidth: 1, borderLeftColor: CATALOG_COLOR.border, paddingVertical: CATALOG_SPACE.sm },
1066
+ anatomyColumns: { flexDirection: 'row', borderTopWidth: 1, borderTopColor: CATALOG_COLOR.border },
1067
+ anatomyColumn: { flex: 1, borderLeftWidth: 1, borderLeftColor: CATALOG_COLOR.border, paddingVertical: CATALOG_SPACE.sm },
1068
+ // Host-authored text (`anatomy.groups[].label`) — never a transforming role like `microLabel`,
1069
+ // so the author's own casing survives; see tokens.ts's role table.
1070
+ legendGroupLabel: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.textMuted, paddingHorizontal: CATALOG_SPACE.lg, paddingTop: CATALOG_SPACE.md, paddingBottom: CATALOG_SPACE.xs },
1071
+ legendRow: { flexDirection: 'row', alignItems: 'flex-start', gap: CATALOG_SPACE.sm, paddingHorizontal: CATALOG_SPACE.lg, paddingVertical: CATALOG_SPACE.sm, borderTopWidth: 1, borderTopColor: CATALOG_COLOR.border },
1072
+ legendPinSlot: { width: 34, alignItems: 'center' },
1073
+ legendBody: { flex: 1, gap: 2 },
1074
+ legendNameRow: { flexDirection: 'row', alignItems: 'baseline', gap: CATALOG_SPACE.xs },
1075
+ legendName: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.text },
1076
+ legendDesc: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted },
1077
+ legendExt: { ...CATALOG_TEXT.microLabel, color: CATALOG_COLOR.textMuted },
1078
+ extPin: { minWidth: 22, height: 22, paddingHorizontal: 6, borderRadius: 11, borderWidth: 1.5, borderColor: CATALOG_COLOR.accent, backgroundColor: CATALOG_COLOR.surface, alignItems: 'center', justifyContent: 'center' },
1079
+ extPinText: { ...CATALOG_TEXT.microLabel, color: CATALOG_COLOR.accent },
1080
+
1081
+ // Slice D1 item 7: a filled accent pin; dashed when the member has no area of its own
1082
+ // (documentation-only).
1083
+ pin: { minWidth: 22, height: 22, paddingHorizontal: 6, borderRadius: 11, backgroundColor: CATALOG_COLOR.accent, alignItems: 'center', justifyContent: 'center' },
1084
+ pinDashed: { backgroundColor: CATALOG_COLOR.surface, borderWidth: 1.5, borderColor: CATALOG_COLOR.accent, borderStyle: 'dashed' },
1085
+ pinText: { ...CATALOG_TEXT.microLabel, color: CATALOG_COLOR.surface },
1086
+ pinTextDashed: { color: CATALOG_COLOR.accent },
1087
+
1088
+ // Same body rhythm as a part (`partBody`), so the whole's disclosure sits below its line too.
1089
+ whole: { marginTop: CATALOG_SPACE.xl, gap: CATALOG_SPACE.sm },
1090
+
1091
+ partHeadRow: { flexDirection: 'row', alignItems: 'center' },
1092
+ // Slice D1 item 7: the fixed 52px gutter for a Part's pin and tree line.
1093
+ pinBox: { width: PIN_GUTTER, alignItems: 'flex-start' },
1094
+ partHeadMain: { flex: 1 },
1095
+ // D2 item A1: the body's own rhythm gap applies to every member (numbered or not); only the
1096
+ // gutter indent is conditional on `numbered`, so a Related member (never numbered) still gets
1097
+ // the same spacing as a numbered Part's own body.
1098
+ partBody: { gap: CATALOG_SPACE.sm },
1099
+ partBodyIndented: { marginLeft: PIN_GUTTER },
1100
+
1101
+ // Slice D1 item 7 "Tree line": a child's whole block (its pin and all) sits one more 52px gutter
1102
+ // right of its parent, so the parent's own pin-to-body gutter and the child's nesting read as
1103
+ // the same 52px step; the elbow itself is drawn in `Branch`'s own measured absolute overlay.
1104
+ branch: { position: 'relative' },
1105
+ subparts: { marginTop: PART_GAP, marginLeft: PIN_GUTTER, gap: PART_GAP },
1106
+ treeLine: { position: 'absolute', borderLeftWidth: 2, borderBottomWidth: 2, borderColor: CATALOG_COLOR.treeLine, borderBottomLeftRadius: 24 },
1107
+
1108
+ memberHead: { flexDirection: 'row', alignItems: 'baseline', gap: CATALOG_SPACE.sm, flexWrap: 'wrap' },
1109
+ memberName: { color: CATALOG_COLOR.text },
1110
+ memberDesc: { ...CATALOG_TEXT.body, color: CATALOG_COLOR.textMuted, maxWidth: 700, marginTop: CATALOG_SPACE.xs },
1111
+ // V2b item 5: `partBody`'s own gap already contributes CATALOG_SPACE.sm after this wrapper; this
1112
+ // adds just enough (lg - sm) on top of it so the total gap to the next line is exactly
1113
+ // CATALOG_SPACE.lg, instead of double-stacking with `Blocks`' own page-level marginBottom.
1114
+ memberSpecimens: { marginTop: CATALOG_SPACE.sm, marginBottom: CATALOG_SPACE.lg - CATALOG_SPACE.sm },
1115
+ belowLine: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted, maxWidth: 700 },
1116
+ relatedLink: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.accent },
1117
+ wholeBelowRow: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.xs, marginTop: CATALOG_SPACE.sm, flexWrap: 'wrap' },
1118
+ wholeName: { ...CATALOG_TEXT.codeSmallStrong, color: CATALOG_COLOR.text },
1119
+ // V2d item 3: v8's own `.uses` line, inside the disclosure.
1120
+ usesLine: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.text, maxWidth: 700 },
1121
+ usesCode: { ...CATALOG_TEXT.codeSmall, color: CATALOG_COLOR.text },
1122
+
1123
+ badge: {
1124
+ alignSelf: 'center',
1125
+ paddingHorizontal: CATALOG_SPACE.sm,
1126
+ paddingVertical: 3,
1127
+ borderRadius: CATALOG_RADIUS.control,
1128
+ borderWidth: 1,
1129
+ borderColor: CATALOG_COLOR.borderStrong,
1130
+ backgroundColor: CATALOG_COLOR.surface,
1131
+ },
1132
+ badgeMuted: { backgroundColor: CATALOG_COLOR.surfaceMuted },
1133
+ badgeText: { ...CATALOG_TEXT.microLabel, color: CATALOG_COLOR.text },
1134
+ badgeTextMuted: { color: CATALOG_COLOR.textMuted },
1135
+
1136
+ // V2c item 8: the same visual `SectionBlock`'s own page-level OS badge uses.
1137
+ osBadge: {
1138
+ alignSelf: 'center',
1139
+ paddingHorizontal: CATALOG_SPACE.sm,
1140
+ paddingVertical: 3,
1141
+ borderRadius: CATALOG_RADIUS.control,
1142
+ borderWidth: 1,
1143
+ borderColor: CATALOG_COLOR.borderStrong,
1144
+ backgroundColor: CATALOG_COLOR.surface,
1145
+ },
1146
+ osBadgeText: { ...CATALOG_TEXT.microLabel, color: CATALOG_COLOR.text },
1147
+
1148
+ // V2c item 4: the same `focused && styles.focusRing` pattern every other catalog control uses
1149
+ // (`PagerButton`, `CatalogSidebar`'s nav/footer links) — outline-based, so focusing never shifts
1150
+ // layout.
1151
+ focusRing: { outlineWidth: CATALOG_LAYOUT.focusRingWidth, outlineStyle: 'solid', outlineColor: CATALOG_COLOR.focusRing },
1152
+
1153
+ // Slice D1 item 9: "Notes and API" is now 12/18 regular in the accent color, 4px under the line
1154
+ // below the specimens — `partBody`'s own CATALOG_SPACE.sm gap already contributes 8px, so this
1155
+ // subtracts the difference (sm - xs = 4) the same way `memberSpecimens` does above.
1156
+ disclosureSpacer: { marginTop: CATALOG_SPACE.xs - CATALOG_SPACE.sm },
1157
+ disclosureToggle: {
1158
+ flexDirection: 'row',
1159
+ alignItems: 'center',
1160
+ gap: CATALOG_SPACE.xs,
1161
+ alignSelf: 'flex-start',
1162
+ minHeight: CATALOG_LAYOUT.controlSize,
1163
+ justifyContent: 'center',
1164
+ },
1165
+ chevron: { ...CATALOG_TEXT.icon, color: CATALOG_COLOR.accent },
1166
+ disclosureToggleLabel: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.accent },
1167
+ disclosureBody: { gap: CATALOG_SPACE.sm, paddingTop: CATALOG_SPACE.xs },
1168
+ note: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.text },
1169
+ disclosureText: { ...CATALOG_TEXT.body, color: CATALOG_COLOR.textMuted, maxWidth: 700 },
1170
+ disclosureLead: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.text },
1171
+ apiBlock: { backgroundColor: CATALOG_COLOR.codeSurface, borderRadius: CATALOG_RADIUS.md, padding: CATALOG_SPACE.md, marginTop: CATALOG_SPACE.xs },
1172
+ apiText: { ...CATALOG_TEXT.codeSmall, color: CATALOG_COLOR.codeOnSurface },
1173
+
1174
+ // Slice D1 item 4: "Parts"/"Related" are the only two section labels — 80px above each, 64px
1175
+ // between each authored section's own group within a bucket.
1176
+ labeledGroup: { marginTop: LABEL_GAP, paddingTop: CATALOG_SPACE.xl, borderTopWidth: 1, borderTopColor: CATALOG_COLOR.border },
1177
+ sectionLabel: { ...CATALOG_TEXT.label, color: CATALOG_COLOR.textMuted, marginBottom: CATALOG_SPACE.lg },
1178
+ labeledGroupBody: { gap: PART_GAP },
1179
+ blocksSection: { gap: PART_GAP },
1180
+
1181
+ foldedList: { backgroundColor: CATALOG_COLOR.surface, borderWidth: 1, borderColor: CATALOG_COLOR.borderStrong, borderRadius: CATALOG_RADIUS.card, overflow: 'hidden' },
1182
+ foldedListRow: { paddingHorizontal: CATALOG_SPACE.lg, paddingVertical: CATALOG_SPACE.md },
1183
+ foldedListRowDivider: { borderBottomWidth: 1, borderBottomColor: CATALOG_COLOR.border },
1184
+ foldedRow: { gap: CATALOG_SPACE.xs },
1185
+ foldedRowToggle: { minHeight: CATALOG_LAYOUT.controlSize, justifyContent: 'center' },
1186
+ foldedRowHead: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.sm },
1187
+ foldedRowBody: { gap: CATALOG_SPACE.sm, paddingTop: CATALOG_SPACE.sm },
1188
+ });