@krapwoo/ds-viewer 0.13.1 → 0.14.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
@@ -537,6 +537,20 @@ export default defineCatalogPage({
537
537
  resolves to a real catalog address (a page, another member, or an alias — in this family or any
538
538
  other), otherwise it shows in code; an unknown name logs a `[Catalog]` warning the same way the
539
539
  page-level field's does.
540
+ - **"Also used by …"** is derived, never authored — there is no field for it anywhere. It's the
541
+ inverse of the "Uses …" line above: if some other page or member's own `composedOf` names this
542
+ one with `relationship: 'built-in'`, that's already a declaration that the other one uses it;
543
+ `buildCatalogSections` inverts every such declaration across the whole catalog and shows the
544
+ result as a short line below the member's specimens (joining the same line as its source path),
545
+ so the same relationship never has to be authored a second time from this end. Only `'built-in'`
546
+ counts — `'slot'` and `'related'` never produce a line. A user that is this member's own parent,
547
+ the page's own whole, the member itself, or any other member of the same family page is excluded:
548
+ that edge is the family tree already, not a second user. One user shows as `Also used by X
549
+ (role)` (role omitted when empty); two or three as `Also used by X, Y and Z`, with no roles —
550
+ they bloat the line; more than three as `Also used by X, Y and Z +N more`, with `+N more` as
551
+ plain text. A user that resolves to a real catalog address links the same way a
552
+ `related`/`composedOf` name does; otherwise it shows as plain text. Nothing renders when nothing
553
+ else uses the member — the overwhelming majority of them.
540
554
  - **`notShown`** (page-level) is a short "Not shown in the browser: …" line under the page head,
541
555
  for behavior a web catalog can't demonstrate (a timer, a native gesture). A member's own
542
556
  `notShown` works the same way, under its specimens. Like `aliases`, the page-level field only
@@ -309,6 +309,48 @@ pages, components, groupOrder) {
309
309
  ...pages.flatMap((page) => (page.members ?? []).flatMap((member) => member.aliases ?? [])),
310
310
  ...pages.flatMap((page) => page.aliases ?? []),
311
311
  ]);
312
+ // A member only ever looks itself up (below) by its own canonical `id`, but `knownComposedOfNames`
313
+ // (N8, above) deliberately lets a `composedOf` entry's own `component` name a member's *alias*, or
314
+ // the member's `component` field instead of its `id` — both resolve as working links through
315
+ // `FamilyPage`'s own `resolvesAddress`. Without resolving through the same map here, an
316
+ // alias-named or component-named entry would link fine going forward but derive nothing going
317
+ // back: a missing "Also used by" line looks identical to "nothing uses this".
318
+ const canonicalMemberIdByAlias = new Map();
319
+ for (const page of pages) {
320
+ for (const member of page.members ?? []) {
321
+ for (const alias of member.aliases ?? [])
322
+ canonicalMemberIdByAlias.set(alias, member.id);
323
+ if (member.component && member.component !== member.id)
324
+ canonicalMemberIdByAlias.set(member.component, member.id);
325
+ }
326
+ }
327
+ const canonicalUsedByTarget = (name) => canonicalMemberIdByAlias.get(name) ?? name;
328
+ // "Also used by" (derived, never authored): the inverse of every `composedOf` entry in the
329
+ // catalog whose `relationship` is `'built-in'` — the forward direction renders as "Uses X"
330
+ // (`UsesLine`); this is that same relationship read from the other end, keyed by the entry's own
331
+ // `component` (the used thing) — gathered once, up front, so a family member just looks up its
332
+ // own id below. A page's own `composedOf` attributes to that page's id (its address, e.g. a
333
+ // family's whole); a family member's own `composedOf` attributes to that member's own id.
334
+ const usedByTarget = new Map();
335
+ const addUsedBy = (target, component, role) => {
336
+ const list = usedByTarget.get(target);
337
+ if (list)
338
+ list.push({ component, role });
339
+ else
340
+ usedByTarget.set(target, [{ component, role }]);
341
+ };
342
+ for (const page of pages) {
343
+ for (const entry of page.composedOf ?? []) {
344
+ if (entry.relationship === 'built-in')
345
+ addUsedBy(canonicalUsedByTarget(entry.component), page.id, entry.role);
346
+ }
347
+ for (const member of page.members ?? []) {
348
+ for (const entry of member.composedOf ?? []) {
349
+ if (entry.relationship === 'built-in')
350
+ addUsedBy(canonicalUsedByTarget(entry.component), member.id, entry.role);
351
+ }
352
+ }
353
+ }
312
354
  // Every id/alias a family page's own `members` and `aliases` use, keyed by page id — gathered up
313
355
  // front so `validateFamily` (above) can check catalog-wide uniqueness against every *other*
314
356
  // page's ids.
@@ -375,6 +417,10 @@ pages, components, groupOrder) {
375
417
  for (const issue of issues)
376
418
  console.error(`[Catalog] ${issue}`);
377
419
  }
420
+ // "Also used by": every id this page's own members (the whole included) use is excluded
421
+ // from its own members' derived users — a sibling (or the whole) composing a part is still
422
+ // the same family tree, already expressed by `parent`/the page itself, not a second user.
423
+ const pageMemberIds = new Set(page.members.map((m) => m.id));
378
424
  family = {
379
425
  whole: page.whole,
380
426
  sections: page.sections,
@@ -386,10 +432,26 @@ pages, components, groupOrder) {
386
432
  relatedPages: page.relatedPages,
387
433
  members: page.members.map((member) => {
388
434
  const memberComponent = member.component ? componentByName.get(member.component) : undefined;
435
+ // Dedupe by `component`, keeping the first role: page-level and whole-member `composedOf`
436
+ // attribute to the same string for a family's whole (`validateFamily` forces
437
+ // `whole === page.id`), and `validateComposedOf` has no duplicate check of its own, so the
438
+ // same user declared twice would otherwise render "Also used by P and P".
439
+ const dedupedUsers = new Map();
440
+ for (const entry of usedByTarget.get(member.id) ?? []) {
441
+ if (pageMemberIds.has(entry.component))
442
+ continue;
443
+ if (!dedupedUsers.has(entry.component))
444
+ dedupedUsers.set(entry.component, entry);
445
+ }
446
+ const alsoUsedBy = [...dedupedUsers.values()].map((entry) => ({
447
+ component: entry.component,
448
+ role: entry.role || undefined,
449
+ }));
389
450
  return {
390
451
  ...member,
391
452
  props: memberComponent ? mergePropNotes(memberComponent, undefined) : undefined,
392
453
  path: memberComponent?.file ?? member.source,
454
+ alsoUsedBy,
393
455
  };
394
456
  }),
395
457
  };
@@ -11,6 +11,7 @@ import { hashForId } from './catalogNavigation';
11
11
  // component with no test of its own that imports it directly under Node, so it only ever goes
12
12
  // through the bundler.
13
13
  import {
14
+ alsoUsedBySegments,
14
15
  anatomyColumns,
15
16
  anatomyPinFor,
16
17
  ariaLevelFor,
@@ -26,7 +27,7 @@ import {
26
27
  type PartTreeEntry,
27
28
  type RelatedSegment,
28
29
  } from './familyModel';
29
- import type { CatalogAnatomy, CatalogAnatomyGroup, CatalogMember, CatalogMemberSection, ComposedOfEntry, FamilyDef, PreviewWidths, PropDef, RelatedPage, SectionDef } from './types';
30
+ import type { AlsoUsedByEntry, CatalogAnatomy, CatalogAnatomyGroup, CatalogMember, CatalogMemberSection, ComposedOfEntry, FamilyDef, PreviewWidths, PropDef, RelatedPage, SectionDef } from './types';
30
31
 
31
32
  /** `SectionDef`'s parts `presentationBlocks`/`Blocks` actually read, built from one family member
32
33
  * so its own specimens render through the exact same renderers (`VariantGroup`, `ComparisonGrid`,
@@ -125,21 +126,28 @@ function MemberNameRow({
125
126
  );
126
127
  }
127
128
 
128
- /** `[related ·] [relationship ·] source path:line` (spec §5.2 "Below the specimens"). A member
129
- * name inside `related` is a link to that member's address — a real `href`, like every other
129
+ /** `[related ·] [relationship ·] source [· Also used by …]` (spec §5.2 "Below the specimens",
130
+ * derived "also used by" slice). A member name inside `related`, and a resolvable component in
131
+ * the derived "Also used by" segment, both link to that address — a real `href`, like every other
130
132
  * in-app link, so clicking it goes through the same `hashchange` routing as typing the address or
131
- * using search (`CatalogShell`). */
133
+ * using search (`CatalogShell`). An unresolvable user (a declared user with no catalog page of its
134
+ * own) renders as plain text instead, the same way `UsesLine` treats an unresolvable name. */
132
135
  function BelowSpecimensLine({
133
136
  related,
134
137
  showRelationship,
135
138
  relationship,
136
139
  source,
140
+ alsoUsedBy,
141
+ resolvesAddress,
137
142
  }: {
138
143
  related?: RelatedSegment[];
139
144
  showRelationship: boolean;
140
145
  relationship: string;
141
146
  source: string;
147
+ alsoUsedBy: AlsoUsedByEntry[];
148
+ resolvesAddress: (name: string) => boolean;
142
149
  }) {
150
+ const usedBy = alsoUsedBySegments(alsoUsedBy);
143
151
  return (
144
152
  <Text style={styles.belowLine}>
145
153
  {related &&
@@ -155,13 +163,37 @@ function BelowSpecimensLine({
155
163
  {related && ' · '}
156
164
  {showRelationship && `${relationship} · `}
157
165
  {source}
166
+ {usedBy.length > 0 && ' · '}
167
+ {usedBy.map((segment, i) =>
168
+ segment.component && resolvesAddress(segment.component) ? (
169
+ <Text key={i} role="link" style={styles.relatedLink} {...({ href: hashForId(segment.component) } as Record<string, unknown>)}>
170
+ {segment.text}
171
+ </Text>
172
+ ) : (
173
+ <Text key={i}>{segment.text}</Text>
174
+ ),
175
+ )}
158
176
  </Text>
159
177
  );
160
178
  }
161
179
 
162
- /** Slice D1 item 6: the whole's own one-line summary — `<name> · <fidelity badge> · <source>` — in
163
- * place of the below-specimens line (the whole has no `relationship`/`related` to show). */
164
- function WholeBelowLine({ name, fidelity, source }: { name: string; fidelity: CatalogMember['fidelity']; source: string }) {
180
+ /** Slice D1 item 6: the whole's own one-line summary — `<name> · <fidelity badge> · <source>
181
+ * [· Also used by …]` — in place of the below-specimens line (the whole has no
182
+ * `relationship`/`related` to show, but it is still a member, so it still derives users). */
183
+ function WholeBelowLine({
184
+ name,
185
+ fidelity,
186
+ source,
187
+ alsoUsedBy,
188
+ resolvesAddress,
189
+ }: {
190
+ name: string;
191
+ fidelity: CatalogMember['fidelity'];
192
+ source: string;
193
+ alsoUsedBy: AlsoUsedByEntry[];
194
+ resolvesAddress: (name: string) => boolean;
195
+ }) {
196
+ const usedBy = alsoUsedBySegments(alsoUsedBy);
165
197
  return (
166
198
  <View style={styles.wholeBelowRow}>
167
199
  <Text style={styles.wholeName}>{name}</Text>
@@ -169,6 +201,18 @@ function WholeBelowLine({ name, fidelity, source }: { name: string; fidelity: Ca
169
201
  <FidelityBadge fidelity={fidelity} />
170
202
  <Text style={styles.belowLine}> · </Text>
171
203
  <Text style={styles.belowLine}>{source}</Text>
204
+ {usedBy.length > 0 && <Text style={styles.belowLine}> · </Text>}
205
+ {usedBy.map((segment, i) =>
206
+ segment.component && resolvesAddress(segment.component) ? (
207
+ <Text key={i} role="link" style={styles.relatedLink} {...({ href: hashForId(segment.component) } as Record<string, unknown>)}>
208
+ {segment.text}
209
+ </Text>
210
+ ) : (
211
+ <Text key={i} style={styles.belowLine}>
212
+ {segment.text}
213
+ </Text>
214
+ ),
215
+ )}
172
216
  </View>
173
217
  );
174
218
  }
@@ -294,7 +338,7 @@ function MemberDisclosureToggle({
294
338
  );
295
339
  }
296
340
 
297
- type FamilyMember = CatalogMember & { props?: PropDef[]; path: string };
341
+ type FamilyMember = CatalogMember & { props?: PropDef[]; path: string; alsoUsedBy?: AlsoUsedByEntry[] };
298
342
 
299
343
  interface MemberRenderProps {
300
344
  member: FamilyMember;
@@ -377,6 +421,8 @@ function FamilyPart({ member, number, level, allMemberIds, defaultPreviewWidths,
377
421
  showRelationship={false}
378
422
  relationship={member.relationship}
379
423
  source={member.source}
424
+ alsoUsedBy={member.alsoUsedBy ?? []}
425
+ resolvesAddress={resolvesAddress}
380
426
  />
381
427
  <View style={styles.disclosureSpacer}>
382
428
  <MemberDisclosureToggle member={member} apiLabel={apiLabel} resolvesAddress={resolvesAddress} />
@@ -421,7 +467,14 @@ function FoldedRow({ member, number, allMemberIds, resolvesAddress, open, onTogg
421
467
  web must never bubble its own click into this row's own press handler and close it. */}
422
468
  {open && (
423
469
  <View style={styles.foldedRowBody}>
424
- <BelowSpecimensLine related={related} showRelationship={false} relationship={member.relationship} source={member.source} />
470
+ <BelowSpecimensLine
471
+ related={related}
472
+ showRelationship={false}
473
+ relationship={member.relationship}
474
+ source={member.source}
475
+ alsoUsedBy={member.alsoUsedBy ?? []}
476
+ resolvesAddress={resolvesAddress}
477
+ />
425
478
  {parts.length > 0 && (
426
479
  <DisclosureBody member={member} parts={parts} resolvedProps={member.props} resolvesAddress={resolvesAddress} />
427
480
  )}
@@ -1055,7 +1108,13 @@ export function FamilyPage<TId extends string>({
1055
1108
  </View>
1056
1109
  )}
1057
1110
  {wholeMember.notShown && <Text style={styles.belowLine}>{wholeMember.notShown}</Text>}
1058
- <WholeBelowLine name={wholeMember.id} fidelity={wholeMember.fidelity} source={wholeMember.source} />
1111
+ <WholeBelowLine
1112
+ name={wholeMember.id}
1113
+ fidelity={wholeMember.fidelity}
1114
+ source={wholeMember.source}
1115
+ alsoUsedBy={wholeMember.alsoUsedBy ?? []}
1116
+ resolvesAddress={resolvesAddress}
1117
+ />
1059
1118
  <View style={styles.disclosureSpacer}>
1060
1119
  <MemberDisclosureToggle member={wholeMember} apiLabel={apiLabel} resolvesAddress={resolvesAddress} />
1061
1120
  </View>
@@ -4,7 +4,7 @@
4
4
  * building, disclosure content, and link/pin lookups. Outline numbering (`numberFor`) and family
5
5
  * validation (`validateFamily`) live in `pageApi.ts` instead — see that file's own comments.
6
6
  */
7
- import type { CatalogMember, CatalogMemberSection, PropDef } from './types.ts';
7
+ import type { AlsoUsedByEntry, CatalogMember, CatalogMemberSection, PropDef } from './types.ts';
8
8
 
9
9
  /** One node of a `blocks` section's own Part tree (Slice D1 item 7 "Tree line"). */
10
10
  export interface PartTreeEntry {
@@ -163,6 +163,37 @@ export interface RelatedSegment {
163
163
  memberId?: string;
164
164
  }
165
165
 
166
+ /** One segment of an "Also used by" line: plain text, or another component's own id, rendered as a
167
+ * link when it resolves to a real catalog address and plain text otherwise (`FamilyPage`'s own
168
+ * `resolvesAddress`, the same test `UsesLine` already uses). */
169
+ export interface UsedBySegment {
170
+ text: string;
171
+ component?: string;
172
+ }
173
+
174
+ /** Builds the "Also used by …" segments (field spec "Rendering") from a member's derived
175
+ * `alsoUsedBy` list: `[]` for none (the caller renders nothing), one user with its own role in
176
+ * parentheses when non-empty, two or three joined with commas and "and" (no roles — they bloat the
177
+ * line), or the first three plus a plain-text "+N more" beyond that. */
178
+ export function alsoUsedBySegments(entries: readonly AlsoUsedByEntry[]): UsedBySegment[] {
179
+ if (entries.length === 0) return [];
180
+ const segments: UsedBySegment[] = [{ text: 'Also used by ' }];
181
+ if (entries.length === 1) {
182
+ const [entry] = entries;
183
+ segments.push({ text: entry.component, component: entry.component });
184
+ if (entry.role) segments.push({ text: ` (${entry.role})` });
185
+ return segments;
186
+ }
187
+ const shown = entries.slice(0, 3);
188
+ shown.forEach((entry, i) => {
189
+ segments.push({ text: entry.component, component: entry.component });
190
+ if (i < shown.length - 2) segments.push({ text: ', ' });
191
+ else if (i === shown.length - 2) segments.push({ text: ' and ' });
192
+ });
193
+ if (entries.length > 3) segments.push({ text: ` +${entries.length - 3} more` });
194
+ return segments;
195
+ }
196
+
166
197
  /** Escapes a string for literal use inside a `RegExp` (the characters the regex engine would
167
198
  * otherwise treat as metacharacters). A Swift declaration name never contains any of these, but
168
199
  * this keeps `linkifyRelated` correct even if one ever did. */
@@ -48,7 +48,7 @@ export { defineCatalogPage, buildCatalogSections } from './pageApi';
48
48
  export type { CatalogPage, CatalogPageInput, GeneratedComponent, GeneratedPropRecord } from './pageApi';
49
49
  export type {
50
50
  PropDef, SectionDef, NavGroup, SpecimenSize, SpecimenSurfaceKind, ComparisonDef, ComparisonCell, ComparisonAxisItem, GridAxis,
51
- PreviewWidths, TokenSection, ComposedOfEntry, CatalogMember, CatalogMemberSection, FamilyDef,
51
+ PreviewWidths, TokenSection, ComposedOfEntry, AlsoUsedByEntry, CatalogMember, CatalogMemberSection, FamilyDef,
52
52
  CatalogAnatomy, CatalogAnatomyGroup, CatalogAnatomyItem, RelatedPage,
53
53
  } from './types';
54
54
  export { UpdatePanel } from './UpdatePanel';
@@ -1,5 +1,5 @@
1
1
  import type React from 'react';
2
- import type { CatalogAnatomy, CatalogMember, CatalogMemberSection, ComparisonDef, ComposedOfEntry, NavGroup, PreviewWidths, PropDef, RelatedPage, SectionDef, SpecimenSize, SpecimenSurfaceKind, TokenSection, VariantSlot } from './types.ts';
2
+ import type { AlsoUsedByEntry, CatalogAnatomy, CatalogMember, CatalogMemberSection, ComparisonDef, ComposedOfEntry, NavGroup, PreviewWidths, PropDef, RelatedPage, 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. */
@@ -447,6 +447,44 @@ export function buildCatalogSections<TId extends string>(
447
447
  ...pages.flatMap((page) => page.aliases ?? []),
448
448
  ]);
449
449
 
450
+ // A member only ever looks itself up (below) by its own canonical `id`, but `knownComposedOfNames`
451
+ // (N8, above) deliberately lets a `composedOf` entry's own `component` name a member's *alias*, or
452
+ // the member's `component` field instead of its `id` — both resolve as working links through
453
+ // `FamilyPage`'s own `resolvesAddress`. Without resolving through the same map here, an
454
+ // alias-named or component-named entry would link fine going forward but derive nothing going
455
+ // back: a missing "Also used by" line looks identical to "nothing uses this".
456
+ const canonicalMemberIdByAlias = new Map<string, string>();
457
+ for (const page of pages) {
458
+ for (const member of page.members ?? []) {
459
+ for (const alias of member.aliases ?? []) canonicalMemberIdByAlias.set(alias, member.id);
460
+ if (member.component && member.component !== member.id) canonicalMemberIdByAlias.set(member.component, member.id);
461
+ }
462
+ }
463
+ const canonicalUsedByTarget = (name: string) => canonicalMemberIdByAlias.get(name) ?? name;
464
+
465
+ // "Also used by" (derived, never authored): the inverse of every `composedOf` entry in the
466
+ // catalog whose `relationship` is `'built-in'` — the forward direction renders as "Uses X"
467
+ // (`UsesLine`); this is that same relationship read from the other end, keyed by the entry's own
468
+ // `component` (the used thing) — gathered once, up front, so a family member just looks up its
469
+ // own id below. A page's own `composedOf` attributes to that page's id (its address, e.g. a
470
+ // family's whole); a family member's own `composedOf` attributes to that member's own id.
471
+ const usedByTarget = new Map<string, Array<{ component: string; role: string }>>();
472
+ const addUsedBy = (target: string, component: string, role: string) => {
473
+ const list = usedByTarget.get(target);
474
+ if (list) list.push({ component, role });
475
+ else usedByTarget.set(target, [{ component, role }]);
476
+ };
477
+ for (const page of pages) {
478
+ for (const entry of page.composedOf ?? []) {
479
+ if (entry.relationship === 'built-in') addUsedBy(canonicalUsedByTarget(entry.component), page.id, entry.role);
480
+ }
481
+ for (const member of page.members ?? []) {
482
+ for (const entry of member.composedOf ?? []) {
483
+ if (entry.relationship === 'built-in') addUsedBy(canonicalUsedByTarget(entry.component), member.id, entry.role);
484
+ }
485
+ }
486
+ }
487
+
450
488
  // Every id/alias a family page's own `members` and `aliases` use, keyed by page id — gathered up
451
489
  // front so `validateFamily` (above) can check catalog-wide uniqueness against every *other*
452
490
  // page's ids.
@@ -507,6 +545,10 @@ export function buildCatalogSections<TId extends string>(
507
545
  if ((globalThis as { __DEV__?: boolean }).__DEV__) {
508
546
  for (const issue of issues) console.error(`[Catalog] ${issue}`);
509
547
  }
548
+ // "Also used by": every id this page's own members (the whole included) use is excluded
549
+ // from its own members' derived users — a sibling (or the whole) composing a part is still
550
+ // the same family tree, already expressed by `parent`/the page itself, not a second user.
551
+ const pageMemberIds = new Set(page.members!.map((m) => m.id));
510
552
  family = {
511
553
  whole: page.whole!,
512
554
  sections: page.sections!,
@@ -518,10 +560,24 @@ export function buildCatalogSections<TId extends string>(
518
560
  relatedPages: page.relatedPages,
519
561
  members: page.members!.map((member) => {
520
562
  const memberComponent = member.component ? componentByName.get(member.component) : undefined;
563
+ // Dedupe by `component`, keeping the first role: page-level and whole-member `composedOf`
564
+ // attribute to the same string for a family's whole (`validateFamily` forces
565
+ // `whole === page.id`), and `validateComposedOf` has no duplicate check of its own, so the
566
+ // same user declared twice would otherwise render "Also used by P and P".
567
+ const dedupedUsers = new Map<string, { component: string; role: string }>();
568
+ for (const entry of usedByTarget.get(member.id) ?? []) {
569
+ if (pageMemberIds.has(entry.component)) continue;
570
+ if (!dedupedUsers.has(entry.component)) dedupedUsers.set(entry.component, entry);
571
+ }
572
+ const alsoUsedBy: AlsoUsedByEntry[] = [...dedupedUsers.values()].map((entry) => ({
573
+ component: entry.component,
574
+ role: entry.role || undefined,
575
+ }));
521
576
  return {
522
577
  ...member,
523
578
  props: memberComponent ? mergePropNotes(memberComponent, undefined) : undefined,
524
579
  path: memberComponent?.file ?? member.source,
580
+ alsoUsedBy,
525
581
  };
526
582
  }),
527
583
  };
@@ -107,6 +107,19 @@ export interface ComposedOfEntry {
107
107
  relationship: 'built-in' | 'slot' | 'related';
108
108
  }
109
109
 
110
+ /** Derived (never authored) — the inverse of every other page's or member's own `composedOf`
111
+ * entry whose `relationship` is `'built-in'`: if something else's `composedOf` names this member,
112
+ * that something is "also used by" it. The forward direction (that other `composedOf`) renders as
113
+ * "Uses X" (`UsesLine`); this is the same relationship read from the other end. Computed once, by
114
+ * `buildCatalogSections`, from data every author already declared from the other end — there is
115
+ * no field on `CatalogMember` or `CatalogPageInput` for this; a page can never author it directly. */
116
+ export interface AlsoUsedByEntry {
117
+ /** The other page's or member's own id — its catalog address. */
118
+ component: string;
119
+ /** That `ComposedOfEntry`'s own `role`, omitted when it's empty. */
120
+ role?: string;
121
+ }
122
+
110
123
  /** One row or column heading in a grid. */
111
124
  export interface ComparisonAxisItem {
112
125
  key: string;
@@ -413,7 +426,11 @@ export interface RelatedPage {
413
426
  * outline — set on `SectionDef.family` by `buildCatalogSections` when a page has `members`. */
414
427
  export interface FamilyDef {
415
428
  whole: string;
416
- members: Array<CatalogMember & { props?: PropDef[]; path: string }>;
429
+ /** `alsoUsedBy` is derived — `buildCatalogSections` always populates it (`[]` when nothing else
430
+ * uses this member) — see `AlsoUsedByEntry`. Optional only so a hand-built `FamilyDef` (a demo,
431
+ * a fixture) that predates this field still type-checks; a reader treats an absent value the
432
+ * same as `[]`. */
433
+ members: Array<CatalogMember & { props?: PropDef[]; path: string; alsoUsedBy?: AlsoUsedByEntry[] }>;
417
434
  sections: CatalogMemberSection[];
418
435
  outline: Map<string, { number: string; level: number }>;
419
436
  notShown?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krapwoo/ds-viewer",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A browsable, always-current catalog for an Expo / React Native design system.",