@krapwoo/ds-viewer 0.4.2 → 0.4.4

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.
@@ -48,12 +48,17 @@ export function neighbors<TId extends string>(
48
48
  }
49
49
 
50
50
  /** Sidebar filter: case-insensitive substring match, sorted ids, empty groups removed. */
51
- export function filterGroups<TId extends string>(groups: readonly NavGroup<TId>[], query: string): NavGroup<TId>[] {
51
+ export function filterGroups<TId extends string>(
52
+ groups: readonly NavGroup<TId>[],
53
+ query: string,
54
+ /** Display titles by id; a page matches on its id or its title. */
55
+ labels?: ReadonlyMap<string, string>,
56
+ ): NavGroup<TId>[] {
52
57
  const q = query.trim().toLowerCase();
53
58
  return groups
54
59
  .map((group) => ({
55
60
  label: group.label,
56
- ids: sortIds(q ? group.ids.filter((id) => id.toLowerCase().includes(q)) : group.ids),
61
+ ids: sortIds(q ? group.ids.filter((id) => id.toLowerCase().includes(q) || (labels?.get(id)?.toLowerCase().includes(q) ?? false)) : group.ids),
57
62
  }))
58
63
  .filter((group) => group.ids.length > 0);
59
64
  }
@@ -8,7 +8,7 @@
8
8
  * It also decides whether two blocks sit side by side (choosePlacement) and how many columns the
9
9
  * Props box uses (propsColumns). ComparisonGrid, ComparisonGroups, and ComparisonList render it.
10
10
  */
11
- import type { ComparisonAxisItem, ComparisonDef, GridAxis, PreviewWidths, SectionDef, SpecimenSize, VariantSlot } from './types.ts';
11
+ import type { ComparisonAxisItem, ComparisonDef, GridAxis, PreviewWidths, SectionDef, SpecimenSize, TokenSection, VariantSlot } from './types.ts';
12
12
  import type React from 'react';
13
13
 
14
14
  /** Approved geometry. `cellPadding` equals CATALOG_SPACE.lg (asserted in tests). The laptop
@@ -115,12 +115,17 @@ export interface ListGeometry {
115
115
  /** Balanced wrapping for a one-axis list inside a single bordered card (1px outer border). Each
116
116
  * cell's 1px divider sits inside its own width, so no cell is ever wider than 402px. Uses as few
117
117
  * rows as fit, then spreads items evenly across them so a short last row leaves as little empty
118
- * space as possible. `fillers` blank cells complete the last row. */
119
- export function listGeometry(itemCount: number, availableWidth: number, size: SpecimenSize): ListGeometry {
118
+ * space as possible. `fillers` blank cells complete the last row. `columnCap` (from
119
+ * `VariantSlot.maxColumns`) only ever lowers the natural, width-based column count — a narrow
120
+ * container still drops below it when it has to, so the cap can never widen a row past what the
121
+ * available width actually fits. */
122
+ export function listGeometry(itemCount: number, availableWidth: number, size: SpecimenSize, columnCap?: number): ListGeometry {
120
123
  if (itemCount <= 0) return { columns: 0, rows: 0, fillers: 0, cellWidth: 0, containerWidth: 0 };
121
124
  const inner = Math.max(0, availableWidth - 2);
122
125
  const min = COLUMN_MIN_WIDTH[size];
123
- const maxColumns = Math.max(1, Math.min(itemCount, Math.floor(inner / min)));
126
+ const widthLimit = Math.floor(inner / min);
127
+ const limit = columnCap !== undefined ? Math.min(widthLimit, columnCap) : widthLimit;
128
+ const maxColumns = Math.max(1, Math.min(itemCount, limit));
124
129
  const rows = Math.ceil(itemCount / maxColumns);
125
130
  const columns = Math.ceil(itemCount / rows);
126
131
  const fitted = Math.floor(inner / columns);
@@ -228,8 +233,9 @@ export interface ListGroup {
228
233
  export type PresentationBlock =
229
234
  | { kind: 'grid'; title: string; size: SpecimenSize; comparison: ComparisonDef }
230
235
  | { kind: 'grouped'; title: string; size: SpecimenSize; groups: ListGroup[] }
231
- | { kind: 'list'; title: string; size: SpecimenSize; items: ListItem[] }
236
+ | { kind: 'list'; title: string; size: SpecimenSize; items: ListItem[]; maxColumns?: 1 | 2 | 3 | 4 | 5 }
232
237
  | { kind: 'preview'; title: string; widths: PreviewWidths }
238
+ | { kind: 'tokenSections'; title: string; sections: TokenSection[]; columns: 1 | 2 | 3 }
233
239
  | { kind: 'empty'; title: string; message: string };
234
240
 
235
241
  export interface PresentationOptions {
@@ -280,6 +286,10 @@ function groupStates(variants: VariantSlot, states: VariantSlot): { groups: List
280
286
  * never shows an empty block next to a real one; with nothing documented at all it shows one
281
287
  * "No examples documented." block. */
282
288
  export function presentationBlocks<TId extends string>(def: SectionDef<TId>, options: PresentationOptions = {}): PresentationBlock[] {
289
+ if (def.tokenSections && def.tokenSections.length > 0) {
290
+ // Each section carries its own label, so the block itself has none.
291
+ return [{ kind: 'tokenSections', title: '', sections: def.tokenSections, columns: def.tokenColumns ?? 1 }];
292
+ }
283
293
  if (def.tokenGallery) {
284
294
  const title = def.fullWidthLabel ?? 'Tokens';
285
295
  return [def.render ? { kind: 'preview', title, widths: 'full' } : { kind: 'empty', title, message: 'Nothing to preview.' }];
@@ -303,7 +313,13 @@ export function presentationBlocks<TId extends string>(def: SectionDef<TId>, opt
303
313
  blocks.push({ kind: 'grouped', title: 'Variant × configuration', size: def.specimenSize ?? 'regular', groups: grouped.groups });
304
314
  states = grouped.leftovers;
305
315
  } else if (def.variants) {
306
- blocks.push({ kind: 'list', title: 'Variants', size: slotSize(def, def.variants), items: slotItems(def.variants) });
316
+ blocks.push({
317
+ kind: 'list',
318
+ title: 'Variants',
319
+ size: slotSize(def, def.variants),
320
+ items: slotItems(def.variants),
321
+ ...(def.variants.maxColumns !== undefined ? { maxColumns: def.variants.maxColumns } : {}),
322
+ });
307
323
  } else if (def.render) {
308
324
  blocks.push({ kind: 'preview', title: 'Preview', widths: previewWidths(def.previewWidths, options.defaultPreviewWidths) });
309
325
  }
@@ -316,6 +332,7 @@ export function presentationBlocks<TId extends string>(def: SectionDef<TId>, opt
316
332
  title: secondary ? 'Other configurations' : 'States / configurations',
317
333
  size: slotSize(def, states),
318
334
  items: slotItems(states),
335
+ ...(states.maxColumns !== undefined ? { maxColumns: states.maxColumns } : {}),
319
336
  });
320
337
  }
321
338
 
@@ -331,9 +348,11 @@ export interface PlacementBlock {
331
348
  width?: number;
332
349
  /** Measured height of the whole block, label included, while stacked. */
333
350
  height: number;
334
- /** For a list: item count and specimen size, so its side-by-side height can be predicted. */
351
+ /** For a list: item count, specimen size, and any explicit column cap, so its side-by-side
352
+ * height can be predicted from the same geometry the rendered list uses. */
335
353
  itemCount?: number;
336
354
  size?: SpecimenSize;
355
+ maxColumns?: 1 | 2 | 3 | 4 | 5;
337
356
  }
338
357
 
339
358
  /** Side by side or stacked, for a page with exactly two blocks. Tries both and keeps the shorter;
@@ -346,8 +365,8 @@ export function choosePlacement({ available, gap, first, second }: { available:
346
365
  const room = available - firstWidth - gap;
347
366
  const minCell = second.size === 'wide' ? MATRIX_LAYOUT.columnMaxWidth : COLUMN_MIN_WIDTH[second.size];
348
367
  if (room < minCell + 2) return 'stacked';
349
- const stackedRows = listGeometry(second.itemCount, available, second.size).rows;
350
- const sideRows = listGeometry(second.itemCount, room, second.size).rows;
368
+ const stackedRows = listGeometry(second.itemCount, available, second.size, second.maxColumns).rows;
369
+ const sideRows = listGeometry(second.itemCount, room, second.size, second.maxColumns).rows;
351
370
  const rowHeight = second.height / Math.max(1, stackedRows);
352
371
  const sideHeight = Math.max(first.height, sideRows * rowHeight);
353
372
  const stackedHeight = first.height + gap + second.height;
@@ -24,6 +24,10 @@ export { ReferenceDetails } from './ReferenceDetails';
24
24
  export { PropsTable } from './PropsTable';
25
25
  export { VariantGroup } from './VariantGroup';
26
26
  export { TokenRow } from './TokenRow';
27
+ export { MotionSpecimen } from './MotionSpecimen';
28
+ export type { MotionSpecimenProps, MotionSpecimenSpringConfig } from './MotionSpecimen';
29
+ export { SpecimenSurface } from './SpecimenSurface';
30
+ export { TokenSections, TokenGrid, TokenTile } from './TokenLayouts';
27
31
  export { DividedStack } from './DividedStack';
28
32
  export { Swatch } from './Swatch';
29
33
  export { PhoneFrame } from './PhoneFrame';
@@ -33,7 +37,10 @@ export { buildComponentManifest } from './manifest';
33
37
  export type { ComponentManifestEntry, ManifestExample } from './manifest';
34
38
  export { defineCatalogPage, buildCatalogSections } from './pageApi';
35
39
  export type { CatalogPage, CatalogPageInput, GeneratedComponent, GeneratedPropRecord } from './pageApi';
36
- export type { PropDef, SectionDef, NavGroup, SpecimenSize, ComparisonDef, ComparisonCell, ComparisonAxisItem, GridAxis, PreviewWidths } from './types';
40
+ export type {
41
+ PropDef, SectionDef, NavGroup, SpecimenSize, SpecimenSurfaceKind, ComparisonDef, ComparisonCell, ComparisonAxisItem, GridAxis,
42
+ PreviewWidths, TokenSection, ComposedOfEntry,
43
+ } from './types';
37
44
  export { UpdatePanel } from './UpdatePanel';
38
45
  export { UPDATE_PAGE_ID } from './catalogNavigation';
39
46
  export type { UpdateNotice } from './types';
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Pure value-domain model for `MotionSpecimen`'s spring kind, kept free of any `react-native`
3
+ * import so `node --test` can assert on it directly (see
4
+ * `native/catalog/__tests__/motionValueDomain.test.ts`).
5
+ *
6
+ * A spring's `restDisplacementThreshold`/`restSpeedThreshold` are in the units of the value being
7
+ * animated — a config built for pixel-space points (e.g. a bottom sheet's snap-point spring) stops
8
+ * within a fraction of a pixel of its target, which is invisible; the same thresholds applied to a
9
+ * normalized 0-to-1 progress would end the animation before it ever emits a frame. `MotionSpecimen` must animate the domain the config was
10
+ * actually tuned for, rather than always normalizing to 0–1, or it misrepresents any spring whose
11
+ * production value is pixel-space.
12
+ *
13
+ * 'distance': the `Animated.Value` IS the pixel distance (0 → `distance`); `translateX` reads it
14
+ * directly, with no interpolation. 'unit': the value is a normalized 0-to-1 progress; `translateX`
15
+ * interpolates it to the pixel distance. Timing specimens always use 'distance' because their
16
+ * easing and duration are independent of the animated value's units.
17
+ */
18
+ export type MotionValueRange = 'distance' | 'unit';
19
+
20
+ /** The Replay button's accessible name. A motion page shows many specimens, so each name must say
21
+ * which one it replays: the specimen's visible label, else its duration (timing) or "spring". */
22
+ export function motionReplayLabel(specimen: { kind: 'timing' | 'spring'; duration?: number; label?: string }): string {
23
+ if (specimen.label) return `Replay ${specimen.label}`;
24
+ return specimen.kind === 'timing' ? `Replay ${specimen.duration}ms timing` : 'Replay spring';
25
+ }
26
+
27
+ /** The `Animated.Value` target to animate toward — and the value a reduced-motion replay jumps to
28
+ * instantly — for a given domain and pixel distance. */
29
+ export function motionValueTarget(valueRange: MotionValueRange, distance: number): number {
30
+ return valueRange === 'distance' ? distance : 1;
31
+ }
32
+
33
+ /** Whether `translateX` reads the animated value directly (true, 'distance') or must interpolate
34
+ * it from a 0-to-1 progress to the pixel distance (false, 'unit'). */
35
+ export function motionTranslatesDirectly(valueRange: MotionValueRange): boolean {
36
+ return valueRange === 'distance';
37
+ }
@@ -1,5 +1,5 @@
1
1
  import type React from 'react';
2
- import type { ComparisonDef, NavGroup, PreviewWidths, PropDef, SectionDef, SpecimenSize, VariantSlot } from './types';
2
+ import type { ComparisonDef, ComposedOfEntry, NavGroup, PreviewWidths, PropDef, SectionDef, SpecimenSize, SpecimenSurfaceKind, TokenSection, VariantSlot } from './types';
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. */
@@ -9,6 +9,14 @@ export interface CatalogPageInput<TId extends string = string> {
9
9
  * default when writing the generated page index, so an authored page never has to repeat a name
10
10
  * already implied by its file. */
11
11
  id?: TId;
12
+ /** Display name; see `SectionDef.title`. */
13
+ title?: string;
14
+ /** Project-relative file shown as the page's Source when the page documents something other
15
+ * than a component, e.g. a token page pointing at `src/design-system/tokens.ts`. A component's
16
+ * own file always wins. */
17
+ source?: string;
18
+ /** See `SectionDef.osComponent`. */
19
+ osComponent?: 'full' | 'partial';
12
20
  description: string;
13
21
  whenToUse?: string;
14
22
  a11y?: string;
@@ -16,11 +24,15 @@ export interface CatalogPageInput<TId extends string = string> {
16
24
  states?: VariantSlot;
17
25
  render?: () => React.ReactNode;
18
26
  tokenGallery?: boolean;
27
+ tokenSections?: TokenSection[];
28
+ tokenColumns?: 1 | 2 | 3;
19
29
  fullWidthLabel?: string;
20
30
  comparison?: ComparisonDef;
21
31
  specimenSize?: SpecimenSize;
32
+ specimenSurface?: SpecimenSurfaceKind;
22
33
  previewWidths?: PreviewWidths;
23
34
  hide?: SectionDef['hide'];
35
+ composedOf?: ComposedOfEntry[];
24
36
  /** Export name this page documents; defaults to `id` for component pages. */
25
37
  component?: string;
26
38
  /** Sidebar group label. */
@@ -77,6 +89,17 @@ function mergePropNotes(component: GeneratedComponent, propNotes: Record<string,
77
89
  return [...own, ...inherited];
78
90
  }
79
91
 
92
+ /** Human-readable problems with a page's `composedOf`, in declaration order: one line per entry
93
+ * whose `component` isn't in `knownNames` (every real component name, documented or not, plus
94
+ * every page id — the full catalog inventory `buildCatalogSections` already has on hand). Empty
95
+ * when every name resolves, including when `composedOf` itself is omitted. */
96
+ export function validateComposedOf(composedOf: ComposedOfEntry[] | undefined, knownNames: ReadonlySet<string>): string[] {
97
+ if (!composedOf) return [];
98
+ return composedOf
99
+ .filter((entry) => !knownNames.has(entry.component))
100
+ .map((entry) => `composedOf references unknown component "${entry.component}".`);
101
+ }
102
+
80
103
  function orderGroups<TId extends string>(groupIds: Map<string, TId[]>, groupOrder: string[] | undefined): NavGroup<TId>[] {
81
104
  const listed = groupOrder ?? [];
82
105
  const labels = [...groupIds.keys()];
@@ -102,6 +125,10 @@ export function buildCatalogSections<TId extends string>(
102
125
  const documented = new Set(pages.map((page) => page.component ?? page.id));
103
126
  const groupIds = new Map<string, TId[]>();
104
127
  const addToGroup = (group: string, id: TId) => groupIds.set(group, [...(groupIds.get(group) ?? []), id]);
128
+ // Every name a page's `composedOf` could legitimately point at: every real component (documented
129
+ // or not — an undocumented one is still a real part of the design system) plus every page id
130
+ // (token pages, standalone recipes, …).
131
+ const knownComposedOfNames = new Set<string>([...componentByName.keys(), ...pages.map((page) => page.id)]);
105
132
 
106
133
  // Design §4 "duplicate-page-id" rule's viewer-side equivalent — this is the one place in the
107
134
  // whole viewer that already resolves every page's id to build sections/groups, so it costs
@@ -112,6 +139,7 @@ export function buildCatalogSections<TId extends string>(
112
139
  if ((globalThis as { __DEV__?: boolean }).__DEV__) {
113
140
  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".`);
114
141
  seenIds.add(page.id);
142
+ for (const issue of validateComposedOf(page.composedOf, knownComposedOfNames)) console.warn(`[Catalog] ${page.id}: ${issue}`);
115
143
  }
116
144
  const componentName = page.component ?? page.id;
117
145
  const component = componentByName.get(componentName);
@@ -121,20 +149,27 @@ export function buildCatalogSections<TId extends string>(
121
149
  // to the page's own file as its Source instead of an empty path.
122
150
  return {
123
151
  id: page.id,
152
+ title: page.title,
153
+ osComponent: page.osComponent,
124
154
  description: page.description,
125
- path: component?.file ?? page.file,
155
+ path: component?.file ?? page.source ?? page.file,
126
156
  whenToUse: page.whenToUse,
127
157
  props: component ? mergePropNotes(component, page.propNotes) : undefined,
128
158
  a11y: page.a11y,
129
159
  variants: page.variants,
130
160
  states: page.states,
131
161
  render: page.render,
132
- tokenGallery: page.tokenGallery,
162
+ // `tokenSections` makes a page a token gallery by itself.
163
+ tokenGallery: page.tokenGallery || (page.tokenSections !== undefined && page.tokenSections.length > 0) || undefined,
164
+ tokenSections: page.tokenSections,
165
+ tokenColumns: page.tokenColumns,
133
166
  fullWidthLabel: page.fullWidthLabel,
134
167
  comparison: page.comparison,
135
168
  specimenSize: page.specimenSize,
169
+ specimenSurface: page.specimenSurface,
136
170
  previewWidths: page.previewWidths,
137
171
  hide: component ? page.hide : { ...page.hide, props: true },
172
+ composedOf: page.composedOf,
138
173
  };
139
174
  });
140
175
 
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `SpecimenSurface`'s width policy, kept free of any `react-native` import so `node --test` can
3
+ * assert on it directly (see `native/catalog/__tests__/specimenSurfaceStyle.test.ts`).
4
+ *
5
+ * The surface always stretches to the full cross-axis width of whatever wraps it (a comparison
6
+ * cell, typically centered) rather than shrink-wrapping to its own content — `alignSelf: 'stretch'`
7
+ * overrides a centered parent's `alignItems` for this one child. A compact specimen (e.g. Button)
8
+ * still reads as centered, via `SpecimenSurface`'s own `alignItems`/`justifyContent: 'center'` on
9
+ * the now-full-width surface. Before this, a percentage-width child (Divider's `width: '100%'`, a
10
+ * phone mockup's percentage-width content) resolved against the surface's own shrink-wrapped,
11
+ * intrinsic width inside a centered comparison cell, and rendered at 0px.
12
+ */
13
+ export const SPECIMEN_SURFACE_WIDTH_STYLE = {
14
+ alignSelf: 'stretch',
15
+ } as const;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Pure layout maths for token pages — no React or React Native runtime imports, so it is unit
3
+ * tested directly (`__tests__/tokenLayout.test.ts`).
4
+ *
5
+ * Token pages lay out in two levels:
6
+ * - sections (one titled card per kind of token, e.g. "Semantic" vs "Palette") flow in up to
7
+ * `tokenColumns` columns, dropping to fewer rather than squeezing a section under its minimum;
8
+ * - inside a section, `TokenGrid` tiles as many equal tiles per row as keep their minimum width.
9
+ */
10
+
11
+ /** Narrowest a side-by-side token section may get before the page drops a column. */
12
+ export const TOKEN_SECTION_MIN_WIDTH = 280;
13
+ /** Gap between side-by-side sections and between tiles. */
14
+ export const TOKEN_LAYOUT_GAP = 20;
15
+
16
+ /** How many section columns fit in `available` px, at most `requested`, never below 1. */
17
+ export function sectionColumns(available: number, requested: number): number {
18
+ const fit = Math.floor((available + TOKEN_LAYOUT_GAP) / (TOKEN_SECTION_MIN_WIDTH + TOKEN_LAYOUT_GAP));
19
+ return Math.max(1, Math.min(requested, fit));
20
+ }
21
+
22
+ /** Equal tiles per row for a `TokenGrid`: as many as keep `minTileWidth` (capped by `maxColumns`),
23
+ * sized so the row is filled exactly. */
24
+ export function tileGeometry(available: number, minTileWidth: number, maxColumns = Infinity): { columns: number; tileWidth: number } {
25
+ const fit = Math.floor((available + TOKEN_LAYOUT_GAP) / (minTileWidth + TOKEN_LAYOUT_GAP));
26
+ const columns = Math.max(1, Math.min(maxColumns, fit));
27
+ const tileWidth = columns === 1 ? available : (available - TOKEN_LAYOUT_GAP * (columns - 1)) / columns;
28
+ return { columns, tileWidth };
29
+ }
@@ -48,6 +48,12 @@ export interface VariantSlot {
48
48
  * content width — for wide block-level components (Banner, Card, Toast, InputField) rather than
49
49
  * small instances meant to sit centered (Button, Badge, Pill). @default false */
50
50
  itemsFill?: boolean;
51
+ /** Caps how many columns this slot's `ComparisonList` ever wraps into — e.g. a size scale that
52
+ * must read as one row of exactly 3, never more, regardless of how much laptop width is free.
53
+ * `listGeometry` still drops to fewer columns than this when the available width requires it;
54
+ * it only ever lowers the natural, width-based column count, never raises it. Omit to let the
55
+ * slot's specimen size and available width decide alone (today's behavior, unchanged). */
56
+ maxColumns?: 1 | 2 | 3 | 4 | 5;
51
57
  items: VariantExample[];
52
58
  }
53
59
 
@@ -59,6 +65,28 @@ export type SpecimenSize = 'compact' | 'regular' | 'wide';
59
65
  * content that is not a phone component (catalog chrome, token galleries). */
60
66
  export type PreviewWidths = readonly number[] | 'full';
61
67
 
68
+ /** Background behind a live component specimen (the list/grouped/grid cell, or the preview frame)
69
+ * — a neutral (default), explicit white, dark, or transparent catalog-owned stage, so a specimen
70
+ * reads as visibly separate from the surrounding white card rather than floating on it, which
71
+ * matters most for a low-contrast or motion-only component that could otherwise blend into a
72
+ * plain white background. Purely a fixed catalog-chrome backdrop: it never inspects or infers the
73
+ * wrapped component's own colors, and is never applied to token galleries/sections, which keep
74
+ * their current full-width, unstaged presentation regardless of this setting. */
75
+ export type SpecimenSurfaceKind = 'neutral' | 'white' | 'dark' | 'transparent';
76
+
77
+ /** One real component this page's component is built from or composed with — rendered as a
78
+ * "Composition" subsection in Quick reference. `relationship` is `'built-in'` for a component
79
+ * always present (e.g. Toast is built from a Banner internally), `'slot'` for an optional
80
+ * caller-supplied child (e.g. a `leadingIcon` prop), or `'related'` for a component commonly used
81
+ * alongside this one without either containing the other. In development, `buildCatalogSections`
82
+ * warns in the browser console when `component` isn't a known component or page id. `doctor`
83
+ * doesn't check it, and it never fails a build. */
84
+ export interface ComposedOfEntry {
85
+ component: string;
86
+ role: string;
87
+ relationship: 'built-in' | 'slot' | 'related';
88
+ }
89
+
62
90
  /** One row or column heading in a grid. */
63
91
  export interface ComparisonAxisItem {
64
92
  key: string;
@@ -102,6 +130,13 @@ export interface ComparisonDef {
102
130
  * typed to the app's real section list without this file needing to know what they are. */
103
131
  export interface SectionDef<TId extends string = string> {
104
132
  id: TId;
133
+ /** Display name for the heading, sidebar and search (e.g. "Control heights"). Defaults to `id`,
134
+ * which stays the page's address (`#ControlHeights`) and its name in `explain`. */
135
+ title?: string;
136
+ /** Marks a component that is, or contains, a native platform control rather than a custom one:
137
+ * `'full'` shows an "OS component" badge under the title, `'partial'` "Partly OS component".
138
+ * Say in `description` which part is native and how it differs on web. */
139
+ osComponent?: 'full' | 'partial';
105
140
  description: string;
106
141
  path: string;
107
142
  /** One sentence disambiguating this component from its closest look-alike(s) — the deciding
@@ -163,6 +198,14 @@ export interface SectionDef<TId extends string = string> {
163
198
  * configurations list, and shows only a Quick reference card with the source path (there's no
164
199
  * component behavior to document). */
165
200
  tokenGallery?: boolean;
201
+ /** Token pages with more than one kind of token: one titled card per section (e.g. "Semantic"
202
+ * and "Palette", or "Body" and "Title"), instead of a single "Tokens" card. When set, `render`
203
+ * is not used and the page counts as a token gallery. See `TokenSection`. */
204
+ tokenSections?: TokenSection[];
205
+ /** How many sections sit side by side (1–3, default 1). Narrow sections such as type families
206
+ * use 2–3; the page drops columns rather than squeeze a section under 280px. A section with
207
+ * `wide: true` always takes a full row. */
208
+ tokenColumns?: 1 | 2 | 3;
166
209
  /** Overrides the `tokenGallery` block's label (default `'Tokens'`) — e.g. `'Preview'` for a page
167
210
  * that's a composed, realistic usage example rather than a list of raw token values. Ignored
168
211
  * unless `tokenGallery` is also set. */
@@ -174,6 +217,10 @@ export interface SectionDef<TId extends string = string> {
174
217
  /** Width class for this page's specimens. Without it, full-width (`itemsFill`) slots are 'wide'
175
218
  * and everything else is 'regular'. */
176
219
  specimenSize?: SpecimenSize;
220
+ /** Background stage behind every live component specimen on this page (list/grouped/grid cells,
221
+ * and a `render()` preview that isn't a token gallery). Defaults to `'neutral'`. See
222
+ * `SpecimenSurfaceKind`. */
223
+ specimenSurface?: SpecimenSurfaceKind;
177
224
  /** Widths for this page's `render()` preview, e.g. `[402, 320]` to add a small-phone example.
178
225
  * Defaults to the catalog's default (CatalogShell `defaultPreviewWidths`), else `[402]`. */
179
226
  previewWidths?: PreviewWidths;
@@ -188,6 +235,10 @@ export interface SectionDef<TId extends string = string> {
188
235
  props?: boolean;
189
236
  accessibility?: boolean;
190
237
  };
238
+ /** What this component is built from or composed with — shown as a "Composition" subsection in
239
+ * Quick reference. See `ComposedOfEntry`. Omit when the component has no meaningful composition
240
+ * to disclose (most leaf components). */
241
+ composedOf?: ComposedOfEntry[];
191
242
  }
192
243
 
193
244
  /** A labeled group of section ids in the sidebar (e.g. "Components" vs "Tokens"). */
@@ -206,3 +257,14 @@ export interface UpdateNotice {
206
257
  summary: string[];
207
258
  releasedAt?: string;
208
259
  }
260
+
261
+ /** One titled card on a token page (`SectionDef.tokenSections`). */
262
+ export interface TokenSection {
263
+ /** The kind of token this card holds, e.g. "Semantic colours", "Palette", "Body". */
264
+ title: string;
265
+ /** One line under the title: what this kind of token is for. */
266
+ desc?: string;
267
+ /** Take a full row even when the page sets `tokenColumns` (e.g. palette ramps). */
268
+ wide?: boolean;
269
+ render: () => React.ReactNode;
270
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krapwoo/ds-viewer",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A browsable, always-current catalog for an Expo / React Native design system.",
@@ -8,6 +8,10 @@
8
8
  "type": "git",
9
9
  "url": "https://github.com/krapwoo/design-system-viewer.git"
10
10
  },
11
+ "bugs": {
12
+ "url": "https://github.com/krapwoo/design-system-viewer/issues"
13
+ },
14
+ "homepage": "https://github.com/krapwoo/design-system-viewer#readme",
11
15
  "engines": {
12
16
  "node": ">=20.19.0"
13
17
  },
@@ -57,6 +61,7 @@
57
61
  },
58
62
  "scripts": {
59
63
  "build": "tsc -p tsconfig.json",
64
+ "prepack": "npm run build",
60
65
  "test": "node --experimental-strip-types --test 'cli/**/*.test.ts' 'config/**/*.test.ts' 'native/catalog/__tests__/*.test.ts'",
61
66
  "typecheck": "tsc -p tsconfig.json --noEmit",
62
67
  "kit:dev": "npm run build && cd kit-host && node node_modules/@krapwoo/ds-viewer/dist/cli/main.js dev",
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
- import { View, Text, StyleSheet } from 'react-native';
3
- import { defineCatalogPage, DividedStack, VariantGroup, TokenRow } from '@krapwoo/ds-viewer';
2
+ import { View, Text, StyleSheet, Easing } from 'react-native';
3
+ import { defineCatalogPage, DividedStack, VariantGroup, TokenRow, MotionSpecimen } from '@krapwoo/ds-viewer';
4
4
  import {
5
5
  DS_MOTION_DURATION,
6
6
  DS_MOTION_DURATION_STEPS,
@@ -21,6 +21,7 @@ import {
21
21
 
22
22
  const styles = StyleSheet.create({
23
23
  tokenStack: { gap: DS_SPACING[800] },
24
+ rowContent: { gap: DS_SPACING[400] },
24
25
  previewRow: { flexDirection: 'row', alignItems: 'center', gap: DS_SPACING[600] },
25
26
  previewLabel: { minWidth: 64, ...DS_TYPOGRAPHY.labelSm, color: DS_SEMANTIC.text.regular },
26
27
  previewValue: { ...DS_TYPOGRAPHY.bodyXs, color: DS_SEMANTIC.text.muted },
@@ -41,10 +42,18 @@ function MotionGallery() {
41
42
  <View style={styles.tokenStack}>
42
43
  {DS_MOTION_DURATION_STEPS.map((step, i) => (
43
44
  <TokenRow key={step} use={DS_MOTION_DURATION_USE[step]} last={i === DS_MOTION_DURATION_STEPS.length - 1}>
44
- <View style={styles.previewRow}>
45
- <Text style={styles.previewLabel}>{step}</Text>
46
- <View style={[styles.durationBar, { width: (DS_MOTION_DURATION[step] / maxDuration) * 120 }]} />
47
- <Text style={styles.previewValue}>{DS_MOTION_DURATION[step]}ms</Text>
45
+ <View style={styles.rowContent}>
46
+ <View style={styles.previewRow}>
47
+ <Text style={styles.previewLabel}>{step}</Text>
48
+ <View style={[styles.durationBar, { width: (DS_MOTION_DURATION[step] / maxDuration) * 120 }]} />
49
+ <Text style={styles.previewValue}>{DS_MOTION_DURATION[step]}ms</Text>
50
+ </View>
51
+ <MotionSpecimen
52
+ kind="timing"
53
+ duration={DS_MOTION_DURATION[step]}
54
+ easing={Easing.bezier(...DS_MOTION_EASING.standard)}
55
+ label={`${DS_MOTION_DURATION[step]}ms · standard easing`}
56
+ />
48
57
  </View>
49
58
  </TokenRow>
50
59
  ))}
@@ -54,10 +63,18 @@ function MotionGallery() {
54
63
  <View style={styles.tokenStack}>
55
64
  {DS_MOTION_LOOP_DURATION_STEPS.map((step, i) => (
56
65
  <TokenRow key={step} use={DS_MOTION_LOOP_DURATION_USE[step]} last={i === DS_MOTION_LOOP_DURATION_STEPS.length - 1}>
57
- <View style={styles.previewRow}>
58
- <Text style={styles.previewLabel}>{step}</Text>
59
- <View style={[styles.durationBar, { width: (DS_MOTION_LOOP_DURATION[step] / maxLoopDuration) * 120 }]} />
60
- <Text style={styles.previewValue}>{DS_MOTION_LOOP_DURATION[step]}ms</Text>
66
+ <View style={styles.rowContent}>
67
+ <View style={styles.previewRow}>
68
+ <Text style={styles.previewLabel}>{step}</Text>
69
+ <View style={[styles.durationBar, { width: (DS_MOTION_LOOP_DURATION[step] / maxLoopDuration) * 120 }]} />
70
+ <Text style={styles.previewValue}>{DS_MOTION_LOOP_DURATION[step]}ms</Text>
71
+ </View>
72
+ <MotionSpecimen
73
+ kind="timing"
74
+ duration={DS_MOTION_LOOP_DURATION[step]}
75
+ easing={Easing.bezier(...DS_MOTION_EASING.standard)}
76
+ label={`${DS_MOTION_LOOP_DURATION[step]}ms · one cycle`}
77
+ />
61
78
  </View>
62
79
  </TokenRow>
63
80
  ))}
@@ -67,9 +84,17 @@ function MotionGallery() {
67
84
  <View style={styles.tokenStack}>
68
85
  {DS_MOTION_EASING_STEPS.map((step, i) => (
69
86
  <TokenRow key={step} use={DS_MOTION_EASING_USE[step]} last={i === DS_MOTION_EASING_STEPS.length - 1}>
70
- <View style={styles.previewRow}>
71
- <Text style={styles.previewLabel}>{step}</Text>
72
- <Text style={styles.easingValue}>cubic-bezier({DS_MOTION_EASING[step].join(', ')})</Text>
87
+ <View style={styles.rowContent}>
88
+ <View style={styles.previewRow}>
89
+ <Text style={styles.previewLabel}>{step}</Text>
90
+ <Text style={styles.easingValue}>cubic-bezier({DS_MOTION_EASING[step].join(', ')})</Text>
91
+ </View>
92
+ <MotionSpecimen
93
+ kind="timing"
94
+ duration={DS_MOTION_DURATION.base}
95
+ easing={Easing.bezier(...DS_MOTION_EASING[step])}
96
+ label={`${DS_MOTION_DURATION.base}ms · ${step}`}
97
+ />
73
98
  </View>
74
99
  </TokenRow>
75
100
  ))}
@@ -78,13 +103,16 @@ function MotionGallery() {
78
103
  <VariantGroup name="Spring" desc="for Animated.spring(value, DS_MOTION_SPRING) — snap-point transitions, not fixed-duration timing">
79
104
  <View style={styles.tokenStack}>
80
105
  <TokenRow use={DS_MOTION_SPRING_USE} last>
81
- <View style={styles.springGrid}>
82
- {(Object.entries(DS_MOTION_SPRING) as [string, number | boolean][]).map(([key, value]) => (
83
- <View key={key} style={styles.springField}>
84
- <Text style={styles.previewLabel}>{key}</Text>
85
- <Text style={styles.previewValue}>{String(value)}</Text>
86
- </View>
87
- ))}
106
+ <View style={styles.rowContent}>
107
+ <View style={styles.springGrid}>
108
+ {(Object.entries(DS_MOTION_SPRING) as [string, number | boolean][]).map(([key, value]) => (
109
+ <View key={key} style={styles.springField}>
110
+ <Text style={styles.previewLabel}>{key}</Text>
111
+ <Text style={styles.previewValue}>{String(value)}</Text>
112
+ </View>
113
+ ))}
114
+ </View>
115
+ <MotionSpecimen kind="spring" spring={DS_MOTION_SPRING} label="Animated.spring(value, DS_MOTION_SPRING)" />
88
116
  </View>
89
117
  </TokenRow>
90
118
  </View>