@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.
@@ -1,5 +1,5 @@
1
1
  import type React from 'react';
2
- import type { ComparisonDef, ComposedOfEntry, NavGroup, PreviewWidths, PropDef, SectionDef, SpecimenSize, SpecimenSurfaceKind, TokenSection, VariantSlot } from './types';
2
+ import type { CatalogAnatomy, CatalogMember, CatalogMemberSection, ComparisonDef, ComposedOfEntry, NavGroup, PreviewWidths, PropDef, SectionDef, SpecimenSize, SpecimenSurfaceKind, TokenSection, VariantSlot } from './types.ts';
3
3
 
4
4
  /** `SectionDef` without the derived fields (`id`/`path`/`props` come from detection and
5
5
  * generated props), plus the three fields design §1 "Page shape" adds. */
@@ -46,6 +46,35 @@ export interface CatalogPageInput<TId extends string = string> {
46
46
  /** Extra text per prop, appended to its generated description. A note naming a prop that no
47
47
  * longer exists is a `doctor` error from 0.3 — 0.1 has no `doctor`, so it is silently unused. */
48
48
  propNotes?: Record<string, string>;
49
+ /** Spec §5.1: documents a family of related declarations on one page instead of a single
50
+ * component. When set, the page's own `component`/`whenToUse`/`a11y`/specimens are not used —
51
+ * those live on the members (the whole is a member too) — and the viewer renders a `FamilyPage`
52
+ * (`SectionDef.family`) rather than the normal single-component layout. Requires `whole` and
53
+ * `sections`; validated by this file's own `validateFamily`. */
54
+ members?: CatalogMember[];
55
+ /** The family's whole: a member id, required when `members` is set. Its own id must equal this
56
+ * page's `id`. */
57
+ whole?: string;
58
+ /** How the family's non-whole members are grouped and rendered, required when `members` is set.
59
+ * Ordering only — numbering follows each member's own `parent` (`numberFor`); a Related member
60
+ * (outside every `inWhole` section) is never numbered. */
61
+ sections?: CatalogMemberSection[];
62
+ /** Extra names for the page itself (its whole) — e.g. retired page ids that are not member ids.
63
+ * Included in uniqueness validation, routing and search alongside `members[].aliases`. V2c item
64
+ * 15: only takes effect on a page with `members` (a family page) — accepted but unused on a
65
+ * normal page, which never resolves it to anywhere. */
66
+ aliases?: string[];
67
+ /** Family-level "Not shown in the browser" line, under the page head. V2c item 15: only takes
68
+ * effect on a page with `members` — unused on a normal page. */
69
+ notShown?: string;
70
+ /** Label for the disclosure's API part (spec V2b item 9), e.g. `'Swift API'`. Defaults to
71
+ * `'API'` when absent — a generic viewer must never hardcode a host app's own wording. V2c
72
+ * item 15: only takes effect on a page with `members` — unused on a normal page. */
73
+ apiLabel?: string;
74
+ /** Slice D1 item 1: the anatomy box `FamilyPage` renders first, right after the page head —
75
+ * a picture of the whole with numbered pins and a legend. Only takes effect on a page with
76
+ * `members`. */
77
+ anatomy?: CatalogAnatomy;
49
78
  }
50
79
 
51
80
  export interface CatalogPage<TId extends string = string> extends CatalogPageInput<TId> {
@@ -95,17 +124,237 @@ function mergePropNotes(component: GeneratedComponent, propNotes: Record<string,
95
124
  return [...own, ...inherited];
96
125
  }
97
126
 
98
- /** Human-readable problems with a page's `composedOf`, in declaration order: one line per entry
99
- * whose `component` isn't in `knownNames` (every real component name, documented or not, plus
100
- * every page id — the full catalog inventory `buildCatalogSections` already has on hand). Empty
101
- * when every name resolves, including when `composedOf` itself is omitted. */
102
- export function validateComposedOf(composedOf: ComposedOfEntry[] | undefined, knownNames: ReadonlySet<string>): string[] {
127
+ /** Human-readable problems with a page's (or, V2d, a family member's own) `composedOf`, in
128
+ * declaration order: one line per entry whose `component` isn't in `knownNames` (every real
129
+ * component name, documented or not, plus every page id and family member id — the full catalog
130
+ * inventory `buildCatalogSections` already has on hand). Empty when every name resolves,
131
+ * including when `composedOf` itself is omitted. Takes only the `component` field (never the
132
+ * narrower `ComposedOfEntry[]` type) so `cli/doctor.ts`'s own statically-read `{ component:
133
+ * string }[]` shape — which never carries `role`/`relationship` — satisfies it too. */
134
+ export function validateComposedOf(composedOf: { component: string }[] | undefined, knownNames: ReadonlySet<string>): string[] {
103
135
  if (!composedOf) return [];
104
136
  return composedOf
105
137
  .filter((entry) => !knownNames.has(entry.component))
106
138
  .map((entry) => `composedOf references unknown component "${entry.component}".`);
107
139
  }
108
140
 
141
+ /** The subset of a family page's shape `validateFamily` and `numberFor` need. The real page
142
+ * carries full `CatalogMember` records; these functions only ever read `id`/`parent`/`aliases`. */
143
+ export interface FamilyOutlineInput {
144
+ whole?: string;
145
+ sections?: CatalogMemberSection[];
146
+ members?: Array<Pick<CatalogMember, 'id' | 'parent'>>;
147
+ }
148
+
149
+ export interface FamilyPageLike extends FamilyOutlineInput {
150
+ id: string;
151
+ /** N6: `hasSpecimens` is not a `CatalogMember` field — each caller computes it (a non-empty
152
+ * `variants`/`states`/`comparison` at runtime, `StaticMember.hasExamples` in `doctor`) so
153
+ * `validateFamily` can flag a `list`-section member authored with a specimen that `FoldedRow`
154
+ * never renders. */
155
+ members?: Array<Pick<CatalogMember, 'id' | 'parent' | 'aliases'> & { hasSpecimens?: boolean }>;
156
+ aliases?: string[];
157
+ /** Slice D1 item 3: the minimal shape `validateFamily` needs from `CatalogPageInput.anatomy` —
158
+ * decoupled from the full `CatalogAnatomy` type (which carries a `picture` React node this pure
159
+ * module never touches). */
160
+ anatomy?: FamilyAnatomyLike;
161
+ }
162
+
163
+ /** Slice D1 item 3. */
164
+ export interface FamilyAnatomyLike {
165
+ groups: ReadonlyArray<{ label: string; items: ReadonlyArray<{ member: string } | { other: string }> }>;
166
+ }
167
+
168
+ /** Validates a family page (spec §5.1): exactly one whole belonging to no section; every other
169
+ * member in exactly one section; every parent resolving within the page with no cycles; member
170
+ * and alias ids unique catalog-wide and never colliding with a page id, except the whole (whose
171
+ * id equals its own page's id). Returns `[]` for a page without `members`. `allPageIds` and
172
+ * `otherMemberIds` are the ids used by every *other* page/member in the catalog. */
173
+ export function validateFamily(page: FamilyPageLike, allPageIds: ReadonlySet<string>, otherMemberIds: ReadonlySet<string>): string[] {
174
+ const { members } = page;
175
+ if (!members || members.length === 0) return [];
176
+ const issues: string[] = [];
177
+
178
+ if (!page.whole || !page.sections) {
179
+ issues.push(`Family "${page.id}": "members" requires both "whole" and "sections".`);
180
+ return issues;
181
+ }
182
+ const whole = page.whole;
183
+ const byId = new Map(members.map((m) => [m.id, m]));
184
+
185
+ if (!byId.has(whole)) {
186
+ issues.push(`Family "${page.id}": whole "${whole}" is not one of its members.`);
187
+ }
188
+ if (whole !== page.id) {
189
+ issues.push(`Family "${page.id}": whole "${whole}" must have the same id as the page.`);
190
+ }
191
+
192
+ // V2c item 2: a section listing an id that is not one of the page's own members — reported
193
+ // here, rather than left to crash the renderer or silently occupy authored order in `numberFor`.
194
+ // N6: a `list`-section member authored with a specimen — `FoldedRow` never renders one, so this
195
+ // degrades to a silently dropped specimen rather than a crash, same as the unknown-id case.
196
+ for (const section of page.sections) {
197
+ for (const id of section.members) {
198
+ const resolved = byId.get(id);
199
+ if (!resolved) {
200
+ issues.push(`Family "${page.id}": section "${section.label}" lists "${id}", which is not a member of this page.`);
201
+ continue;
202
+ }
203
+ if (section.kind === 'list' && resolved.hasSpecimens) {
204
+ issues.push(`Family "${page.id}": "${id}" has specimens, but list sections show members as folded rows without specimens.`);
205
+ }
206
+ }
207
+ }
208
+
209
+ const sectionCountById = new Map<string, number>();
210
+ for (const section of page.sections) {
211
+ for (const id of section.members) sectionCountById.set(id, (sectionCountById.get(id) ?? 0) + 1);
212
+ }
213
+ if (sectionCountById.has(whole)) {
214
+ issues.push(`Family "${page.id}": the whole "${whole}" must not appear in any section.`);
215
+ }
216
+ for (const m of members) {
217
+ if (m.id === whole) continue;
218
+ const count = sectionCountById.get(m.id) ?? 0;
219
+ if (count === 0) issues.push(`Family "${page.id}": member "${m.id}" is not in any section.`);
220
+ else if (count > 1) issues.push(`Family "${page.id}": member "${m.id}" is in more than one section.`);
221
+ }
222
+
223
+ for (const m of members) {
224
+ if (m.parent === undefined) continue;
225
+ if (!byId.has(m.parent)) {
226
+ issues.push(`Family "${page.id}": member "${m.id}" has an unknown parent "${m.parent}".`);
227
+ continue;
228
+ }
229
+ const seen = new Set([m.id]);
230
+ let current: string | undefined = m.parent;
231
+ let cycle = false;
232
+ while (current !== undefined) {
233
+ if (seen.has(current)) {
234
+ cycle = true;
235
+ break;
236
+ }
237
+ seen.add(current);
238
+ current = byId.get(current)?.parent;
239
+ }
240
+ if (cycle) issues.push(`Family "${page.id}": member "${m.id}" has a parent cycle.`);
241
+ }
242
+
243
+ const seenInPage = new Set<string>();
244
+ const checkId = (id: string, isWhole: boolean) => {
245
+ if (seenInPage.has(id)) issues.push(`Family "${page.id}": id "${id}" is used more than once on this page.`);
246
+ seenInPage.add(id);
247
+ if (isWhole) return;
248
+ if (allPageIds.has(id)) issues.push(`Family "${page.id}": id "${id}" collides with another page's id.`);
249
+ if (otherMemberIds.has(id)) issues.push(`Family "${page.id}": id "${id}" collides with another member or alias elsewhere in the catalog.`);
250
+ };
251
+ for (const m of members) {
252
+ checkId(m.id, m.id === whole);
253
+ for (const alias of m.aliases ?? []) checkId(alias, false);
254
+ }
255
+ for (const alias of page.aliases ?? []) checkId(alias, false);
256
+
257
+ // Slice D1 item 3: an anatomy `member` item must name a real, non-whole member of this page, and
258
+ // each member may be pinned to the picture's legend at most once.
259
+ // D2 item A2: a member item whose member has no number — outside every `inWhole` section, or
260
+ // under a parent that isn't itself numbered — must also be reported; left unchecked, it renders
261
+ // a blank 34px pin slot in the legend and nothing at all from `AnatomyPin` in the picture.
262
+ if (page.anatomy) {
263
+ const numbered = numberFor(page);
264
+ const seenAnatomyMembers = new Set<string>();
265
+ for (const group of page.anatomy.groups) {
266
+ for (const item of group.items) {
267
+ if (!('member' in item)) continue;
268
+ if (item.member === whole) {
269
+ issues.push(`Family "${page.id}": anatomy item "${item.member}" names the whole.`);
270
+ continue;
271
+ }
272
+ if (!byId.has(item.member)) {
273
+ issues.push(`Family "${page.id}": anatomy item "${item.member}" is not a member of this page.`);
274
+ continue;
275
+ }
276
+ if (seenAnatomyMembers.has(item.member)) {
277
+ issues.push(`Family "${page.id}": anatomy lists member "${item.member}" more than once.`);
278
+ continue;
279
+ }
280
+ seenAnatomyMembers.add(item.member);
281
+ if (!numbered.has(item.member)) {
282
+ issues.push(`Family "${page.id}": anatomy item "${item.member}" has no number.`);
283
+ }
284
+ }
285
+ }
286
+ }
287
+
288
+ return issues;
289
+ }
290
+
291
+ /** Slice D1 item 2: anatomy-first numbering — the only numbering rule for every family page (not
292
+ * only one with `anatomy` set; it superseded the earlier whole-and-letters rule outright). The
293
+ * whole gets no number. A **top-level part** is any member of an `inWhole` section whose `parent`
294
+ * is the whole, or who has no `parent` at all (an in-whole root with no Swift parent edge is still
295
+ * top-level) — numbered `1`, `2`, `3`… in authored order (the order a member first appears
296
+ * scanning `inWhole` sections, in section order). A child takes its parent's number plus `.1`,
297
+ * `.2`, … in authored order. Members outside any `inWhole` section (Related) get no number at
298
+ * all. Returns an empty map for a page without members. */
299
+ export function numberFor(page: FamilyOutlineInput): Map<string, { number: string; level: number }> {
300
+ const outline = new Map<string, { number: string; level: number }>();
301
+ const { whole, sections, members } = page;
302
+ if (!whole || !sections || !members) return outline;
303
+
304
+ const memberIds = new Set(members.map((m) => m.id));
305
+ // Only `inWhole` sections contribute to numbering — a Related section's members are never
306
+ // top-level parts, and never occupy authored order (so they can't be skipped-over roots either).
307
+ const authoredOrder: string[] = [];
308
+ const seen = new Set<string>();
309
+ for (const section of sections) {
310
+ if (!section.inWhole) continue;
311
+ for (const id of section.members) {
312
+ if (!seen.has(id) && memberIds.has(id)) {
313
+ seen.add(id);
314
+ authoredOrder.push(id);
315
+ }
316
+ }
317
+ }
318
+
319
+ const parentOf = new Map(members.map((m) => [m.id, m.parent]));
320
+ const childrenOf = new Map<string, string[]>();
321
+ for (const id of authoredOrder) {
322
+ const parent = parentOf.get(id);
323
+ if (parent === undefined) continue;
324
+ childrenOf.set(parent, [...(childrenOf.get(parent) ?? []), id]);
325
+ }
326
+
327
+ // V2c item 14's cycle guard: bad data already reported by `validateFamily` must degrade to a
328
+ // partial outline, never an infinite recursion.
329
+ const assignChildren = (parentId: string, parentNumber: string, level: number, visited: Set<string>) => {
330
+ const kids = childrenOf.get(parentId) ?? [];
331
+ kids.forEach((childId, index) => {
332
+ if (visited.has(childId)) return;
333
+ visited.add(childId);
334
+ const number = `${parentNumber}.${index + 1}`;
335
+ outline.set(childId, { number, level: level + 1 });
336
+ assignChildren(childId, number, level + 1, visited);
337
+ });
338
+ };
339
+
340
+ const visited = new Set<string>([whole]);
341
+ const topLevel = authoredOrder.filter((id) => {
342
+ const parent = parentOf.get(id);
343
+ return parent === undefined || parent === whole;
344
+ });
345
+ let next = 0;
346
+ for (const id of topLevel) {
347
+ if (visited.has(id)) continue;
348
+ visited.add(id);
349
+ next += 1;
350
+ const number = String(next);
351
+ outline.set(id, { number, level: 2 });
352
+ assignChildren(id, number, 2, visited);
353
+ }
354
+
355
+ return outline;
356
+ }
357
+
109
358
  function orderGroups<TId extends string>(groupIds: Map<string, TId[]>, groupOrder: string[] | undefined): NavGroup<TId>[] {
110
359
  const listed = groupOrder ?? [];
111
360
  const labels = [...groupIds.keys()];
@@ -128,13 +377,47 @@ export function buildCatalogSections<TId extends string>(
128
377
  groupOrder?: string[],
129
378
  ): { sections: SectionDef<TId>[]; groups: NavGroup<TId>[] } {
130
379
  const componentByName = new Map(components.map((c) => [c.name, c]));
131
- const documented = new Set(pages.map((page) => page.component ?? page.id));
380
+ // A page with `members` documents every member's own `component` instead of `page.component ??
381
+ // page.id` — that fallback never applies to a family page (spec §5.1).
382
+ const documented = new Set<string>();
383
+ for (const page of pages) {
384
+ if (page.members && page.members.length > 0) {
385
+ for (const member of page.members) if (member.component) documented.add(member.component);
386
+ } else {
387
+ documented.add(page.component ?? page.id);
388
+ }
389
+ }
132
390
  const groupIds = new Map<string, TId[]>();
133
391
  const addToGroup = (group: string, id: TId) => groupIds.set(group, [...(groupIds.get(group) ?? []), id]);
134
- // Every name a page's `composedOf` could legitimately point at: every real component (documented
135
- // or not — an undocumented one is still a real part of the design system) plus every page id
136
- // (token pages, standalone recipes, …).
137
- const knownComposedOfNames = new Set<string>([...componentByName.keys(), ...pages.map((page) => page.id)]);
392
+ // Every name a page's (or, V2d, a family member's own) `composedOf` could legitimately point
393
+ // at: every real component (documented or not — an undocumented one is still a real part of the
394
+ // design system), every page id (token pages, standalone recipes, …), every family member id
395
+ // (a member's composedOf may point at a sibling member, or a member of another family), and —
396
+ // N8 — every member alias and page-level alias: `FamilyPage`'s own `resolvesAddress` already
397
+ // treats these as working links (`aliasTargets`), so this warning must accept the same names.
398
+ const knownComposedOfNames = new Set<string>([
399
+ ...componentByName.keys(),
400
+ ...pages.map((page) => page.id),
401
+ ...pages.flatMap((page) => (page.members ?? []).map((member) => member.id)),
402
+ ...pages.flatMap((page) => (page.members ?? []).flatMap((member) => member.aliases ?? [])),
403
+ ...pages.flatMap((page) => page.aliases ?? []),
404
+ ]);
405
+
406
+ // Every id/alias a family page's own `members` and `aliases` use, keyed by page id — gathered up
407
+ // front so `validateFamily` (above) can check catalog-wide uniqueness against every *other*
408
+ // page's ids.
409
+ const allPageIds = new Set(pages.map((page) => page.id));
410
+ const familyIdsByPage = new Map<TId, Set<string>>();
411
+ for (const page of pages) {
412
+ if (!page.members) continue;
413
+ const ids = new Set<string>();
414
+ for (const member of page.members) {
415
+ ids.add(member.id);
416
+ for (const alias of member.aliases ?? []) ids.add(alias);
417
+ }
418
+ for (const alias of page.aliases ?? []) ids.add(alias);
419
+ familyIdsByPage.set(page.id, ids);
420
+ }
138
421
 
139
422
  // Design §4 "duplicate-page-id" rule's viewer-side equivalent — this is the one place in the
140
423
  // whole viewer that already resolves every page's id to build sections/groups, so it costs
@@ -142,14 +425,63 @@ export function buildCatalogSections<TId extends string>(
142
425
  // `CatalogShell.tsx` would otherwise just let the last one silently win).
143
426
  const seenIds = new Set<TId>();
144
427
  const sections: SectionDef<TId>[] = pages.map((page) => {
428
+ // N2: `seenIds` must be populated (and consulted, below in the undocumented-component loop)
429
+ // regardless of `__DEV__` — only the `console.warn` calls are development-only diagnostics;
430
+ // a production bundle (Metro's `__DEV__ = false`) still needs the stub-skip to work.
431
+ if (seenIds.has(page.id)) {
432
+ if ((globalThis as { __DEV__?: boolean }).__DEV__) {
433
+ console.warn(`[Catalog] Page id "${page.id}" is used by more than one page; only the last one will show. Give one of them its own "id".`);
434
+ }
435
+ }
436
+ seenIds.add(page.id);
145
437
  if ((globalThis as { __DEV__?: boolean }).__DEV__) {
146
- if (seenIds.has(page.id)) console.warn(`[Catalog] Page id "${page.id}" is used by more than one page; only the last one will show. Give one of them its own "id".`);
147
- seenIds.add(page.id);
148
438
  for (const issue of validateComposedOf(page.composedOf, knownComposedOfNames)) console.warn(`[Catalog] ${page.id}: ${issue}`);
439
+ // V2d item 4: the same validation, per family member's own `composedOf`.
440
+ for (const member of page.members ?? []) {
441
+ for (const issue of validateComposedOf(member.composedOf, knownComposedOfNames)) {
442
+ console.warn(`[Catalog] ${page.id}.${member.id}: ${issue}`);
443
+ }
444
+ }
149
445
  }
150
- const componentName = page.component ?? page.id;
151
- const component = componentByName.get(componentName);
446
+ const isFamily = page.members !== undefined && page.members.length > 0;
447
+ const componentName = isFamily ? undefined : (page.component ?? page.id);
448
+ const component = componentName ? componentByName.get(componentName) : undefined;
152
449
  addToGroup(page.group, page.id);
450
+
451
+ let family: SectionDef<TId>['family'];
452
+ if (isFamily) {
453
+ const otherPageIds = new Set([...allPageIds].filter((id) => id !== page.id));
454
+ const otherMemberIds = new Set<string>();
455
+ for (const [id, ids] of familyIdsByPage) if (id !== page.id) for (const memberId of ids) otherMemberIds.add(memberId);
456
+ // `hasSpecimens` is computed here, as doctor does, so the list-section rule also fires in
457
+ // the development console, not only in `ds-viewer doctor`.
458
+ const members = page.members!.map((member) => ({
459
+ ...member,
460
+ hasSpecimens: Boolean(member.variants?.items?.length || member.states?.items?.length || member.comparison),
461
+ }));
462
+ const issues = validateFamily({ ...page, members, anatomy: page.anatomy }, otherPageIds, otherMemberIds);
463
+ if ((globalThis as { __DEV__?: boolean }).__DEV__) {
464
+ for (const issue of issues) console.error(`[Catalog] ${issue}`);
465
+ }
466
+ family = {
467
+ whole: page.whole!,
468
+ sections: page.sections!,
469
+ outline: numberFor(page),
470
+ notShown: page.notShown,
471
+ aliases: page.aliases,
472
+ apiLabel: page.apiLabel,
473
+ anatomy: page.anatomy,
474
+ members: page.members!.map((member) => {
475
+ const memberComponent = member.component ? componentByName.get(member.component) : undefined;
476
+ return {
477
+ ...member,
478
+ props: memberComponent ? mergePropNotes(memberComponent, undefined) : undefined,
479
+ path: memberComponent?.file ?? member.source,
480
+ };
481
+ }),
482
+ };
483
+ }
484
+
153
485
  // Important finding 4: a standalone page (no matching component record) has no props to show
154
486
  // and nothing to say "This component takes no props" about — hide the Props box, and fall back
155
487
  // to the page's own file as its Source instead of an empty path.
@@ -179,12 +511,22 @@ export function buildCatalogSections<TId extends string>(
179
511
  composedOf: page.composedOf,
180
512
  internalProps: page.internalProps,
181
513
  intentionalStaticPreview: page.intentionalStaticPreview,
514
+ family,
182
515
  };
183
516
  });
184
517
 
185
518
  const undocumented = components.filter((component) => !documented.has(component.name)).sort((a, b) => a.name.localeCompare(b.name));
186
519
  for (const component of undocumented) {
187
520
  const id = component.name as TId;
521
+ // V2c item 7: a component with no page of its own, whose name happens to collide with an
522
+ // authored page's own id (most likely a family page, which never resolves `documented` via the
523
+ // `page.component ?? page.id` fallback) — its stub must never win that id in `sections`.
524
+ if (seenIds.has(id)) {
525
+ if ((globalThis as { __DEV__?: boolean }).__DEV__) {
526
+ console.warn(`[Catalog] Component "${id}" has no catalog page of its own, and "${id}" is already used by another page's id; skipping its stub.`);
527
+ }
528
+ continue;
529
+ }
188
530
  sections.push({ id, description: 'No examples documented.', path: component.file, props: mergePropNotes(component, undefined) });
189
531
  addToGroup('Components', id);
190
532
  }
@@ -183,6 +183,13 @@ export const CATALOG_COLOR = {
183
183
  accentSubtle: '#e9efff',
184
184
  /** 3px keyboard focus ring on every catalog control. */
185
185
  focusRing: '#c9d7ff',
186
+ /** Family-page Part tree connector line (Slice D1/D2) — the approved reference's own line color
187
+ * has no match among the other tokens above. */
188
+ treeLine: '#a9bdf0',
189
+ /** Family-page API code block: a dark surface and its text, the one place the catalog shows a
190
+ * member's raw API signature. Dark on purpose so it reads as code, not as catalog chrome. */
191
+ codeSurface: '#1d1d1f',
192
+ codeOnSurface: '#e8e8e8',
186
193
  /** @deprecated kept for the public API; re-points at `CATALOG_FONT.code` so there is one source
187
194
  * of truth. New code should reach for `CATALOG_FONT.code` (or a `CATALOG_TEXT` `code*` role)
188
195
  * instead. */
@@ -72,6 +72,9 @@ export interface VariantSlot {
72
72
  * it only ever lowers the natural, width-based column count, never raises it. Omit to let the
73
73
  * slot's specimen size and available width decide alone (today's behavior, unchanged). */
74
74
  maxColumns?: 1 | 2 | 3 | 4 | 5;
75
+ /** Names the one property this grid varies (e.g. "Expression", "Shape"). Shown as the grid's
76
+ * label, including on family pages, which otherwise hide block labels. */
77
+ axis?: string;
75
78
  items: VariantExample[];
76
79
  }
77
80
 
@@ -274,6 +277,9 @@ export interface SectionDef<TId extends string = string> {
274
277
  * so the exemption is a visible declaration in the page rather than a silent gap — write an
275
278
  * example instead whenever the reader really does choose the value. */
276
279
  internalProps?: Record<string, string>;
280
+ /** Set when the authored page has `members`: the viewer renders a `FamilyPage` instead of the
281
+ * normal single-component layout. See `CatalogPageInput.members` and `FamilyDef`. */
282
+ family?: FamilyDef;
277
283
  }
278
284
 
279
285
  /** A labeled group of section ids in the sidebar (e.g. "Components" vs "Tokens"). */
@@ -313,6 +319,106 @@ export interface VersionStatus {
313
319
  update: UpdateNotice | null;
314
320
  }
315
321
 
322
+ /** One documented declaration on a family page (spec §5.1): its name, source location, fidelity,
323
+ * specimens or reference text, and API. The whole (the page's own `id`) is a member too. */
324
+ export interface CatalogMember {
325
+ id: string;
326
+ /** A detected component export. Its generated props go in the disclosure, and its stub page is
327
+ * suppressed. Omit for a documentation-only member with no rendered specimen. */
328
+ component?: string;
329
+ /** The native source shown in the line below the specimens, `path:line`. */
330
+ source: string;
331
+ fidelity: 'replicated' | 'approximated' | 'documentation-only';
332
+ /** For example "Anatomy", "Native integration", "Helper". Slice D1 removed the below-specimens
333
+ * line's own relationship/section-label display entirely (the anatomy box replaced it); kept as
334
+ * authored data for your own reference, but not read by the renderer. */
335
+ relationship: string;
336
+ /** The member that contains or uses this one. Omitted on the whole and on related roots. */
337
+ parent?: string;
338
+ /** Short sentence for members outside the whole (e.g. "Wraps BotAvatarView"). Member names
339
+ * inside it become links to that member's address. */
340
+ related?: string;
341
+ description: string;
342
+ variants?: VariantSlot;
343
+ states?: VariantSlot;
344
+ comparison?: ComparisonDef;
345
+ /** Bullets inside the disclosure. */
346
+ notes?: string[];
347
+ whenToUse?: string;
348
+ a11y?: string;
349
+ /** Swift declaration excerpt, generated by the host app's scanner, shown in the disclosure. */
350
+ api?: string;
351
+ /** What this member is built from or composed with — same meaning as `SectionDef.composedOf`,
352
+ * shown in the disclosure as one "Uses …" line (`disclosureContent`'s `'uses'` part). */
353
+ composedOf?: ComposedOfEntry[];
354
+ notShown?: string;
355
+ /** Display only, same meaning as `SectionDef.hide` — not a doctor exemption. */
356
+ hide?: { accessibility?: boolean; props?: boolean };
357
+ /** Extra names (e.g. retired page ids) that resolve to this member. */
358
+ aliases?: string[];
359
+ /** Same meaning as `SectionDef.osComponent` — rendered as the same small badge in the member
360
+ * header. */
361
+ osComponent?: 'full' | 'partial';
362
+ previewWidths?: PreviewWidths;
363
+ previewLayout?: 'frames' | 'table';
364
+ specimenSize?: SpecimenSize;
365
+ specimenSurface?: SpecimenSurfaceKind;
366
+ /** Same meaning as `SectionDef.intentionalStaticPreview` — accepted per member, but not yet
367
+ * consumed by anything (neither the viewer nor `doctor`'s member-level checks read it). */
368
+ intentionalStaticPreview?: { reason: string };
369
+ }
370
+
371
+ /** One section of a family page (spec §5.1): `kind` alone decides full blocks versus the folded
372
+ * list card. `inWhole` sections come right after the whole; the others come after them — ordering
373
+ * only, numbering follows each member's own `parent` (see `numberFor`); a Related (non-`inWhole`)
374
+ * member is never numbered. */
375
+ export interface CatalogMemberSection {
376
+ label: string;
377
+ kind: 'blocks' | 'list';
378
+ inWhole: boolean;
379
+ members: string[];
380
+ }
381
+
382
+ /** Slice D1: one row of a family page's anatomy legend — either a `member` of this page (numbered,
383
+ * linked to that member's address) or a component `other`wise documented elsewhere (never
384
+ * numbered; linked only when the name resolves to a real catalog address). `description` is this
385
+ * legend row's own text, independent of the member's own `description`. */
386
+ export type CatalogAnatomyItem = ({ member: string } | { other: string }) & { description: string };
387
+
388
+ /** One labeled group of anatomy legend rows (e.g. "In this picture", "No area of their own",
389
+ * "From other pages") — a group never splits across legend columns (`AnatomyPin`/`FamilyPage`). */
390
+ export interface CatalogAnatomyGroup {
391
+ label: string;
392
+ items: CatalogAnatomyItem[];
393
+ }
394
+
395
+ /** Slice D1 item 1: the anatomy box at the top of a family page — a picture of the whole with
396
+ * numbered pins (via `AnatomyPin`, looked up through context so the picture never hardcodes a
397
+ * number), and a legend beside it (six items or fewer) or below it in up to three balanced
398
+ * columns. Omit for a family page with no anatomy picture. */
399
+ export interface CatalogAnatomy {
400
+ picture?: React.ReactNode;
401
+ groups: CatalogAnatomyGroup[];
402
+ }
403
+
404
+ /** A family page's members with resolved props and paths, the whole, sections, and the computed
405
+ * outline — set on `SectionDef.family` by `buildCatalogSections` when a page has `members`. */
406
+ export interface FamilyDef {
407
+ whole: string;
408
+ members: Array<CatalogMember & { props?: PropDef[]; path: string }>;
409
+ sections: CatalogMemberSection[];
410
+ outline: Map<string, { number: string; level: number }>;
411
+ notShown?: string;
412
+ /** Extra names for the page itself (its whole) — e.g. retired page ids. See
413
+ * `CatalogPageInput.aliases`. */
414
+ aliases?: string[];
415
+ /** Label for the disclosure's API part (`disclosureLabel`). See `CatalogPageInput.apiLabel`.
416
+ * Defaults to `'API'` when absent. */
417
+ apiLabel?: string;
418
+ /** Slice D1 item 1. See `CatalogPageInput.anatomy`. */
419
+ anatomy?: CatalogAnatomy;
420
+ }
421
+
316
422
  /** One titled card on a token page (`SectionDef.tokenSections`). */
317
423
  export interface TokenSection {
318
424
  /** The kind of token this card holds, e.g. "Semantic colours", "Palette", "Body". */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krapwoo/ds-viewer",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A browsable, always-current catalog for an Expo / React Native design system.",