@krapwoo/ds-viewer 0.10.0 → 0.11.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.
package/README.md CHANGED
@@ -445,6 +445,88 @@ path. For anything beyond a single list of tokens, use the token-page layouts be
445
445
  declared in another component's folder, such as a shared `IconName`, is that component's
446
446
  options, so a Banner page never needs one example per icon.
447
447
 
448
+ ### Family pages
449
+
450
+ A page can document a family of related declarations — a view and its internal parts, or a
451
+ pattern and the pieces it's built from — on one page instead of scattering them across separate
452
+ catalog pages, each member getting its own address and search entry. Give it `members`, `whole`
453
+ and `sections` instead of `component`/`whenToUse`/`a11y`/specimens, which live on the members (the
454
+ whole is a member too):
455
+
456
+ ```tsx
457
+ export default defineCatalogPage({
458
+ id: 'Toast', group: 'Patterns', description: 'How a Toast is assembled from a Banner.',
459
+ whole: 'Toast',
460
+ sections: [
461
+ { label: 'Parts', kind: 'blocks', inWhole: true, members: ['ToastBanner'] },
462
+ { label: 'Related', kind: 'blocks', inWhole: false, members: ['ToastStandaloneBanner'] },
463
+ ],
464
+ members: [
465
+ { id: 'Toast', source: 'components/Toast/Toast.tsx:1', fidelity: 'documentation-only', relationship: 'Main pattern', description: '...' },
466
+ {
467
+ id: 'ToastBanner', component: 'Banner', parent: 'Toast',
468
+ source: 'components/Banner/Banner.tsx:28', fidelity: 'replicated', relationship: 'Anatomy', description: '...',
469
+ whenToUse: '...', a11y: '...', variants: { items: [/* ... */] },
470
+ },
471
+ {
472
+ id: 'ToastStandaloneBanner', source: 'components/Banner/Banner.tsx:28', fidelity: 'documentation-only', relationship: 'Related',
473
+ related: 'Wraps ToastBanner directly when a notice should stay until dismissed.', description: '...', aliases: ['BannerStandalone'],
474
+ },
475
+ ],
476
+ });
477
+ ```
478
+
479
+ - **`whole`** is a member id, and must equal the page's own `id`. It renders first, with no header
480
+ of its own (the page title already names it) and no number.
481
+ - **`sections`** (`{ label, kind: 'blocks' | 'list', inWhole, members }`) group the rest: `blocks`
482
+ renders each member as its own full block (specimens and all); `list` renders one card of folded
483
+ rows, each a toggle that expands in place. `inWhole` sections render under the page's one
484
+ "Parts" label; the rest render under its one "Related" label — the only two section labels the
485
+ page ever shows; each section's own `label` stays in the data (for the anatomy legend and your
486
+ own reference) but is never rendered. Numbering follows each member's own `parent` (below), not
487
+ this ordering.
488
+ - **`parent`** nests a member under another (e.g. a subview under its view). A top-level part (an
489
+ `inWhole` member whose `parent` is the whole, or who has no `parent` at all) is numbered `1`,
490
+ `2`, `3`… in the order it's first listed across `sections`; a child takes its parent's number
491
+ plus `.1`, `.2`, … the same way. A Related member (outside every `inWhole` section) gets no
492
+ number at all.
493
+ - **`anatomy`** (page-level) is the first box after the page head, labeled “Anatomy” like the
494
+ specimen grids: `{ picture, groups }`, where each group is
495
+ `{ label, items: [{ member, description } | { other, description }] }`. A `member` item names one
496
+ of this page's own members (not the whole) and is numbered to match its Part; an `other` item
497
+ names a component documented elsewhere, shows an unnumbered ↗ pin, and links only when the name
498
+ resolves to a real catalog address. With six items or fewer the legend sits beside `picture`;
499
+ with more it goes below it in up to three columns, each group placed in the currently shortest
500
+ column so the columns end nearly level (a group never splits). Mark `picture` with
501
+ `<AnatomyPin member="ThatMemberId" />` (its number and style come from the page, so `picture`
502
+ never hardcodes one) or `<AnatomyPin other="ThatComponent" />`.
503
+ - **`axis`** on a `variants` or `states` slot names the one property that grid varies (e.g.
504
+ `'Expression'`) and shows as the grid's label. Family pages hide the generic block labels, so
505
+ give each grid an `axis` there.
506
+ - **`fidelity`** (`'replicated' | 'approximated' | 'documentation-only'`) shows as a small badge.
507
+ Use `'documentation-only'` for a member with no rendered specimen (no native counterpart to show,
508
+ or nothing meaningfully different to render).
509
+ - **`related`** is a short sentence for a member outside the whole (e.g. "Wraps X directly …");
510
+ another member's name inside it becomes a link to that member's own address. **`aliases`** (on a
511
+ member, or on the page itself) resolve old/retired ids to the right address, for both routing and
512
+ search — on the page itself, this only takes effect when the page also has `members`; it's
513
+ accepted but unused on a normal (single-component) page.
514
+ - **`composedOf`** on a member — same shape and meaning as the page-level field above — shows as
515
+ one "Uses …" line inside the member's own disclosure, after any notes. A name links when it
516
+ resolves to a real catalog address (a page, another member, or an alias — in this family or any
517
+ other), otherwise it shows in code; an unknown name logs a `[Catalog]` warning the same way the
518
+ page-level field's does.
519
+ - **`notShown`** (page-level) is a short "Not shown in the browser: …" line under the page head,
520
+ for behavior a web catalog can't demonstrate (a timer, a native gesture). A member's own
521
+ `notShown` works the same way, under its specimens. Like `aliases`, the page-level field only
522
+ takes effect on a page with `members`.
523
+ - **`apiLabel`** (default `'API'`) renames the disclosure's reference-excerpt part for a host app
524
+ that generates one (e.g. `'Swift API'`) — never hardcode a specific language's wording. Also
525
+ only takes effect on a page with `members`.
526
+ - Every component-bound member (one with `component` set) wants a real `whenToUse`, a real `a11y`,
527
+ and at least one specimen, the same as a normal page's component — `doctor` checks each one.
528
+ See `viewer-host/viewer-pages/FamilyPage.catalog.tsx` in this repo for a complete, worked example.
529
+
448
530
  ### Token pages
449
531
 
450
532
  A token page shows each token at its real value with its role. Pick the layout by the token's
@@ -7,6 +7,7 @@ import { importedNames, readStaticPages } from "./staticPage.js";
7
7
  import { resolveGlob } from "./glob.js";
8
8
  import { sync } from "./sync.js";
9
9
  import { gridColumnLimit } from "../native/catalog/comparison.js";
10
+ import { validateComposedOf, validateFamily } from "../native/catalog/pageApi.js";
10
11
  function issue(fields) {
11
12
  return fields;
12
13
  }
@@ -18,9 +19,14 @@ function pageRef(page) {
18
19
  * own file (design §2; the viewer already applies this same default via `page.component ??
19
20
  * page.id`, and the generated page index already defaults a missing `id` to the file stem). A
20
21
  * page from a standalone `pages` glob is never touched — `componentFolders` only ever contains
21
- * folders resolved from `config.components`, so a standalone page's own folder is never a member. */
22
+ * folders resolved from `config.components`, so a standalone page's own folder is never a member.
23
+ * A family page (non-empty `members`) is also never touched — same exemption the runtime applies
24
+ * at `pageApi.ts`'s `buildCatalogSections` (`page.members && page.members.length > 0` skips the
25
+ * `page.component ?? page.id` fallback entirely): a family's own guidance/examples always live on
26
+ * its members, never on the page itself, so a documentation-only whole (its file stem naming no
27
+ * real component export) must never be assigned one here either. */
22
28
  export function applyComponentDefaults(pages, componentFolders) {
23
- return pages.map((page) => page.component === undefined && componentFolders.has(path.dirname(page.file))
29
+ return pages.map((page) => page.component === undefined && componentFolders.has(path.dirname(page.file)) && !(page.members && page.members.length > 0)
24
30
  ? { ...page, component: resolvePageId(page, page.file) }
25
31
  : page);
26
32
  }
@@ -59,12 +65,22 @@ function pageHasExamples(page) {
59
65
  }
60
66
  function checkComponentNoExamples(pages, components) {
61
67
  const byComponent = new Map();
68
+ // Plan V3: "a component documented only as a member counts as documented" — any family member
69
+ // naming a component (regardless of which page, or whether that specific member has its own
70
+ // examples — a member with none gets its own `component-no-examples` from `checkFamilyMembers`
71
+ // instead) is enough to clear the component-wide "no catalog page at all" half of this rule.
72
+ const documentedByMember = new Set();
62
73
  for (const page of pages) {
63
74
  if (page.component)
64
75
  byComponent.set(page.component, [...(byComponent.get(page.component) ?? []), page]);
76
+ for (const member of page.members ?? [])
77
+ if (member.component)
78
+ documentedByMember.add(member.component);
65
79
  }
66
80
  return components
67
81
  .filter((c) => {
82
+ if (documentedByMember.has(c.name))
83
+ return false;
68
84
  const componentPages = byComponent.get(c.name) ?? [];
69
85
  // A not-statically-checkable page is given the benefit of the doubt here — it might have
70
86
  // real examples this reader simply couldn't read (Global Constraint: never a partial read;
@@ -83,6 +99,152 @@ function checkComponentNoExamples(pages, components) {
83
99
  });
84
100
  });
85
101
  }
102
+ /** Plan V3: the same two advisories `checkComponentPages` gives a page-level `component` —
103
+ * missing guidance/accessibility, and no examples — evaluated per component-bound member of a
104
+ * family page instead, with the member id in the message. A documentation-only member (no
105
+ * `component`) is exempt from all three. V2c item 9: a member whose `component` no longer
106
+ * resolves to a real export gets its own `component-export-removed` error here (below), naming
107
+ * the member, instead of being silently exempted. */
108
+ function checkFamilyMembers(pages, components) {
109
+ const componentsByName = new Map(components.map((c) => [c.name, c]));
110
+ const issues = [];
111
+ for (const page of pages) {
112
+ // V2c item 6: the same "is this really a family page" predicate the runtime uses
113
+ // (`pageApi.ts`'s `page.members && page.members.length > 0`) — an empty `members` array is a
114
+ // normal, single-component page, never a family.
115
+ if (!page.members || page.members.length === 0 || !page.checkable)
116
+ continue; // never a partial read — see checkGridAxisKeys's own note.
117
+ const ref = pageRef(page);
118
+ // Members in a `list` section render as folded rows without specimens, so asking them for
119
+ // examples would contradict `family-invalid`'s list-section rule.
120
+ const listMemberIds = new Set((page.sections ?? []).filter((s) => s.kind === 'list').flatMap((s) => s.members));
121
+ for (const member of page.members) {
122
+ if (!member.component)
123
+ continue; // documentation-only — exempt.
124
+ // V2c item 9: a member naming a component that no longer exists — reported here rather than
125
+ // silently exempted from every advisory (its props would also silently vanish at runtime).
126
+ if (!componentsByName.has(member.component)) {
127
+ issues.push(issue({
128
+ id: 'component-export-removed', severity: 'error', ...ref,
129
+ message: `Member "${member.id}" documents "${member.component}", which no longer exists as a component export.`,
130
+ fix: `Delete this member, or update its "component" to the export's new name.`,
131
+ }));
132
+ continue;
133
+ }
134
+ if (!member.hasWhenToUse) {
135
+ issues.push(issue({
136
+ id: 'missing-guidance-or-a11y', severity: 'warning', ...ref,
137
+ message: `Member "${member.id}": no "whenToUse" guidance documented.`,
138
+ fix: `Add a one-sentence whenToUse note for "${member.id}", or omit it only when there genuinely is none.`,
139
+ }));
140
+ }
141
+ if (!member.hasA11y) {
142
+ issues.push(issue({
143
+ id: 'missing-guidance-or-a11y', severity: 'warning', ...ref,
144
+ message: `Member "${member.id}": no "a11y" notes documented.`,
145
+ fix: `Add a one-sentence a11y note for "${member.id}", grounded in its real behavior.`,
146
+ }));
147
+ }
148
+ if (!member.hasExamples && !listMemberIds.has(member.id)) {
149
+ issues.push(issue({
150
+ id: 'component-no-examples', severity: 'warning', ...ref,
151
+ message: `Member "${member.id}" ("${member.component}") has no variants, states, or comparison — it will show no specimen.`,
152
+ fix: `Add variants/states/comparison to the "${member.id}" member.`,
153
+ }));
154
+ }
155
+ }
156
+ }
157
+ return issues;
158
+ }
159
+ /** Plan V3's `family-invalid`: reuses `native/catalog/pageApi.ts`'s own `validateFamily` —
160
+ * the exact same rule `pageApi.ts`'s `buildCatalogSections` runs at runtime — against every
161
+ * checkable family page, with the same catalog-wide "every other page/member id" contract that
162
+ * function already takes. N3: `StaticPage.sections` already carries each section's real
163
+ * `label`/`kind`, so `validateFamily`'s messages name the real section (never an empty label),
164
+ * and its N6 list-section rule can tell a `list` section from a `blocks` one. */
165
+ function checkFamilyValidity(pages) {
166
+ // V2c item 6: `members.length > 0`, the same predicate the runtime uses — an empty array is not
167
+ // a family page.
168
+ const familyPages = pages.filter((p) => p.members && p.members.length > 0 && p.checkable);
169
+ const allPageIds = new Set(pages.map((p) => resolvePageId(p, p.file)));
170
+ const familyIdsByPageId = new Map();
171
+ for (const page of familyPages) {
172
+ const id = resolvePageId(page, page.file);
173
+ const ids = new Set();
174
+ for (const member of page.members ?? []) {
175
+ ids.add(member.id);
176
+ for (const alias of member.aliases ?? [])
177
+ ids.add(alias);
178
+ }
179
+ for (const alias of page.aliases ?? [])
180
+ ids.add(alias);
181
+ familyIdsByPageId.set(id, ids);
182
+ }
183
+ const issues = [];
184
+ for (const page of familyPages) {
185
+ const id = resolvePageId(page, page.file);
186
+ const otherPageIds = new Set([...allPageIds].filter((pageId) => pageId !== id));
187
+ const otherMemberIds = new Set();
188
+ for (const [pageId, ids] of familyIdsByPageId)
189
+ if (pageId !== id)
190
+ for (const memberId of ids)
191
+ otherMemberIds.add(memberId);
192
+ const pageLike = {
193
+ id,
194
+ whole: page.whole,
195
+ // `inWhole` is never read by `validateFamily` — only `label`/`kind`/`members` matter here.
196
+ sections: page.sections?.map((s) => ({ ...s, inWhole: true })),
197
+ members: (page.members ?? []).map((m) => ({ id: m.id, parent: m.parent, aliases: m.aliases, hasSpecimens: m.hasExamples })),
198
+ aliases: page.aliases,
199
+ };
200
+ for (const message of validateFamily(pageLike, otherPageIds, otherMemberIds)) {
201
+ issues.push(issue({ id: 'family-invalid', severity: 'error', ...pageRef(page), message, fix: 'Fix the family\'s members/whole/sections to satisfy the rule above.' }));
202
+ }
203
+ }
204
+ return issues;
205
+ }
206
+ /** V2d item 5: resolves each family member's own `composedOf` the same way `buildCatalogSections`
207
+ * resolves it at runtime (`native/catalog/pageApi.ts`'s own `validateComposedOf`, imported
208
+ * directly — the subpath-import boundary that once made importing that file from `dist/cli/`
209
+ * unsafe is gone), against the same known-name universe `checkFamilyValidity` already builds for
210
+ * member ids — every real component name, every page id, and every member id across the whole
211
+ * catalog — so an unknown name is reported here too, not only ever silently warned in a browser
212
+ * console (`npm run check:catalog`). A member whose `composedOf` wasn't itself literally readable
213
+ * (`undefined`) has nothing to check — the same "never a partial read" leniency the page-level
214
+ * field already gets. Always a warning, the same severity the runtime's own console warning
215
+ * carries, never an error. N8: also accepts a member alias and a page-level alias — the same
216
+ * universe `FamilyPage`'s own link resolution (`aliasTargets`) already accepts. */
217
+ function checkMemberComposedOf(pages, components) {
218
+ const knownNames = new Set(components.map((c) => c.name));
219
+ for (const page of pages)
220
+ knownNames.add(resolvePageId(page, page.file));
221
+ for (const page of pages)
222
+ for (const member of page.members ?? [])
223
+ knownNames.add(member.id);
224
+ for (const page of pages)
225
+ for (const member of page.members ?? [])
226
+ for (const alias of member.aliases ?? [])
227
+ knownNames.add(alias);
228
+ for (const page of pages)
229
+ for (const alias of page.aliases ?? [])
230
+ knownNames.add(alias);
231
+ const issues = [];
232
+ for (const page of pages) {
233
+ if (!page.members || page.members.length === 0 || !page.checkable)
234
+ continue; // V2c item 6's own predicate.
235
+ const ref = pageRef(page);
236
+ for (const member of page.members) {
237
+ for (const message of validateComposedOf(member.composedOf, knownNames)) {
238
+ issues.push(issue({
239
+ id: 'composed-of-unknown', severity: 'warning', ...ref,
240
+ message: `Member "${member.id}": ${message}`,
241
+ fix: 'Correct the name, or add { component: \'...\', role: \'...\', relationship: \'...\' } for a component/page/member that really does exist.',
242
+ }));
243
+ }
244
+ }
245
+ }
246
+ return issues;
247
+ }
86
248
  function checkDuplicatePageId(pages) {
87
249
  const byId = new Map();
88
250
  for (const page of pages) {
@@ -225,7 +387,14 @@ function importsModal(file) {
225
387
  }
226
388
  /** `overlay-without-device-frame`: a page whose component opens a React Native `Modal` (which
227
389
  * portals to the document body on the web, covering the whole catalog) but whose page imports no
228
- * device frame. A page whose imports couldn't be read is skipped, never assumed unframed. */
390
+ * device frame. A page whose imports couldn't be read is skipped, never assumed unframed.
391
+ * V2c item 3 (CLI side): deliberately exempts a family member's own `component` — this check
392
+ * keys off `page.component`/`page.viewerImports` (the page file's own top-level imports), neither
393
+ * of which a family page sets; a member naming a Modal-opening component would need its OWN
394
+ * device-frame-import evidence, which `cli/staticPage.ts`'s member reader doesn't currently carry.
395
+ * Left as a gap rather than guessed at — a family member wrapping a Modal in a device frame is
396
+ * rare enough (and the resulting miss only an advisory, never an error) that this is the smallest
397
+ * honest fix here. */
229
398
  export function checkOverlaysFramed(pages, components, projectRoot) {
230
399
  const byName = new Map(components.map((c) => [c.name, c]));
231
400
  const issues = [];
@@ -342,6 +511,13 @@ function checkComponentPages(pages, components) {
342
511
  for (const page of pages) {
343
512
  if (!page.component)
344
513
  continue;
514
+ // Plan V3: a family page's own guidance always lives on its members, never on the page
515
+ // itself — even if `applyComponentDefaults` (or, in principle, the author) ends up giving it
516
+ // a page-level `component` matching a real export, this page-level advisory must still defer
517
+ // entirely to `checkFamilyMembers`'s own per-member one. V2c item 6: `members.length > 0`,
518
+ // the same predicate the runtime uses — an empty array is not a family page.
519
+ if (page.members && page.members.length > 0)
520
+ continue;
345
521
  if (!page.checkable)
346
522
  continue; // never a partial read — see checkGridAxisKeys's own note.
347
523
  const component = componentsByName.get(page.component);
@@ -519,6 +695,9 @@ export function collectIssues(pages, components) {
519
695
  ...checkMissingComposition(pages, components),
520
696
  ...checkMissingWorkingPreview(pages, components),
521
697
  ...checkPreviewOnlyPages(pages),
698
+ ...checkFamilyMembers(pages, components),
699
+ ...checkFamilyValidity(pages),
700
+ ...checkMemberComposedOf(pages, components),
522
701
  ];
523
702
  }
524
703
  /** Issues that aren't tied to one page: `sync`'s own "Could not read" warnings, and a configured
@@ -200,8 +200,137 @@ function readStaticPage(file) {
200
200
  page.internalProps = names;
201
201
  }
202
202
  }
203
+ const membersProp = findProp(pageObject, 'members');
204
+ if (membersProp) {
205
+ const members = readFamilyMembers(membersProp.initializer, consts, gridNames, sourceFile);
206
+ if (!members)
207
+ page.checkable = false;
208
+ else
209
+ page.members = members;
210
+ }
211
+ const wholeProp = findProp(pageObject, 'whole');
212
+ if (wholeProp) {
213
+ const whole = stringLiteral(resolve(wholeProp.initializer, consts, new Set()));
214
+ if (whole === undefined)
215
+ page.checkable = false;
216
+ else
217
+ page.whole = whole;
218
+ }
219
+ const sectionsProp = findProp(pageObject, 'sections');
220
+ if (sectionsProp) {
221
+ const sectionsRead = readSections(sectionsProp.initializer, consts);
222
+ if (!sectionsRead)
223
+ page.checkable = false;
224
+ else
225
+ page.sections = sectionsRead;
226
+ }
227
+ const aliasesProp = findProp(pageObject, 'aliases');
228
+ if (aliasesProp) {
229
+ const aliases = readStringLiteralArray(aliasesProp.initializer, consts);
230
+ if (!aliases)
231
+ page.checkable = false;
232
+ else
233
+ page.aliases = aliases;
234
+ }
203
235
  return page;
204
236
  }
237
+ /** A literal array of string literals (or same-file `const` references to one) — undefined for
238
+ * anything else, including an array containing even one non-literal element. */
239
+ function readStringLiteralArray(node, consts) {
240
+ const resolved = resolve(node, consts, new Set());
241
+ if (!ts.isArrayLiteralExpression(resolved))
242
+ return undefined;
243
+ const items = [];
244
+ for (const element of resolved.elements) {
245
+ const value = stringLiteral(resolve(element, consts, new Set()));
246
+ if (value === undefined)
247
+ return undefined;
248
+ items.push(value);
249
+ }
250
+ return items;
251
+ }
252
+ /** Plan V3 / V2c item 5: a literal array of `CatalogMember`-shaped object literals, down to just
253
+ * the five fields `cli/doctor.ts` needs (`StaticMember`). Undefined for anything that isn't
254
+ * itself a literal array, or whose element isn't an object literal with a literal `id` — the
255
+ * same "never a partial read" contract every other array reader here already follows.
256
+ * `hasExamples` reuses the same item-reading helpers (`readSlotItems`/`readComparison`) the
257
+ * page level uses, rather than a presence-only check — a member authored with `variants: {
258
+ * items: [] }` has no real specimen to show, the same way a page-level empty list doesn't.
259
+ * N1: a member's own `variants`/`states`/`comparison` follows the exact same "never a partial
260
+ * read" contract the page level already does (`readStaticPage`'s own `page.checkable = false`
261
+ * for the same three fields) — a non-literal slot or an unreadable comparison makes the whole
262
+ * members array (and so the whole page) not checkable, rather than silently reading as "no
263
+ * specimen" and firing a confidently false `component-no-examples`. */
264
+ function readFamilyMembers(node, consts, gridNames, sourceFile) {
265
+ const resolved = resolve(node, consts, new Set());
266
+ if (!ts.isArrayLiteralExpression(resolved))
267
+ return undefined;
268
+ const members = [];
269
+ for (const element of resolved.elements) {
270
+ const resolvedElement = resolve(element, consts, new Set());
271
+ if (!ts.isObjectLiteralExpression(resolvedElement))
272
+ return undefined;
273
+ const id = propertyStringLiteral(resolvedElement, 'id', consts);
274
+ if (id === undefined)
275
+ return undefined;
276
+ const component = propertyStringLiteral(resolvedElement, 'component', consts);
277
+ const parent = propertyStringLiteral(resolvedElement, 'parent', consts);
278
+ const aliasesProp = findProp(resolvedElement, 'aliases');
279
+ const aliases = aliasesProp ? readStringLiteralArray(aliasesProp.initializer, consts) : undefined;
280
+ const composedOfProp = findProp(resolvedElement, 'composedOf');
281
+ const composedOf = composedOfProp ? readComposedOf(composedOfProp.initializer, consts) : undefined;
282
+ const variants = readSlotItems(findProp(resolvedElement, 'variants'), consts, component, sourceFile);
283
+ if (!variants.checkable)
284
+ return undefined;
285
+ const states = readSlotItems(findProp(resolvedElement, 'states'), consts, component, sourceFile);
286
+ if (!states.checkable)
287
+ return undefined;
288
+ const comparisonProp = findProp(resolvedElement, 'comparison');
289
+ let comparison;
290
+ if (comparisonProp) {
291
+ comparison = readComparison(comparisonProp.initializer, consts, gridNames);
292
+ if (!comparison)
293
+ return undefined;
294
+ }
295
+ members.push({
296
+ id,
297
+ component,
298
+ parent,
299
+ aliases,
300
+ composedOf,
301
+ hasWhenToUse: findProp(resolvedElement, 'whenToUse') !== undefined,
302
+ hasA11y: findProp(resolvedElement, 'a11y') !== undefined,
303
+ hasExamples: Boolean(variants.items?.length) || Boolean(states.items?.length) || Boolean(comparison),
304
+ });
305
+ }
306
+ return members;
307
+ }
308
+ /** Plan V3 / N3: `CatalogPageInput.sections`, read down to `label`/`kind`/`members` — undefined
309
+ * (not checkable) for anything that isn't itself a literal array, or whose element isn't an
310
+ * object literal with a literal `label`, a literal `kind` of `'blocks'`/`'list'`, and a literal
311
+ * `members` array. */
312
+ function readSections(node, consts) {
313
+ const resolved = resolve(node, consts, new Set());
314
+ if (!ts.isArrayLiteralExpression(resolved))
315
+ return undefined;
316
+ const sections = [];
317
+ for (const element of resolved.elements) {
318
+ const resolvedElement = resolve(element, consts, new Set());
319
+ if (!ts.isObjectLiteralExpression(resolvedElement))
320
+ return undefined;
321
+ const label = propertyStringLiteral(resolvedElement, 'label', consts);
322
+ const kindProp = findProp(resolvedElement, 'kind');
323
+ const kind = kindProp ? stringLiteral(resolve(kindProp.initializer, consts, new Set())) : undefined;
324
+ const membersProp = findProp(resolvedElement, 'members');
325
+ if (label === undefined || (kind !== 'blocks' && kind !== 'list') || !membersProp)
326
+ return undefined;
327
+ const members = readStringLiteralArray(membersProp.initializer, consts);
328
+ if (!members)
329
+ return undefined;
330
+ sections.push({ label, kind, members });
331
+ }
332
+ return sections;
333
+ }
205
334
  /** A literal array of `ComposedOfEntry`-shaped object literals, read down to just `component`
206
335
  * (design §4: role/relationship are authored prose `doctor` never needs for its missing-
207
336
  * composition comparison). Undefined — not an empty array — for anything that isn't itself a
@@ -33,15 +33,20 @@ export function neighbors(order, id) {
33
33
  next: index < order.length - 1 ? order[index + 1] : null,
34
34
  };
35
35
  }
36
- /** Sidebar filter: case-insensitive substring match, sorted ids, empty groups removed. */
36
+ /** Sidebar filter: case-insensitive substring match, sorted ids, empty groups removed. A page
37
+ * matches on its id, its title, or (spec §5.3) any of its family's member ids or aliases. */
37
38
  export function filterGroups(groups, query,
38
39
  /** Display titles by id; a page matches on its id or its title. */
39
- labels) {
40
+ labels,
41
+ /** Every member id/alias a family page's members use, by page id. */
42
+ memberIds) {
40
43
  const q = query.trim().toLowerCase();
41
44
  return groups
42
45
  .map((group) => ({
43
46
  label: group.label,
44
- ids: sortIds(q ? group.ids.filter((id) => id.toLowerCase().includes(q) || (labels?.get(id)?.toLowerCase().includes(q) ?? false)) : group.ids),
47
+ ids: sortIds(q ? group.ids.filter((id) => id.toLowerCase().includes(q)
48
+ || (labels?.get(id)?.toLowerCase().includes(q) ?? false)
49
+ || (memberIds?.get(id)?.some((memberId) => memberId.toLowerCase().includes(q)) ?? false)) : group.ids),
45
50
  }))
46
51
  .filter((group) => group.ids.length > 0);
47
52
  }
@@ -109,6 +114,60 @@ export function resolveActiveFromHash(hash, order) {
109
114
  return UPDATE_PAGE_ID;
110
115
  return idFromHash(hash, order);
111
116
  }
117
+ /** Every member id other than the whole, every member alias, and every family page's own
118
+ * (page-level) alias, mapped to where it resolves (spec §5.3 "Old addresses"). Pages without a
119
+ * `family` contribute nothing. */
120
+ export function aliasTargets(sections) {
121
+ const targets = new Map();
122
+ for (const section of sections) {
123
+ const family = section.family;
124
+ if (!family)
125
+ continue;
126
+ for (const member of family.members) {
127
+ if (member.id === family.whole)
128
+ continue;
129
+ targets.set(member.id, { pageId: section.id, memberId: member.id });
130
+ for (const alias of member.aliases ?? [])
131
+ targets.set(alias, { pageId: section.id, memberId: member.id });
132
+ }
133
+ for (const alias of family.aliases ?? [])
134
+ targets.set(alias, { pageId: section.id });
135
+ }
136
+ return targets;
137
+ }
138
+ export function resolveRoute(hash, order, aliases) {
139
+ const raw = hash.startsWith('#') ? hash.slice(1) : hash;
140
+ if (raw === '')
141
+ return { kind: 'empty' };
142
+ let decoded;
143
+ try {
144
+ decoded = decodeURIComponent(raw);
145
+ }
146
+ catch {
147
+ return { kind: 'notFound', raw };
148
+ }
149
+ if (decoded === UPDATE_PAGE_ID)
150
+ return { kind: 'update' };
151
+ if (order.includes(decoded))
152
+ return { kind: 'page', id: decoded };
153
+ const target = aliases.get(decoded);
154
+ if (target) {
155
+ if (target.memberId !== undefined)
156
+ return { kind: 'member', pageId: target.pageId, memberId: target.memberId };
157
+ return { kind: 'page', id: target.pageId };
158
+ }
159
+ return { kind: 'notFound', raw: decoded };
160
+ }
161
+ /** Member search hits (spec §5.3 "Search"): every entry whose member id or alias contains the
162
+ * query, case-insensitively. The label is always the member id — the Swift declaration name. */
163
+ export function searchResults(entries, query) {
164
+ const q = query.trim().toLowerCase();
165
+ if (!q)
166
+ return [];
167
+ return entries
168
+ .filter((entry) => entry.memberId.toLowerCase().includes(q) || (entry.aliases ?? []).some((alias) => alias.toLowerCase().includes(q)))
169
+ .map((entry) => ({ pageId: entry.pageId, memberId: entry.memberId, label: entry.memberId, familyTitle: entry.familyTitle }));
170
+ }
112
171
  /** Design §5: "Major: ... dismissible per version; the footer line remains." `dismissedVersion`
113
172
  * is whatever a previous dismissal recorded (`CatalogShell`'s own `localStorage` read, Task 14) —
114
173
  * any value other than the *current* `update.latest` (including `null`, or an older dismissed
@@ -257,18 +257,28 @@ export function presentationBlocks(def, options = {}) {
257
257
  title: `${def.comparison.rowLabel} × ${def.comparison.columnLabel}`,
258
258
  size: def.comparison.size ?? def.specimenSize ?? 'regular',
259
259
  comparison: def.comparison,
260
+ // A3: `comparison` replaces the Variants list, but `def.variants.axis` (if still set) is
261
+ // the only axis label a grid has to carry onto a family page, which hides generic titles.
262
+ ...(def.variants?.axis ? { axis: def.variants.axis } : {}),
260
263
  });
261
264
  }
262
265
  else if (grouped && def.variants) {
263
266
  // Grouped rows place several cells beside a row header, so full-width (itemsFill) slots do
264
267
  // not force 402px cells here; a cell's own `fill` still stretches its specimen.
265
- blocks.push({ kind: 'grouped', title: 'Variant × configuration', size: def.specimenSize ?? 'regular', groups: grouped.groups });
268
+ blocks.push({
269
+ kind: 'grouped',
270
+ title: 'Variant × configuration',
271
+ size: def.specimenSize ?? 'regular',
272
+ groups: grouped.groups,
273
+ ...(def.variants.axis ? { axis: def.variants.axis } : {}),
274
+ });
266
275
  states = grouped.leftovers;
267
276
  }
268
277
  else if (def.variants) {
269
278
  blocks.push({
270
279
  kind: 'list',
271
- title: 'Variants',
280
+ title: def.variants.axis ?? 'Variants',
281
+ ...(def.variants.axis ? { axis: def.variants.axis } : {}),
272
282
  size: slotSize(def, def.variants),
273
283
  items: slotItems(def.variants, 'variants', def.specimenSize === 'page'),
274
284
  ...slotColumnCap(def.variants),
@@ -282,7 +292,8 @@ export function presentationBlocks(def, options = {}) {
282
292
  const secondary = blocks.length > 0 && blocks[0].kind !== 'list';
283
293
  blocks.push({
284
294
  kind: 'list',
285
- title: secondary ? 'Other configurations' : 'States / configurations',
295
+ title: states.axis ?? (secondary ? 'Other configurations' : 'States / configurations'),
296
+ ...(states.axis ? { axis: states.axis } : {}),
286
297
  size: slotSize(def, states),
287
298
  items: slotItems(states, 'states', def.specimenSize === 'page'),
288
299
  ...slotColumnCap(states),