@krapwoo/ds-viewer 0.4.3 → 0.4.5

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.
@@ -5,9 +5,11 @@ 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';
9
+ import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
8
10
  import { TokenSections } from './TokenLayouts';
9
11
  import { choosePlacement, listGeometry, presentationBlocks, type PresentationBlock } from './comparison';
10
- import type { PreviewWidths, SectionDef } from './types';
12
+ import type { PreviewWidths, SectionDef, SpecimenSurfaceKind } from './types';
11
13
 
12
14
  // Matches a quoted-string-literal union type, e.g. "'primary' | 'secondary' | 'tertiary'" — anything
13
15
  // else (string, boolean, IconName, () => void, …) has no fixed enum to sweep and is skipped.
@@ -100,38 +102,44 @@ function frameLabel(width: number): string {
100
102
  }
101
103
 
102
104
  /** 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>;
105
+ * chrome and token galleries. Every frame is its own live instance with its own state. `surface`
106
+ * is omitted for token galleries, which keep their current, unstaged presentation. */
107
+ function Preview({ render, widths, surface }: { render: () => React.ReactNode; widths: PreviewWidths; surface?: SpecimenSurfaceKind }) {
108
+ // Token galleries pass no surface: their card stays white.
109
+ const kind = surface ?? 'transparent';
110
+ if (widths === 'full') return <SpecimenSurface surface={kind} style={styles.previewCard}>{render()}</SpecimenSurface>;
106
111
  return (
107
112
  <View style={styles.previewCard}>
108
113
  <View style={styles.frames}>
109
114
  {widths.map((width, i) => (
110
- <View
115
+ <SpecimenSurface
111
116
  key={`${width}-${i}`}
117
+ surface={kind}
112
118
  // Two live instances of the same component: name each so assistive tech can tell them apart.
113
119
  {...(widths.length > 1 ? ({ role: 'group', 'aria-label': frameLabel(width) } as Record<string, unknown>) : null)}
114
120
  style={[styles.frame, { width }]}
115
121
  >
116
122
  {widths.length > 1 && <Text style={styles.frameLabel}>{frameLabel(width)}</Text>}
117
123
  {render()}
118
- </View>
124
+ </SpecimenSurface>
119
125
  ))}
120
126
  </View>
121
127
  </View>
122
128
  );
123
129
  }
124
130
 
125
- function BlockContent<TId extends string>({ block, def }: { block: PresentationBlock; def: SectionDef<TId> }) {
131
+ function BlockContent<TId extends string>({ block, def, surface }: { block: PresentationBlock; def: SectionDef<TId>; surface: SpecimenSurfaceKind }) {
126
132
  switch (block.kind) {
127
133
  case 'grid':
128
- return <ComparisonGrid def={block.comparison} size={block.size} sectionId={def.id} />;
134
+ return <ComparisonGrid def={block.comparison} size={block.size} sectionId={def.id} surface={surface} />;
129
135
  case 'grouped':
130
- return <ComparisonGroups groups={block.groups} size={block.size} label={def.id} />;
136
+ return <ComparisonGroups groups={block.groups} size={block.size} label={def.id} surface={surface} />;
131
137
  case 'list':
132
- return <ComparisonList items={block.items} size={block.size} label={`${def.id}: ${block.title}`} />;
138
+ return <ComparisonList items={block.items} size={block.size} label={`${def.id}: ${block.title}`} maxColumns={block.maxColumns} surface={surface} />;
133
139
  case 'preview':
134
- return def.render ? <Preview render={def.render} widths={block.widths} /> : null;
140
+ // A token gallery's `preview` block shows full-width raw token data, not a component
141
+ // specimen — it keeps its current, unstaged presentation.
142
+ return def.render ? <Preview render={def.render} widths={block.widths} surface={def.tokenGallery ? undefined : surface} /> : null;
135
143
  case 'tokenSections':
136
144
  return <TokenSections sections={block.sections} columns={block.columns} />;
137
145
  default:
@@ -145,7 +153,7 @@ function BlockContent<TId extends string>({ block, def }: { block: PresentationB
145
153
 
146
154
  /** Width a block's card occupies: lists hug their columns; everything else fills the row. */
147
155
  function blockWidth(block: PresentationBlock, available: number): number {
148
- return block.kind === 'list' ? listGeometry(block.items.length, available, block.size).containerWidth : available;
156
+ return block.kind === 'list' ? listGeometry(block.items.length, available, block.size, block.maxColumns).containerWidth : available;
149
157
  }
150
158
 
151
159
  /**
@@ -154,7 +162,7 @@ function blockWidth(block: PresentationBlock, available: number): number {
154
162
  * are never recorded while side by side, so the decision never feeds on its own result; a width
155
163
  * change returns to stacked and decides again.
156
164
  */
157
- function Blocks<TId extends string>({ blocks, def }: { blocks: PresentationBlock[]; def: SectionDef<TId> }) {
165
+ function Blocks<TId extends string>({ blocks, def, surface }: { blocks: PresentationBlock[]; def: SectionDef<TId>; surface: SpecimenSurfaceKind }) {
158
166
  const [available, setAvailable] = useState(0);
159
167
  const [heights, setHeights] = useState<number[]>([]);
160
168
  const [placement, setPlacement] = useState<'side' | 'stacked'>('stacked');
@@ -187,7 +195,7 @@ function Blocks<TId extends string>({ blocks, def }: { blocks: PresentationBlock
187
195
  gap: CATALOG_LAYOUT.blockGap,
188
196
  first: { kind: first.kind, width: blockWidth(first, available), height: heights[0] },
189
197
  second: second.kind === 'list'
190
- ? { kind: 'list', height: heights[1], itemCount: second.items.length, size: second.size }
198
+ ? { kind: 'list', height: heights[1], itemCount: second.items.length, size: second.size, maxColumns: second.maxColumns }
191
199
  : { kind: second.kind, height: heights[1] },
192
200
  });
193
201
  if (decision === 'side') setPlacement('side');
@@ -203,7 +211,7 @@ function Blocks<TId extends string>({ blocks, def }: { blocks: PresentationBlock
203
211
  style={side ? (i === 0 ? { width: blockWidth(block, available), flexShrink: 0 } : styles.blockFill) : undefined}
204
212
  >
205
213
  {block.title ? <Text style={styles.blockLabel}>{block.title}</Text> : null}
206
- <BlockContent block={block} def={def} />
214
+ <BlockContent block={block} def={def} surface={surface} />
207
215
  </View>
208
216
  ))}
209
217
  </View>
@@ -241,6 +249,7 @@ export function SectionBlock<TId extends string>({
241
249
  checkCompleteness(def);
242
250
  // Memoized so Blocks' placement effect runs on real changes, not on every render.
243
251
  const blocks = useMemo(() => presentationBlocks(def, { defaultPreviewWidths }), [def, defaultPreviewWidths]);
252
+ const surface = def.specimenSurface ?? DEFAULT_SPECIMEN_SURFACE;
244
253
  // react-native-web reads `aria-level`; React Native's prop types do not declare it.
245
254
  const headingLevelProps = { 'aria-level': headingLevel } as Record<string, unknown>;
246
255
 
@@ -271,7 +280,7 @@ export function SectionBlock<TId extends string>({
271
280
  )}
272
281
  </View>
273
282
 
274
- {blocks.length > 0 && <Blocks blocks={blocks} def={def} />}
283
+ {blocks.length > 0 && <Blocks blocks={blocks} def={def} surface={surface} />}
275
284
 
276
285
  <ReferenceDetails def={def} />
277
286
  </View>
@@ -0,0 +1,84 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { View, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { CATALOG_COLOR } from './tokens';
4
+ import { DEFAULT_SPECIMEN_SURFACE, SPECIMEN_SURFACE_WIDTH_STYLE } from './specimenSurfaceStyle';
5
+ import { blendsIntoCell, measurePaintedBoxes } from './specimenFill';
6
+ import type { SpecimenSurfaceKind } from './types';
7
+
8
+ type Browser = {
9
+ getComputedStyle?: (element: unknown) => never;
10
+ Element?: new () => unknown;
11
+ requestAnimationFrame?: (callback: () => void) => number;
12
+ };
13
+
14
+ /**
15
+ * An example's whole cell area — shared by `ComparisonList`, `ComparisonGroups`,
16
+ * `ComparisonGrid`, and `SectionBlock`'s preview frames, so a page's `specimenSurface` fills every
17
+ * example cell the same way. With `'auto'` (the default) the cell measures its example after it
18
+ * draws and turns gray only when the example would vanish against white (a white card, sheet or
19
+ * neutral banner; see `specimenFill.ts`). Explicit kinds fill the cell without measuring. Adds no
20
+ * padding or border of its own: pass the cell's own layout in `style`. Measuring needs the
21
+ * browser's DOM; elsewhere `'auto'` leaves the cell white. Extra props (e.g. `role`) go to the View.
22
+ */
23
+ export function SpecimenSurface({
24
+ surface = DEFAULT_SPECIMEN_SURFACE,
25
+ style,
26
+ children,
27
+ ...viewProps
28
+ }: {
29
+ surface?: SpecimenSurfaceKind;
30
+ style?: StyleProp<ViewStyle>;
31
+ children: React.ReactNode;
32
+ [prop: string]: unknown;
33
+ }) {
34
+ const ref = useRef<View>(null);
35
+ const [blends, setBlends] = useState(false);
36
+
37
+ const measure = useCallback(() => {
38
+ if (surface !== 'auto') return;
39
+ const browser = globalThis as unknown as Browser;
40
+ const node = ref.current as unknown;
41
+ if (!browser.getComputedStyle || !browser.Element || !(node instanceof browser.Element)) return;
42
+ const { boxes, cellArea } = measurePaintedBoxes(node as Parameters<typeof measurePaintedBoxes>[0], (element) => browser.getComputedStyle!(element));
43
+ setBlends(blendsIntoCell(boxes, cellArea));
44
+ }, [surface]);
45
+
46
+ useEffect(() => {
47
+ const raf = (globalThis as unknown as Browser).requestAnimationFrame;
48
+ if (surface !== 'auto' || !raf) return;
49
+ // Two frames, so the example's own styles and fonts have applied before measuring.
50
+ let cancelled = false;
51
+ raf(() => raf(() => {
52
+ if (!cancelled) measure();
53
+ }));
54
+ return () => {
55
+ cancelled = true;
56
+ };
57
+ }, [surface, measure]);
58
+
59
+ const fill = surface === 'auto' ? (blends ? FILL.neutral : undefined) : FILL[surface];
60
+ return (
61
+ <View ref={ref} onLayout={surface === 'auto' ? measure : undefined} style={[style, fill]} {...viewProps}>
62
+ {children}
63
+ </View>
64
+ );
65
+ }
66
+
67
+ /** Wraps one example inside its cell: full cell width (so a percentage-width example resolves
68
+ * against the cell), centered unless the example fills the cell. */
69
+ export function SpecimenContent({ fill, children }: { fill?: boolean; children: React.ReactNode }) {
70
+ return <View style={fill ? styles.fill : styles.center}>{children}</View>;
71
+ }
72
+
73
+ // 'dark' reuses CATALOG_COLOR.text (the catalog's own darkest token); there is no dark-surface token.
74
+ const FILL = StyleSheet.create({
75
+ neutral: { backgroundColor: CATALOG_COLOR.specimenStage },
76
+ white: { backgroundColor: CATALOG_COLOR.surface },
77
+ dark: { backgroundColor: CATALOG_COLOR.text },
78
+ transparent: {},
79
+ });
80
+
81
+ const styles = StyleSheet.create({
82
+ center: { ...SPECIMEN_SURFACE_WIDTH_STYLE, alignItems: 'center' },
83
+ fill: { ...SPECIMEN_SURFACE_WIDTH_STYLE },
84
+ });
@@ -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,102 @@
1
+ /**
2
+ * Automatic cell fill for `specimenSurface: 'auto'` (the default): decides whether an example
3
+ * blends into its white comparison cell, so that one cell can turn gray. Kept free of any runtime
4
+ * import so `node --test` can assert on it directly (see `__tests__/specimenFill.test.ts`).
5
+ *
6
+ * The rule looks only at the example's largest painted box (the element with a background that
7
+ * covers the most area): if that box is near-white (every channel close to 255, so pale tints
8
+ * don't count) and has no visible border, the example's edges
9
+ * vanish against the white cell. A shadow alone doesn't count as an edge. Small boxes (under a
10
+ * tenth of the cell) and text-only examples never trigger it.
11
+ */
12
+
13
+ export interface Rgba { r: number; g: number; b: number; a: number }
14
+
15
+ /** One element inside an example, as measured in the browser. */
16
+ export interface PaintedBox {
17
+ area: number;
18
+ background: Rgba | null;
19
+ /** The top border's colour when its width is above 0, else null. */
20
+ border: Rgba | null;
21
+ }
22
+
23
+ /** A fill is near-white when every channel (after compositing over the white cell) is at least
24
+ * this. #fafafa and #f8f8f8 qualify; #f4f4f4 already reads as gray, and pale tints such as a
25
+ * positive banner's rgb(240, 255, 244) read as colour, even when they're as bright as white. */
26
+ const NEAR_WHITE_CHANNEL = 247;
27
+ /** At or above this, a border draws the edge itself (#e4e4e4 is 1.27:1). */
28
+ const VISIBLE_BORDER_CONTRAST = 1.2;
29
+ /** A box smaller than this share of the cell is a detail, not the example's body. */
30
+ const MIN_AREA_SHARE = 0.1;
31
+
32
+ /** Parses a computed CSS colour (`rgb(…)`/`rgba(…)`). Fully transparent or unknown values are null. */
33
+ export function parseCssColor(value: string): Rgba | null {
34
+ const match = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/.exec(value.trim());
35
+ if (!match) return null;
36
+ const alphaText = match[4];
37
+ const a = alphaText === undefined ? 1 : alphaText.endsWith('%') ? Number(alphaText.slice(0, -1)) / 100 : Number(alphaText);
38
+ if (!(a > 0)) return null;
39
+ return { r: Number(match[1]), g: Number(match[2]), b: Number(match[3]), a };
40
+ }
41
+
42
+ function channel(value: number): number {
43
+ const s = value / 255;
44
+ return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
45
+ }
46
+
47
+ /** True when every channel of `color`, composited over white, is at least `NEAR_WHITE_CHANNEL`. */
48
+ export function isNearWhite(color: Rgba): boolean {
49
+ const over = (c: number) => color.a * c + (1 - color.a) * 255;
50
+ return Math.min(over(color.r), over(color.g), over(color.b)) >= NEAR_WHITE_CHANNEL;
51
+ }
52
+
53
+ /** WCAG contrast ratio against white, after compositing a translucent colour over the white cell. */
54
+ export function contrastWithWhite(color: Rgba): number {
55
+ const over = (c: number) => color.a * c + (1 - color.a) * 255;
56
+ const luminance = 0.2126 * channel(over(color.r)) + 0.7152 * channel(over(color.g)) + 0.0722 * channel(over(color.b));
57
+ return 1.05 / (luminance + 0.05);
58
+ }
59
+
60
+ /** True when the example's body is near-white with no visible border, so it vanishes on a white cell. */
61
+ export function blendsIntoCell(boxes: readonly PaintedBox[], cellArea: number): boolean {
62
+ let largest: PaintedBox | undefined;
63
+ for (const box of boxes) {
64
+ if (!box.background || box.area < cellArea * MIN_AREA_SHARE) continue;
65
+ if (!largest || box.area > largest.area) largest = box;
66
+ }
67
+ if (!largest?.background) return false;
68
+ if (!isNearWhite(largest.background)) return false;
69
+ return !(largest.border && contrastWithWhite(largest.border) >= VISIBLE_BORDER_CONTRAST);
70
+ }
71
+
72
+ /** Minimal DOM shapes, so this file needs no DOM lib types. */
73
+ interface MeasurableElement {
74
+ getBoundingClientRect(): { width: number; height: number };
75
+ querySelectorAll(selector: string): ArrayLike<MeasurableElement>;
76
+ }
77
+ type ComputedStyleReader = (element: MeasurableElement) => {
78
+ backgroundColor: string;
79
+ borderTopColor: string;
80
+ borderTopWidth: string;
81
+ visibility: string;
82
+ opacity: string;
83
+ };
84
+
85
+ /** Measures every element inside `cell` (up to `limit`) in the browser. */
86
+ export function measurePaintedBoxes(cell: MeasurableElement, readStyle: ComputedStyleReader, limit = 300): { boxes: PaintedBox[]; cellArea: number } {
87
+ const cellRect = cell.getBoundingClientRect();
88
+ const elements = cell.querySelectorAll('*');
89
+ const boxes: PaintedBox[] = [];
90
+ for (let i = 0; i < Math.min(elements.length, limit); i++) {
91
+ const element = elements[i];
92
+ const style = readStyle(element);
93
+ if (style.visibility === 'hidden' || Number(style.opacity) === 0) continue;
94
+ const rect = element.getBoundingClientRect();
95
+ boxes.push({
96
+ area: rect.width * rect.height,
97
+ background: parseCssColor(style.backgroundColor),
98
+ border: parseFloat(style.borderTopWidth) > 0 ? parseCssColor(style.borderTopColor) : null,
99
+ });
100
+ }
101
+ return { boxes, cellArea: cellRect.width * cellRect.height };
102
+ }
@@ -0,0 +1,21 @@
1
+ import type { SpecimenSurfaceKind } from './types.ts';
2
+
3
+ /**
4
+ * The example wrapper's width policy inside a cell, kept free of any `react-native` import so
5
+ * `node --test` can assert on it directly (see `__tests__/specimenSurfaceStyle.test.ts`).
6
+ *
7
+ * The wrapper always stretches to the full cross-axis width of whatever wraps it (a comparison
8
+ * cell, typically centered) rather than shrink-wrapping to its own content — `alignSelf: 'stretch'`
9
+ * overrides a centered parent's `alignItems` for this one child. A compact specimen (e.g. Button)
10
+ * still reads as centered, via `SpecimenSurface`'s own `alignItems`/`justifyContent: 'center'` on
11
+ * the now-full-width surface. Before this, a percentage-width child (Divider's `width: '100%'`, a
12
+ * phone mockup's percentage-width content) resolved against the surface's own shrink-wrapped,
13
+ * intrinsic width inside a centered comparison cell, and rendered at 0px.
14
+ */
15
+ export const SPECIMEN_SURFACE_WIDTH_STYLE = {
16
+ alignSelf: 'stretch',
17
+ } as const;
18
+
19
+ /** Each example's cell turns gray only when its example would vanish against white (see
20
+ * `specimenFill.ts`). A page can force a fill with `specimenSurface`. */
21
+ export const DEFAULT_SPECIMEN_SURFACE: SpecimenSurfaceKind = 'auto';
@@ -119,6 +119,8 @@ export const CATALOG_COLOR = {
119
119
  surfaceMuted: '#fafafa',
120
120
  /** Hover and pressed feedback on nav rows and buttons. */
121
121
  surfacePressed: '#f1f3f8',
122
+ /** An example's cell when the example itself is white (`specimenSurface`), darker than surfaceMuted so a white card's edges show. */
123
+ specimenStage: '#f2f2f2',
122
124
  pageBackground: '#f6f6f4',
123
125
  chip: '#eeeeee',
124
126
  /** Active nav label, prop types, scale bars. */
@@ -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,26 @@ 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
+ /** Fill of each example's cell. `'auto'` (default): a cell turns gray (#f2f2f2) only when its own
69
+ * example would vanish against white — a near-white body with no visible border, like a white
70
+ * card, sheet or neutral banner — so neighbouring examples keep a white cell. `'neutral'` makes
71
+ * every example cell gray, `'white'` and `'transparent'` keep them white, `'dark'` checks light
72
+ * components on a dark fill. Never applied to token galleries or sections. */
73
+ export type SpecimenSurfaceKind = 'auto' | 'neutral' | 'white' | 'dark' | 'transparent';
74
+
75
+ /** One real component this page's component is built from or composed with — rendered as a
76
+ * "Composition" subsection in Quick reference. `relationship` is `'built-in'` for a component
77
+ * always present (e.g. Toast is built from a Banner internally), `'slot'` for an optional
78
+ * caller-supplied child (e.g. a `leadingIcon` prop), or `'related'` for a component commonly used
79
+ * alongside this one without either containing the other. In development, `buildCatalogSections`
80
+ * warns in the browser console when `component` isn't a known component or page id. `doctor`
81
+ * doesn't check it, and it never fails a build. */
82
+ export interface ComposedOfEntry {
83
+ component: string;
84
+ role: string;
85
+ relationship: 'built-in' | 'slot' | 'related';
86
+ }
87
+
62
88
  /** One row or column heading in a grid. */
63
89
  export interface ComparisonAxisItem {
64
90
  key: string;
@@ -189,6 +215,10 @@ export interface SectionDef<TId extends string = string> {
189
215
  /** Width class for this page's specimens. Without it, full-width (`itemsFill`) slots are 'wide'
190
216
  * and everything else is 'regular'. */
191
217
  specimenSize?: SpecimenSize;
218
+ /** Background stage behind every live component specimen on this page (list/grouped/grid cells,
219
+ * and a `render()` preview that isn't a token gallery). Defaults to `'auto'`. See
220
+ * `SpecimenSurfaceKind`. */
221
+ specimenSurface?: SpecimenSurfaceKind;
192
222
  /** Widths for this page's `render()` preview, e.g. `[402, 320]` to add a small-phone example.
193
223
  * Defaults to the catalog's default (CatalogShell `defaultPreviewWidths`), else `[402]`. */
194
224
  previewWidths?: PreviewWidths;
@@ -203,6 +233,10 @@ export interface SectionDef<TId extends string = string> {
203
233
  props?: boolean;
204
234
  accessibility?: boolean;
205
235
  };
236
+ /** What this component is built from or composed with — shown as a "Composition" subsection in
237
+ * Quick reference. See `ComposedOfEntry`. Omit when the component has no meaningful composition
238
+ * to disclose (most leaf components). */
239
+ composedOf?: ComposedOfEntry[];
206
240
  }
207
241
 
208
242
  /** A labeled group of section ids in the sidebar (e.g. "Components" vs "Tokens"). */