@krapwoo/ds-viewer 0.4.3 → 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.
package/README.md CHANGED
@@ -96,6 +96,12 @@ npm run check:catalog # the same headless check CI runs — every page, zero
96
96
  documenting the viewer framework's own pieces (`CatalogShell`, `SectionBlock`, `ComparisonGrid`,
97
97
  …), shown under a "Viewer" group alongside the starter kit's own pages.
98
98
 
99
+ ## Contributing
100
+
101
+ Found a bug or an improvement while using ds-viewer in your app? Open an issue or a pull request:
102
+ [CONTRIBUTING.md](CONTRIBUTING.md) explains how to try a change in your own app before opening
103
+ one, and what every pull request needs.
104
+
99
105
  ## Picking the right component
100
106
 
101
107
  Several components look alike but solve different problems (InputField vs. SearchField vs.
@@ -184,8 +190,28 @@ actually apply:
184
190
  within what fits a 1280px laptop: 5 compact, 3 regular, or 2 wide columns. `states` items whose
185
191
  key matches a row or column key are not repeated below the grid.
186
192
  - `specimenSize: 'compact' | 'regular' | 'wide'` — the page's specimen width class.
193
+ - `specimenSurface: 'neutral' | 'white' | 'dark' | 'transparent'` — the catalog-owned background
194
+ behind every live specimen on this page (list/grouped/grid cells, and a non-token `render()`
195
+ preview). Defaults to `'neutral'` (a light gray stage, visibly distinct from the surrounding
196
+ white card) — reach for `'dark'` to check a low-contrast component against a dark background, or
197
+ `'transparent'` when the surface itself would be misleading (e.g. a component whose own
198
+ elevation/shadow needs to read against the page, not a chip). Token galleries and sections always
199
+ keep their current, unstaged presentation regardless of this setting.
200
+ - `variants`/`states`' own `maxColumns: 1 | 2 | 3 | 4 | 5` — caps how many columns that slot's list
201
+ ever wraps into (e.g. a 3-item size scale that should always read as one row of exactly 3, never
202
+ more just because a laptop is wide). A narrow window still drops below the cap when it has to —
203
+ `maxColumns` only ever lowers the natural, width-based column count, never raises it.
187
204
  - `group` on a `states` item — the `variants` key it belongs to (a size that only exists for one
188
205
  variant). Grouped states render one row per variant; ungrouped ones go to "Other configurations".
206
+ - `composedOf: [{ component, role, relationship }]` — what this component is built from or
207
+ composed with, shown as a "Composition" subsection in Quick reference. `relationship` is
208
+ `'built-in'` for a component always present inside this one (e.g. Toast is built from a Banner),
209
+ `'slot'` for an optional caller-supplied child (e.g. a `leadingIcon` prop), or `'related'` for a
210
+ component commonly used alongside this one without either containing the other. `component` should be
211
+ a real component or page id: while you browse the catalog in development, an unknown name logs a
212
+ `[Catalog]` warning in the browser console. `doctor` doesn't check it, and it never fails a
213
+ build — e.g.
214
+ `composedOf: [{ component: 'Icon', role: 'Leading glyph', relationship: 'slot' }]`.
189
215
  - `previewWidths: [402, 320]` — extra preview widths for a `render()` page (each capped at 402).
190
216
  `CatalogShell`'s `defaultPreviewWidths="full"` keeps a catalog's previews full width.
191
217
  - `variants: { desc?, align?, itemsFill?, items: [{ key, name, node }] }` — one item per prop enum
@@ -208,9 +234,18 @@ path. For anything beyond a single list of tokens, use the token-page layouts be
208
234
 
209
235
  ### Organising the catalog
210
236
 
211
- - **Two sidebar groups: `Tokens`, then `Components`** (`groupOrder: ['Tokens', 'Components']`).
212
- Pages sort A–Z inside each group. Category groups ("Actions", "Status") make readers guess
213
- where a component lives; search and A–Z already find it.
237
+ - **`group`/`groupOrder` accept any semantic category you want** — not just `Tokens` and
238
+ `Components`. A page's `group` is a plain string shown as its sidebar section label;
239
+ `groupOrder` (e.g. `['Tokens', 'Components', 'Patterns', 'Recipes', 'Experiences']`) only
240
+ controls which order those sections appear in, top to bottom — any group not listed sorts
241
+ alphabetically after the ones that are. This starter kit's own two groups (`Tokens`, then
242
+ `Components`) are one valid shape, not the only one: a larger catalog might add `Patterns`
243
+ (reusable combinations like a settings row), `Recipes` (worked end-to-end screens), or
244
+ `Experiences` (full flows) alongside them. Pages sort A–Z inside each group regardless of how
245
+ many groups you have. Avoid splitting `Components` into finer categories such as "Actions" or
246
+ "Status" purely to browse by — that makes readers guess where a component lives; search and A–Z
247
+ already find it. A distinct semantic group (Tokens vs. Components vs. Patterns) is a different
248
+ decision from that, since it changes what the reader expects to find there.
214
249
  - **Wide components at 3 columns.** Give block-level components (Banner, Card, Toast,
215
250
  InputField) `specimenSize: 'regular'`, so Variants and States sit 3 across on a laptop
216
251
  (about 316px each). `'wide'` (2 columns, phone width) is only for pages where the full phone
@@ -242,6 +277,7 @@ shape:
242
277
  | Narrow sample with a long note (icon size, spacing) | `TokenRow` with `notePlacement="right"`: one line per token | IconSizes |
243
278
  | Several kinds of token on one page | `tokenSections`: one titled card per kind | Colors (semantic vs palette) |
244
279
  | A scale with families (type) | `tokenSections` with `tokenColumns: 2` or `3`, one section per family | Typography (label, body, emphasis…) |
280
+ | A duration/easing or spring value | `MotionSpecimen` next to the token's value | Motion (duration, easing, spring) |
245
281
 
246
282
  - **`tokenSections: [{ title, desc?, wide?, render }]`** replaces `render`. Each section is its
247
283
  own titled card. Keep different kinds of token in different sections, never mixed: semantic roles
@@ -256,6 +292,29 @@ shape:
256
292
  value already say it. If there's nothing grounded to say, leave the note out.
257
293
  - **Label each sample with its name and real value** (`bodyMd · 16/22 · 400`, `medium · 12px`).
258
294
  Use realistic sample text from the product, short enough to fit its column at the largest size.
295
+ - **`MotionSpecimen`** demonstrates a duration/easing or spring token as a real, bounded
296
+ start-to-end slide — a dot travels a fixed track once when it mounts, with a named Replay button
297
+ to play it again. Give it `kind: 'timing'` with `duration` (and optionally a real `Easing`
298
+ function, e.g. `Easing.bezier(...DS_MOTION_EASING.standard)`) for a duration/easing token, or
299
+ `kind: 'spring'` with a `spring` config shaped for `Animated.spring` (e.g. `DS_MOTION_SPRING`) for
300
+ a spring token. It respects the platform's reduce-motion preference automatically — with it on,
301
+ every play (including the initial one) shows the end state immediately instead of animating, so
302
+ it never needs its own per-page reduce-motion handling:
303
+ ```tsx
304
+ <MotionSpecimen kind="timing" duration={DS_MOTION_DURATION.base} easing={Easing.bezier(...DS_MOTION_EASING.standard)} />
305
+ <MotionSpecimen kind="spring" spring={DS_MOTION_SPRING} />
306
+ ```
307
+ A spring's `valueRange` says which domain its `spring` config's rest thresholds are scaled for,
308
+ so the specimen animates the same domain the real config does rather than always normalizing to
309
+ 0–1. `'distance'` (default) is for a config tuned for pixel-space points, e.g. a `BottomSheet`'s
310
+ snap-point spring — the `Animated.Value` animates directly from `0` to the track's pixel
311
+ distance. `'unit'` is for a config whose production value is a normalized 0-to-1 progress or
312
+ blend factor — the `Animated.Value` animates `0` to `1` and is interpolated to the pixel
313
+ distance; `'distance'`'s pixel-scale thresholds would otherwise end a unit-valued spring before
314
+ it ever moves:
315
+ ```tsx
316
+ <MotionSpecimen kind="spring" spring={UNIT_SPRING_CONFIG} valueRange="unit" />
317
+ ```
259
318
 
260
319
  ## Keeping the catalog current
261
320
 
@@ -11,10 +11,8 @@ function placeholderItem(item) {
11
11
  /** A throwaway `SectionDef` built from a `StaticPage` (Task 3) — every specimen's `node` is `null`
12
12
  * (design §4: specimen nodes are never evaluated), since none of the layout functions below ever
13
13
  * look at it; only the counts/keys/sizes they do look at are carried over. Every field Task 3
14
- * reads is carried through here, including `previewWidths`/`fullWidthLabel`/`hide`/`itemsFill` —
15
- * an earlier draft dropped these four, so `explain` reported different geometry than the real
16
- * viewer for any page that set one of them (25 kit pages set `itemsFill`; every `kit-host`
17
- * Viewer page sets `hide`). */
14
+ * reads is carried through here, including `previewWidths`/`fullWidthLabel`/`hide`/`itemsFill`/
15
+ * `maxColumns` — dropping any of them makes `explain` report different geometry than the viewer. */
18
16
  function toSectionDef(page) {
19
17
  const comparison = page.comparison && {
20
18
  rowLabel: page.comparison.rowLabel ?? '',
@@ -32,8 +30,8 @@ function toSectionDef(page) {
32
30
  fullWidthLabel: page.fullWidthLabel,
33
31
  specimenSize: page.specimenSize,
34
32
  previewWidths: page.previewWidths,
35
- variants: page.variantsItems ? { itemsFill: page.variantsItemsFill, items: page.variantsItems.map(placeholderItem) } : undefined,
36
- states: page.statesItems ? { itemsFill: page.statesItemsFill, items: page.statesItems.map(placeholderItem) } : undefined,
33
+ variants: page.variantsItems ? { itemsFill: page.variantsItemsFill, maxColumns: page.variantsMaxColumns, items: page.variantsItems.map(placeholderItem) } : undefined,
34
+ states: page.statesItems ? { itemsFill: page.statesItemsFill, maxColumns: page.statesMaxColumns, items: page.statesItems.map(placeholderItem) } : undefined,
37
35
  comparison,
38
36
  render: page.hasRender ? () => null : undefined,
39
37
  tokenSections: page.tokenSectionTitles?.map((title, i) => ({ title, wide: page.tokenSectionWide?.[i] || undefined, render: () => null })),
@@ -53,7 +51,7 @@ function explainBlock(block) {
53
51
  };
54
52
  }
55
53
  if (block.kind === 'list') {
56
- const geometry = listGeometry(block.items.length, MATRIX_LAYOUT.laptopContentWidth, block.size);
54
+ const geometry = listGeometry(block.items.length, MATRIX_LAYOUT.laptopContentWidth, block.size, block.maxColumns);
57
55
  return {
58
56
  kind: 'list', title: block.title,
59
57
  reason: `${block.items.length} items (${block.size}) → ${geometry.columns} columns × ${geometry.rows} row${geometry.rows === 1 ? '' : 's'}` +
@@ -89,7 +87,14 @@ function explainBlock(block) {
89
87
  * computed uniformly here for either role, since setting it on `second` is simply unused, not wrong. */
90
88
  function toPlacementBlock(block, height, available) {
91
89
  if (block.kind === 'list') {
92
- return { kind: 'list', height, itemCount: block.items.length, size: block.size, width: listGeometry(block.items.length, available, block.size).containerWidth };
90
+ return {
91
+ kind: 'list',
92
+ height,
93
+ itemCount: block.items.length,
94
+ size: block.size,
95
+ maxColumns: block.maxColumns,
96
+ width: listGeometry(block.items.length, available, block.size, block.maxColumns).containerWidth,
97
+ };
93
98
  }
94
99
  return { kind: block.kind, height };
95
100
  }
@@ -146,6 +146,7 @@ function readStaticPage(file) {
146
146
  else {
147
147
  page.variantsItems = variants.items;
148
148
  page.variantsItemsFill = variants.itemsFill;
149
+ page.variantsMaxColumns = variants.maxColumns;
149
150
  }
150
151
  const states = readSlotItems(findProp(pageObject, 'states'), consts);
151
152
  if (!states.checkable)
@@ -153,6 +154,7 @@ function readStaticPage(file) {
153
154
  else {
154
155
  page.statesItems = states.items;
155
156
  page.statesItemsFill = states.itemsFill;
157
+ page.statesMaxColumns = states.maxColumns;
156
158
  }
157
159
  return page;
158
160
  }
@@ -357,11 +359,12 @@ function readLiteralProps(node, consts) {
357
359
  }
358
360
  return result;
359
361
  }
360
- /** A `variants`/`states` field: `{ itemsFill?, items: [...] }`, `items` read by `readListItems`.
361
- * Distinguishes "field absent" (always checkable, everything undefined) from "field present but
362
- * not literal" (not checkable). `itemsFill` is read only if present and literal — like
363
- * `previewWidths`/`fullWidthLabel`, it isn't in design §4's literal-checkable list, so a
364
- * non-literal `itemsFill` is simply left unset, never a reason to reject the whole slot. */
362
+ /** A `variants`/`states` field: `{ itemsFill?, maxColumns?, items: [...] }`, `items` read by
363
+ * `readListItems`. Distinguishes "field absent" (always checkable, everything undefined) from
364
+ * "field present but not literal" (not checkable). `itemsFill` is read only if present and
365
+ * literal — like `previewWidths`/`fullWidthLabel`, it isn't in the original literal-checkable
366
+ * list, so a non-literal `itemsFill` is simply left unset. `maxColumns` does affect the geometry
367
+ * `explain` reports, so it must be a literal integer from 1–5 or the page is not checkable. */
365
368
  function readSlotItems(prop, consts) {
366
369
  if (!prop)
367
370
  return { checkable: true };
@@ -374,5 +377,16 @@ function readSlotItems(prop, consts) {
374
377
  return { checkable: false };
375
378
  const itemsFillProp = ts.isObjectLiteralExpression(resolved) ? findProp(resolved, 'itemsFill') : undefined;
376
379
  const itemsFill = itemsFillProp ? resolve(itemsFillProp.initializer, consts, new Set()).kind === ts.SyntaxKind.TrueKeyword : undefined;
377
- return { items, itemsFill, checkable: true };
380
+ const maxColumnsProp = ts.isObjectLiteralExpression(resolved) ? findProp(resolved, 'maxColumns') : undefined;
381
+ let maxColumns;
382
+ if (maxColumnsProp) {
383
+ const value = resolve(maxColumnsProp.initializer, consts, new Set());
384
+ if (!ts.isNumericLiteral(value))
385
+ return { checkable: false };
386
+ const parsed = Number(value.text);
387
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 5)
388
+ return { checkable: false };
389
+ maxColumns = parsed;
390
+ }
391
+ return { items, itemsFill, maxColumns, checkable: true };
378
392
  }
@@ -75,13 +75,18 @@ export function gridWidthBounds(columnCount, size) {
75
75
  /** Balanced wrapping for a one-axis list inside a single bordered card (1px outer border). Each
76
76
  * cell's 1px divider sits inside its own width, so no cell is ever wider than 402px. Uses as few
77
77
  * rows as fit, then spreads items evenly across them so a short last row leaves as little empty
78
- * space as possible. `fillers` blank cells complete the last row. */
79
- export function listGeometry(itemCount, availableWidth, size) {
78
+ * space as possible. `fillers` blank cells complete the last row. `columnCap` (from
79
+ * `VariantSlot.maxColumns`) only ever lowers the natural, width-based column count — a narrow
80
+ * container still drops below it when it has to, so the cap can never widen a row past what the
81
+ * available width actually fits. */
82
+ export function listGeometry(itemCount, availableWidth, size, columnCap) {
80
83
  if (itemCount <= 0)
81
84
  return { columns: 0, rows: 0, fillers: 0, cellWidth: 0, containerWidth: 0 };
82
85
  const inner = Math.max(0, availableWidth - 2);
83
86
  const min = COLUMN_MIN_WIDTH[size];
84
- const maxColumns = Math.max(1, Math.min(itemCount, Math.floor(inner / min)));
87
+ const widthLimit = Math.floor(inner / min);
88
+ const limit = columnCap !== undefined ? Math.min(widthLimit, columnCap) : widthLimit;
89
+ const maxColumns = Math.max(1, Math.min(itemCount, limit));
85
90
  const rows = Math.ceil(itemCount / maxColumns);
86
91
  const columns = Math.ceil(itemCount / rows);
87
92
  const fitted = Math.floor(inner / columns);
@@ -241,7 +246,13 @@ export function presentationBlocks(def, options = {}) {
241
246
  states = grouped.leftovers;
242
247
  }
243
248
  else if (def.variants) {
244
- blocks.push({ kind: 'list', title: 'Variants', size: slotSize(def, def.variants), items: slotItems(def.variants) });
249
+ blocks.push({
250
+ kind: 'list',
251
+ title: 'Variants',
252
+ size: slotSize(def, def.variants),
253
+ items: slotItems(def.variants),
254
+ ...(def.variants.maxColumns !== undefined ? { maxColumns: def.variants.maxColumns } : {}),
255
+ });
245
256
  }
246
257
  else if (def.render) {
247
258
  blocks.push({ kind: 'preview', title: 'Preview', widths: previewWidths(def.previewWidths, options.defaultPreviewWidths) });
@@ -254,6 +265,7 @@ export function presentationBlocks(def, options = {}) {
254
265
  title: secondary ? 'Other configurations' : 'States / configurations',
255
266
  size: slotSize(def, states),
256
267
  items: slotItems(states),
268
+ ...(states.maxColumns !== undefined ? { maxColumns: states.maxColumns } : {}),
257
269
  });
258
270
  }
259
271
  if (blocks.length === 0 && !hide.variants && !hide.states) {
@@ -274,8 +286,8 @@ export function choosePlacement({ available, gap, first, second }) {
274
286
  const minCell = second.size === 'wide' ? MATRIX_LAYOUT.columnMaxWidth : COLUMN_MIN_WIDTH[second.size];
275
287
  if (room < minCell + 2)
276
288
  return 'stacked';
277
- const stackedRows = listGeometry(second.itemCount, available, second.size).rows;
278
- const sideRows = listGeometry(second.itemCount, room, second.size).rows;
289
+ const stackedRows = listGeometry(second.itemCount, available, second.size, second.maxColumns).rows;
290
+ const sideRows = listGeometry(second.itemCount, room, second.size, second.maxColumns).rows;
279
291
  const rowHeight = second.height / Math.max(1, stackedRows);
280
292
  const sideHeight = Math.max(first.height, sideRows * rowHeight);
281
293
  const stackedHeight = first.height + gap + second.height;
@@ -2,14 +2,26 @@ import React from 'react';
2
2
  import { View, Text, ScrollView, StyleSheet } from 'react-native';
3
3
  import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
4
4
  import { COLUMN_MIN_WIDTH, MATRIX_LAYOUT, axisItems, cellKey, gridWidthBounds, indexCells, validateComparison } from './comparison';
5
- import type { ComparisonDef, SpecimenSize } from './types';
5
+ import { SpecimenSurface } from './SpecimenSurface';
6
+ import type { ComparisonDef, SpecimenSize, SpecimenSurfaceKind } from './types';
6
7
 
7
8
  /**
8
9
  * A grid of two props that combine freely, rendered as an accessible table inside one card.
9
10
  * Columns grow with the window up to 402px and never shrink below the specimen size's minimum;
10
- * a grid wider than its container scrolls horizontally inside the card, never the page.
11
+ * a grid wider than its container scrolls horizontally inside the card, never the page. `surface`
12
+ * is the page's `specimenSurface` behind each cell's example.
11
13
  */
12
- export function ComparisonGrid({ def, size, sectionId }: { def: ComparisonDef; size: SpecimenSize; sectionId: string }) {
14
+ export function ComparisonGrid({
15
+ def,
16
+ size,
17
+ sectionId,
18
+ surface = 'neutral',
19
+ }: {
20
+ def: ComparisonDef;
21
+ size: SpecimenSize;
22
+ sectionId: string;
23
+ surface?: SpecimenSurfaceKind;
24
+ }) {
13
25
  if (__DEV__) {
14
26
  for (const issue of validateComparison({ ...def, size })) console.warn(`[Catalog] ${sectionId}: ${issue}`);
15
27
  }
@@ -53,7 +65,9 @@ export function ComparisonGrid({ def, size, sectionId }: { def: ComparisonDef; s
53
65
  return (
54
66
  <View key={c.key} role="cell" style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
55
67
  {cell?.node !== undefined ? (
56
- <View style={cell.fill ? styles.fill : styles.center}>{cell.node}</View>
68
+ <SpecimenSurface surface={surface} style={cell.fill && styles.fill}>
69
+ {cell.node}
70
+ </SpecimenSurface>
57
71
  ) : (
58
72
  <Text style={styles.unavailable}>
59
73
  {cell?.unavailableReason ? `Not supported — ${cell.unavailableReason}` : 'Missing example'}
@@ -88,7 +102,6 @@ const styles = StyleSheet.create({
88
102
  headerCell: { backgroundColor: CATALOG_COLOR.surfaceMuted, justifyContent: 'center' },
89
103
  bodyRow: { minHeight: MATRIX_LAYOUT.rowMinHeight },
90
104
  specimenCell: { alignItems: 'center', justifyContent: 'center' },
91
- center: { alignItems: 'center' },
92
105
  fill: { alignSelf: 'stretch' },
93
106
  headerText: { fontSize: CATALOG_TYPE.tableHeader, fontWeight: '800', letterSpacing: 0.44, color: CATALOG_COLOR.text },
94
107
  rowHeaderText: { fontSize: CATALOG_TYPE.md, fontWeight: '700', color: CATALOG_COLOR.text },
@@ -2,15 +2,27 @@ import React from 'react';
2
2
  import { View, Text, ScrollView, StyleSheet } from 'react-native';
3
3
  import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
4
4
  import { COLUMN_MIN_WIDTH, MATRIX_LAYOUT, type ListGroup } from './comparison';
5
- import type { SpecimenSize } from './types';
5
+ import { SpecimenSurface } from './SpecimenSurface';
6
+ import type { SpecimenSize, SpecimenSurfaceKind } from './types';
6
7
 
7
8
  /**
8
9
  * Grouped rows: one row per variant, headed by the variant's name, holding that variant's own
9
10
  * configurations. Each cell keeps its own caption because the rows do not share column meanings
10
11
  * (a circle's size is not a bar's thickness). Each row is its own labeled list. Shorter rows end
11
- * in blank cells; a card wider than its container scrolls horizontally inside itself.
12
+ * in blank cells; a card wider than its container scrolls horizontally inside itself. `surface` is
13
+ * the page's `specimenSurface` behind each item.
12
14
  */
13
- export function ComparisonGroups({ groups, size, label }: { groups: ListGroup[]; size: SpecimenSize; label: string }) {
15
+ export function ComparisonGroups({
16
+ groups,
17
+ size,
18
+ label,
19
+ surface = 'neutral',
20
+ }: {
21
+ groups: ListGroup[];
22
+ size: SpecimenSize;
23
+ label: string;
24
+ surface?: SpecimenSurfaceKind;
25
+ }) {
14
26
  const columns = Math.max(1, ...groups.map((g) => g.items.length));
15
27
  const min = COLUMN_MIN_WIDTH[size];
16
28
  const minWidth = MATRIX_LAYOUT.rowHeaderWidth + columns * min + 2;
@@ -35,7 +47,9 @@ export function ComparisonGroups({ groups, size, label }: { groups: ListGroup[];
35
47
  <Text style={styles.captionText}>{item.label}</Text>
36
48
  </View>
37
49
  <View style={styles.specimen}>
38
- <View style={item.fill ? styles.fill : styles.center}>{item.node as React.ReactNode}</View>
50
+ <SpecimenSurface surface={surface} style={item.fill && styles.fill}>
51
+ {item.node as React.ReactNode}
52
+ </SpecimenSurface>
39
53
  </View>
40
54
  </View>
41
55
  );
@@ -88,6 +102,5 @@ const styles = StyleSheet.create({
88
102
  alignItems: 'center',
89
103
  justifyContent: 'center',
90
104
  },
91
- center: { alignItems: 'center' },
92
105
  fill: { alignSelf: 'stretch' },
93
106
  });
@@ -2,16 +2,31 @@ import React, { useState } from 'react';
2
2
  import { View, Text, StyleSheet, type LayoutChangeEvent } from 'react-native';
3
3
  import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
4
4
  import { MATRIX_LAYOUT, listGeometry, type ListItem } from './comparison';
5
- import type { SpecimenSize } from './types';
5
+ import { SpecimenSurface } from './SpecimenSurface';
6
+ import type { SpecimenSize, SpecimenSurfaceKind } from './types';
6
7
 
7
8
  /**
8
9
  * One-axis examples inside ONE shared card. Cells wrap into balanced rows (listGeometry); each cell
9
10
  * has its own caption strip so labels stay attached when rows wrap. Blank cells complete an uneven
10
- * last row. Wide specimens use fixed 402px cells and the card hugs its columns.
11
+ * last row. Wide specimens use fixed 402px cells and the card hugs its columns. `maxColumns` caps
12
+ * how many columns a row ever wraps into (from `VariantSlot.maxColumns`); `surface` is the page's
13
+ * `specimenSurface` behind each item.
11
14
  */
12
- export function ComparisonList({ items, size, label }: { items: ListItem[]; size: SpecimenSize; label: string }) {
15
+ export function ComparisonList({
16
+ items,
17
+ size,
18
+ label,
19
+ maxColumns,
20
+ surface = 'neutral',
21
+ }: {
22
+ items: ListItem[];
23
+ size: SpecimenSize;
24
+ label: string;
25
+ maxColumns?: 1 | 2 | 3 | 4 | 5;
26
+ surface?: SpecimenSurfaceKind;
27
+ }) {
13
28
  const [available, setAvailable] = useState<number>(MATRIX_LAYOUT.laptopContentWidth);
14
- const geometry = listGeometry(items.length, available, size);
29
+ const geometry = listGeometry(items.length, available, size, maxColumns);
15
30
  const onLayout = (event: LayoutChangeEvent) => {
16
31
  const width = Math.round(event.nativeEvent.layout.width);
17
32
  if (width > 0 && width !== available) setAvailable(width);
@@ -46,7 +61,9 @@ export function ComparisonList({ items, size, label }: { items: ListItem[]; size
46
61
  <Text style={styles.captionText}>{item.label}</Text>
47
62
  </View>
48
63
  <View style={styles.specimen}>
49
- <View style={item.fill ? styles.fill : styles.center}>{item.node as React.ReactNode}</View>
64
+ <SpecimenSurface surface={surface} style={item.fill && styles.fill}>
65
+ {item.node as React.ReactNode}
66
+ </SpecimenSurface>
50
67
  </View>
51
68
  </View>
52
69
  );
@@ -84,6 +101,5 @@ const styles = StyleSheet.create({
84
101
  alignItems: 'center',
85
102
  justifyContent: 'center',
86
103
  },
87
- center: { alignItems: 'center' },
88
104
  fill: { alignSelf: 'stretch' },
89
105
  });
@@ -0,0 +1,152 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { Animated, AccessibilityInfo, Pressable, StyleSheet, Text, View } from 'react-native';
3
+ import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
4
+ import { motionReplayLabel, motionTranslatesDirectly, motionValueTarget, type MotionValueRange } from './motionValueDomain';
5
+
6
+ /** `Animated.spring`'s physics fields — the same shape as a design system's own spring-config
7
+ * token (e.g. `DS_MOTION_SPRING`), kept local here rather than imported from any app's tokens so
8
+ * this stays an app-agnostic catalog piece. */
9
+ export interface MotionSpecimenSpringConfig {
10
+ stiffness?: number;
11
+ damping?: number;
12
+ mass?: number;
13
+ overshootClamping?: boolean;
14
+ restDisplacementThreshold?: number;
15
+ restSpeedThreshold?: number;
16
+ }
17
+
18
+ export type MotionSpecimenProps =
19
+ | {
20
+ kind: 'timing';
21
+ /** Duration in ms, e.g. a page's own `DS_MOTION_DURATION.base`. */
22
+ duration: number;
23
+ /** A real `Easing` function, e.g. `Easing.bezier(...DS_MOTION_EASING.standard)`. Defaults to
24
+ * `Animated.timing`'s own ease-in-out when omitted; pass `Easing.linear` for linear. */
25
+ easing?: (value: number) => number;
26
+ /** Track length in px the dot travels, start to end. @default 160 */
27
+ distance?: number;
28
+ label?: string;
29
+ }
30
+ | {
31
+ kind: 'spring';
32
+ spring: MotionSpecimenSpringConfig;
33
+ /** Which domain `spring`'s rest thresholds are scaled for (see `motionValueDomain.ts`):
34
+ * `'distance'` (default) for a config tuned for pixel-space points, e.g. a `BottomSheet`'s
35
+ * snap-point spring; `'unit'` for a config tuned for a normalized 0-to-1 progress/blend
36
+ * value — `'distance'`'s pixel-scale thresholds would otherwise end a unit-valued spring
37
+ * before it ever moves. Does not change the track's own pixel length (`distance`), only
38
+ * which domain the `Animated.Value` itself animates in. */
39
+ valueRange?: MotionValueRange;
40
+ distance?: number;
41
+ label?: string;
42
+ };
43
+
44
+ const DOT_SIZE = 20;
45
+ const DEFAULT_DISTANCE = 160;
46
+
47
+ /**
48
+ * A reusable, bounded start-to-end motion demo for a duration/easing or spring token: a dot slides
49
+ * once across a fixed track when the specimen mounts, and a "Replay" button plays it again on
50
+ * demand. Respects the platform's reduce-motion preference — when enabled, every play (including
51
+ * the initial one) jumps straight to the end state instead of animating, exactly as the real
52
+ * product components built on these tokens should. Uses only `Animated`/`AccessibilityInfo` from
53
+ * `react-native`, already a peer dependency — no new runtime dependency.
54
+ */
55
+ export function MotionSpecimen(props: MotionSpecimenProps) {
56
+ const { label, distance = DEFAULT_DISTANCE } = props;
57
+ const valueRange: MotionValueRange = props.kind === 'spring' ? props.valueRange ?? 'distance' : 'distance';
58
+ const target = motionValueTarget(valueRange, distance);
59
+ const progress = useRef(new Animated.Value(0)).current;
60
+ const activeAnim = useRef<Animated.CompositeAnimation | null>(null);
61
+ const [reduceMotion, setReduceMotion] = useState(false);
62
+
63
+ const play = useCallback(
64
+ (instant: boolean) => {
65
+ activeAnim.current?.stop();
66
+ progress.setValue(0);
67
+ if (instant) {
68
+ progress.setValue(target);
69
+ return;
70
+ }
71
+ const anim =
72
+ props.kind === 'timing'
73
+ ? Animated.timing(progress, { toValue: target, duration: props.duration, easing: props.easing, useNativeDriver: true })
74
+ : Animated.spring(progress, { toValue: target, useNativeDriver: true, ...props.spring });
75
+ activeAnim.current = anim;
76
+ anim.start();
77
+ },
78
+ // `props` covers every field `play` reads (duration/easing/spring differ per kind); `target`
79
+ // is derived from `props` (distance, kind, valueRange), not independent state.
80
+ // eslint-disable-next-line react-hooks/exhaustive-deps
81
+ [progress, props, target],
82
+ );
83
+
84
+ useEffect(() => {
85
+ let mounted = true;
86
+ AccessibilityInfo.isReduceMotionEnabled().then((enabled) => {
87
+ if (!mounted) return;
88
+ setReduceMotion(enabled);
89
+ play(enabled);
90
+ });
91
+ const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', (enabled: boolean) => setReduceMotion(enabled));
92
+ return () => {
93
+ mounted = false;
94
+ subscription.remove();
95
+ activeAnim.current?.stop();
96
+ };
97
+ // Plays exactly once on mount; `play` only needs to be re-created, never re-run, when props change.
98
+ // eslint-disable-next-line react-hooks/exhaustive-deps
99
+ }, []);
100
+
101
+ const translateX = motionTranslatesDirectly(valueRange)
102
+ ? progress
103
+ : progress.interpolate({ inputRange: [0, target], outputRange: [0, distance] });
104
+
105
+ return (
106
+ <View style={styles.wrap}>
107
+ <View style={[styles.track, { width: distance + DOT_SIZE }]}>
108
+ <Animated.View style={[styles.dot, { transform: [{ translateX }] }]} />
109
+ </View>
110
+ <View style={styles.footer}>
111
+ {label ? <Text style={styles.label}>{label}</Text> : <View style={styles.labelSpacer} />}
112
+ <Pressable
113
+ onPress={() => play(reduceMotion)}
114
+ accessibilityRole="button"
115
+ accessibilityLabel={motionReplayLabel({ kind: props.kind, duration: props.kind === 'timing' ? props.duration : undefined, label })}
116
+ style={({ pressed }) => [styles.replayButton, pressed && styles.replayButtonPressed]}
117
+ >
118
+ <Text style={styles.replayText}>Replay</Text>
119
+ </Pressable>
120
+ </View>
121
+ </View>
122
+ );
123
+ }
124
+
125
+ const styles = StyleSheet.create({
126
+ wrap: { gap: CATALOG_SPACE.sm, alignItems: 'flex-start' },
127
+ track: {
128
+ height: DOT_SIZE,
129
+ borderRadius: DOT_SIZE / 2,
130
+ backgroundColor: CATALOG_COLOR.border,
131
+ justifyContent: 'center',
132
+ },
133
+ dot: {
134
+ width: DOT_SIZE,
135
+ height: DOT_SIZE,
136
+ borderRadius: DOT_SIZE / 2,
137
+ backgroundColor: CATALOG_COLOR.accent,
138
+ },
139
+ footer: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.md },
140
+ label: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
141
+ labelSpacer: { flex: 1 },
142
+ replayButton: {
143
+ paddingHorizontal: CATALOG_SPACE.md,
144
+ paddingVertical: CATALOG_SPACE.xs,
145
+ borderRadius: CATALOG_RADIUS.control,
146
+ borderWidth: 1,
147
+ borderColor: CATALOG_COLOR.borderStrong,
148
+ backgroundColor: CATALOG_COLOR.surface,
149
+ },
150
+ replayButtonPressed: { backgroundColor: CATALOG_COLOR.surfacePressed },
151
+ replayText: { fontSize: CATALOG_TYPE.sm, fontWeight: '700', color: CATALOG_COLOR.text },
152
+ });
@@ -67,6 +67,14 @@ export function ReferenceDetails<TId extends string>({ def }: { def: SectionDef<
67
67
  <Text role="heading" {...HEADING_LEVEL_2} style={styles.heading}>Quick reference</Text>
68
68
  {def.path && <Fact label="Source" value={def.path} mono />}
69
69
  {!hide.accessibility && <Fact label="Accessibility" value={def.a11y ?? 'No accessibility notes documented.'} />}
70
+ {def.composedOf && def.composedOf.length > 0 && (
71
+ <>
72
+ <Text style={styles.subheading}>Composition</Text>
73
+ {def.composedOf.map((entry, i) => (
74
+ <Fact key={`${entry.component}-${entry.relationship}-${i}`} label={`${entry.component} · ${entry.relationship}`} value={entry.role} />
75
+ ))}
76
+ </>
77
+ )}
70
78
  </View>
71
79
  </View>
72
80
  </View>
@@ -95,6 +103,13 @@ const styles = StyleSheet.create({
95
103
  marginBottom: 10,
96
104
  },
97
105
  body: { fontSize: CATALOG_TYPE.md, lineHeight: 20, color: CATALOG_COLOR.textMuted },
106
+ subheading: {
107
+ fontSize: CATALOG_TYPE.sm,
108
+ fontWeight: '700',
109
+ color: CATALOG_COLOR.text,
110
+ marginTop: CATALOG_SPACE.md,
111
+ marginBottom: CATALOG_SPACE.xs,
112
+ },
98
113
  fact: {
99
114
  flexDirection: 'row',
100
115
  justifyContent: 'space-between',
@@ -5,9 +5,10 @@ import { ComparisonGrid } from './ComparisonGrid';
5
5
  import { ComparisonGroups } from './ComparisonGroups';
6
6
  import { ComparisonList } from './ComparisonList';
7
7
  import { ReferenceDetails } from './ReferenceDetails';
8
+ import { SpecimenSurface } from './SpecimenSurface';
8
9
  import { TokenSections } from './TokenLayouts';
9
10
  import { choosePlacement, listGeometry, presentationBlocks, type PresentationBlock } from './comparison';
10
- import type { PreviewWidths, SectionDef } from './types';
11
+ import type { PreviewWidths, SectionDef, SpecimenSurfaceKind } from './types';
11
12
 
12
13
  // Matches a quoted-string-literal union type, e.g. "'primary' | 'secondary' | 'tertiary'" — anything
13
14
  // else (string, boolean, IconName, () => void, …) has no fixed enum to sweep and is skipped.
@@ -100,9 +101,11 @@ function frameLabel(width: number): string {
100
101
  }
101
102
 
102
103
  /** A component preview: one frame per width (each at most phone width), or full width for catalog
103
- * chrome and token galleries. Every frame is its own live instance with its own state. */
104
- function Preview({ render, widths }: { render: () => React.ReactNode; widths: PreviewWidths }) {
105
- if (widths === 'full') return <View style={styles.previewCard}>{render()}</View>;
104
+ * chrome and token galleries. Every frame is its own live instance with its own state. `surface`
105
+ * is omitted for token galleries, which keep their current, unstaged presentation. */
106
+ function Preview({ render, widths, surface }: { render: () => React.ReactNode; widths: PreviewWidths; surface?: SpecimenSurfaceKind }) {
107
+ const stage = (node: React.ReactNode) => (surface ? <SpecimenSurface surface={surface}>{node}</SpecimenSurface> : node);
108
+ if (widths === 'full') return <View style={styles.previewCard}>{stage(render())}</View>;
106
109
  return (
107
110
  <View style={styles.previewCard}>
108
111
  <View style={styles.frames}>
@@ -114,7 +117,7 @@ function Preview({ render, widths }: { render: () => React.ReactNode; widths: Pr
114
117
  style={[styles.frame, { width }]}
115
118
  >
116
119
  {widths.length > 1 && <Text style={styles.frameLabel}>{frameLabel(width)}</Text>}
117
- {render()}
120
+ {stage(render())}
118
121
  </View>
119
122
  ))}
120
123
  </View>
@@ -122,16 +125,18 @@ function Preview({ render, widths }: { render: () => React.ReactNode; widths: Pr
122
125
  );
123
126
  }
124
127
 
125
- function BlockContent<TId extends string>({ block, def }: { block: PresentationBlock; def: SectionDef<TId> }) {
128
+ function BlockContent<TId extends string>({ block, def, surface }: { block: PresentationBlock; def: SectionDef<TId>; surface: SpecimenSurfaceKind }) {
126
129
  switch (block.kind) {
127
130
  case 'grid':
128
- return <ComparisonGrid def={block.comparison} size={block.size} sectionId={def.id} />;
131
+ return <ComparisonGrid def={block.comparison} size={block.size} sectionId={def.id} surface={surface} />;
129
132
  case 'grouped':
130
- return <ComparisonGroups groups={block.groups} size={block.size} label={def.id} />;
133
+ return <ComparisonGroups groups={block.groups} size={block.size} label={def.id} surface={surface} />;
131
134
  case 'list':
132
- return <ComparisonList items={block.items} size={block.size} label={`${def.id}: ${block.title}`} />;
135
+ return <ComparisonList items={block.items} size={block.size} label={`${def.id}: ${block.title}`} maxColumns={block.maxColumns} surface={surface} />;
133
136
  case 'preview':
134
- return def.render ? <Preview render={def.render} widths={block.widths} /> : null;
137
+ // A token gallery's `preview` block shows full-width raw token data, not a component
138
+ // specimen — it keeps its current, unstaged presentation.
139
+ return def.render ? <Preview render={def.render} widths={block.widths} surface={def.tokenGallery ? undefined : surface} /> : null;
135
140
  case 'tokenSections':
136
141
  return <TokenSections sections={block.sections} columns={block.columns} />;
137
142
  default:
@@ -145,7 +150,7 @@ function BlockContent<TId extends string>({ block, def }: { block: PresentationB
145
150
 
146
151
  /** Width a block's card occupies: lists hug their columns; everything else fills the row. */
147
152
  function blockWidth(block: PresentationBlock, available: number): number {
148
- return block.kind === 'list' ? listGeometry(block.items.length, available, block.size).containerWidth : available;
153
+ return block.kind === 'list' ? listGeometry(block.items.length, available, block.size, block.maxColumns).containerWidth : available;
149
154
  }
150
155
 
151
156
  /**
@@ -154,7 +159,7 @@ function blockWidth(block: PresentationBlock, available: number): number {
154
159
  * are never recorded while side by side, so the decision never feeds on its own result; a width
155
160
  * change returns to stacked and decides again.
156
161
  */
157
- function Blocks<TId extends string>({ blocks, def }: { blocks: PresentationBlock[]; def: SectionDef<TId> }) {
162
+ function Blocks<TId extends string>({ blocks, def, surface }: { blocks: PresentationBlock[]; def: SectionDef<TId>; surface: SpecimenSurfaceKind }) {
158
163
  const [available, setAvailable] = useState(0);
159
164
  const [heights, setHeights] = useState<number[]>([]);
160
165
  const [placement, setPlacement] = useState<'side' | 'stacked'>('stacked');
@@ -187,7 +192,7 @@ function Blocks<TId extends string>({ blocks, def }: { blocks: PresentationBlock
187
192
  gap: CATALOG_LAYOUT.blockGap,
188
193
  first: { kind: first.kind, width: blockWidth(first, available), height: heights[0] },
189
194
  second: second.kind === 'list'
190
- ? { kind: 'list', height: heights[1], itemCount: second.items.length, size: second.size }
195
+ ? { kind: 'list', height: heights[1], itemCount: second.items.length, size: second.size, maxColumns: second.maxColumns }
191
196
  : { kind: second.kind, height: heights[1] },
192
197
  });
193
198
  if (decision === 'side') setPlacement('side');
@@ -203,7 +208,7 @@ function Blocks<TId extends string>({ blocks, def }: { blocks: PresentationBlock
203
208
  style={side ? (i === 0 ? { width: blockWidth(block, available), flexShrink: 0 } : styles.blockFill) : undefined}
204
209
  >
205
210
  {block.title ? <Text style={styles.blockLabel}>{block.title}</Text> : null}
206
- <BlockContent block={block} def={def} />
211
+ <BlockContent block={block} def={def} surface={surface} />
207
212
  </View>
208
213
  ))}
209
214
  </View>
@@ -241,6 +246,7 @@ export function SectionBlock<TId extends string>({
241
246
  checkCompleteness(def);
242
247
  // Memoized so Blocks' placement effect runs on real changes, not on every render.
243
248
  const blocks = useMemo(() => presentationBlocks(def, { defaultPreviewWidths }), [def, defaultPreviewWidths]);
249
+ const surface = def.specimenSurface ?? 'neutral';
244
250
  // react-native-web reads `aria-level`; React Native's prop types do not declare it.
245
251
  const headingLevelProps = { 'aria-level': headingLevel } as Record<string, unknown>;
246
252
 
@@ -271,7 +277,7 @@ export function SectionBlock<TId extends string>({
271
277
  )}
272
278
  </View>
273
279
 
274
- {blocks.length > 0 && <Blocks blocks={blocks} def={def} />}
280
+ {blocks.length > 0 && <Blocks blocks={blocks} def={def} surface={surface} />}
275
281
 
276
282
  <ReferenceDetails def={def} />
277
283
  </View>
@@ -0,0 +1,46 @@
1
+ import React from 'react';
2
+ import { View, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_SPACE } from './tokens';
4
+ import { SPECIMEN_SURFACE_WIDTH_STYLE } from './specimenSurfaceStyle';
5
+ import type { SpecimenSurfaceKind } from './types';
6
+
7
+ /**
8
+ * The catalog-owned background directly behind a live component specimen — shared by
9
+ * `ComparisonList`, `ComparisonGroups`, `ComparisonGrid`, and `SectionBlock`'s component preview,
10
+ * so a page's `specimenSurface` renders identically everywhere a specimen appears. Purely a fixed
11
+ * backdrop: it never reads the wrapped component's own colors, never adds a border, and is never
12
+ * used for token galleries/sections (those keep their existing full-width presentation). Always
13
+ * stretches to its parent's width (see `specimenSurfaceStyle.ts`) rather than shrink-wrapping to
14
+ * its content, centering that content itself — so a percentage-width specimen resolves against
15
+ * the real comparison-cell width instead of collapsing to 0px.
16
+ */
17
+ export function SpecimenSurface({
18
+ surface = 'neutral',
19
+ style,
20
+ children,
21
+ }: {
22
+ surface?: SpecimenSurfaceKind;
23
+ style?: StyleProp<ViewStyle>;
24
+ children: React.ReactNode;
25
+ }) {
26
+ return <View style={[styles.base, SURFACE_FILL[surface], style]}>{children}</View>;
27
+ }
28
+
29
+ // 'dark' reuses CATALOG_COLOR.text (the catalog's own darkest token) rather than adding a new
30
+ // background-specific color — the catalog chrome has no existing dark-surface token to reach for.
31
+ const SURFACE_FILL: Record<SpecimenSurfaceKind, ViewStyle> = {
32
+ neutral: { backgroundColor: CATALOG_COLOR.surfaceMuted },
33
+ white: { backgroundColor: CATALOG_COLOR.surface },
34
+ dark: { backgroundColor: CATALOG_COLOR.text },
35
+ transparent: { backgroundColor: 'transparent' },
36
+ };
37
+
38
+ const styles = StyleSheet.create({
39
+ base: {
40
+ ...SPECIMEN_SURFACE_WIDTH_STYLE,
41
+ alignItems: 'center',
42
+ justifyContent: 'center',
43
+ borderRadius: CATALOG_RADIUS.sm,
44
+ padding: CATALOG_SPACE.md,
45
+ },
46
+ });
@@ -11,7 +11,9 @@ import { CATALOG_TYPE, CATALOG_COLOR, CATALOG_SPACE } from './tokens';
11
11
  *
12
12
  * Draws a 1px `CATALOG_COLOR.border` bottom divider by default (the same divider PropsTable's rows use) so a stack of TokenRows
13
13
  * reads as a list, not a loose pile of paragraphs. Pass `last` on the final row in a stack to drop
14
- * the divider, matching PropsTable's own `rowLast` convention.
14
+ * the divider, matching PropsTable's own `rowLast` convention. Balanced `CATALOG_SPACE.md` padding
15
+ * sits above and below both the content and its use-note, so a stack of rows breathes evenly
16
+ * instead of only the divider-adjacent gap existing.
15
17
  */
16
18
  export function TokenRow({
17
19
  children,
@@ -45,11 +47,10 @@ export function TokenRow({
45
47
  }
46
48
 
47
49
  const styles = StyleSheet.create({
48
- item: { gap: CATALOG_SPACE.xs },
49
- itemRight: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.xl },
50
+ item: { gap: CATALOG_SPACE.xs, paddingVertical: CATALOG_SPACE.md },
51
+ itemRight: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.xl, paddingVertical: CATALOG_SPACE.md },
50
52
  useRight: { flex: 1 },
51
53
  itemDivider: {
52
- paddingBottom: CATALOG_SPACE.md,
53
54
  borderBottomWidth: 1,
54
55
  borderBottomColor: CATALOG_COLOR.border,
55
56
  },
@@ -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,7 +233,7 @@ 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 }
233
238
  | { kind: 'tokenSections'; title: string; sections: TokenSection[]; columns: 1 | 2 | 3 }
234
239
  | { kind: 'empty'; title: string; message: string };
@@ -308,7 +313,13 @@ export function presentationBlocks<TId extends string>(def: SectionDef<TId>, opt
308
313
  blocks.push({ kind: 'grouped', title: 'Variant × configuration', size: def.specimenSize ?? 'regular', groups: grouped.groups });
309
314
  states = grouped.leftovers;
310
315
  } else if (def.variants) {
311
- 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
+ });
312
323
  } else if (def.render) {
313
324
  blocks.push({ kind: 'preview', title: 'Preview', widths: previewWidths(def.previewWidths, options.defaultPreviewWidths) });
314
325
  }
@@ -321,6 +332,7 @@ export function presentationBlocks<TId extends string>(def: SectionDef<TId>, opt
321
332
  title: secondary ? 'Other configurations' : 'States / configurations',
322
333
  size: slotSize(def, states),
323
334
  items: slotItems(states),
335
+ ...(states.maxColumns !== undefined ? { maxColumns: states.maxColumns } : {}),
324
336
  });
325
337
  }
326
338
 
@@ -336,9 +348,11 @@ export interface PlacementBlock {
336
348
  width?: number;
337
349
  /** Measured height of the whole block, label included, while stacked. */
338
350
  height: number;
339
- /** 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. */
340
353
  itemCount?: number;
341
354
  size?: SpecimenSize;
355
+ maxColumns?: 1 | 2 | 3 | 4 | 5;
342
356
  }
343
357
 
344
358
  /** Side by side or stacked, for a page with exactly two blocks. Tries both and keeps the shorter;
@@ -351,8 +365,8 @@ export function choosePlacement({ available, gap, first, second }: { available:
351
365
  const room = available - firstWidth - gap;
352
366
  const minCell = second.size === 'wide' ? MATRIX_LAYOUT.columnMaxWidth : COLUMN_MIN_WIDTH[second.size];
353
367
  if (room < minCell + 2) return 'stacked';
354
- const stackedRows = listGeometry(second.itemCount, available, second.size).rows;
355
- 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;
356
370
  const rowHeight = second.height / Math.max(1, stackedRows);
357
371
  const sideHeight = Math.max(first.height, sideRows * rowHeight);
358
372
  const stackedHeight = first.height + gap + second.height;
@@ -24,6 +24,9 @@ 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';
27
30
  export { TokenSections, TokenGrid, TokenTile } from './TokenLayouts';
28
31
  export { DividedStack } from './DividedStack';
29
32
  export { Swatch } from './Swatch';
@@ -34,7 +37,10 @@ export { buildComponentManifest } from './manifest';
34
37
  export type { ComponentManifestEntry, ManifestExample } from './manifest';
35
38
  export { defineCatalogPage, buildCatalogSections } from './pageApi';
36
39
  export type { CatalogPage, CatalogPageInput, GeneratedComponent, GeneratedPropRecord } from './pageApi';
37
- export type { PropDef, SectionDef, NavGroup, SpecimenSize, ComparisonDef, ComparisonCell, ComparisonAxisItem, GridAxis, PreviewWidths, TokenSection } from './types';
40
+ export type {
41
+ PropDef, SectionDef, NavGroup, SpecimenSize, SpecimenSurfaceKind, ComparisonDef, ComparisonCell, ComparisonAxisItem, GridAxis,
42
+ PreviewWidths, TokenSection, ComposedOfEntry,
43
+ } from './types';
38
44
  export { UpdatePanel } from './UpdatePanel';
39
45
  export { UPDATE_PAGE_ID } from './catalogNavigation';
40
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, TokenSection, 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. */
@@ -29,8 +29,10 @@ export interface CatalogPageInput<TId extends string = string> {
29
29
  fullWidthLabel?: string;
30
30
  comparison?: ComparisonDef;
31
31
  specimenSize?: SpecimenSize;
32
+ specimenSurface?: SpecimenSurfaceKind;
32
33
  previewWidths?: PreviewWidths;
33
34
  hide?: SectionDef['hide'];
35
+ composedOf?: ComposedOfEntry[];
34
36
  /** Export name this page documents; defaults to `id` for component pages. */
35
37
  component?: string;
36
38
  /** Sidebar group label. */
@@ -87,6 +89,17 @@ function mergePropNotes(component: GeneratedComponent, propNotes: Record<string,
87
89
  return [...own, ...inherited];
88
90
  }
89
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
+
90
103
  function orderGroups<TId extends string>(groupIds: Map<string, TId[]>, groupOrder: string[] | undefined): NavGroup<TId>[] {
91
104
  const listed = groupOrder ?? [];
92
105
  const labels = [...groupIds.keys()];
@@ -112,6 +125,10 @@ export function buildCatalogSections<TId extends string>(
112
125
  const documented = new Set(pages.map((page) => page.component ?? page.id));
113
126
  const groupIds = new Map<string, TId[]>();
114
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)]);
115
132
 
116
133
  // Design §4 "duplicate-page-id" rule's viewer-side equivalent — this is the one place in the
117
134
  // whole viewer that already resolves every page's id to build sections/groups, so it costs
@@ -122,6 +139,7 @@ export function buildCatalogSections<TId extends string>(
122
139
  if ((globalThis as { __DEV__?: boolean }).__DEV__) {
123
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".`);
124
141
  seenIds.add(page.id);
142
+ for (const issue of validateComposedOf(page.composedOf, knownComposedOfNames)) console.warn(`[Catalog] ${page.id}: ${issue}`);
125
143
  }
126
144
  const componentName = page.component ?? page.id;
127
145
  const component = componentByName.get(componentName);
@@ -148,8 +166,10 @@ export function buildCatalogSections<TId extends string>(
148
166
  fullWidthLabel: page.fullWidthLabel,
149
167
  comparison: page.comparison,
150
168
  specimenSize: page.specimenSize,
169
+ specimenSurface: page.specimenSurface,
151
170
  previewWidths: page.previewWidths,
152
171
  hide: component ? page.hide : { ...page.hide, props: true },
172
+ composedOf: page.composedOf,
153
173
  };
154
174
  });
155
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;
@@ -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;
@@ -189,6 +217,10 @@ export interface SectionDef<TId extends string = string> {
189
217
  /** Width class for this page's specimens. Without it, full-width (`itemsFill`) slots are 'wide'
190
218
  * and everything else is 'regular'. */
191
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;
192
224
  /** Widths for this page's `render()` preview, e.g. `[402, 320]` to add a small-phone example.
193
225
  * Defaults to the catalog's default (CatalogShell `defaultPreviewWidths`), else `[402]`. */
194
226
  previewWidths?: PreviewWidths;
@@ -203,6 +235,10 @@ export interface SectionDef<TId extends string = string> {
203
235
  props?: boolean;
204
236
  accessibility?: boolean;
205
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[];
206
242
  }
207
243
 
208
244
  /** A labeled group of section ids in the sidebar (e.g. "Components" vs "Tokens"). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krapwoo/ds-viewer",
3
- "version": "0.4.3",
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>