@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
package/README.md CHANGED
@@ -92,9 +92,15 @@ npm run kit:dev # builds the CLI, then opens the starter kit's own cata
92
92
  npm run check:catalog # the same headless check CI runs — every page, zero console errors
93
93
  ```
94
94
 
95
- `kit-host/` also hosts `viewer-pages/` — standalone catalog pages (not shipped in the package)
96
- documenting the viewer framework's own pieces (`CatalogShell`, `SectionBlock`, `ComparisonGrid`,
97
- …), shown under a "Viewer" group alongside the starter kit's own pages.
95
+ The viewer's own building blocks (`CatalogShell`, `SectionBlock`, `ComparisonGrid`, the catalog's
96
+ own colours and type, …) are documented in a separate catalog for people working on ds-viewer
97
+ itself, so the starter kit's catalog shows only what a project using the kit gets:
98
+
99
+ ```bash
100
+ npm run viewer:dev # opens viewer-host/, the DS Viewer internals catalog (not shipped)
101
+ ```
102
+
103
+ `check:catalog`, `check:doctor` and `check:types` cover both catalogs.
98
104
 
99
105
  ## Contributing
100
106
 
@@ -259,6 +265,15 @@ actually apply:
259
265
  the one child document that the address itself selects, `children` render inline with no
260
266
  boundary — never read either case as native containment parity.
261
267
  - `previewWidths: [402, 320]` — extra preview widths for a `render()` page (each capped at 402).
268
+ Add a second width only when the component looks different there (labels truncate, a row
269
+ wraps); otherwise it's the same picture twice. Leave it out by default.
270
+ - **`PhoneFrame`** — wrap a demo that is a whole screen or opens an overlay (a sheet, a dialog, a
271
+ toast sliding in, a dropdown's picker) in `<PhoneFrame>`. It is a real device: the demo lays out
272
+ in an iPhone SE viewport (375 × 667 points), scaled down only when its cell is narrower. In the
273
+ browser viewer it opens that example in its own device-sized document, so overlays that portal
274
+ to the page (React Native's `Modal`) open inside the phone, not over the catalog.
275
+ - Full-width (`itemsFill`) variants and states wrap at most **3** across; set `maxColumns` to
276
+ change that. A filled example that keeps its own fixed width is centred in its cell.
262
277
  Default to one preview; add a second width only when a narrower (or wider) viewport actually
263
278
  changes something worth comparing — wrapping, truncation, or reflow — not just to show the same,
264
279
  identically-laid-out content twice. `CatalogShell`'s `defaultPreviewWidths="full"` keeps a
@@ -308,9 +323,9 @@ path. For anything beyond a single list of tokens, use the token-page layouts be
308
323
  `Components`. A page's `group` is a plain string shown as its sidebar section label;
309
324
  `groupOrder` (e.g. `['Tokens', 'Components', 'Patterns', 'Recipes', 'Experiences']`) only
310
325
  controls which order those sections appear in, top to bottom — any group not listed sorts
311
- alphabetically after the ones that are. This starter kit's own two groups (`Tokens`, then
312
- `Components`) are one valid shape, not the only one: a larger catalog might add `Patterns`
313
- (reusable combinations like a settings row), `Recipes` (worked end-to-end screens), or
326
+ alphabetically after the ones that are. The starter kit uses `Tokens`, then every
327
+ component under `Components`, then `Patterns` (whole screens built from the kit, such as
328
+ SavedTrips) and `Reference` (the generated manifest). A larger catalog might add `Recipes` or
314
329
  `Experiences` (full flows) alongside them. Pages sort A–Z inside each group regardless of how
315
330
  many groups you have. Avoid splitting `Components` into finer categories such as "Actions" or
316
331
  "Status" purely to browse by — that makes readers guess where a component lives; search and A–Z
@@ -480,7 +480,9 @@ export function checkPageTypeErrors(pages, options = {}) {
480
480
  // `createComponentReader` (where a mismatch only degrades prop reading silently; here it would
481
481
  // produce CI-failing errors the host's own `tsc` would never raise). Only `noEmit`,
482
482
  // `skipLibCheck`, and the merged `paths` are forced on top.
483
- const host = readHostTsconfigOptions(pageFiles[0]);
483
+ // `readHostTsconfigOptions` searches upward from the given file's folder; a file name inside
484
+ // `hostRoot` starts that search at the host's own root.
485
+ const host = readHostTsconfigOptions(options.hostRoot ? path.join(options.hostRoot, 'ds-viewer.config.ts') : pageFiles[0]);
484
486
  const compilerOptions = {
485
487
  ...host.options,
486
488
  noEmit: true,
@@ -569,7 +571,7 @@ export function runDoctor(config) {
569
571
  : undefined;
570
572
  let issues = [
571
573
  ...collectIssues(pages, components),
572
- ...checkPageTypeErrors(pages, { fallbackPaths }),
574
+ ...checkPageTypeErrors(pages, { fallbackPaths, hostRoot: config.projectRoot }),
573
575
  ...collectGlobalIssues({ syncWarnings: syncResult.warnings, tokenFileCount }),
574
576
  ];
575
577
  // Design's own `--json` example gives a project-relative `file`; `StaticPage.file` is absolute
package/dist/cli/init.js CHANGED
@@ -141,14 +141,11 @@ function ensureSharedProjectFiles(projectRoot) {
141
141
  return written;
142
142
  }
143
143
  const KIT_REQUIRED_PACKAGES = ['react-native-svg', 'react-native-safe-area-context'];
144
- /** The starter kit's own intended sidebar order (the same list kit-host/ds-viewer.config.ts
145
- * declares, minus "Viewer" — that group only holds kit-host's own framework-docs pages under
146
- * `viewer-pages/`, which `init --new` never copies). Written into a fresh project's config so its
147
- * sidebar isn't left alphabetical (controller end-to-end finding E3). */
148
- const KIT_GROUP_ORDER = [
149
- 'Actions', 'Surfaces', 'Inputs', 'Controls', 'Selection', 'Feedback', 'Navigation',
150
- 'Overlays', 'Layout', 'Sub-Parts', 'Recipes', 'Tokens', 'Reference',
151
- ];
144
+ /** The starter kit's own sidebar order (the same list kit-host/ds-viewer.config.ts declares),
145
+ * written into a fresh project's config. Follows the README's "Organising the catalog": Tokens,
146
+ * then every component in one A–Z group, then the non-component kinds of page (Patterns: whole
147
+ * screens built from the kit; Reference: the generated manifest). */
148
+ const KIT_GROUP_ORDER = ['Tokens', 'Components', 'Patterns', 'Reference'];
152
149
  /** Default `installer` — a real `npx expo install`, run only for whichever of
153
150
  * `KIT_REQUIRED_PACKAGES` the project doesn't already depend on. Test-injected in every unit test
154
151
  * (this plan's Global Constraints: never run a real `expo install` in tests). */
@@ -189,10 +189,19 @@ export function remainingStates(states, comparison) {
189
189
  export function slotSize(def, slot) {
190
190
  return def.specimenSize ?? (slot.itemsFill ? 'wide' : 'regular');
191
191
  }
192
- function slotItems(slot) {
192
+ /** Full-width (filled) examples are block components (cards, banners, rows): narrower than about a
193
+ * third of a laptop's content width they stop reading as themselves, so they wrap at most 3
194
+ * across unless the page sets its own `maxColumns`. Small centred examples wrap freely. */
195
+ export const FILLED_SLOT_MAX_COLUMNS = 3;
196
+ function slotColumnCap(slot) {
197
+ if (slot.maxColumns !== undefined)
198
+ return { maxColumns: slot.maxColumns };
199
+ return slot.itemsFill ? { maxColumns: FILLED_SLOT_MAX_COLUMNS } : {};
200
+ }
201
+ function slotItems(slot, from) {
193
202
  // Nullish, not `||`: an item's own explicit `fill: false` must override an inherited
194
203
  // `itemsFill: true`, which `false || slot.itemsFill` would silently discard.
195
- 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 }));
204
+ 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 }));
196
205
  }
197
206
  /** Preview widths, each capped at phone width. 'full' is kept for catalog chrome and token pages. */
198
207
  function previewWidths(requested, fallback) {
@@ -212,8 +221,8 @@ function groupStates(variants, states) {
212
221
  const groups = variants.items.map((variant) => {
213
222
  const own = states.items.filter((s) => s.group === variant.key);
214
223
  const items = own.length > 0
215
- ? own.map((s) => ({ key: s.key, label: s.name, node: s.node, fill: fill(s, states), surface: s.surface, align: s.align }))
216
- : [{ key: variant.key, label: variant.name, node: variant.node, fill: fill(variant, variants), surface: variant.surface, align: variant.align }];
224
+ ? own.map((s) => ({ key: s.key, slot: 'states', label: s.name, node: s.node, fill: fill(s, states), surface: s.surface, align: s.align }))
225
+ : [{ key: variant.key, slot: 'variants', label: variant.name, node: variant.node, fill: fill(variant, variants), surface: variant.surface, align: variant.align }];
217
226
  return { key: variant.key, label: variant.name, items };
218
227
  });
219
228
  const rest = states.items.filter((s) => s.group === undefined || !variantKeys.has(s.group));
@@ -260,8 +269,8 @@ export function presentationBlocks(def, options = {}) {
260
269
  kind: 'list',
261
270
  title: 'Variants',
262
271
  size: slotSize(def, def.variants),
263
- items: slotItems(def.variants),
264
- ...(def.variants.maxColumns !== undefined ? { maxColumns: def.variants.maxColumns } : {}),
272
+ items: slotItems(def.variants, 'variants'),
273
+ ...slotColumnCap(def.variants),
265
274
  });
266
275
  }
267
276
  else if (def.render) {
@@ -274,8 +283,8 @@ export function presentationBlocks(def, options = {}) {
274
283
  kind: 'list',
275
284
  title: secondary ? 'Other configurations' : 'States / configurations',
276
285
  size: slotSize(def, states),
277
- items: slotItems(states),
278
- ...(states.maxColumns !== undefined ? { maxColumns: states.maxColumns } : {}),
286
+ items: slotItems(states, 'states'),
287
+ ...slotColumnCap(states),
279
288
  });
280
289
  }
281
290
  if (blocks.length === 0 && !hide.variants && !hide.states) {
@@ -7,6 +7,7 @@ import { SectionBlock } from './SectionBlock';
7
7
  import { UpdatePanel } from './UpdatePanel';
8
8
  import { DS_VIEWER_SECRET_HEADER, footerLink, groupLabelFor, hashForId, neighbors, orderedIds, resolveActiveFromHash, shouldShowMajorBanner, UPDATE_PAGE_ID } from './catalogNavigation';
9
9
  import { findOverlayNode, overlayAddressFromSearch } from './overlayViewport';
10
+ import { SpecimenAddressProvider } from './SpecimenAddress';
10
11
  import type { NavGroup, PreviewWidths, SectionDef, UpdateNotice, VersionStatus } from './types';
11
12
 
12
13
  /** `active` can be a real page id, or the reserved update-page id — never both a generic `TId`
@@ -255,7 +256,10 @@ export function CatalogShell<TId extends string>({
255
256
  if (overlayAddress) {
256
257
  return (
257
258
  <View style={styles.overlayDocumentRoot}>
258
- {overlayNode ?? <Text style={styles.overlayUnavailable}>This example is no longer available.</Text>}
259
+ {/* The addressed example's own device frame recognises this document as its viewport. */}
260
+ <SpecimenAddressProvider address={overlayAddress}>
261
+ {overlayNode ?? <Text style={styles.overlayUnavailable}>This example is no longer available.</Text>}
262
+ </SpecimenAddressProvider>
259
263
  </View>
260
264
  );
261
265
  }
@@ -3,6 +3,8 @@ import { View, Text, ScrollView, StyleSheet } from 'react-native';
3
3
  import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
4
4
  import { COLUMN_MIN_WIDTH, MATRIX_LAYOUT, axisItems, cellKey, gridWidthBounds, indexCells, validateComparison } from './comparison';
5
5
  import { SpecimenContent, SpecimenSurface } from './SpecimenSurface';
6
+ import { SpecimenAddressProvider } from './SpecimenAddress';
7
+ import { comparisonCellItemKey } from './overlayViewport';
6
8
  import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
7
9
  import type { ComparisonDef, SpecimenSize, SpecimenSurfaceKind } from './types';
8
10
 
@@ -66,7 +68,9 @@ export function ComparisonGrid({
66
68
  return (
67
69
  cell?.node !== undefined ? (
68
70
  <SpecimenSurface key={c.key} role="cell" surface={cell.surface ?? surface} style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
69
- <SpecimenContent fill={cell.fill} align={cell.align}>{cell.node}</SpecimenContent>
71
+ <SpecimenAddressProvider address={{ pageId: sectionId, slot: 'comparison', itemKey: comparisonCellItemKey(row.key, c.key) }}>
72
+ <SpecimenContent fill={cell.fill} align={cell.align}>{cell.node}</SpecimenContent>
73
+ </SpecimenAddressProvider>
70
74
  </SpecimenSurface>
71
75
  ) : (
72
76
  <View key={c.key} role="cell" style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
@@ -3,6 +3,7 @@ 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
5
  import { SpecimenContent, SpecimenSurface } from './SpecimenSurface';
6
+ import { SpecimenAddressProvider } from './SpecimenAddress';
6
7
  import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
7
8
  import type { SpecimenSize, SpecimenSurfaceKind } from './types';
8
9
 
@@ -18,10 +19,13 @@ export function ComparisonGroups({
18
19
  size,
19
20
  label,
20
21
  surface = DEFAULT_SPECIMEN_SURFACE,
22
+ pageId,
21
23
  }: {
22
24
  groups: ListGroup[];
23
25
  size: SpecimenSize;
24
26
  label: string;
27
+ /** The page these examples belong to, so a device frame inside one can address it. */
28
+ pageId?: string;
25
29
  surface?: SpecimenSurfaceKind;
26
30
  }) {
27
31
  const columns = Math.max(1, ...groups.map((g) => g.items.length));
@@ -48,7 +52,9 @@ export function ComparisonGroups({
48
52
  <Text style={styles.captionText}>{item.label}</Text>
49
53
  </View>
50
54
  <SpecimenSurface surface={item.surface ?? surface} style={styles.specimen}>
51
- <SpecimenContent fill={item.fill} align={item.align}>{item.node as React.ReactNode}</SpecimenContent>
55
+ <SpecimenAddressProvider address={pageId && item.slot ? { pageId, slot: item.slot, itemKey: item.key } : undefined}>
56
+ <SpecimenContent fill={item.fill} align={item.align}>{item.node as React.ReactNode}</SpecimenContent>
57
+ </SpecimenAddressProvider>
52
58
  </SpecimenSurface>
53
59
  </View>
54
60
  );
@@ -3,6 +3,7 @@ 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
5
  import { SpecimenContent, SpecimenSurface } from './SpecimenSurface';
6
+ import { SpecimenAddressProvider } from './SpecimenAddress';
6
7
  import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
7
8
  import type { SpecimenSize, SpecimenSurfaceKind } from './types';
8
9
 
@@ -19,10 +20,13 @@ export function ComparisonList({
19
20
  label,
20
21
  maxColumns,
21
22
  surface = DEFAULT_SPECIMEN_SURFACE,
23
+ pageId,
22
24
  }: {
23
25
  items: ListItem[];
24
26
  size: SpecimenSize;
25
27
  label: string;
28
+ /** The page these examples belong to, so a device frame inside one can address it. */
29
+ pageId?: string;
26
30
  maxColumns?: 1 | 2 | 3 | 4 | 5;
27
31
  surface?: SpecimenSurfaceKind;
28
32
  }) {
@@ -62,7 +66,9 @@ export function ComparisonList({
62
66
  <Text style={styles.captionText}>{item.label}</Text>
63
67
  </View>
64
68
  <SpecimenSurface surface={item.surface ?? surface} style={styles.specimen}>
65
- <SpecimenContent fill={item.fill} align={item.align}>{item.node as React.ReactNode}</SpecimenContent>
69
+ <SpecimenAddressProvider address={pageId && item.slot ? { pageId, slot: item.slot, itemKey: item.key } : undefined}>
70
+ <SpecimenContent fill={item.fill} align={item.align}>{item.node as React.ReactNode}</SpecimenContent>
71
+ </SpecimenAddressProvider>
66
72
  </SpecimenSurface>
67
73
  </View>
68
74
  );
@@ -1,36 +1,100 @@
1
- import React from 'react';
2
- import { View, StyleSheet } from 'react-native';
3
- import { CATALOG_RADIUS, CATALOG_COLOR } from './tokens';
1
+ import React, { useState } from 'react';
2
+ import { Platform, StyleSheet, Text, View, type LayoutChangeEvent } from 'react-native';
3
+ import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
4
+ import { DEVICE_FRAME, deviceFrameScale } from './deviceFrame';
5
+ import { resolveOverlayViewport } from './overlayViewport';
6
+ import { useSpecimenAddress } from './SpecimenAddress';
4
7
 
5
- const WIDTH_CAP = 280;
6
- const HEIGHT = 480;
8
+ function isWeb(): boolean {
9
+ return Platform.OS === 'web' && typeof window !== 'undefined';
10
+ }
7
11
 
8
12
  /**
9
- * A bounded "phone screen" preview frame — contains a live demo (e.g. a BottomSheet or Dropdown's
10
- * picker) inside a fixed-size box instead of letting its absolutely-positioned overlay cover the
11
- * whole documentation page. Purely a documentation device: a real app screen never needs to fake
12
- * being a phone screen inside itself, since the actual device viewport already does that job — that's
13
- * why this lives in the catalog framework (like VariantGroup/TokenRow) rather than the host app's
14
- * own component library (a downstream app would never import this file).
13
+ * A real phone screen for a live demo: a screen, a sheet, a dialog, a toast sliding in.
15
14
  *
16
- * `width: '100%'` capped at 280 (not a fixed width) — a fixed px width could exceed the card's real
17
- * content width once the surrounding column shrinks below its own max-width cap (it does at common
18
- * viewport sizes), overflowing past the card's edge since the card itself doesn't clip.
15
+ * - **Real dimensions.** The demo lays out in a viewport of exactly `DEVICE_FRAME` (iPhone SE,
16
+ * 375 × 667 points). A cell narrower than that scales the whole device down rather than
17
+ * squeezing its layout.
18
+ * - **A real viewport on the web.** Inside a catalog example, the frame opens that one example in
19
+ * its own child document at device size (the same mechanism as `BoundedOverlayViewport`). Its
20
+ * window is the device: anything that portals to the document body, such as React Native's
21
+ * `Modal`, opens inside the phone instead of over the whole catalog, and the window reports the
22
+ * device's size.
23
+ * - **Elsewhere** (native, or a frame outside a catalog example) it renders inline at the same
24
+ * size; overlays that portal to the body are then not contained.
19
25
  *
20
- * `alignSelf: 'center'` is this frame's own responsibility, not its `SpecimenContent` wrapper's:
21
- * a `fill` specimen's wrapper now stretches by default (preserving an unrelated full-width
22
- * auto-sized specimen elsewhere), which leaves a capped-width child like this one flush to one
23
- * side instead of centered — see `specimenContentAlignItems` in `specimenSurfaceStyle.ts`.
26
+ * Centred in its cell by its own full-width measuring wrapper, whatever the cell's alignment.
27
+ * Purely a documentation device: a real app screen never frames itself, so this lives in the
28
+ * catalog framework rather than in an app's component library.
24
29
  */
25
30
  export function PhoneFrame({ children }: { children: React.ReactNode }) {
26
- return <View style={styles.frame}>{children}</View>;
31
+ const address = useSpecimenAddress();
32
+ const [available, setAvailable] = useState(0);
33
+ const [status, setStatus] = useState<'loading' | 'ready' | 'unavailable'>('loading');
34
+ const onLayout = (event: LayoutChangeEvent) => {
35
+ const width = Math.floor(event.nativeEvent.layout.width);
36
+ if (width > 0 && width !== available) setAvailable(width);
37
+ };
38
+ const scale = deviceFrameScale(available);
39
+ // Until the cell is measured, fit it at the device's proportions rather than drawing at full size
40
+ // for a frame (which would briefly overflow a cell narrower than the device).
41
+ const box = available > 0
42
+ ? { width: DEVICE_FRAME.width * scale, height: DEVICE_FRAME.height * scale }
43
+ : { width: '100%' as const, maxWidth: DEVICE_FRAME.width, aspectRatio: DEVICE_FRAME.width / DEVICE_FRAME.height };
44
+
45
+ if (isWeb() && address) {
46
+ const decision = resolveOverlayViewport({
47
+ address,
48
+ isWeb: true,
49
+ currentSearch: window.location.search,
50
+ pathname: window.location.pathname,
51
+ });
52
+ // This document is the device itself: fill it edge to edge.
53
+ if (decision.mode === 'inline') return <View style={styles.screenFill}>{children}</View>;
54
+ return (
55
+ <View style={styles.measure} onLayout={onLayout}>
56
+ <View style={[styles.device, box]}>
57
+ {status !== 'ready' && (
58
+ <Text style={styles.status}>{status === 'unavailable' ? 'This example is unavailable right now.' : 'Loading…'}</Text>
59
+ )}
60
+ {
61
+ // react-native-web has no iframe primitive; this is a plain same-origin iframe.
62
+ React.createElement('iframe' as unknown as string, {
63
+ src: decision.href,
64
+ title: `${address.pageId} on ${DEVICE_FRAME.name} (${DEVICE_FRAME.width} × ${DEVICE_FRAME.height})`,
65
+ style: {
66
+ position: 'absolute',
67
+ top: 0,
68
+ left: 0,
69
+ width: DEVICE_FRAME.width,
70
+ height: DEVICE_FRAME.height,
71
+ border: 0,
72
+ transform: `scale(${scale})`,
73
+ transformOrigin: '0 0',
74
+ visibility: status === 'ready' ? 'visible' : 'hidden',
75
+ },
76
+ onLoad: () => setStatus('ready'),
77
+ onError: () => setStatus('unavailable'),
78
+ })
79
+ }
80
+ </View>
81
+ </View>
82
+ );
83
+ }
84
+
85
+ return (
86
+ <View style={styles.measure} onLayout={onLayout}>
87
+ <View style={[styles.device, box]}>
88
+ <View style={[styles.screenInline, { transform: [{ scale }], transformOrigin: 'top left' } as object]}>{children}</View>
89
+ </View>
90
+ </View>
91
+ );
27
92
  }
28
93
 
29
94
  const styles = StyleSheet.create({
30
- frame: {
31
- width: '100%',
32
- maxWidth: WIDTH_CAP,
33
- height: HEIGHT,
95
+ // Spans the cell so the device can be measured against it and centred in it.
96
+ measure: { width: '100%', alignItems: 'center' },
97
+ device: {
34
98
  borderRadius: CATALOG_RADIUS.md,
35
99
  borderWidth: StyleSheet.hairlineWidth,
36
100
  borderColor: CATALOG_COLOR.border,
@@ -38,6 +102,26 @@ const styles = StyleSheet.create({
38
102
  overflow: 'hidden',
39
103
  alignItems: 'center',
40
104
  justifyContent: 'center',
41
- alignSelf: 'center',
42
105
  },
106
+ screenInline: {
107
+ width: DEVICE_FRAME.width,
108
+ height: DEVICE_FRAME.height,
109
+ position: 'absolute',
110
+ top: 0,
111
+ left: 0,
112
+ alignItems: 'center',
113
+ justifyContent: 'center',
114
+ overflow: 'hidden',
115
+ },
116
+ // The child document's whole window is the device screen: exactly the device's height, never
117
+ // more, so a `flex: 1` scroll area inside a demo shrinks to fit and scrolls instead of growing.
118
+ screenFill: {
119
+ width: '100%',
120
+ height: DEVICE_FRAME.height,
121
+ backgroundColor: CATALOG_COLOR.surfacePressed,
122
+ alignItems: 'center',
123
+ justifyContent: 'center',
124
+ overflow: 'hidden',
125
+ },
126
+ status: { position: 'absolute', fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
43
127
  });
@@ -86,10 +86,18 @@ export function ReferenceDetails<TId extends string>({ def }: { def: SectionDef<
86
86
  if (next > 0 && next !== width) setWidth(next);
87
87
  };
88
88
  if (def.tokenGallery) {
89
+ // Full-width pages with no component API (token galleries, and whole screens such as a
90
+ // starter kit's Patterns) still disclose what they're built from when they say so.
89
91
  return (
90
92
  <View style={styles.card}>
91
93
  <Text role="heading" {...HEADING_LEVEL_2} style={styles.heading}>Quick reference</Text>
92
94
  {def.path && <Fact label="Source" value={def.path} mono />}
95
+ {def.composedOf && def.composedOf.length > 0 && (
96
+ <>
97
+ <Text style={styles.subheading}>Composition</Text>
98
+ <CompositionFacts composedOf={def.composedOf} />
99
+ </>
100
+ )}
93
101
  </View>
94
102
  );
95
103
  }
@@ -6,6 +6,7 @@ import { ComparisonGroups } from './ComparisonGroups';
6
6
  import { ComparisonList } from './ComparisonList';
7
7
  import { ReferenceDetails } from './ReferenceDetails';
8
8
  import { SpecimenSurface } from './SpecimenSurface';
9
+ import { SpecimenAddressProvider } from './SpecimenAddress';
9
10
  import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
10
11
  import { TokenSections } from './TokenLayouts';
11
12
  import { MATRIX_LAYOUT, choosePlacement, listGeometry, presentationBlocks, type PresentationBlock } from './comparison';
@@ -169,15 +170,19 @@ function BlockContent<TId extends string>({ block, def, surface }: { block: Pres
169
170
  case 'grid':
170
171
  return <ComparisonGrid def={block.comparison} size={block.size} sectionId={def.id} surface={surface} />;
171
172
  case 'grouped':
172
- return <ComparisonGroups groups={block.groups} size={block.size} label={def.id} surface={surface} />;
173
+ return <ComparisonGroups groups={block.groups} size={block.size} label={def.id} surface={surface} pageId={def.id} />;
173
174
  case 'list':
174
- return <ComparisonList items={block.items} size={block.size} label={`${def.id}: ${block.title}`} maxColumns={block.maxColumns} surface={surface} />;
175
+ return <ComparisonList items={block.items} size={block.size} label={`${def.id}: ${block.title}`} maxColumns={block.maxColumns} surface={surface} pageId={def.id} />;
175
176
  case 'preview':
176
177
  // A token gallery's `preview` block shows full-width raw token data, not a component
177
178
  // specimen — it keeps its current, unstaged presentation.
178
179
  // `Preview`'s own `widths === 'full'` check already excludes a full-width or token-gallery
179
180
  // preview from ever reaching the table layout, regardless of `def.previewLayout`.
180
- return def.render ? <Preview render={def.render} widths={block.widths} surface={def.tokenGallery ? undefined : surface} layout={def.previewLayout === 'table' ? 'table' : undefined} /> : null;
181
+ return def.render ? (
182
+ <SpecimenAddressProvider address={{ pageId: def.id, slot: 'preview', itemKey: 'preview' }}>
183
+ <Preview render={def.render} widths={block.widths} surface={def.tokenGallery ? undefined : surface} layout={def.previewLayout === 'table' ? 'table' : undefined} />
184
+ </SpecimenAddressProvider>
185
+ ) : null;
181
186
  case 'tokenSections':
182
187
  return <TokenSections sections={block.sections} columns={block.columns} />;
183
188
  default:
@@ -369,7 +374,8 @@ const styles = StyleSheet.create({
369
374
  blocks: { gap: CATALOG_LAYOUT.blockGap, marginBottom: CATALOG_LAYOUT.blockGap },
370
375
  blocksSide: { flexDirection: 'row', alignItems: 'flex-start' },
371
376
  blockFill: { flex: 1, minWidth: 0 },
372
- frames: { flexDirection: 'row', flexWrap: 'wrap', gap: CATALOG_SPACE.xl, alignItems: 'flex-start' },
377
+ // Centred in the card, like every other example (a single phone-width frame used to sit left).
378
+ frames: { flexDirection: 'row', flexWrap: 'wrap', gap: CATALOG_SPACE.xl, alignItems: 'flex-start', justifyContent: 'center' },
373
379
  frame: { maxWidth: '100%', gap: CATALOG_SPACE.sm },
374
380
  frameLabel: {
375
381
  fontSize: CATALOG_TYPE.tableHeader,
@@ -0,0 +1,15 @@
1
+ import React, { createContext, useContext } from 'react';
2
+ import type { OverlayAddress } from './overlayViewport';
3
+
4
+ const SpecimenAddressContext = createContext<OverlayAddress | undefined>(undefined);
5
+
6
+ /** Tells the example rendered inside it which catalog example it is (page, slot, item key), so a
7
+ * `PhoneFrame` in it can open that one example in its own device-sized document. Set by the
8
+ * catalog's own list, grid and preview renderers, and by the child document itself. */
9
+ export function SpecimenAddressProvider({ address, children }: { address: OverlayAddress | undefined; children: React.ReactNode }) {
10
+ return <SpecimenAddressContext.Provider value={address}>{children}</SpecimenAddressContext.Provider>;
11
+ }
12
+
13
+ export function useSpecimenAddress(): OverlayAddress | undefined {
14
+ return useContext(SpecimenAddressContext);
15
+ }
@@ -1,7 +1,11 @@
1
1
  import React, { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { View, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
3
3
  import { CATALOG_COLOR } from './tokens';
4
- import { DEFAULT_SPECIMEN_SURFACE, SPECIMEN_SURFACE_WIDTH_STYLE, specimenContentAlignItems, specimenContentBoundedStyle } from './specimenSurfaceStyle';
4
+ import {
5
+ DEFAULT_SPECIMEN_SURFACE, SPECIMEN_SURFACE_WIDTH_STYLE, overflowsCell, paintedExtent, shouldCenterFilledContent, specimenContentAlignItems,
6
+ specimenContentBoundedStyle, visibleBoxes, type LayoutBox,
7
+ } from './specimenSurfaceStyle';
8
+ import { useSpecimenAddress } from './SpecimenAddress';
5
9
  import { blendsIntoCell, measurePaintedBoxes } from './specimenFill';
6
10
  import type { SpecimenAlign, SpecimenSurfaceKind } from './types';
7
11
 
@@ -75,12 +79,70 @@ export function SpecimenSurface({
75
79
  * (e.g. Badge's `alignSelf: 'flex-start'`) free to override that positioning outright, since
76
80
  * `alignItems` only sets a child's *default* `alignSelf`. `fill` keeps the existing column +
77
81
  * `alignItems` stretch behavior, unchanged. */
82
+ type MeasurableNode = {
83
+ getBoundingClientRect: () => { left: number; right: number; width: number };
84
+ children: ArrayLike<MeasurableNode>;
85
+ childElementCount: number;
86
+ tagName: string;
87
+ };
88
+
89
+ /** The example's element tree as `LayoutBox`es (up to 400 elements), so `visibleBoxes` can skip
90
+ * what scrolling or clipping containers hide. */
91
+ function readTree(root: MeasurableNode, browser: Browser): LayoutBox {
92
+ let budget = 400;
93
+ const read = (el: MeasurableNode): LayoutBox => {
94
+ const rect = el.getBoundingClientRect();
95
+ const style = browser.getComputedStyle!(el) as unknown as { backgroundColor: string; borderTopWidth: string; visibility: string; overflowX: string };
96
+ const kids: LayoutBox[] = [];
97
+ const children = el.children;
98
+ for (let i = 0; i < children.length && budget > 0; i++) {
99
+ budget -= 1;
100
+ const child = children[i];
101
+ if (child.getBoundingClientRect().width < 1) continue;
102
+ kids.push(read(child));
103
+ }
104
+ return {
105
+ left: rect.left,
106
+ right: rect.right,
107
+ painted:
108
+ style.visibility !== 'hidden' &&
109
+ (el.childElementCount === 0 || el.tagName === 'svg' || el.tagName === 'IFRAME' ||
110
+ style.backgroundColor !== 'rgba(0, 0, 0, 0)' || parseFloat(style.borderTopWidth) > 0),
111
+ clips: style.overflowX === 'hidden' || style.overflowX === 'auto' || style.overflowX === 'scroll' || style.overflowX === 'clip',
112
+ children: kids,
113
+ };
114
+ };
115
+ return read(root);
116
+ }
117
+
78
118
  export function SpecimenContent({ fill, align, children }: { fill?: boolean; align?: SpecimenAlign; children: React.ReactNode }) {
119
+ // After layout (browser only): a `fill` example whose visible content is narrower than the cell
120
+ // (a fixed-width skeleton, a tooltip around a small trigger) is centred instead of sitting
121
+ // against the leading edge; an explicit `align` always wins. And in development, an example
122
+ // wider than its cell logs a warning naming a wider `specimenSize`.
123
+ const address = useSpecimenAddress();
124
+ const ref = useRef<View>(null);
125
+ const [centerNarrow, setCenterNarrow] = useState(false);
126
+ const warned = useRef(false);
127
+ const measure = useCallback(() => {
128
+ const browser = globalThis as unknown as Browser;
129
+ const node = ref.current as unknown as MeasurableNode | null;
130
+ if (!browser.Element || !browser.getComputedStyle || !node?.getBoundingClientRect) return;
131
+ const wrapperWidth = node.getBoundingClientRect().width;
132
+ const visible = paintedExtent(visibleBoxes(readTree(node, browser)));
133
+ if (fill && align === undefined && !centerNarrow && shouldCenterFilledContent(visible, wrapperWidth)) setCenterNarrow(true);
134
+ if ((globalThis as { __DEV__?: boolean }).__DEV__ && !warned.current && overflowsCell(visible, wrapperWidth)) {
135
+ warned.current = true;
136
+ console.warn(
137
+ `[Catalog] ${address ? `${address.pageId} (${address.itemKey})` : 'An example'} is ${Math.round(visible)}px wide in a ${Math.round(wrapperWidth)}px cell, so it's cut off. Give the page a wider specimenSize ('regular' or 'wide').`,
138
+ );
139
+ }
140
+ }, [fill, align, centerNarrow, address]);
79
141
  if (fill) {
80
- const alignItems = specimenContentAlignItems(fill, align);
81
- return <View style={[SPECIMEN_SURFACE_WIDTH_STYLE, { alignItems }]}>{children}</View>;
142
+ const alignItems = centerNarrow ? 'center' : specimenContentAlignItems(fill, align);
143
+ return <View ref={ref} onLayout={measure} style={[SPECIMEN_SURFACE_WIDTH_STYLE, { alignItems }]}>{children}</View>;
82
144
  }
83
- return <View style={specimenContentBoundedStyle(align)}>{children}</View>;
145
+ return <View ref={ref} onLayout={measure} style={specimenContentBoundedStyle(align)}>{children}</View>;
84
146
  }
85
147
 
86
148
  // 'dark' reuses CATALOG_COLOR.text (the catalog's own darkest token); there is no dark-surface token.