@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.
@@ -1,5 +1,5 @@
1
1
  import React, { useEffect, useMemo, useState } from 'react';
2
- import { View, Text, Pressable, StyleSheet, type LayoutChangeEvent } from 'react-native';
2
+ import { View, Text, Pressable, StyleSheet, type LayoutChangeEvent, type TextStyle } from 'react-native';
3
3
  import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TEXT } from './tokens';
4
4
  import { ComparisonGrid } from './ComparisonGrid';
5
5
  import { ComparisonGroups } from './ComparisonGroups';
@@ -53,7 +53,7 @@ export interface SectionPager<TId extends string> {
53
53
  labelOf?: (id: TId) => string;
54
54
  }
55
55
 
56
- function PagerButton<TId extends string>({
56
+ export function PagerButton<TId extends string>({
57
57
  direction,
58
58
  targetId,
59
59
  onNavigate,
@@ -209,7 +209,40 @@ function placementBlock(block: PresentationBlock, height: number): PlacementBloc
209
209
  * are never recorded while side by side, so the decision never feeds on its own result; a width
210
210
  * change returns to stacked and decides again.
211
211
  */
212
- function Blocks<TId extends string>({ blocks, def, surface }: { blocks: PresentationBlock[]; def: SectionDef<TId>; surface: SpecimenSurfaceKind }) {
212
+ /** The previous/next pager buttons, in their row — shared by `SectionBlock` and `FamilyPage` so a
213
+ * family page's own page head gets the same pager chrome as every other page. */
214
+ export function SectionPagerRow<TId extends string>({ pager }: { pager: SectionPager<TId> }) {
215
+ return (
216
+ <View style={styles.pager}>
217
+ <PagerButton direction="previous" targetId={pager.previousId} onNavigate={pager.onNavigate} labelOf={pager.labelOf} />
218
+ <PagerButton direction="next" targetId={pager.nextId} onNavigate={pager.onNavigate} labelOf={pager.labelOf} />
219
+ </View>
220
+ );
221
+ }
222
+
223
+ /** The page's specimen blocks (grid, grouped rows, list, or preview), stacked or side by side —
224
+ * exported so `FamilyPage` can render a member's own specimens through the exact same placement
225
+ * logic a normal page's blocks use, rather than a second copy of it. */
226
+ export function Blocks<TId extends string>({
227
+ blocks,
228
+ def,
229
+ surface,
230
+ showLabels = true,
231
+ trimOuterMargin = false,
232
+ }: {
233
+ blocks: PresentationBlock[];
234
+ def: SectionDef<TId>;
235
+ surface: SpecimenSurfaceKind;
236
+ /** V2b item 4: a family member's own specimens render with no uppercase "Variants" / "States /
237
+ * configurations" block label — v8 has none inside a member block. A page's own `SectionBlock`
238
+ * keeps its labels (the default, unchanged). */
239
+ showLabels?: boolean;
240
+ /** V2b item 5: drops this component's own page-level `marginBottom` — `FamilyPage`'s own
241
+ * wrapper (`memberSpecimens`) supplies the exact spacing a member block needs instead, so the
242
+ * two never double-stack. A page's own `SectionBlock` keeps the margin (the default,
243
+ * unchanged). */
244
+ trimOuterMargin?: boolean;
245
+ }) {
213
246
  const [available, setAvailable] = useState(0);
214
247
  const [heights, setHeights] = useState<number[]>([]);
215
248
  const [placement, setPlacement] = useState<'side' | 'stacked'>('stacked');
@@ -248,15 +281,22 @@ function Blocks<TId extends string>({ blocks, def, surface }: { blocks: Presenta
248
281
 
249
282
  const side = placement === 'side';
250
283
  return (
251
- <View onLayout={onContainerLayout} style={[styles.blocks, side && styles.blocksSide]}>
284
+ <View onLayout={onContainerLayout} style={[styles.blocks, side && styles.blocksSide, trimOuterMargin && styles.blocksNoMargin]}>
252
285
  {blocks.map((block, i) => (
253
286
  <View
254
- key={block.title || block.kind}
287
+ // A4: keyed by index, not `block.title || block.kind` — a member that sets the same
288
+ // `axis` on both `variants` and `states` now produces two blocks with identical titles,
289
+ // which would otherwise collide as React keys (as the family-section list already keys
290
+ // by index for the same reason — FamilyPage.tsx).
291
+ key={`${block.kind}-${i}`}
255
292
  onLayout={onBlockLayout(i)}
256
293
  // Side by side, each block fills its own half: a card always fills the column it is given.
257
294
  style={side ? styles.blockFill : undefined}
258
295
  >
259
- {block.title ? <Text style={styles.blockLabel}>{block.title}</Text> : null}
296
+ {/* A3: `axis` now reaches grid/grouped blocks too (not only list), so any block kind
297
+ carrying one shows its (already informative) title on a family page, which otherwise
298
+ hides every generic block label via `showLabels={false}`. */}
299
+ {(showLabels && block.title) || ('axis' in block && block.axis) ? <Text style={styles.blockLabel}>{('axis' in block && block.axis) || block.title}</Text> : null}
260
300
  <BlockContent block={block} def={def} surface={surface} />
261
301
  </View>
262
302
  ))}
@@ -318,12 +358,7 @@ export function SectionBlock<TId extends string>({
318
358
  )}
319
359
  <Text style={styles.desc}>{def.description}</Text>
320
360
  </View>
321
- {pager && (
322
- <View style={styles.pager}>
323
- <PagerButton direction="previous" targetId={pager.previousId} onNavigate={pager.onNavigate} labelOf={pager.labelOf} />
324
- <PagerButton direction="next" targetId={pager.nextId} onNavigate={pager.onNavigate} labelOf={pager.labelOf} />
325
- </View>
326
- )}
361
+ {pager && <SectionPagerRow pager={pager} />}
327
362
  </View>
328
363
 
329
364
  {blocks.length > 0 && <Blocks blocks={blocks} def={def} surface={surface} />}
@@ -356,7 +391,15 @@ const styles = StyleSheet.create({
356
391
  backgroundColor: CATALOG_COLOR.surface,
357
392
  },
358
393
  osBadgeText: { ...CATALOG_TEXT.microLabel, color: CATALOG_COLOR.text },
359
- title: { ...CATALOG_TEXT.pageTitle, color: CATALOG_COLOR.text, marginBottom: 5 },
394
+ // `wordBreak` isn't declared on React Native's own `TextStyle` (react-native-web's own
395
+ // `Text/types.js` declares it; a browser applies any valid CSS property name it recognizes
396
+ // regardless — same caveat ReferenceDetails.tsx's own `mono` style documents), so this is cast
397
+ // the same way. Without it, an unbroken page title (a component identifier with no spaces,
398
+ // e.g. `TranscriptVideoPreviewPlayerView`) has no space for the browser's default line-breaking
399
+ // to use: it stays one unbroken run at its full intrinsic width — wide enough to push past
400
+ // `titleText`'s own column and into the pager — instead of wrapping inside the column like any
401
+ // title with spaces already does.
402
+ title: { ...CATALOG_TEXT.pageTitle, color: CATALOG_COLOR.text, marginBottom: 5, wordBreak: 'break-word' } as TextStyle,
360
403
  desc: { ...CATALOG_TEXT.body, color: CATALOG_COLOR.textMuted, maxWidth: 700 },
361
404
  pager: { flexDirection: 'row', gap: 6, flexShrink: 0 },
362
405
  pagerButton: {
@@ -375,6 +418,7 @@ const styles = StyleSheet.create({
375
418
  pagerGlyph: { ...CATALOG_TEXT.icon, color: CATALOG_COLOR.text },
376
419
  pagerGlyphDisabled: { color: CATALOG_COLOR.textMuted },
377
420
  blocks: { gap: CATALOG_LAYOUT.blockGap, marginBottom: CATALOG_LAYOUT.blockGap },
421
+ blocksNoMargin: { marginBottom: 0 },
378
422
  blocksSide: { flexDirection: 'row', alignItems: 'flex-start' },
379
423
  blockFill: { flex: 1, minWidth: 0 },
380
424
  // Centred in the card, like every other example (a single phone-width frame used to sit left).
@@ -3,7 +3,7 @@
3
3
  * runner. CatalogShell, CatalogSidebar, and SectionBlock all derive page order from here, so the
4
4
  * sidebar order, previous/next order, and fragment fallback can never drift apart.
5
5
  */
6
- import type { NavGroup, UpdateNotice, VersionStatus } from './types.ts';
6
+ import type { NavGroup, SectionDef, UpdateNotice, VersionStatus } from './types.ts';
7
7
 
8
8
  /** THE canonical within-group ordering of section ids. */
9
9
  export function sortIds<TId extends string>(ids: readonly TId[]): TId[] {
@@ -47,18 +47,25 @@ export function neighbors<TId extends string>(
47
47
  };
48
48
  }
49
49
 
50
- /** Sidebar filter: case-insensitive substring match, sorted ids, empty groups removed. */
50
+ /** Sidebar filter: case-insensitive substring match, sorted ids, empty groups removed. A page
51
+ * matches on its id, its title, or (spec §5.3) any of its family's member ids or aliases. */
51
52
  export function filterGroups<TId extends string>(
52
53
  groups: readonly NavGroup<TId>[],
53
54
  query: string,
54
55
  /** Display titles by id; a page matches on its id or its title. */
55
56
  labels?: ReadonlyMap<string, string>,
57
+ /** Every member id/alias a family page's members use, by page id. */
58
+ memberIds?: ReadonlyMap<string, readonly string[]>,
56
59
  ): NavGroup<TId>[] {
57
60
  const q = query.trim().toLowerCase();
58
61
  return groups
59
62
  .map((group) => ({
60
63
  label: group.label,
61
- ids: sortIds(q ? group.ids.filter((id) => id.toLowerCase().includes(q) || (labels?.get(id)?.toLowerCase().includes(q) ?? false)) : group.ids),
64
+ ids: sortIds(q ? group.ids.filter((id) =>
65
+ id.toLowerCase().includes(q)
66
+ || (labels?.get(id)?.toLowerCase().includes(q) ?? false)
67
+ || (memberIds?.get(id)?.some((memberId) => memberId.toLowerCase().includes(q)) ?? false),
68
+ ) : group.ids),
62
69
  }))
63
70
  .filter((group) => group.ids.length > 0);
64
71
  }
@@ -131,6 +138,85 @@ export function resolveActiveFromHash<TId extends string>(
131
138
  return idFromHash(hash, order);
132
139
  }
133
140
 
141
+ /** Where an alias or member id resolves: the family page `pageId`, and — unless it names the
142
+ * whole itself — the specific `memberId` within it. Built by `aliasTargets`. */
143
+ export interface AliasTarget {
144
+ pageId: string;
145
+ memberId?: string;
146
+ }
147
+
148
+ /** Every member id other than the whole, every member alias, and every family page's own
149
+ * (page-level) alias, mapped to where it resolves (spec §5.3 "Old addresses"). Pages without a
150
+ * `family` contribute nothing. */
151
+ export function aliasTargets<TId extends string>(sections: readonly SectionDef<TId>[]): Map<string, AliasTarget> {
152
+ const targets = new Map<string, AliasTarget>();
153
+ for (const section of sections) {
154
+ const family = section.family;
155
+ if (!family) continue;
156
+ for (const member of family.members) {
157
+ if (member.id === family.whole) continue;
158
+ targets.set(member.id, { pageId: section.id, memberId: member.id });
159
+ for (const alias of member.aliases ?? []) targets.set(alias, { pageId: section.id, memberId: member.id });
160
+ }
161
+ for (const alias of family.aliases ?? []) targets.set(alias, { pageId: section.id });
162
+ }
163
+ return targets;
164
+ }
165
+
166
+ /** Where a URL fragment should take the viewer (spec §5.3). Unlike `resolveActiveFromHash`
167
+ * (kept, unchanged, for existing callers), this never silently falls back to the first page: an
168
+ * unknown or malformed fragment is reported as `notFound` instead. */
169
+ export type Route<TId extends string> =
170
+ | { kind: 'page'; id: TId }
171
+ | { kind: 'member'; pageId: TId; memberId: string }
172
+ | { kind: 'update' }
173
+ | { kind: 'notFound'; raw: string }
174
+ | { kind: 'empty' };
175
+
176
+ export function resolveRoute<TId extends string>(
177
+ hash: string,
178
+ order: readonly TId[],
179
+ aliases: ReadonlyMap<string, AliasTarget>,
180
+ ): Route<TId> {
181
+ const raw = hash.startsWith('#') ? hash.slice(1) : hash;
182
+ if (raw === '') return { kind: 'empty' };
183
+ let decoded: string;
184
+ try {
185
+ decoded = decodeURIComponent(raw);
186
+ } catch {
187
+ return { kind: 'notFound', raw };
188
+ }
189
+ if (decoded === UPDATE_PAGE_ID) return { kind: 'update' };
190
+ if ((order as readonly string[]).includes(decoded)) return { kind: 'page', id: decoded as TId };
191
+ const target = aliases.get(decoded);
192
+ if (target) {
193
+ if (target.memberId !== undefined) return { kind: 'member', pageId: target.pageId as TId, memberId: target.memberId };
194
+ return { kind: 'page', id: target.pageId as TId };
195
+ }
196
+ return { kind: 'notFound', raw: decoded };
197
+ }
198
+
199
+ /** One member search candidate: a family page's member, with its owning page and family title. */
200
+ export interface MemberSearchEntry<TId extends string> {
201
+ pageId: TId;
202
+ memberId: string;
203
+ aliases?: readonly string[];
204
+ familyTitle: string;
205
+ }
206
+
207
+ /** Member search hits (spec §5.3 "Search"): every entry whose member id or alias contains the
208
+ * query, case-insensitively. The label is always the member id — the Swift declaration name. */
209
+ export function searchResults<TId extends string>(
210
+ entries: readonly MemberSearchEntry<TId>[],
211
+ query: string,
212
+ ): Array<{ pageId: TId; memberId: string; label: string; familyTitle: string }> {
213
+ const q = query.trim().toLowerCase();
214
+ if (!q) return [];
215
+ return entries
216
+ .filter((entry) => entry.memberId.toLowerCase().includes(q) || (entry.aliases ?? []).some((alias) => alias.toLowerCase().includes(q)))
217
+ .map((entry) => ({ pageId: entry.pageId, memberId: entry.memberId, label: entry.memberId, familyTitle: entry.familyTitle }));
218
+ }
219
+
134
220
  /** Design §5: "Major: ... dismissible per version; the footer line remains." `dismissedVersion`
135
221
  * is whatever a previous dismissal recorded (`CatalogShell`'s own `localStorage` read, Task 14) —
136
222
  * any value other than the *current* `update.latest` (including `null`, or an older dismissed
@@ -250,9 +250,9 @@ export interface ListGroup {
250
250
  }
251
251
 
252
252
  export type PresentationBlock =
253
- | { kind: 'grid'; title: string; size: SpecimenSize; comparison: ComparisonDef }
254
- | { kind: 'grouped'; title: string; size: SpecimenSize; groups: ListGroup[] }
255
- | { kind: 'list'; title: string; size: SpecimenSize; items: ListItem[]; maxColumns?: 1 | 2 | 3 | 4 | 5 }
253
+ | { kind: 'grid'; title: string; size: SpecimenSize; comparison: ComparisonDef; axis?: string }
254
+ | { kind: 'grouped'; title: string; size: SpecimenSize; groups: ListGroup[]; axis?: string }
255
+ | { kind: 'list'; title: string; size: SpecimenSize; items: ListItem[]; maxColumns?: 1 | 2 | 3 | 4 | 5; axis?: string }
256
256
  | { kind: 'preview'; title: string; widths: PreviewWidths; layout?: 'frames' | 'table' }
257
257
  | { kind: 'tokenSections'; title: string; sections: TokenSection[]; columns: 1 | 2 | 3 }
258
258
  | { kind: 'empty'; title: string; message: string };
@@ -340,16 +340,26 @@ export function presentationBlocks<TId extends string>(def: SectionDef<TId>, opt
340
340
  title: `${def.comparison.rowLabel} × ${def.comparison.columnLabel}`,
341
341
  size: def.comparison.size ?? def.specimenSize ?? 'regular',
342
342
  comparison: def.comparison,
343
+ // A3: `comparison` replaces the Variants list, but `def.variants.axis` (if still set) is
344
+ // the only axis label a grid has to carry onto a family page, which hides generic titles.
345
+ ...(def.variants?.axis ? { axis: def.variants.axis } : {}),
343
346
  });
344
347
  } else if (grouped && def.variants) {
345
348
  // Grouped rows place several cells beside a row header, so full-width (itemsFill) slots do
346
349
  // not force 402px cells here; a cell's own `fill` still stretches its specimen.
347
- blocks.push({ kind: 'grouped', title: 'Variant × configuration', size: def.specimenSize ?? 'regular', groups: grouped.groups });
350
+ blocks.push({
351
+ kind: 'grouped',
352
+ title: 'Variant × configuration',
353
+ size: def.specimenSize ?? 'regular',
354
+ groups: grouped.groups,
355
+ ...(def.variants.axis ? { axis: def.variants.axis } : {}),
356
+ });
348
357
  states = grouped.leftovers;
349
358
  } else if (def.variants) {
350
359
  blocks.push({
351
360
  kind: 'list',
352
- title: 'Variants',
361
+ title: def.variants.axis ?? 'Variants',
362
+ ...(def.variants.axis ? { axis: def.variants.axis } : {}),
353
363
  size: slotSize(def, def.variants),
354
364
  items: slotItems(def.variants, 'variants', def.specimenSize === 'page'),
355
365
  ...slotColumnCap(def.variants),
@@ -363,7 +373,8 @@ export function presentationBlocks<TId extends string>(def: SectionDef<TId>, opt
363
373
  const secondary = blocks.length > 0 && blocks[0].kind !== 'list';
364
374
  blocks.push({
365
375
  kind: 'list',
366
- title: secondary ? 'Other configurations' : 'States / configurations',
376
+ title: states.axis ?? (secondary ? 'Other configurations' : 'States / configurations'),
377
+ ...(states.axis ? { axis: states.axis } : {}),
367
378
  size: slotSize(def, states),
368
379
  items: slotItems(states, 'states', def.specimenSize === 'page'),
369
380
  ...slotColumnCap(states),
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Pure family-page UI logic (spec §5.1–§5.2) — no React or React Native imports, so it runs under
3
+ * Node's test runner. `FamilyPage.tsx` builds on this module rather than duplicating its own tree
4
+ * building, disclosure content, and link/pin lookups. Outline numbering (`numberFor`) and family
5
+ * validation (`validateFamily`) live in `pageApi.ts` instead — see that file's own comments.
6
+ */
7
+ import type { CatalogMember, CatalogMemberSection, PropDef } from './types.ts';
8
+
9
+ /** One node of a `blocks` section's own Part tree (Slice D1 item 7 "Tree line"). */
10
+ export interface PartTreeEntry {
11
+ id: string;
12
+ children: PartTreeEntry[];
13
+ }
14
+
15
+ /** E1: moved from `FamilyPage.tsx` so it runs under Node's test runner — pure logic that alone
16
+ * decides how a `blocks` section's own members nest and in what order its Parts render.
17
+ * Reconstructs parentage mechanically from the already-computed `numberFor` outline (a child's
18
+ * number is its parent's number plus one more `.` segment), scoped to the member ids given, not a
19
+ * second copy of `numberFor`'s own nesting decision. Scoping to one section's own ids (rather than
20
+ * every member of the page) is how family data is authored — a section's `members` already lists
21
+ * a full parent/children subtree together — so a parent belonging to a *different* section is
22
+ * simply absent from `memberIds`, and the would-be child becomes a root of this tree instead of
23
+ * nesting under a part this call never sees. */
24
+ export function buildPartTree(memberIds: readonly string[], outline: ReadonlyMap<string, { number: string; level: number }>): PartTreeEntry[] {
25
+ const idByNumber = new Map<string, string>();
26
+ for (const id of memberIds) {
27
+ const number = outline.get(id)?.number;
28
+ if (number) idByNumber.set(number, id);
29
+ }
30
+ const childrenOf = new Map<string, string[]>();
31
+ const roots: string[] = [];
32
+ for (const id of memberIds) {
33
+ const number = outline.get(id)?.number;
34
+ const parentNumber = number?.includes('.') ? number.slice(0, number.lastIndexOf('.')) : undefined;
35
+ const parentId = parentNumber ? idByNumber.get(parentNumber) : undefined;
36
+ if (parentId) childrenOf.set(parentId, [...(childrenOf.get(parentId) ?? []), id]);
37
+ else roots.push(id);
38
+ }
39
+ const build = (id: string): PartTreeEntry => ({ id, children: (childrenOf.get(id) ?? []).map(build) });
40
+ return roots.map(build);
41
+ }
42
+
43
+ /** Slice D1 item 5: an anatomy pin's number and style, looked up by member id — the pure lookup
44
+ * behind `AnatomyPin` (which reads this through React context so a page's `anatomy.picture` never
45
+ * hardcodes a number). `undefined` for an unknown member, or one with no number (outside any
46
+ * `inWhole` section, or under an unnumbered parent — `numberFor` never assigns those one;
47
+ * `validateFamily`'s D2 item A2 now reports any anatomy item naming such a member, but this stays
48
+ * defensive for a caller outside the anatomy legend/picture, which that rule doesn't cover). A
49
+ * dashed pin marks a `documentation-only` member — spec: "no area of their own". */
50
+ export function anatomyPinFor(
51
+ memberId: string,
52
+ members: ReadonlyMap<string, Pick<CatalogMember, 'fidelity'>>,
53
+ outline: ReadonlyMap<string, { number: string }>,
54
+ ): { number: string; dashed: boolean } | undefined {
55
+ const member = members.get(memberId);
56
+ if (!member) return undefined;
57
+ const entry = outline.get(memberId);
58
+ if (!entry) return undefined;
59
+ return { number: entry.number, dashed: member.fidelity === 'documentation-only' };
60
+ }
61
+
62
+ /** Distributes a legend's groups into up to `maxColumns` columns so the columns end as close to
63
+ * level as possible: each group, in authored order, goes into the column that is currently
64
+ * shortest (the leftmost on a tie). A group's height is its items plus one row for its label. A
65
+ * group never splits across columns, and the groups in each column keep their authored order. */
66
+ export function anatomyColumns<T extends { items: ReadonlyArray<unknown> }>(groups: readonly T[], maxColumns = 3): T[][] {
67
+ if (groups.length === 0) return [];
68
+ const columnCount = Math.min(maxColumns, groups.length);
69
+ const columns: T[][] = Array.from({ length: columnCount }, () => []);
70
+ const heights = new Array<number>(columnCount).fill(0);
71
+ for (const group of groups) {
72
+ let shortest = 0;
73
+ for (let i = 1; i < columnCount; i += 1) if (heights[i] < heights[shortest]) shortest = i;
74
+ columns[shortest].push(group);
75
+ heights[shortest] += group.items.length + 1;
76
+ }
77
+ return columns;
78
+ }
79
+
80
+ /** `aria-level` for a member at a given outline level: the page title is 1, so every outline
81
+ * level is shifted down by one, capped at 6 (spec §5.2 "Heading semantics"). The whole (outline
82
+ * level 1) and section labels both land on 2 this way too. */
83
+ export function ariaLevelFor(level: number): number {
84
+ return Math.min(level + 1, 6);
85
+ }
86
+
87
+ /** The disclosure's closed-state summary label (spec §5.2). V2b item 9: "Swift API" is Hermex
88
+ * wording and must not be hardcoded in a generic viewer — `apiLabel` is the page's own label
89
+ * (default `'API'`), and `hasProps` is whether the member has resolved props to show (the same
90
+ * condition `disclosureContent` uses for its own "props" part). V2d item 2: `composedOf` ("Uses")
91
+ * counts the same way `notes` already does here — either one alone (or together) gives "Notes"
92
+ * alone, or combines with api/props into "Notes and ${apiLabel}". */
93
+ export function disclosureLabel(member: CatalogMember, apiLabel = 'API', hasProps = false): string {
94
+ const hasNotesOrUses = Boolean(member.notes && member.notes.length > 0) || Boolean(member.composedOf && member.composedOf.length > 0);
95
+ const hasApiOrProps = Boolean(member.api) || hasProps;
96
+ if (hasNotesOrUses && hasApiOrProps) return `Notes and ${apiLabel}`;
97
+ if (hasApiOrProps) return apiLabel;
98
+ if (hasNotesOrUses) return 'Notes';
99
+ return 'Details';
100
+ }
101
+
102
+ export type DisclosurePart = 'notes' | 'uses' | 'whenToUse' | 'a11y' | 'props' | 'api';
103
+
104
+ /** The disclosure's content, in order (spec §5.2 "Disclosure", V2d item 2): notes, then Uses
105
+ * (`composedOf`, non-empty only), then Guidance (`whenToUse`), then Accessibility (`a11y`, unless
106
+ * `hide.accessibility`), then the generated Props table (unless `hide.props`), then the Swift
107
+ * excerpt (`api`). Omits any part whose data is absent. Errata 6: `props` is listed only when the
108
+ * caller actually has resolved props to show — `resolvedProps` is the member's own generated
109
+ * `props` (from its `component`, if any) — never merely because `hide.props` is unset; a
110
+ * documentation-only member with no component has no props to omit from, so it never shows an
111
+ * (empty) Props table. */
112
+ export function disclosureContent(member: CatalogMember, resolvedProps?: readonly PropDef[]): DisclosurePart[] {
113
+ const parts: DisclosurePart[] = [];
114
+ if (member.notes && member.notes.length > 0) parts.push('notes');
115
+ if (member.composedOf && member.composedOf.length > 0) parts.push('uses');
116
+ if (member.whenToUse) parts.push('whenToUse');
117
+ if (member.a11y && !member.hide?.accessibility) parts.push('a11y');
118
+ if (resolvedProps && resolvedProps.length > 0 && !member.hide?.props) parts.push('props');
119
+ if (member.api) parts.push('api');
120
+ return parts;
121
+ }
122
+
123
+ /** The section a member id belongs to, or `undefined` for the whole (which is in no section) or an
124
+ * unknown id. Used to decide whether addressing a member should open a folded row (its section's
125
+ * `kind` is `'list'`). */
126
+ export function sectionFor(sections: readonly CatalogMemberSection[], memberId: string): CatalogMemberSection | undefined {
127
+ return sections.find((section) => section.members.includes(memberId));
128
+ }
129
+
130
+ /** N7: the whole member, resolved from `family.whole` against `family.members` — `undefined` for
131
+ * bad data (a `whole` that isn't one of its own members, already reported by `validateFamily`),
132
+ * so `FamilyPage` can skip rendering the whole block instead of crashing on an absent member.
133
+ * Generic over the member's own shape so `FamilyPage` keeps its resolved `props`/`path` fields
134
+ * (`FamilyDef.members`'s own type) through the lookup. */
135
+ export function resolveWhole<T>(members: ReadonlyMap<string, T>, whole: string): T | undefined {
136
+ return members.get(whole);
137
+ }
138
+
139
+ /** V2c item 1 (one-shot jump) + item 11 (repeat activation): in-flight state for a member-address
140
+ * scroll/focus jump, reset fresh on every new activation (`FamilyPage`'s own effect, keyed by a
141
+ * nonce from `CatalogShell` so a repeat activation of the SAME member still counts as new).
142
+ * `passesLeft` starts at 2 for a folded (`list`) row — the row's first, still-closed layout, then
143
+ * its second, now-open one (V2b item 8) — or 1 for a block member, which has only one layout to
144
+ * scroll against. */
145
+ export interface MemberJumpState {
146
+ memberId: string;
147
+ passesLeft: number;
148
+ }
149
+
150
+ /** Whether a member-address scroll/focus pass should run right now: only while `state` still
151
+ * belongs to the member currently addressed and still has a pass left. Once every pass for the
152
+ * current activation has run, toggling an unrelated (or even the same) row afterwards — which
153
+ * changes nothing this state tracks — must never make this true again. */
154
+ export function shouldRunMemberJump(state: MemberJumpState | undefined, memberId: string): boolean {
155
+ return state !== undefined && state.memberId === memberId && state.passesLeft > 0;
156
+ }
157
+
158
+ /** One segment of a `related` sentence (spec §5.2): plain text, or a member id that should render
159
+ * as a link to that member's address (`RelatedSegment.memberId` is also that member's display
160
+ * name — see `CatalogMember.id`'s own doc comment). */
161
+ export interface RelatedSegment {
162
+ text: string;
163
+ memberId?: string;
164
+ }
165
+
166
+ /** Escapes a string for literal use inside a `RegExp` (the characters the regex engine would
167
+ * otherwise treat as metacharacters). A Swift declaration name never contains any of these, but
168
+ * this keeps `linkifyRelated` correct even if one ever did. */
169
+ function escapeRegExp(value: string): string {
170
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
171
+ }
172
+
173
+ /** Splits a `related` sentence (spec §5.1 `CatalogMember.related`) into segments, turning every
174
+ * whole-word occurrence of another member's id into a linked segment; every other run of text is
175
+ * a plain segment. Matches the longest id first, so a shorter id that is a prefix of a longer one
176
+ * (e.g. "BotAvatarView" inside "BotAvatarViewLegacy") never splits the longer one apart. Returns
177
+ * the whole text as one plain segment when there are no ids, or no match. */
178
+ export function linkifyRelated(text: string, memberIds: readonly string[]): RelatedSegment[] {
179
+ if (memberIds.length === 0) return [{ text }];
180
+ const ids = [...memberIds].sort((a, b) => b.length - a.length);
181
+ const pattern = new RegExp(`\\b(?:${ids.map(escapeRegExp).join('|')})\\b`, 'g');
182
+ const segments: RelatedSegment[] = [];
183
+ let lastIndex = 0;
184
+ let match: RegExpExecArray | null;
185
+ while ((match = pattern.exec(text))) {
186
+ if (match.index > lastIndex) segments.push({ text: text.slice(lastIndex, match.index) });
187
+ segments.push({ text: match[0], memberId: match[0] });
188
+ lastIndex = match.index + match[0].length;
189
+ }
190
+ if (lastIndex < text.length) segments.push({ text: text.slice(lastIndex) });
191
+ return segments.length > 0 ? segments : [{ text }];
192
+ }
@@ -15,7 +15,9 @@
15
15
  export { CatalogShell } from './CatalogShell';
16
16
  export { CatalogSidebar } from './CatalogSidebar';
17
17
  export { CatalogSearchInput } from './CatalogSearchInput';
18
- export { SectionBlock } from './SectionBlock';
18
+ export { SectionBlock, PagerButton, SectionPagerRow } from './SectionBlock';
19
+ export type { SectionPager } from './SectionBlock';
20
+ export { FamilyPage, AnatomyPin } from './FamilyPage';
19
21
  export { ComparisonGrid } from './ComparisonGrid';
20
22
  export { grid, axisItems, axisProp } from './comparison';
21
23
  export { ComparisonGroups } from './ComparisonGroups';
@@ -46,7 +48,8 @@ export { defineCatalogPage, buildCatalogSections } from './pageApi';
46
48
  export type { CatalogPage, CatalogPageInput, GeneratedComponent, GeneratedPropRecord } from './pageApi';
47
49
  export type {
48
50
  PropDef, SectionDef, NavGroup, SpecimenSize, SpecimenSurfaceKind, ComparisonDef, ComparisonCell, ComparisonAxisItem, GridAxis,
49
- PreviewWidths, TokenSection, ComposedOfEntry,
51
+ PreviewWidths, TokenSection, ComposedOfEntry, CatalogMember, CatalogMemberSection, FamilyDef,
52
+ CatalogAnatomy, CatalogAnatomyGroup, CatalogAnatomyItem,
50
53
  } from './types';
51
54
  export { UpdatePanel } from './UpdatePanel';
52
55
  export { UPDATE_PAGE_ID } from './catalogNavigation';
@@ -6,7 +6,7 @@
6
6
  * render) resolves an address back to the `node` it names.
7
7
  */
8
8
  import type React from 'react';
9
- import type { SectionDef } from './types.ts';
9
+ import type { ComparisonDef, SectionDef, VariantSlot } from './types.ts';
10
10
 
11
11
  /** Which block on a page an addressed specimen's `node` lives in — see `findOverlayNode`. */
12
12
  export type OverlaySlot = 'variants' | 'states' | 'comparison' | 'preview';
@@ -109,19 +109,39 @@ export function resolveOverlayViewport(args: {
109
109
  return { mode: 'frame', href: `${args.pathname}${overlayViewportHrefSuffix(args.address)}` };
110
110
  }
111
111
 
112
+ /** The slots `findOverlayNode` reads — shared by a top-level `SectionDef` and a family member
113
+ * (`CatalogMember`), which carry the exact same `variants`/`states`/`comparison`/`render` shape. */
114
+ interface SpecimenSource {
115
+ variants?: VariantSlot;
116
+ states?: VariantSlot;
117
+ comparison?: ComparisonDef;
118
+ render?: () => React.ReactNode;
119
+ }
120
+
121
+ function readSpecimenSlot(source: SpecimenSource, address: OverlayAddress): React.ReactNode | undefined {
122
+ if (address.slot === 'variants') return source.variants?.items.find((item) => item.key === address.itemKey)?.node;
123
+ if (address.slot === 'states') return source.states?.items.find((item) => item.key === address.itemKey)?.node;
124
+ // A `render()` page's preview (one per page; `itemKey` is always 'preview').
125
+ if (address.slot === 'preview') return source.render?.();
126
+ // Matched by recomputing each cell's own canonical key, never by splitting `address.itemKey` —
127
+ // a row/column key may itself contain any delimiter this module could pick.
128
+ return source.comparison?.cells.find((cell) => comparisonCellItemKey(cell.rowKey, cell.columnKey) === address.itemKey)?.node;
129
+ }
130
+
112
131
  /** The authored `node` an `OverlayAddress` points at, or `undefined` for a stale/unknown address
113
132
  * (a removed example, a renamed key, a typo) — `CatalogShell`'s chromeless selected-document
114
- * render shows an honest "unavailable" recovery rather than rendering nothing silently. */
133
+ * render shows an honest "unavailable" recovery rather than rendering nothing silently.
134
+ * V2c item 3: a specimen built inside a family member (`SectionBlock.tsx`'s `memberAsSectionDef`)
135
+ * addresses itself with the member's own id as `pageId` — that never matches a top-level
136
+ * `SectionDef.id`, so this also searches every family page's own `family.members` for it. */
115
137
  export function findOverlayNode(sections: readonly SectionDef<string>[], address: OverlayAddress): React.ReactNode | undefined {
116
138
  const def = sections.find((section) => section.id === address.pageId);
117
- if (!def) return undefined;
118
- if (address.slot === 'variants') return def.variants?.items.find((item) => item.key === address.itemKey)?.node;
119
- if (address.slot === 'states') return def.states?.items.find((item) => item.key === address.itemKey)?.node;
120
- // A `render()` page's preview (one per page; `itemKey` is always 'preview').
121
- if (address.slot === 'preview') return def.render?.();
122
- // Matched by recomputing each cell's own canonical key, never by splitting `address.itemKey` —
123
- // a row/column key may itself contain any delimiter this module could pick.
124
- return def.comparison?.cells.find((cell) => comparisonCellItemKey(cell.rowKey, cell.columnKey) === address.itemKey)?.node;
139
+ if (def) return readSpecimenSlot(def, address);
140
+ for (const section of sections) {
141
+ const member = section.family?.members.find((m) => m.id === address.pageId);
142
+ if (member) return readSpecimenSlot(member, address);
143
+ }
144
+ return undefined;
125
145
  }
126
146
 
127
147
  /** The canonical, unambiguous `itemKey` for one comparison cell's row+column — this module's own