@krapwoo/ds-viewer 0.4.7 → 0.4.9

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 (62) hide show
  1. package/README.md +21 -6
  2. package/dist/cli/dev.js +66 -37
  3. package/dist/cli/doctor.js +4 -2
  4. package/dist/cli/endpoint.js +33 -0
  5. package/dist/cli/init.js +5 -8
  6. package/dist/native/catalog/comparison.js +17 -8
  7. package/native/catalog/CatalogShell.tsx +5 -1
  8. package/native/catalog/ComparisonGrid.tsx +5 -1
  9. package/native/catalog/ComparisonGroups.tsx +7 -1
  10. package/native/catalog/ComparisonList.tsx +7 -1
  11. package/native/catalog/PhoneFrame.tsx +108 -24
  12. package/native/catalog/ReferenceDetails.tsx +8 -0
  13. package/native/catalog/SectionBlock.tsx +10 -4
  14. package/native/catalog/SpecimenAddress.tsx +15 -0
  15. package/native/catalog/SpecimenSurface.tsx +66 -4
  16. package/native/catalog/UpdatePanel.tsx +114 -103
  17. package/native/catalog/comparison.ts +21 -8
  18. package/native/catalog/deviceFrame.ts +14 -0
  19. package/native/catalog/overlayViewport.ts +4 -2
  20. package/native/catalog/specimenSurfaceStyle.ts +54 -0
  21. package/native/catalog/updatePanelState.ts +68 -1
  22. package/package.json +5 -4
  23. package/starter-kit/components/AnimatedChevron/AnimatedChevron.catalog.tsx +1 -1
  24. package/starter-kit/components/Avatar/Avatar.catalog.tsx +1 -1
  25. package/starter-kit/components/Badge/Badge.catalog.tsx +1 -1
  26. package/starter-kit/components/Banner/Banner.catalog.tsx +2 -1
  27. package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +5 -1
  28. package/starter-kit/components/Button/Button.catalog.tsx +1 -1
  29. package/starter-kit/components/ButtonGroup/ButtonGroup.catalog.tsx +8 -1
  30. package/starter-kit/components/Card/Card.catalog.tsx +2 -1
  31. package/starter-kit/components/Checkbox/Checkbox.catalog.tsx +2 -2
  32. package/starter-kit/components/Dialog/Dialog.catalog.tsx +1 -1
  33. package/starter-kit/components/Divider/Divider.catalog.tsx +2 -1
  34. package/starter-kit/components/Dock/Dock.catalog.tsx +4 -1
  35. package/starter-kit/components/Dropdown/Dropdown.catalog.tsx +1 -1
  36. package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +2 -1
  37. package/starter-kit/components/FieldContainer/FieldContainer.catalog.tsx +5 -1
  38. package/starter-kit/components/InputClearButton/InputClearButton.catalog.tsx +1 -1
  39. package/starter-kit/components/InputField/InputField.catalog.tsx +3 -1
  40. package/starter-kit/components/List/List.catalog.tsx +2 -1
  41. package/starter-kit/components/ListItem/ListItem.catalog.tsx +7 -1
  42. package/starter-kit/components/Loading/Loading.catalog.tsx +2 -1
  43. package/starter-kit/components/Pill/Pill.catalog.tsx +1 -1
  44. package/starter-kit/components/PillRow/PillRow.catalog.tsx +1 -1
  45. package/starter-kit/components/ProgressDots/ProgressDots.catalog.tsx +1 -1
  46. package/starter-kit/components/Radio/Radio.catalog.tsx +2 -2
  47. package/starter-kit/components/SearchField/SearchField.catalog.tsx +2 -1
  48. package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +2 -1
  49. package/starter-kit/components/SegmentedToggle/SegmentedToggle.catalog.tsx +1 -3
  50. package/starter-kit/components/Shimmer/Breathing.catalog.tsx +2 -1
  51. package/starter-kit/components/Shimmer/Shimmer.catalog.tsx +2 -1
  52. package/starter-kit/components/Shimmer/SkeletonGroup.catalog.tsx +5 -1
  53. package/starter-kit/components/Surface/Surface.catalog.tsx +2 -1
  54. package/starter-kit/components/Switch/Switch.catalog.tsx +2 -2
  55. package/starter-kit/components/TextArea/TextArea.catalog.tsx +2 -1
  56. package/starter-kit/components/Toast/Toast.catalog.tsx +2 -1
  57. package/starter-kit/components/Tooltip/Tooltip.catalog.tsx +2 -1
  58. package/starter-kit/components/TopNav/TopNav.catalog.tsx +4 -1
  59. package/starter-kit/components/UnderlineTabs/UnderlineTabs.catalog.tsx +1 -3
  60. package/starter-kit/pages/ReportIssue.catalog.tsx +18 -1
  61. package/starter-kit/pages/SavedTrips.catalog.tsx +27 -6
  62. 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
package/dist/cli/dev.js CHANGED
@@ -106,6 +106,7 @@ function packageManagerLabel(pm) {
106
106
  * (the endpoint, Task 12, refuses `POST /update` itself); this function only ever runs a plan it
107
107
  * has already been told is safe to apply. */
108
108
  export async function performUpdate(config, currentVersion, plan, deps) {
109
+ const startAt = deps.from === 'doctor' ? 3 : deps.from === 'migrate' ? 2 : 1;
109
110
  const migrationCount = plan.files.filter((f) => f.reason.startsWith('migration:')).length;
110
111
  const pm = detectPackageManager(config.projectRoot);
111
112
  // Names the detected package manager and the real migration count (Important finding, Fable
@@ -133,30 +134,34 @@ export async function performUpdate(config, currentVersion, plan, deps) {
133
134
  state: i < completedCount ? 'done' : i === completedCount ? 'now' : 'todo',
134
135
  })),
135
136
  });
136
- report(1);
137
- const { command, args } = installUpgradeCommand(pm, '@krapwoo/ds-viewer', plan.latest);
138
- try {
139
- await deps.execImpl(command, args, { cwd: config.projectRoot, encoding: 'utf8' });
140
- }
141
- catch (error) {
142
- deps.onStatus({ phase: 'failure', log: error.message, failedStep: stepDefs[1].failLabel ?? stepDefs[1].label });
143
- return;
144
- }
145
- report(2);
146
- try {
147
- await deps.execImpl('node', [installedMainJs(config.projectRoot), 'migrate', '--from', currentVersion], { cwd: config.projectRoot, encoding: 'utf8' });
137
+ report(startAt);
138
+ if (startAt <= 1) {
139
+ const { command, args } = installUpgradeCommand(pm, '@krapwoo/ds-viewer', plan.latest);
140
+ try {
141
+ await deps.execImpl(command, args, { cwd: config.projectRoot, encoding: 'utf8' });
142
+ }
143
+ catch (error) {
144
+ deps.onStatus({ phase: 'failure', log: error.message, failedStep: stepDefs[1].failLabel ?? stepDefs[1].label, step: 'install' });
145
+ return;
146
+ }
147
+ report(2);
148
148
  }
149
- catch (error) {
150
- deps.onStatus({ phase: 'failure', log: error.message, failedStep: stepDefs[2].label });
151
- return;
149
+ if (startAt <= 2) {
150
+ try {
151
+ await deps.execImpl('node', [installedMainJs(config.projectRoot), 'migrate', '--from', currentVersion], { cwd: config.projectRoot, encoding: 'utf8' });
152
+ }
153
+ catch (error) {
154
+ deps.onStatus({ phase: 'failure', log: error.message, failedStep: stepDefs[2].label, step: 'migrate' });
155
+ return;
156
+ }
157
+ report(3);
152
158
  }
153
- report(3);
154
159
  let doctorOutput;
155
160
  try {
156
161
  doctorOutput = await deps.execImpl('node', [installedMainJs(config.projectRoot), 'doctor'], { cwd: config.projectRoot, encoding: 'utf8' });
157
162
  }
158
163
  catch (error) {
159
- deps.onStatus({ phase: 'failure', log: error.message, failedStep: stepDefs[3].label });
164
+ deps.onStatus({ phase: 'failure', log: error.message, failedStep: stepDefs[3].label, step: 'doctor' });
160
165
  return;
161
166
  }
162
167
  report(4);
@@ -331,32 +336,56 @@ export async function dev(initialConfig, devOptions = {}) {
331
336
  let child;
332
337
  const secret = randomBytes(24).toString('hex');
333
338
  const allowedOrigin = `http://localhost:${port}`;
339
+ // The plan the last update ran with, so a failed migrate or doctor step can be resumed.
340
+ let lastPlan;
341
+ const runUpdate = (plan, from) => {
342
+ lastPlan = plan;
343
+ watchersPaused = true;
344
+ void performUpdate(config, ownVersion, plan, {
345
+ execImpl: defaultExecImpl,
346
+ from,
347
+ onStatus: (status) => {
348
+ updateStatus = status;
349
+ if (status.phase === 'failure')
350
+ watchersPaused = false; // the update stopped — keep developing on the old version.
351
+ },
352
+ restart: async (result) => {
353
+ writeFileSync(statusFile, JSON.stringify({ phase: 'success', ...result }));
354
+ await performRestart({ projectRoot: config.projectRoot, port, child, watchers, endpointServer, doctorSummary: result.doctorSummary, files: result.files });
355
+ },
356
+ }).catch((error) => {
357
+ // `performUpdate` only reports 'failure' from its own guarded steps; anything thrown outside
358
+ // them (e.g. writing the status file) lands here instead of as an unhandled rejection.
359
+ updateStatus = { phase: 'failure', log: error.message, failedStep: 'Updating', step: 'other' };
360
+ watchersPaused = false;
361
+ });
362
+ };
334
363
  const endpointDeps = {
335
364
  secret,
336
365
  allowedOrigin,
337
366
  buildPlan: () => buildUpdatePlan(config, ownVersion),
338
- startUpdate: (plan) => {
339
- watchersPaused = true;
340
- void performUpdate(config, ownVersion, plan, {
341
- execImpl: defaultExecImpl,
342
- onStatus: (status) => {
343
- updateStatus = status;
344
- if (status.phase === 'failure')
345
- watchersPaused = false; // the update stopped — keep developing on the old version.
346
- },
347
- restart: async (result) => {
348
- writeFileSync(statusFile, JSON.stringify({ phase: 'success', ...result }));
349
- await performRestart({ projectRoot: config.projectRoot, port, child, watchers, endpointServer, doctorSummary: result.doctorSummary, files: result.files });
350
- },
351
- }).catch((error) => {
352
- // Minor finding, Fable correction pass: `performUpdate` only ever reports `'failure'` from
353
- // its own guarded steps — a throw outside them (e.g. `writeFileSync(statusFile)` itself
354
- // failing) previously became an unhandled rejection with nothing watching for it.
355
- updateStatus = { phase: 'failure', log: error.message, failedStep: 'Updating' };
356
- watchersPaused = false;
367
+ // Install, migrate, doctor, restart; `from` resumes after a failed migrate or doctor step.
368
+ startUpdate: (plan) => runUpdate(plan),
369
+ getStatus: () => updateStatus,
370
+ dismissStatus: () => {
371
+ updateStatus = { phase: 'idle' };
372
+ },
373
+ resumeUpdate: (step) => {
374
+ if (lastPlan) {
375
+ runUpdate(lastPlan, step);
376
+ return;
377
+ }
378
+ // No plan in memory (this process didn't run the failed update): rebuild it first.
379
+ updateStatus = { phase: 'updating', steps: [{ label: 'Preparing', state: 'now' }] };
380
+ void buildUpdatePlan(config, ownVersion).then((plan) => {
381
+ if ('error' in plan)
382
+ updateStatus = { phase: 'failure', log: plan.error, failedStep: 'Preparing', step: 'other' };
383
+ else
384
+ runUpdate(plan, step);
385
+ }, (error) => {
386
+ updateStatus = { phase: 'failure', log: error.message, failedStep: 'Preparing', step: 'other' };
357
387
  });
358
388
  },
359
- getStatus: () => updateStatus,
360
389
  getVersionStatus: version.getVersionStatus,
361
390
  checkNow: version.checkNow,
362
391
  setAutoCheck: version.setAutoCheck,
@@ -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
@@ -166,6 +166,39 @@ export function createUpdateEndpoint(deps) {
166
166
  });
167
167
  return;
168
168
  }
169
+ if (req.method === 'POST' && req.url === '/update/dismiss' && deps.dismissStatus) {
170
+ const phase = deps.getStatus().phase;
171
+ if (starting || phase === 'updating' || phase === 'restarting') {
172
+ sendJson(res, 409, { error: 'An update is running.' }, deps.allowedOrigin);
173
+ return;
174
+ }
175
+ deps.dismissStatus();
176
+ sendJson(res, 200, deps.getStatus(), deps.allowedOrigin);
177
+ return;
178
+ }
179
+ if (req.method === 'POST' && req.url === '/update/resume' && deps.resumeUpdate) {
180
+ const status = deps.getStatus();
181
+ const step = status.phase === 'failure' ? status.step : undefined;
182
+ if (starting || (step !== 'migrate' && step !== 'doctor')) {
183
+ sendJson(res, 409, { error: 'There is no failed migrate or doctor step to resume.' }, deps.allowedOrigin);
184
+ return;
185
+ }
186
+ // Same reasoning as POST /update: answer first, then run the work on the next tick.
187
+ starting = true;
188
+ sendJson(res, 202, { started: true }, deps.allowedOrigin);
189
+ setImmediate(() => {
190
+ try {
191
+ deps.resumeUpdate(step);
192
+ }
193
+ catch (error) {
194
+ console.warn('Update could not resume: ' + error.message);
195
+ }
196
+ finally {
197
+ starting = false;
198
+ }
199
+ });
200
+ return;
201
+ }
169
202
  if (req.method === 'GET' && req.url === '/update/status') {
170
203
  sendJson(res, 200, deps.getStatus(), deps.allowedOrigin);
171
204
  return;
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
  }