@krapwoo/ds-viewer 0.4.7 → 0.4.8

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.
Files changed (58) hide show
  1. package/README.md +21 -6
  2. package/dist/cli/doctor.js +4 -2
  3. package/dist/cli/init.js +5 -8
  4. package/dist/native/catalog/comparison.js +17 -8
  5. package/native/catalog/CatalogShell.tsx +5 -1
  6. package/native/catalog/ComparisonGrid.tsx +5 -1
  7. package/native/catalog/ComparisonGroups.tsx +7 -1
  8. package/native/catalog/ComparisonList.tsx +7 -1
  9. package/native/catalog/PhoneFrame.tsx +108 -24
  10. package/native/catalog/ReferenceDetails.tsx +8 -0
  11. package/native/catalog/SectionBlock.tsx +10 -4
  12. package/native/catalog/SpecimenAddress.tsx +15 -0
  13. package/native/catalog/SpecimenSurface.tsx +66 -4
  14. package/native/catalog/comparison.ts +21 -8
  15. package/native/catalog/deviceFrame.ts +14 -0
  16. package/native/catalog/overlayViewport.ts +4 -2
  17. package/native/catalog/specimenSurfaceStyle.ts +54 -0
  18. package/package.json +5 -4
  19. package/starter-kit/components/AnimatedChevron/AnimatedChevron.catalog.tsx +1 -1
  20. package/starter-kit/components/Avatar/Avatar.catalog.tsx +1 -1
  21. package/starter-kit/components/Badge/Badge.catalog.tsx +1 -1
  22. package/starter-kit/components/Banner/Banner.catalog.tsx +2 -1
  23. package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +5 -1
  24. package/starter-kit/components/Button/Button.catalog.tsx +1 -1
  25. package/starter-kit/components/ButtonGroup/ButtonGroup.catalog.tsx +8 -1
  26. package/starter-kit/components/Card/Card.catalog.tsx +2 -1
  27. package/starter-kit/components/Checkbox/Checkbox.catalog.tsx +2 -2
  28. package/starter-kit/components/Dialog/Dialog.catalog.tsx +1 -1
  29. package/starter-kit/components/Divider/Divider.catalog.tsx +2 -1
  30. package/starter-kit/components/Dock/Dock.catalog.tsx +4 -1
  31. package/starter-kit/components/Dropdown/Dropdown.catalog.tsx +1 -1
  32. package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +2 -1
  33. package/starter-kit/components/FieldContainer/FieldContainer.catalog.tsx +5 -1
  34. package/starter-kit/components/InputClearButton/InputClearButton.catalog.tsx +1 -1
  35. package/starter-kit/components/InputField/InputField.catalog.tsx +3 -1
  36. package/starter-kit/components/List/List.catalog.tsx +2 -1
  37. package/starter-kit/components/ListItem/ListItem.catalog.tsx +7 -1
  38. package/starter-kit/components/Loading/Loading.catalog.tsx +2 -1
  39. package/starter-kit/components/Pill/Pill.catalog.tsx +1 -1
  40. package/starter-kit/components/PillRow/PillRow.catalog.tsx +1 -1
  41. package/starter-kit/components/ProgressDots/ProgressDots.catalog.tsx +1 -1
  42. package/starter-kit/components/Radio/Radio.catalog.tsx +2 -2
  43. package/starter-kit/components/SearchField/SearchField.catalog.tsx +2 -1
  44. package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +2 -1
  45. package/starter-kit/components/SegmentedToggle/SegmentedToggle.catalog.tsx +1 -3
  46. package/starter-kit/components/Shimmer/Breathing.catalog.tsx +2 -1
  47. package/starter-kit/components/Shimmer/Shimmer.catalog.tsx +2 -1
  48. package/starter-kit/components/Shimmer/SkeletonGroup.catalog.tsx +5 -1
  49. package/starter-kit/components/Surface/Surface.catalog.tsx +2 -1
  50. package/starter-kit/components/Switch/Switch.catalog.tsx +2 -2
  51. package/starter-kit/components/TextArea/TextArea.catalog.tsx +2 -1
  52. package/starter-kit/components/Toast/Toast.catalog.tsx +2 -1
  53. package/starter-kit/components/Tooltip/Tooltip.catalog.tsx +2 -1
  54. package/starter-kit/components/TopNav/TopNav.catalog.tsx +4 -1
  55. package/starter-kit/components/UnderlineTabs/UnderlineTabs.catalog.tsx +1 -3
  56. package/starter-kit/pages/ReportIssue.catalog.tsx +18 -1
  57. package/starter-kit/pages/SavedTrips.catalog.tsx +27 -6
  58. package/starter-kit/pages/TripPlannerForm.catalog.tsx +7 -1
@@ -229,6 +229,9 @@ export function remainingStates(states: VariantSlot | undefined, comparison: Com
229
229
 
230
230
  export interface ListItem {
231
231
  key: string;
232
+ /** Which authored slot this item came from, so a device frame inside it can address it
233
+ * (`overlayViewport.ts`). */
234
+ slot?: 'variants' | 'states';
232
235
  label: string;
233
236
  node: unknown;
234
237
  fill?: boolean;
@@ -265,10 +268,20 @@ export function slotSize<TId extends string>(def: SectionDef<TId>, slot: Variant
265
268
  return def.specimenSize ?? (slot.itemsFill ? 'wide' : 'regular');
266
269
  }
267
270
 
268
- function slotItems(slot: VariantSlot): ListItem[] {
271
+ /** Full-width (filled) examples are block components (cards, banners, rows): narrower than about a
272
+ * third of a laptop's content width they stop reading as themselves, so they wrap at most 3
273
+ * across unless the page sets its own `maxColumns`. Small centred examples wrap freely. */
274
+ export const FILLED_SLOT_MAX_COLUMNS = 3;
275
+
276
+ function slotColumnCap(slot: VariantSlot): { maxColumns?: 1 | 2 | 3 | 4 | 5 } {
277
+ if (slot.maxColumns !== undefined) return { maxColumns: slot.maxColumns };
278
+ return slot.itemsFill ? { maxColumns: FILLED_SLOT_MAX_COLUMNS } : {};
279
+ }
280
+
281
+ function slotItems(slot: VariantSlot, from: 'variants' | 'states'): ListItem[] {
269
282
  // Nullish, not `||`: an item's own explicit `fill: false` must override an inherited
270
283
  // `itemsFill: true`, which `false || slot.itemsFill` would silently discard.
271
- return slot.items.map((item) => ({ key: item.key, label: item.name, node: item.node, fill: item.fill ?? slot.itemsFill, surface: item.surface, align: item.align }));
284
+ return slot.items.map((item) => ({ key: item.key, slot: from, label: item.name, node: item.node, fill: item.fill ?? slot.itemsFill, surface: item.surface, align: item.align }));
272
285
  }
273
286
 
274
287
  /** Preview widths, each capped at phone width. 'full' is kept for catalog chrome and token pages. */
@@ -289,8 +302,8 @@ function groupStates(variants: VariantSlot, states: VariantSlot): { groups: List
289
302
  const groups = variants.items.map((variant) => {
290
303
  const own = states.items.filter((s) => s.group === variant.key);
291
304
  const items = own.length > 0
292
- ? own.map((s) => ({ key: s.key, label: s.name, node: s.node, fill: fill(s, states), surface: s.surface, align: s.align }))
293
- : [{ key: variant.key, label: variant.name, node: variant.node, fill: fill(variant, variants), surface: variant.surface, align: variant.align }];
305
+ ? own.map((s) => ({ key: s.key, slot: 'states' as const, label: s.name, node: s.node, fill: fill(s, states), surface: s.surface, align: s.align }))
306
+ : [{ key: variant.key, slot: 'variants' as const, label: variant.name, node: variant.node, fill: fill(variant, variants), surface: variant.surface, align: variant.align }];
294
307
  return { key: variant.key, label: variant.name, items };
295
308
  });
296
309
  const rest = states.items.filter((s) => s.group === undefined || !variantKeys.has(s.group));
@@ -337,8 +350,8 @@ export function presentationBlocks<TId extends string>(def: SectionDef<TId>, opt
337
350
  kind: 'list',
338
351
  title: 'Variants',
339
352
  size: slotSize(def, def.variants),
340
- items: slotItems(def.variants),
341
- ...(def.variants.maxColumns !== undefined ? { maxColumns: def.variants.maxColumns } : {}),
353
+ items: slotItems(def.variants, 'variants'),
354
+ ...slotColumnCap(def.variants),
342
355
  });
343
356
  } else if (def.render) {
344
357
  blocks.push({ kind: 'preview', title: 'Preview', widths: previewWidths(def.previewWidths, options.defaultPreviewWidths) });
@@ -351,8 +364,8 @@ export function presentationBlocks<TId extends string>(def: SectionDef<TId>, opt
351
364
  kind: 'list',
352
365
  title: secondary ? 'Other configurations' : 'States / configurations',
353
366
  size: slotSize(def, states),
354
- items: slotItems(states),
355
- ...(states.maxColumns !== undefined ? { maxColumns: states.maxColumns } : {}),
367
+ items: slotItems(states, 'states'),
368
+ ...slotColumnCap(states),
356
369
  });
357
370
  }
358
371
 
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The device every `PhoneFrame` shows: a real phone's viewport, in points, at the small end of
3
+ * current phones (iPhone SE, 3rd generation). Examples lay out at exactly these dimensions; a cell
4
+ * narrower than the device scales the whole viewport down instead of squeezing its layout. Kept
5
+ * free of runtime imports so `node --test` can check it.
6
+ */
7
+ export const DEVICE_FRAME = { name: 'iPhone SE', width: 375, height: 667 } as const;
8
+
9
+ /** How much to scale the device so it fits `availableWidth` (never enlarged). An unmeasured cell
10
+ * (0) renders at full size rather than collapsing. */
11
+ export function deviceFrameScale(availableWidth: number): number {
12
+ if (!(availableWidth > 0)) return 1;
13
+ return Math.min(1, availableWidth / DEVICE_FRAME.width);
14
+ }
@@ -9,7 +9,7 @@ import type React from 'react';
9
9
  import type { SectionDef } from './types.ts';
10
10
 
11
11
  /** Which block on a page an addressed specimen's `node` lives in — see `findOverlayNode`. */
12
- export type OverlaySlot = 'variants' | 'states' | 'comparison';
12
+ export type OverlaySlot = 'variants' | 'states' | 'comparison' | 'preview';
13
13
 
14
14
  /** Identifies exactly one authored specimen: a page id (`SectionDef.id`), which block it lives
15
15
  * in, and that item's own key — `VariantExample.key` for `'variants'`/`'states'`, or this
@@ -23,7 +23,7 @@ export interface OverlayAddress {
23
23
  itemKey: string;
24
24
  }
25
25
 
26
- const OVERLAY_SLOTS: readonly OverlaySlot[] = ['variants', 'states', 'comparison'];
26
+ const OVERLAY_SLOTS: readonly OverlaySlot[] = ['variants', 'states', 'comparison', 'preview'];
27
27
 
28
28
  /** `encodeURIComponent` leaves `.` untouched (it's an unreserved character), which would let a
29
29
  * literal `.` inside a segment collide with the `.`-joined encoding below; escape it too so
@@ -117,6 +117,8 @@ export function findOverlayNode(sections: readonly SectionDef<string>[], address
117
117
  if (!def) return undefined;
118
118
  if (address.slot === 'variants') return def.variants?.items.find((item) => item.key === address.itemKey)?.node;
119
119
  if (address.slot === 'states') return def.states?.items.find((item) => item.key === address.itemKey)?.node;
120
+ // A `render()` page's preview (one per page; `itemKey` is always 'preview').
121
+ if (address.slot === 'preview') return def.render?.();
120
122
  // Matched by recomputing each cell's own canonical key, never by splitting `address.itemKey` —
121
123
  // a row/column key may itself contain any delimiter this module could pick.
122
124
  return def.comparison?.cells.find((cell) => comparisonCellItemKey(cell.rowKey, cell.columnKey) === address.itemKey)?.node;
@@ -88,3 +88,57 @@ export function specimenContentBoundedStyle(align: SpecimenAlign | undefined) {
88
88
  /** Each example's cell turns gray only when its example would vanish against white (see
89
89
  * `specimenFill.ts`). A page can force a fill with `specimenSurface`. */
90
90
  export const DEFAULT_SPECIMEN_SURFACE: SpecimenSurfaceKind = 'auto';
91
+
92
+ /** A `fill` example stretches to its cell, but one with its own fixed width (a 160-point skeleton
93
+ * bar) stays that width; it should then sit in the middle of the cell, not against its leading
94
+ * edge. True when the measured example is narrower than its wrapper (by more than rounding). */
95
+ export function shouldCenterFilledContent(contentWidth: number, wrapperWidth: number): boolean {
96
+ if (!(contentWidth > 0) || !(wrapperWidth > 0)) return false;
97
+ return contentWidth < wrapperWidth - 1;
98
+ }
99
+
100
+ /** The width of what's actually visible in an example: from the leftmost to the rightmost painted
101
+ * box (text, an icon, anything with a fill), ignoring transparent layout wrappers that may span
102
+ * the whole cell. */
103
+ export function paintedExtent(boxes: readonly { left: number; right: number; painted: boolean }[]): number {
104
+ let left = Infinity;
105
+ let right = -Infinity;
106
+ for (const box of boxes) {
107
+ if (!box.painted || box.right <= box.left) continue;
108
+ left = Math.min(left, box.left);
109
+ right = Math.max(right, box.right);
110
+ }
111
+ return Number.isFinite(left) ? right - left : 0;
112
+ }
113
+
114
+ /** True when an example is wider than its cell (beyond rounding): its edges are cut off or touch
115
+ * the neighbouring cell, so the page should use a wider `specimenSize`. */
116
+ export function overflowsCell(contentWidth: number, cellWidth: number): boolean {
117
+ return contentWidth > 0 && cellWidth > 0 && contentWidth > cellWidth + 1;
118
+ }
119
+
120
+ /** One element of an example as measured in the browser. `clips` is true for a container that
121
+ * hides or scrolls its overflow (a scrolling row, a device frame). */
122
+ export interface LayoutBox {
123
+ left: number;
124
+ right: number;
125
+ painted: boolean;
126
+ clips: boolean;
127
+ children: LayoutBox[];
128
+ }
129
+
130
+ /** The boxes a reader can actually see: a clipping container counts as its own box, and what it
131
+ * hides or scrolls inside it is skipped. */
132
+ export function visibleBoxes(root: LayoutBox): { left: number; right: number; painted: boolean }[] {
133
+ const out: { left: number; right: number; painted: boolean }[] = [];
134
+ const walk = (box: LayoutBox, isRoot: boolean) => {
135
+ if (!isRoot && box.clips) {
136
+ out.push({ left: box.left, right: box.right, painted: true });
137
+ return;
138
+ }
139
+ if (!isRoot) out.push({ left: box.left, right: box.right, painted: box.painted });
140
+ for (const child of box.children) walk(child, false);
141
+ };
142
+ walk(root, true);
143
+ return out;
144
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krapwoo/ds-viewer",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A browsable, always-current catalog for an Expo / React Native design system.",
@@ -65,9 +65,10 @@
65
65
  "test": "node --experimental-strip-types --test 'cli/**/*.test.ts' 'config/**/*.test.ts' 'native/catalog/__tests__/*.test.ts'",
66
66
  "typecheck": "tsc -p tsconfig.json --noEmit",
67
67
  "kit:dev": "npm run build && cd kit-host && node node_modules/@krapwoo/ds-viewer/dist/cli/main.js dev",
68
- "check:catalog": "npm run build && node scripts/checkCatalogConsole.mjs kit-host",
69
- "check:doctor": "npm run build && cd kit-host && node node_modules/@krapwoo/ds-viewer/dist/cli/main.js doctor --ci",
70
- "check:types": "tsc --noEmit -p kit-host",
68
+ "viewer:dev": "npm run build && cd viewer-host && node node_modules/@krapwoo/ds-viewer/dist/cli/main.js dev",
69
+ "check:catalog": "npm run build && node scripts/checkCatalogConsole.mjs kit-host && node scripts/checkCatalogConsole.mjs viewer-host",
70
+ "check:doctor": "npm run build && cd kit-host && node node_modules/@krapwoo/ds-viewer/dist/cli/main.js doctor --ci && cd ../viewer-host && node node_modules/@krapwoo/ds-viewer/dist/cli/main.js doctor --ci",
71
+ "check:types": "tsc --noEmit -p kit-host && tsc --noEmit -p viewer-host",
71
72
  "release": "node scripts/release.mjs"
72
73
  }
73
74
  }
@@ -5,7 +5,7 @@ import { DS_SEMANTIC } from '../../tokens';
5
5
 
6
6
  export default defineCatalogPage({
7
7
  component: 'AnimatedChevron',
8
- group: 'Sub-Parts',
8
+ group: 'Components',
9
9
  description: 'A chevron that morphs between down (collapsed) and up (expanded) — flipping vertically in place instead of rotating through a sideways-pointing angle, and instead of swapping icons. The morph Banner\'s own collapsible header uses for its disclosure indicator. Not commonly used on its own; exported for building a custom disclosure/accordion toggle.',
10
10
  a11y: 'Purely decorative — no accessibility role of its own. Wrap it in an accessible parent (the way Banner\'s own header Pressable does) if the toggle needs to be announced.',
11
11
  variants: {
@@ -31,7 +31,7 @@ const AVATAR_COMPARISON = grid(
31
31
 
32
32
  export default defineCatalogPage({
33
33
  component: 'Avatar',
34
- group: 'Surfaces',
34
+ group: 'Components',
35
35
  specimenSize: 'compact',
36
36
  comparison: AVATAR_COMPARISON,
37
37
  description: 'A circular image, or an initials fallback on a solid fill when there\'s no image (or it fails to load).',
@@ -51,7 +51,7 @@ const BADGE_COMPARISON = grid(
51
51
 
52
52
  export default defineCatalogPage({
53
53
  component: 'Badge',
54
- group: 'Surfaces',
54
+ group: 'Components',
55
55
  specimenSize: 'compact',
56
56
  comparison: BADGE_COMPARISON,
57
57
  description: 'A small status chip — five semantic variants, with optional leading/trailing icons or icon-only.',
@@ -4,7 +4,8 @@ import { Banner } from './Banner';
4
4
 
5
5
  export default defineCatalogPage({
6
6
  component: 'Banner',
7
- group: 'Surfaces',
7
+ group: 'Components',
8
+ specimenSize: 'regular',
8
9
  composedOf: [
9
10
  { component: 'AnimatedChevron', role: "The expand/collapse chevron in the header of a collapsible banner.", relationship: 'built-in' },
10
11
  ],
@@ -69,7 +69,11 @@ function BottomSheetDemo() {
69
69
 
70
70
  export default defineCatalogPage({
71
71
  component: 'BottomSheet',
72
- group: 'Navigation',
72
+ group: 'Components',
73
+ composedOf: [
74
+ { component: 'TopNav', role: "The header slot, typically a TopNav with a close or back action.", relationship: 'slot' },
75
+ { component: 'Dock', role: "The footer slot, typically a Dock with the sheet's primary actions; a Dock footer gets its shadow once the content scrolls.", relationship: 'slot' },
76
+ ],
73
77
  description: 'A sheet that slides up over a dismissible backdrop. Height is driven by its content (not fixed snap points) up to 90% of the available height, then the content area scrolls. Compose header with TopNav and footer with Dock.',
74
78
  whenToUse: 'For longer, browsable content, or anything that benefits from a TopNav/Dock header-footer structure. For a short, focused decision that interrupts the flow, use Dialog.',
75
79
  a11y: 'The backdrop is a Pressable with accessibilityRole="button" and accessibilityLabel="Dismiss"; header/content/footer carry their own accessibility (e.g. TopNav\'s title as accessibilityRole="header").',
@@ -55,7 +55,7 @@ const BUTTON_COMPARISON = grid(
55
55
 
56
56
  export default defineCatalogPage({
57
57
  component: 'Button',
58
- group: 'Actions',
58
+ group: 'Components',
59
59
  composedOf: [
60
60
  { component: 'Loading', role: "The spinner that replaces the label and icon while loading is true.", relationship: 'built-in' },
61
61
  ],
@@ -5,7 +5,11 @@ import { Button } from '../Button';
5
5
 
6
6
  export default defineCatalogPage({
7
7
  component: 'ButtonGroup',
8
- group: 'Actions',
8
+ group: 'Components',
9
+ composedOf: [
10
+ { component: 'Button', role: "Its children: up to two Buttons side by side, or three stacked.", relationship: 'slot' },
11
+ ],
12
+ specimenSize: 'regular',
9
13
  description: 'Groups Button elements in one of two layouts: horizontal (up to two, auto-width, trailing-aligned) or vertical (up to three, stretched full width — the same layout Dock\'s own button area uses). Every button in a group should read as one family — the same variant tier (primary/secondary/tertiary/white, never ghost), the same size, and either all icon+label or all label-only, never a mix. Dev-console warns if two or more children disagree.',
10
14
  a11y: 'A plain View; each Button child carries its own accessibility role and label.',
11
15
  variants: {
@@ -15,6 +19,9 @@ export default defineCatalogPage({
15
19
  key: 'horizontal',
16
20
  name: 'Horizontal',
17
21
  props: { variant: 'horizontal' },
22
+ // Shown at its own width, centred: stretched, its trailing alignment pushes it to the
23
+ // cell's edge, which reads as a misplaced example rather than as trailing alignment.
24
+ fill: false,
18
25
  node: (
19
26
  <ButtonGroup variant="horizontal">
20
27
  <Button label="Cancel" variant="tertiary" onPress={() => {}} />
@@ -11,7 +11,8 @@ const styles = StyleSheet.create({
11
11
 
12
12
  export default defineCatalogPage({
13
13
  component: 'Card',
14
- group: 'Surfaces',
14
+ group: 'Components',
15
+ specimenSize: 'regular',
15
16
  description: 'The primary content surface — a rounded white card with a soft resting shadow. Pass children, or onPress to make the whole card a button.',
16
17
  whenToUse: 'A standalone, self-contained unit with its own shadow. For a row in a homogeneous stack of peers (with dividers between them), use ListItem inside a List instead.',
17
18
  a11y: 'When onPress is set, renders a Pressable with accessibilityRole="button" and a visible focus ring; a plain card is a non-interactive View.',
@@ -9,8 +9,8 @@ function CheckboxDemo() {
9
9
 
10
10
  export default defineCatalogPage({
11
11
  component: 'Checkbox',
12
- group: 'Controls',
13
- specimenSize: 'compact',
12
+ group: 'Components',
13
+ specimenSize: 'regular',
14
14
  description: 'A square selection control — the box fills with the accent colour and a checkmark when checked.',
15
15
  whenToUse: 'An independent on/off fact about this one item — any number can be checked at once. For a setting that takes effect immediately, use Switch; for one-of-many exclusive selection, use Radio.',
16
16
  a11y: 'Renders a Pressable with accessibilityRole="checkbox" and accessibilityState.checked; the optional label doubles as its accessibilityLabel.',
@@ -33,7 +33,7 @@ function DialogDemo() {
33
33
 
34
34
  export default defineCatalogPage({
35
35
  component: 'Dialog',
36
- group: 'Overlays',
36
+ group: 'Components',
37
37
  description: 'A card centred on screen over a dismissible backdrop — fades and scales in, distinct from BottomSheet\'s bottom-anchored slide. Named Dialog (not Modal) to avoid shadowing React Native\'s own built-in Modal.',
38
38
  whenToUse: 'A short, focused decision that interrupts the flow (confirm/cancel, a single form). For anything longer, browsable, or that needs its own internal scrolling, use BottomSheet.',
39
39
  a11y: 'The backdrop is a Pressable with accessibilityRole="button" and accessibilityLabel="Dismiss"; content you pass as children carries its own accessibility.',
@@ -11,7 +11,8 @@ const styles = StyleSheet.create({
11
11
 
12
12
  export default defineCatalogPage({
13
13
  component: 'Divider',
14
- group: 'Layout',
14
+ group: 'Components',
15
+ specimenSize: 'regular',
15
16
  description: 'A 1px hairline separator at the divider token colour.',
16
17
  a11y: 'A plain, non-interactive View — purely decorative.',
17
18
  // No `variant` prop and no other real configuration exists on Divider — a single hairline is the
@@ -5,7 +5,10 @@ import { Dock } from './Dock';
5
5
 
6
6
  export default defineCatalogPage({
7
7
  component: 'Dock',
8
- group: 'Navigation',
8
+ group: 'Components',
9
+ composedOf: [
10
+ { component: 'Button', role: "Its children: up to three full-width Buttons, stacked.", relationship: 'slot' },
11
+ ],
9
12
  description: 'Pinned to the bottom of the screen, above the home indicator — holds up to three full-width Buttons stacked vertically, with an optional small caption area above them.',
10
13
  a11y: 'A plain View; each Button child carries its own accessibility role and label.',
11
14
  // No `variant` prop exists on Dock — its "Variants" column just shows the one default look,
@@ -38,7 +38,7 @@ function DropdownDemo() {
38
38
 
39
39
  export default defineCatalogPage({
40
40
  component: 'Dropdown',
41
- group: 'Inputs',
41
+ group: 'Components',
42
42
  composedOf: [
43
43
  { component: 'FieldContainer', role: "The field frame around the label, value and chevron (the trigger).", relationship: 'built-in' },
44
44
  { component: 'BottomSheet', role: "The sheet listing the options while the dropdown is open.", relationship: 'built-in' },
@@ -5,7 +5,8 @@ import { DS_SEMANTIC } from '../../tokens';
5
5
 
6
6
  export default defineCatalogPage({
7
7
  component: 'EmptyState',
8
- group: 'Layout',
8
+ group: 'Components',
9
+ specimenSize: 'regular',
9
10
  composedOf: [
10
11
  { component: 'Avatar', role: "The icon circle above the title (iconName, default 'users').", relationship: 'built-in' },
11
12
  { component: 'ButtonGroup', role: "Stacks the action buttons when action is set.", relationship: 'built-in' },
@@ -12,7 +12,11 @@ const styles = StyleSheet.create({
12
12
 
13
13
  export default defineCatalogPage({
14
14
  component: 'FieldContainer',
15
- group: 'Sub-Parts',
15
+ group: 'Components',
16
+ composedOf: [
17
+ { component: 'Surface', role: "Reads the surrounding Surface tone: a recessed fill on white, opaque white inside a muted Surface.", relationship: 'related' },
18
+ ],
19
+ specimenSize: 'regular',
16
20
  description: 'The shared field chrome — white surface, medium radius, a 1px border that darkens on focus and mutes when disabled — behind InputField, TextArea, Dropdown, and SearchField, so all four share one visually-consistent field look instead of each re-implementing it.',
17
21
  a11y: 'Renders a plain View by default. Passing onPress (without disabled) makes it a Pressable with accessibilityRole="button" and the given accessibilityLabel — a consumer building a real editable field (InputField\'s editable mode) relies on its own inner TextInput for accessibility instead.',
18
22
  variants: {
@@ -4,7 +4,7 @@ import { InputClearButton } from './InputClearButton';
4
4
 
5
5
  export default defineCatalogPage({
6
6
  component: 'InputClearButton',
7
- group: 'Sub-Parts',
7
+ group: 'Components',
8
8
  description: 'The clear (×) button InputField and SearchField both show once a field is active and holds a value — a filled circle-x icon sized to reach the 44pt touch target via hitSlop, not visual size.',
9
9
  a11y: 'A Pressable with accessibilityRole="button" and the given (or default "Clear field") accessibilityLabel; hitSlop of 10 on every side pads its 24×24 visual size out to the 44pt minimum.',
10
10
  variants: {
@@ -17,9 +17,11 @@ function InputFieldDemo() {
17
17
 
18
18
  export default defineCatalogPage({
19
19
  component: 'InputField',
20
- group: 'Inputs',
20
+ group: 'Components',
21
+ specimenSize: 'regular',
21
22
  composedOf: [
22
23
  { component: 'InputClearButton', role: "The clear (\u00d7) button, shown while the field has a value and is focused (or active, when not editable).", relationship: 'built-in' },
24
+ { component: 'Surface', role: 'Reads the surrounding Surface tone so the field stays visible on a muted Surface.', relationship: 'related' },
23
25
  ],
24
26
  description: 'A floating-label field. Resting: a centred, body-sized label with no border. Active (focused, or picker with active set) or filled: the label floats to a small top caption, row 2 shows the value/input, and a border fades in while active.',
25
27
  whenToUse: 'A named field with a fixed identity ("To", "Arrive by") across the interaction. For free-text search with no floating label, use SearchField; for one of a small known set of choices, use Dropdown.',
@@ -8,7 +8,8 @@ import { DS_SEMANTIC } from '../../tokens';
8
8
 
9
9
  export default defineCatalogPage({
10
10
  component: 'List',
11
- group: 'Layout',
11
+ group: 'Components',
12
+ specimenSize: 'regular',
12
13
  composedOf: [
13
14
  { component: 'Divider', role: "The hairline between consecutive items (none after the last).", relationship: 'built-in' },
14
15
  ],
@@ -9,7 +9,13 @@ import { DS_SEMANTIC } from '../../tokens';
9
9
 
10
10
  export default defineCatalogPage({
11
11
  component: 'ListItem',
12
- group: 'Layout',
12
+ group: 'Components',
13
+ composedOf: [
14
+ { component: 'Avatar', role: "The leading slot, typically an Avatar (or an icon).", relationship: 'slot' },
15
+ { component: 'Switch', role: "A trailing-slot option, e.g. a settings toggle.", relationship: 'slot' },
16
+ { component: 'Button', role: "A trailing-slot option, e.g. an Accept action.", relationship: 'slot' },
17
+ ],
18
+ specimenSize: 'regular',
13
19
  description: 'A single row: optional leading/trailing slots flanking a title (+ optional subtitle/footer). Stack several inside a List for a settings screen, menu, or search-results list.',
14
20
  whenToUse: "A row in a set of visually-light peers sharing a List's own surface and dividers. For a standalone unit with its own shadow, use Card instead.",
15
21
  a11y: 'A tappable row (onPress set) renders as a Pressable with accessibilityRole="button" and an accessibilityLabel built from title + subtitle; a plain row is a non-interactive View.',
@@ -5,7 +5,8 @@ import { DS_SEMANTIC } from '../../tokens';
5
5
 
6
6
  export default defineCatalogPage({
7
7
  component: 'Loading',
8
- group: 'Feedback',
8
+ group: 'Components',
9
+ specimenSize: 'regular',
9
10
  description: 'An indeterminate loader — the shape fills up, empties out, then fills again, seamlessly. Two variants: circle (used internally by Button and Pill for their own loading states) and linear.',
10
11
  a11y: 'Exposed to assistive tech as accessibilityRole="progressbar" with accessibilityLabel="Loading" — no extra wiring needed at the call site.',
11
12
  // One example per `variant` enum value — `circle` is the default, so it comes first.
@@ -40,7 +40,7 @@ const PILL_COMPARISON = grid(
40
40
 
41
41
  export default defineCatalogPage({
42
42
  component: 'Pill',
43
- group: 'Actions',
43
+ group: 'Components',
44
44
  composedOf: [
45
45
  { component: 'Loading', role: "The spinner shown instead of the label and icon while loading is true.", relationship: 'built-in' },
46
46
  ],
@@ -4,7 +4,7 @@ import { PillRow } from './PillRow';
4
4
 
5
5
  export default defineCatalogPage({
6
6
  component: 'PillRow',
7
- group: 'Actions',
7
+ group: 'Components',
8
8
  composedOf: [
9
9
  { component: 'Pill', role: "Each item in pills, plus the trailing icon-only Edit pill when shown.", relationship: 'built-in' },
10
10
  ],
@@ -4,7 +4,7 @@ import { ProgressDots } from './ProgressDots';
4
4
 
5
5
  export default defineCatalogPage({
6
6
  component: 'ProgressDots',
7
- group: 'Feedback',
7
+ group: 'Components',
8
8
  description: 'A row of dots for a stepped flow; the active dot widens into a pill.',
9
9
  a11y: 'Decorative progress indicator; convey the "step X of Y" position in text for screen readers.',
10
10
  // No `variant` prop exists on ProgressDots — its "Variants" column just shows the one default
@@ -20,8 +20,8 @@ function RadioGroupDemo() {
20
20
 
21
21
  export default defineCatalogPage({
22
22
  component: 'Radio',
23
- group: 'Controls',
24
- specimenSize: 'compact',
23
+ group: 'Components',
24
+ specimenSize: 'regular',
25
25
  description: 'A single circular selection control — a filled dot appears in the ring when selected. A group of mutually-exclusive Radios is just multiple instances sharing one selected value in the consumer.',
26
26
  whenToUse: 'One selection from a mutually-exclusive set — checking one should un-check another. For an independent on/off fact, use Checkbox; for a setting that takes effect immediately, use Switch.',
27
27
  a11y: 'Renders a Pressable with accessibilityRole="radio" and accessibilityState.selected; the optional label doubles as its accessibilityLabel.',
@@ -9,7 +9,8 @@ function SearchFieldDemo() {
9
9
 
10
10
  export default defineCatalogPage({
11
11
  component: 'SearchField',
12
- group: 'Inputs',
12
+ group: 'Components',
13
+ specimenSize: 'regular',
13
14
  composedOf: [
14
15
  { component: 'FieldContainer', role: "The field frame around the search icon, input and clear button.", relationship: 'built-in' },
15
16
  { component: 'InputClearButton', role: "The clear (\u00d7) button, shown while focused with text.", relationship: 'built-in' },
@@ -4,7 +4,8 @@ import { SectionHeader } from './SectionHeader';
4
4
 
5
5
  export default defineCatalogPage({
6
6
  component: 'SectionHeader',
7
- group: 'Layout',
7
+ group: 'Components',
8
+ specimenSize: 'regular',
8
9
  composedOf: [
9
10
  { component: 'Button', role: "The trailing button, rendered when trailingButtonLabel is set.", relationship: 'built-in' },
10
11
  ],
@@ -21,9 +21,7 @@ function SegmentedToggleDemo() {
21
21
 
22
22
  export default defineCatalogPage({
23
23
  component: 'SegmentedToggle',
24
- group: 'Selection',
25
- // Phone width plus a small phone, where long labels truncate.
26
- previewWidths: [402, 320],
24
+ group: 'Components',
27
25
  description: 'A row of mutually-exclusive options on a recessed track, with a white thumb that slides to the selected segment. Two or more options.',
28
26
  whenToUse: 'A filled, heavier-weight control for a primary, prominent choice on the screen. For quieter secondary navigation, use UnderlineTabs.',
29
27
  a11y: 'The row is accessibilityRole="tablist"; each segment is a "tab" with accessibilityState.selected reflecting the current value.',
@@ -10,7 +10,8 @@ const styles = StyleSheet.create({
10
10
 
11
11
  export default defineCatalogPage({
12
12
  component: 'Breathing',
13
- group: 'Feedback',
13
+ group: 'Components',
14
+ specimenSize: 'regular',
14
15
  description:
15
16
  "The shared animated primitive behind Shimmer — a solid block that breathes between a low and high colour on one shared, module-level clock, so every Breathing/Shimmer instance on screen stays phase-locked instead of drifting out of sync. Exported so a custom composite skeleton can reuse the same pulse directly instead of going through Shimmer's fixed text/circle/container shapes.",
16
17
  a11y: 'Renders a plain Animated.View with no role by default — pass accessibilityRole="progressbar" and accessibilityLabel yourself, the same way Shimmer does internally for its own standalone (non-grouped) blocks.',
@@ -11,7 +11,8 @@ const styles = StyleSheet.create({
11
11
 
12
12
  export default defineCatalogPage({
13
13
  component: 'Shimmer',
14
- group: 'Feedback',
14
+ group: 'Components',
15
+ specimenSize: 'regular',
15
16
  description: 'Loading placeholders that breathe while content loads. `text` stands in for one line of text (an element with 3 lines gets 3 stacked `text` Shimmers, not one tall one), `circle` is always for a circular element, and `container` covers everything else (Card, Banner, image, …) — sized to match the real element it replaces, not an arbitrary block. When several stand in for one element, wrap them in `SkeletonGroup`.',
16
17
  whenToUse: 'A composite skeleton (several Shimmers standing in for one element) → wrap them in SkeletonGroup so it announces "Loading" once, not once per block. A single lone Shimmer needs no wrapper — it announces on its own.',
17
18
  a11y: 'A standalone Shimmer is exposed as accessibilityRole="progressbar" + accessibilityLabel="Loading". Inside a SkeletonGroup, each block goes silent and the group carries one busy "Loading" announcement for the whole skeleton — so a screen reader says "Loading" once, not once per block.',
@@ -11,7 +11,11 @@ const styles = StyleSheet.create({
11
11
 
12
12
  export default defineCatalogPage({
13
13
  component: 'SkeletonGroup',
14
- group: 'Sub-Parts',
14
+ group: 'Components',
15
+ composedOf: [
16
+ { component: 'Shimmer', role: 'Its children: the stacked Shimmers that stand in for one element, announced once as a single Loading region.', relationship: 'slot' },
17
+ ],
18
+ specimenSize: 'regular',
15
19
  description: 'An accessibility wrapper for a composite skeleton — several Shimmers standing in for one real element (e.g. a list row: an avatar + a two-line label). It announces the whole thing as one "Loading" region and silences the individual blocks, so a screen reader says "Loading" once instead of once per Shimmer. Adds no layout of its own — pass your own flexDirection/gap via style.',
16
20
  whenToUse: 'Wrap 2+ Shimmers that together stand in for one element. A single lone Shimmer already announces on its own and needs no wrapper.',
17
21
  a11y: 'Carries accessibilityRole="progressbar" + accessibilityLabel (default "Loading") + accessibilityState={{ busy: true }} for the whole region; provides a context that makes every descendant Shimmer drop its own announcement. Net effect: one "Loading" announcement per skeleton, not one per block.',
@@ -11,7 +11,8 @@ const styles = StyleSheet.create({
11
11
 
12
12
  export default defineCatalogPage({
13
13
  component: 'Surface',
14
- group: 'Surfaces',
14
+ group: 'Components',
15
+ specimenSize: 'regular',
15
16
  description:
16
17
  "Declares \"this subtree's background is tone\" — renders a plain View filled with the matching token (surface.white or surface.main) and provides that tone to descendants via context, so a tone-aware component inside (e.g. FieldContainer/InputField) can automatically pick a fill that contrasts with it instead of assuming white.",
17
18
  whenToUse:
@@ -14,8 +14,8 @@ function SwitchLabelDemo() {
14
14
 
15
15
  export default defineCatalogPage({
16
16
  component: 'Switch',
17
- group: 'Controls',
18
- specimenSize: 'compact',
17
+ group: 'Components',
18
+ specimenSize: 'regular',
19
19
  description: 'A boolean on/off toggle. The thumb slides and the track crossfades colour, sharing SegmentedToggle/UnderlineTabs\' own slide-animation hook for a consistent motion feel.',
20
20
  whenToUse: 'A setting that takes effect immediately, no separate save step. For recording a fact a future action (like a form submit) will act on, use Checkbox; for one-of-many exclusive selection, use Radio.',
21
21
  a11y: 'Renders a Pressable with accessibilityRole="switch" and accessibilityState.checked — pass accessibilityLabel to say what it controls.',
@@ -9,7 +9,8 @@ function TextAreaDemo() {
9
9
 
10
10
  export default defineCatalogPage({
11
11
  component: 'TextArea',
12
- group: 'Inputs',
12
+ group: 'Components',
13
+ specimenSize: 'regular',
13
14
  composedOf: [
14
15
  { component: 'FieldContainer', role: "The field frame around the multi-line input.", relationship: 'built-in' },
15
16
  ],
@@ -28,7 +28,8 @@ function ToastDemo() {
28
28
 
29
29
  export default defineCatalogPage({
30
30
  component: 'Toast',
31
- group: 'Feedback',
31
+ group: 'Components',
32
+ specimenSize: 'regular',
32
33
  description: 'A transient confirmation bar. Without a variant it renders the dark surface with inverse text; a variant shifts to a pastel status color.',
33
34
  whenToUse: "Transient and self-contained, floating over the screen, expected to go away on its own or via its own action. For a persistent, in-flow message about a standing condition, use Banner.",
34
35
  a11y: 'Rendered with accessibilityRole="alert" so screen readers announce it when it appears.',
@@ -31,7 +31,8 @@ function TooltipDemo() {
31
31
 
32
32
  export default defineCatalogPage({
33
33
  component: 'Tooltip',
34
- group: 'Overlays',
34
+ group: 'Components',
35
+ specimenSize: 'regular',
35
36
  description: 'A small floating label anchored above or below its wrapped trigger. Fully controlled — drive `visible` from the trigger\'s own onLongPress/onPressIn, since mobile has no hover.',
36
37
  a11y: 'The bubble is a plain, non-interactive View; the trigger you wrap it around carries its own accessibility.',
37
38
  variants: {