@krapwoo/ds-viewer 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,8 +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';
9
+ import { TokenSections } from './TokenLayouts';
8
10
  import { choosePlacement, listGeometry, presentationBlocks, type PresentationBlock } from './comparison';
9
- import type { PreviewWidths, SectionDef } from './types';
11
+ import type { PreviewWidths, SectionDef, SpecimenSurfaceKind } from './types';
10
12
 
11
13
  // Matches a quoted-string-literal union type, e.g. "'primary' | 'secondary' | 'tertiary'" — anything
12
14
  // else (string, boolean, IconName, () => void, …) has no fixed enum to sweep and is skipped.
@@ -45,23 +47,27 @@ export interface SectionPager<TId extends string> {
45
47
  previousId: TId | null;
46
48
  nextId: TId | null;
47
49
  onNavigate: (id: TId) => void;
50
+ /** Display title for a page id, for the buttons' accessible names. */
51
+ labelOf?: (id: TId) => string;
48
52
  }
49
53
 
50
54
  function PagerButton<TId extends string>({
51
55
  direction,
52
56
  targetId,
53
57
  onNavigate,
58
+ labelOf,
54
59
  }: {
55
60
  direction: 'previous' | 'next';
56
61
  targetId: TId | null;
57
62
  onNavigate: (id: TId) => void;
63
+ labelOf?: (id: TId) => string;
58
64
  }) {
59
65
  const [hovered, setHovered] = useState(false);
60
66
  const [focused, setFocused] = useState(false);
61
67
  const disabled = targetId == null;
62
68
  const label = disabled
63
69
  ? direction === 'previous' ? 'No previous page' : 'No next page'
64
- : `${direction === 'previous' ? 'Previous' : 'Next'}: ${targetId}`;
70
+ : `${direction === 'previous' ? 'Previous' : 'Next'}: ${labelOf ? labelOf(targetId) : targetId}`;
65
71
  return (
66
72
  <Pressable
67
73
  onPress={() => {
@@ -95,9 +101,11 @@ function frameLabel(width: number): string {
95
101
  }
96
102
 
97
103
  /** A component preview: one frame per width (each at most phone width), or full width for catalog
98
- * chrome and token galleries. Every frame is its own live instance with its own state. */
99
- function Preview({ render, widths }: { render: () => React.ReactNode; widths: PreviewWidths }) {
100
- 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>;
101
109
  return (
102
110
  <View style={styles.previewCard}>
103
111
  <View style={styles.frames}>
@@ -109,7 +117,7 @@ function Preview({ render, widths }: { render: () => React.ReactNode; widths: Pr
109
117
  style={[styles.frame, { width }]}
110
118
  >
111
119
  {widths.length > 1 && <Text style={styles.frameLabel}>{frameLabel(width)}</Text>}
112
- {render()}
120
+ {stage(render())}
113
121
  </View>
114
122
  ))}
115
123
  </View>
@@ -117,16 +125,20 @@ function Preview({ render, widths }: { render: () => React.ReactNode; widths: Pr
117
125
  );
118
126
  }
119
127
 
120
- 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 }) {
121
129
  switch (block.kind) {
122
130
  case 'grid':
123
- 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} />;
124
132
  case 'grouped':
125
- 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} />;
126
134
  case 'list':
127
- 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} />;
128
136
  case 'preview':
129
- 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;
140
+ case 'tokenSections':
141
+ return <TokenSections sections={block.sections} columns={block.columns} />;
130
142
  default:
131
143
  return (
132
144
  <View style={styles.previewCard}>
@@ -138,7 +150,7 @@ function BlockContent<TId extends string>({ block, def }: { block: PresentationB
138
150
 
139
151
  /** Width a block's card occupies: lists hug their columns; everything else fills the row. */
140
152
  function blockWidth(block: PresentationBlock, available: number): number {
141
- 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;
142
154
  }
143
155
 
144
156
  /**
@@ -147,7 +159,7 @@ function blockWidth(block: PresentationBlock, available: number): number {
147
159
  * are never recorded while side by side, so the decision never feeds on its own result; a width
148
160
  * change returns to stacked and decides again.
149
161
  */
150
- 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 }) {
151
163
  const [available, setAvailable] = useState(0);
152
164
  const [heights, setHeights] = useState<number[]>([]);
153
165
  const [placement, setPlacement] = useState<'side' | 'stacked'>('stacked');
@@ -180,7 +192,7 @@ function Blocks<TId extends string>({ blocks, def }: { blocks: PresentationBlock
180
192
  gap: CATALOG_LAYOUT.blockGap,
181
193
  first: { kind: first.kind, width: blockWidth(first, available), height: heights[0] },
182
194
  second: second.kind === 'list'
183
- ? { 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 }
184
196
  : { kind: second.kind, height: heights[1] },
185
197
  });
186
198
  if (decision === 'side') setPlacement('side');
@@ -191,12 +203,12 @@ function Blocks<TId extends string>({ blocks, def }: { blocks: PresentationBlock
191
203
  <View onLayout={onContainerLayout} style={[styles.blocks, side && styles.blocksSide]}>
192
204
  {blocks.map((block, i) => (
193
205
  <View
194
- key={block.title}
206
+ key={block.title || block.kind}
195
207
  onLayout={onBlockLayout(i)}
196
208
  style={side ? (i === 0 ? { width: blockWidth(block, available), flexShrink: 0 } : styles.blockFill) : undefined}
197
209
  >
198
- <Text style={styles.blockLabel}>{block.title}</Text>
199
- <BlockContent block={block} def={def} />
210
+ {block.title ? <Text style={styles.blockLabel}>{block.title}</Text> : null}
211
+ <BlockContent block={block} def={def} surface={surface} />
200
212
  </View>
201
213
  ))}
202
214
  </View>
@@ -234,6 +246,7 @@ export function SectionBlock<TId extends string>({
234
246
  checkCompleteness(def);
235
247
  // Memoized so Blocks' placement effect runs on real changes, not on every render.
236
248
  const blocks = useMemo(() => presentationBlocks(def, { defaultPreviewWidths }), [def, defaultPreviewWidths]);
249
+ const surface = def.specimenSurface ?? 'neutral';
237
250
  // react-native-web reads `aria-level`; React Native's prop types do not declare it.
238
251
  const headingLevelProps = { 'aria-level': headingLevel } as Record<string, unknown>;
239
252
 
@@ -247,19 +260,24 @@ export function SectionBlock<TId extends string>({
247
260
  <View style={styles.titleText}>
248
261
  {/* The focus target is the heading itself, so assistive tech announces its role and level. */}
249
262
  <View ref={headingRef} tabIndex={-1} role="heading" {...headingLevelProps} style={styles.headingTarget}>
250
- <Text style={styles.title}>{def.id}</Text>
263
+ <Text style={styles.title}>{def.title ?? def.id}</Text>
251
264
  </View>
265
+ {def.osComponent && (
266
+ <View style={styles.osBadge}>
267
+ <Text style={styles.osBadgeText}>{def.osComponent === 'full' ? 'OS component' : 'Partly OS component'}</Text>
268
+ </View>
269
+ )}
252
270
  <Text style={styles.desc}>{def.description}</Text>
253
271
  </View>
254
272
  {pager && (
255
273
  <View style={styles.pager}>
256
- <PagerButton direction="previous" targetId={pager.previousId} onNavigate={pager.onNavigate} />
257
- <PagerButton direction="next" targetId={pager.nextId} onNavigate={pager.onNavigate} />
274
+ <PagerButton direction="previous" targetId={pager.previousId} onNavigate={pager.onNavigate} labelOf={pager.labelOf} />
275
+ <PagerButton direction="next" targetId={pager.nextId} onNavigate={pager.onNavigate} labelOf={pager.labelOf} />
258
276
  </View>
259
277
  )}
260
278
  </View>
261
279
 
262
- {blocks.length > 0 && <Blocks blocks={blocks} def={def} />}
280
+ {blocks.length > 0 && <Blocks blocks={blocks} def={def} surface={surface} />}
263
281
 
264
282
  <ReferenceDetails def={def} />
265
283
  </View>
@@ -278,6 +296,17 @@ const styles = StyleSheet.create({
278
296
  },
279
297
  titleText: { flex: 1 },
280
298
  headingTarget: { alignSelf: 'flex-start' },
299
+ osBadge: {
300
+ alignSelf: 'flex-start',
301
+ marginBottom: CATALOG_SPACE.sm,
302
+ paddingHorizontal: CATALOG_SPACE.sm,
303
+ paddingVertical: 3,
304
+ borderRadius: CATALOG_RADIUS.control,
305
+ borderWidth: 1,
306
+ borderColor: CATALOG_COLOR.borderStrong,
307
+ backgroundColor: CATALOG_COLOR.surface,
308
+ },
309
+ osBadgeText: { fontSize: CATALOG_TYPE.xs, fontWeight: '700', letterSpacing: 0.4, color: CATALOG_COLOR.text },
281
310
  title: { fontSize: CATALOG_TYPE.pageTitle, fontWeight: '700', color: CATALOG_COLOR.text, marginBottom: 5 },
282
311
  desc: { fontSize: CATALOG_TYPE.md, lineHeight: 20, color: CATALOG_COLOR.textMuted, maxWidth: 700 },
283
312
  pager: { flexDirection: 'row', gap: 6, flexShrink: 0 },
@@ -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
+ });
@@ -0,0 +1,92 @@
1
+ import React, { useState } from 'react';
2
+ import { View, Text, StyleSheet, type LayoutChangeEvent } from 'react-native';
3
+ import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
4
+ import { sectionColumns, tileGeometry, TOKEN_LAYOUT_GAP } from './tokenLayout';
5
+ import type { TokenSection } from './types';
6
+
7
+ /** Measures its own width; renders nothing inside until the width is known. */
8
+ function useWidth(): [number, (event: LayoutChangeEvent) => void] {
9
+ const [width, setWidth] = useState(0);
10
+ return [width, (event) => {
11
+ const next = Math.round(event.nativeEvent.layout.width);
12
+ if (next > 0 && next !== width) setWidth(next);
13
+ }];
14
+ }
15
+
16
+ /**
17
+ * A token page's sections (`SectionDef.tokenSections`): one titled card per kind of token, flowing
18
+ * in up to `columns` columns. A `wide` section takes a full row; the page drops to fewer columns
19
+ * rather than squeeze a section under its minimum width.
20
+ */
21
+ export function TokenSections({ sections, columns }: { sections: TokenSection[]; columns: 1 | 2 | 3 }) {
22
+ const [width, onLayout] = useWidth();
23
+ const perRow = sectionColumns(width, columns);
24
+ const columnWidth = perRow === 1 ? width : (width - TOKEN_LAYOUT_GAP * (perRow - 1)) / perRow;
25
+ return (
26
+ <View onLayout={onLayout} style={styles.sections}>
27
+ {width > 0 && sections.map((section) => (
28
+ <View key={section.title} style={{ width: section.wide || perRow === 1 ? width : columnWidth }}>
29
+ <Text style={styles.sectionLabel}>{section.title}</Text>
30
+ {section.desc ? <Text style={styles.sectionDesc}>{section.desc}</Text> : null}
31
+ <View style={styles.card}>{section.render()}</View>
32
+ </View>
33
+ ))}
34
+ </View>
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Tokens as tiles in columns — for tokens whose preview is small and self-contained (radius,
40
+ * shadow, colour, control height). As many equal tiles per row as keep `minTileWidth`.
41
+ */
42
+ export function TokenGrid({ children, minTileWidth = 140, maxColumns }: { children: React.ReactNode; minTileWidth?: number; maxColumns?: number }) {
43
+ const [width, onLayout] = useWidth();
44
+ const { tileWidth } = tileGeometry(width, minTileWidth, maxColumns);
45
+ return (
46
+ <View onLayout={onLayout} style={styles.grid}>
47
+ {width > 0 && React.Children.map(children, (child) => (child ? <View style={{ width: tileWidth }}>{child}</View> : null))}
48
+ </View>
49
+ );
50
+ }
51
+
52
+ /**
53
+ * One token in a `TokenGrid`: its preview, then its name and value, then its role. `note` says
54
+ * where or why the token is used (from its comments or real usages) — omit it when there is none.
55
+ */
56
+ export function TokenTile({ name, value, note, children }: { name: string; value?: string; note?: string; children: React.ReactNode }) {
57
+ return (
58
+ <View style={styles.tile}>
59
+ <View style={styles.tilePreview}>{children}</View>
60
+ <Text style={styles.tileName}>{name}</Text>
61
+ {value ? <Text style={styles.tileValue}>{value}</Text> : null}
62
+ {note ? <Text style={styles.tileNote}>{note}</Text> : null}
63
+ </View>
64
+ );
65
+ }
66
+
67
+ const styles = StyleSheet.create({
68
+ sections: { flexDirection: 'row', flexWrap: 'wrap', gap: TOKEN_LAYOUT_GAP, rowGap: CATALOG_LAYOUT.blockGap, alignItems: 'flex-start' },
69
+ sectionLabel: {
70
+ fontSize: CATALOG_TYPE.sm,
71
+ fontWeight: '700',
72
+ letterSpacing: 0.72,
73
+ textTransform: 'uppercase',
74
+ color: CATALOG_COLOR.text,
75
+ },
76
+ sectionDesc: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted, marginTop: CATALOG_SPACE.xs },
77
+ card: {
78
+ marginTop: CATALOG_LAYOUT.blockLabelGap,
79
+ backgroundColor: CATALOG_COLOR.surface,
80
+ borderWidth: 1,
81
+ borderColor: CATALOG_COLOR.borderStrong,
82
+ borderRadius: CATALOG_RADIUS.card,
83
+ padding: CATALOG_SPACE.xl,
84
+ gap: CATALOG_SPACE.md,
85
+ },
86
+ grid: { flexDirection: 'row', flexWrap: 'wrap', gap: TOKEN_LAYOUT_GAP },
87
+ tile: { gap: CATALOG_SPACE.xs },
88
+ tilePreview: { minHeight: 56, justifyContent: 'center', marginBottom: CATALOG_SPACE.xs },
89
+ tileName: { fontSize: CATALOG_TYPE.sm, fontWeight: '700', color: CATALOG_COLOR.text },
90
+ tileValue: { fontSize: CATALOG_TYPE.xs, color: CATALOG_COLOR.textMuted },
91
+ tileNote: { fontSize: CATALOG_TYPE.xs, color: CATALOG_COLOR.textMuted, lineHeight: 16 },
92
+ });
@@ -11,21 +11,46 @@ 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
- export function TokenRow({ children, use, last = false }: { children: React.ReactNode; use: string; last?: boolean }) {
18
+ export function TokenRow({
19
+ children,
20
+ use,
21
+ last = false,
22
+ notePlacement = 'below',
23
+ sampleWidth = 280,
24
+ }: {
25
+ children: React.ReactNode;
26
+ use: string;
27
+ last?: boolean;
28
+ /** `'right'` puts the note beside a narrow sample (icon sizes, spacing, control heights) so each
29
+ * token is one line; the sample keeps `sampleWidth` and the note takes the rest. */
30
+ notePlacement?: 'below' | 'right';
31
+ sampleWidth?: number;
32
+ }) {
33
+ if (notePlacement === 'right') {
34
+ return (
35
+ <View style={[styles.itemRight, !last && styles.itemDivider]}>
36
+ <View style={{ width: sampleWidth, flexShrink: 0 }}>{children}</View>
37
+ {use ? <Text style={[styles.use, styles.useRight]}>{use}</Text> : null}
38
+ </View>
39
+ );
40
+ }
17
41
  return (
18
42
  <View style={[styles.item, !last && styles.itemDivider]}>
19
43
  {children}
20
- <Text style={styles.use}>{use}</Text>
44
+ {use ? <Text style={styles.use}>{use}</Text> : null}
21
45
  </View>
22
46
  );
23
47
  }
24
48
 
25
49
  const styles = StyleSheet.create({
26
- item: { gap: CATALOG_SPACE.xs },
50
+ item: { gap: CATALOG_SPACE.xs, paddingVertical: CATALOG_SPACE.md },
51
+ itemRight: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.xl, paddingVertical: CATALOG_SPACE.md },
52
+ useRight: { flex: 1 },
27
53
  itemDivider: {
28
- paddingBottom: CATALOG_SPACE.md,
29
54
  borderBottomWidth: 1,
30
55
  borderBottomColor: CATALOG_COLOR.border,
31
56
  },