@krapwoo/ds-viewer 0.4.6 → 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 (65) hide show
  1. package/README.md +121 -21
  2. package/dist/cli/doctor.js +141 -2
  3. package/dist/cli/explain.js +44 -6
  4. package/dist/cli/init.js +5 -8
  5. package/dist/cli/props.js +80 -1
  6. package/dist/cli/staticPage.js +228 -6
  7. package/dist/native/catalog/comparison.js +43 -9
  8. package/native/catalog/BoundedOverlayViewport.tsx +112 -0
  9. package/native/catalog/CatalogShell.tsx +26 -0
  10. package/native/catalog/ComparisonGrid.tsx +6 -2
  11. package/native/catalog/ComparisonGroups.tsx +8 -2
  12. package/native/catalog/ComparisonList.tsx +8 -2
  13. package/native/catalog/PhoneFrame.tsx +109 -19
  14. package/native/catalog/ReferenceDetails.tsx +83 -20
  15. package/native/catalog/SectionBlock.tsx +75 -7
  16. package/native/catalog/SpecimenAddress.tsx +15 -0
  17. package/native/catalog/SpecimenSurface.tsx +80 -10
  18. package/native/catalog/comparison.ts +57 -10
  19. package/native/catalog/deviceFrame.ts +14 -0
  20. package/native/catalog/index.ts +4 -0
  21. package/native/catalog/overlayViewport.ts +158 -0
  22. package/native/catalog/pageApi.ts +6 -0
  23. package/native/catalog/specimenSurfaceStyle.ts +124 -1
  24. package/native/catalog/types.ts +42 -12
  25. package/package.json +5 -4
  26. package/starter-kit/components/AnimatedChevron/AnimatedChevron.catalog.tsx +1 -1
  27. package/starter-kit/components/Avatar/Avatar.catalog.tsx +1 -1
  28. package/starter-kit/components/Badge/Badge.catalog.tsx +1 -1
  29. package/starter-kit/components/Banner/Banner.catalog.tsx +5 -1
  30. package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +5 -1
  31. package/starter-kit/components/Button/Button.catalog.tsx +4 -1
  32. package/starter-kit/components/ButtonGroup/ButtonGroup.catalog.tsx +8 -1
  33. package/starter-kit/components/Card/Card.catalog.tsx +2 -1
  34. package/starter-kit/components/Checkbox/Checkbox.catalog.tsx +2 -2
  35. package/starter-kit/components/Dialog/Dialog.catalog.tsx +1 -1
  36. package/starter-kit/components/Divider/Divider.catalog.tsx +2 -1
  37. package/starter-kit/components/Dock/Dock.catalog.tsx +4 -1
  38. package/starter-kit/components/Dropdown/Dropdown.catalog.tsx +7 -1
  39. package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +7 -1
  40. package/starter-kit/components/FieldContainer/FieldContainer.catalog.tsx +5 -1
  41. package/starter-kit/components/InputClearButton/InputClearButton.catalog.tsx +1 -1
  42. package/starter-kit/components/InputField/InputField.catalog.tsx +6 -1
  43. package/starter-kit/components/List/List.catalog.tsx +5 -1
  44. package/starter-kit/components/ListItem/ListItem.catalog.tsx +7 -1
  45. package/starter-kit/components/Loading/Loading.catalog.tsx +2 -1
  46. package/starter-kit/components/Pill/Pill.catalog.tsx +4 -1
  47. package/starter-kit/components/PillRow/PillRow.catalog.tsx +4 -1
  48. package/starter-kit/components/ProgressDots/ProgressDots.catalog.tsx +1 -1
  49. package/starter-kit/components/Radio/Radio.catalog.tsx +2 -2
  50. package/starter-kit/components/SearchField/SearchField.catalog.tsx +6 -1
  51. package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +5 -1
  52. package/starter-kit/components/SegmentedToggle/SegmentedToggle.catalog.tsx +1 -3
  53. package/starter-kit/components/Shimmer/Breathing.catalog.tsx +2 -1
  54. package/starter-kit/components/Shimmer/Shimmer.catalog.tsx +2 -1
  55. package/starter-kit/components/Shimmer/SkeletonGroup.catalog.tsx +5 -1
  56. package/starter-kit/components/Surface/Surface.catalog.tsx +2 -1
  57. package/starter-kit/components/Switch/Switch.catalog.tsx +2 -2
  58. package/starter-kit/components/TextArea/TextArea.catalog.tsx +5 -1
  59. package/starter-kit/components/Toast/Toast.catalog.tsx +9 -1
  60. package/starter-kit/components/Tooltip/Tooltip.catalog.tsx +2 -1
  61. package/starter-kit/components/TopNav/TopNav.catalog.tsx +4 -1
  62. package/starter-kit/components/UnderlineTabs/UnderlineTabs.catalog.tsx +1 -3
  63. package/starter-kit/pages/ReportIssue.catalog.tsx +18 -1
  64. package/starter-kit/pages/SavedTrips.catalog.tsx +27 -6
  65. package/starter-kit/pages/TripPlannerForm.catalog.tsx +7 -1
@@ -140,7 +140,7 @@ function readStaticPage(file) {
140
140
  else
141
141
  page.checkable = false;
142
142
  }
143
- const variants = readSlotItems(findProp(pageObject, 'variants'), consts);
143
+ const variants = readSlotItems(findProp(pageObject, 'variants'), consts, page.component, sourceFile);
144
144
  if (!variants.checkable)
145
145
  page.checkable = false;
146
146
  else {
@@ -148,7 +148,7 @@ function readStaticPage(file) {
148
148
  page.variantsItemsFill = variants.itemsFill;
149
149
  page.variantsMaxColumns = variants.maxColumns;
150
150
  }
151
- const states = readSlotItems(findProp(pageObject, 'states'), consts);
151
+ const states = readSlotItems(findProp(pageObject, 'states'), consts, page.component, sourceFile);
152
152
  if (!states.checkable)
153
153
  page.checkable = false;
154
154
  else {
@@ -156,8 +156,42 @@ function readStaticPage(file) {
156
156
  page.statesItemsFill = states.itemsFill;
157
157
  page.statesMaxColumns = states.maxColumns;
158
158
  }
159
+ const composedOfProp = findProp(pageObject, 'composedOf');
160
+ page.hasComposedOf = composedOfProp !== undefined;
161
+ if (composedOfProp)
162
+ page.composedOf = readComposedOf(composedOfProp.initializer, consts);
163
+ const intentionalStaticPreviewProp = findProp(pageObject, 'intentionalStaticPreview');
164
+ if (intentionalStaticPreviewProp) {
165
+ const resolved = resolve(intentionalStaticPreviewProp.initializer, consts, new Set());
166
+ if (ts.isObjectLiteralExpression(resolved)) {
167
+ const reasonProp = findProp(resolved, 'reason');
168
+ if (reasonProp)
169
+ page.intentionalStaticPreviewReason = stringLiteral(resolve(reasonProp.initializer, consts, new Set()));
170
+ }
171
+ }
159
172
  return page;
160
173
  }
174
+ /** A literal array of `ComposedOfEntry`-shaped object literals, read down to just `component`
175
+ * (design §4: role/relationship are authored prose `doctor` never needs for its missing-
176
+ * composition comparison). Undefined — not an empty array — for anything that isn't itself a
177
+ * literal array, or whose element isn't an object literal with a literal `component`: `doctor`
178
+ * must never mistake "couldn't read this" for "author declared no composition here". */
179
+ function readComposedOf(node, consts) {
180
+ const resolved = resolve(node, consts, new Set());
181
+ if (!ts.isArrayLiteralExpression(resolved))
182
+ return undefined;
183
+ const entries = [];
184
+ for (const element of resolved.elements) {
185
+ const resolvedElement = resolve(element, consts, new Set());
186
+ if (!ts.isObjectLiteralExpression(resolvedElement))
187
+ return undefined;
188
+ const component = propertyStringLiteral(resolvedElement, 'component', consts);
189
+ if (component === undefined)
190
+ return undefined;
191
+ entries.push({ component });
192
+ }
193
+ return entries;
194
+ }
161
195
  /** Local name -> imported name, for every named import from `moduleSpecifier`. */
162
196
  function importedNames(sourceFile, moduleSpecifier) {
163
197
  const names = new Map();
@@ -219,6 +253,20 @@ function resolve(node, consts, seen) {
219
253
  function stringLiteral(node) {
220
254
  return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) ? node.text : undefined;
221
255
  }
256
+ /** `true`/`false` only from a known `true`/`false` literal; any other expression (a variable, a
257
+ * computed value) stays `undefined` rather than being misread as `false`. Unlike the page's own
258
+ * `hide.*`/`tokenGallery`/`itemsFill`/`wide` booleans — which intentionally treat "not literally
259
+ * `true`" as `false`, since those already default to `false` when omitted — `fill`'s own default
260
+ * is inherited (`SpecimenPresentation.fill` defaults to the slot's `itemsFill`, not to `false`),
261
+ * so a non-literal value here must stay honestly unknown instead of claiming "not filling". */
262
+ function literalBooleanOrUnknown(node, consts) {
263
+ const resolved = resolve(node, consts, new Set());
264
+ if (resolved.kind === ts.SyntaxKind.TrueKeyword)
265
+ return true;
266
+ if (resolved.kind === ts.SyntaxKind.FalseKeyword)
267
+ return false;
268
+ return undefined;
269
+ }
222
270
  function propertyName(name) {
223
271
  return ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : name.getText();
224
272
  }
@@ -271,6 +319,34 @@ function readAxis(node, consts) {
271
319
  }
272
320
  return undefined;
273
321
  }
322
+ /** A literal array of `ComparisonCell`-shaped object literals: `rowKey`/`columnKey` required
323
+ * (string literals only), `surface`/`align`/`fill` read the same per-field-lenient way
324
+ * `readListItems` reads them. `node`/`unavailableReason` are never inspected — design §4 never
325
+ * evaluates a cell's specimen node. Undefined (no cell metadata at all, not "every field unset")
326
+ * for anything that isn't itself a literal array, or whose element isn't itself an object literal
327
+ * with literal `rowKey`/`columnKey` — a `.flatMap()`-built `cells` array (the starter kit's own
328
+ * convention for a `grid()`-free literal comparison) stays unrepresented rather than guessed at. */
329
+ function readComparisonCells(node, consts) {
330
+ const resolved = resolve(node, consts, new Set());
331
+ if (!ts.isArrayLiteralExpression(resolved))
332
+ return undefined;
333
+ const cells = [];
334
+ for (const element of resolved.elements) {
335
+ const resolvedElement = resolve(element, consts, new Set());
336
+ if (!ts.isObjectLiteralExpression(resolvedElement))
337
+ return undefined;
338
+ const rowKey = propertyStringLiteral(resolvedElement, 'rowKey', consts);
339
+ const columnKey = propertyStringLiteral(resolvedElement, 'columnKey', consts);
340
+ if (rowKey === undefined || columnKey === undefined)
341
+ return undefined;
342
+ const surface = propertyStringLiteral(resolvedElement, 'surface', consts);
343
+ const align = propertyStringLiteral(resolvedElement, 'align', consts);
344
+ const fillProp = findProp(resolvedElement, 'fill');
345
+ const fill = fillProp ? literalBooleanOrUnknown(fillProp.initializer, consts) : undefined;
346
+ cells.push({ rowKey, columnKey, surface, align, fill });
347
+ }
348
+ return cells;
349
+ }
274
350
  /** A page's `comparison` field: either a direct `ComparisonDef`-shaped object literal, or (the
275
351
  * starter kit's own convention since Task 1/2) a call to this package's own `grid()` helper — the
276
352
  * one call shape this reader specially recognizes, reading its `rows`/`columns` arguments and
@@ -305,12 +381,14 @@ function readComparison(node, consts, gridNames) {
305
381
  const rowLabelProp = findProp(resolved, 'rowLabel');
306
382
  const columnLabelProp = findProp(resolved, 'columnLabel');
307
383
  const sizeProp = findProp(resolved, 'size');
384
+ const cellsProp = findProp(resolved, 'cells');
308
385
  return {
309
386
  rowLabel: rowLabelProp ? stringLiteral(resolve(rowLabelProp.initializer, consts, new Set())) : undefined,
310
387
  columnLabel: columnLabelProp ? stringLiteral(resolve(columnLabelProp.initializer, consts, new Set())) : undefined,
311
388
  rows,
312
389
  columns,
313
390
  size: sizeProp ? stringLiteral(resolve(sizeProp.initializer, consts, new Set())) : undefined,
391
+ cells: cellsProp ? readComparisonCells(cellsProp.initializer, consts) : undefined,
314
392
  };
315
393
  }
316
394
  return undefined;
@@ -318,7 +396,7 @@ function readComparison(node, consts, gridNames) {
318
396
  /** A literal array of list-item object literals — `key`/`name` required; `props` (read as a flat
319
397
  * literal map, non-literal values simply omitted) and `group` optional. Each item's `node` field
320
398
  * (always present, always JSX) is never inspected — reading the sibling fields never depends on it. */
321
- function readListItems(node, consts) {
399
+ function readListItems(node, consts, componentName, sourceFile) {
322
400
  const resolved = resolve(node, consts, new Set());
323
401
  if (!ts.isArrayLiteralExpression(resolved))
324
402
  return undefined;
@@ -333,10 +411,154 @@ function readListItems(node, consts) {
333
411
  return undefined;
334
412
  const group = propertyStringLiteral(resolvedElement, 'group', consts);
335
413
  const propsProp = findProp(resolvedElement, 'props');
336
- items.push({ key, name, group, props: propsProp ? readLiteralProps(propsProp.initializer, consts) : undefined });
414
+ const fillProp = findProp(resolvedElement, 'fill');
415
+ const fill = fillProp ? literalBooleanOrUnknown(fillProp.initializer, consts) : undefined;
416
+ const surface = propertyStringLiteral(resolvedElement, 'surface', consts);
417
+ const align = propertyStringLiteral(resolvedElement, 'align', consts);
418
+ const nodeProp = findProp(resolvedElement, 'node');
419
+ const nodeExample = nodeProp ? readNodeExample(nodeProp.initializer, consts, componentName, sourceFile) : {};
420
+ items.push({
421
+ key, name, group, props: propsProp ? readLiteralProps(propsProp.initializer, consts) : undefined, fill, surface, align,
422
+ ...nodeExample,
423
+ });
337
424
  }
338
425
  return items;
339
426
  }
427
+ /** Generic, additive reading of one item's own `node:` JSX (design §3, plan Task 3) — used only by
428
+ * `cli/doctor.ts`'s interactive-preview advisory. Never evaluates `node`; only reads its literal
429
+ * AST shape. `node` matching `componentName` by plain tag-name text (not module resolution — this
430
+ * reader never resolves imports) means "a direct instance of the documented component": its own
431
+ * JSX attributes are read generically (`nodeAttributes`). Any other JSX tag is checked only for
432
+ * whether it's a confidently-identified, genuinely stateful same-file wrapper
433
+ * (`nodeStatefulWrapper`) — never asserted one way or the other when that can't be confirmed.
434
+ * Anything that isn't a direct JSX element at all (`null`, a conditional, a function call, …)
435
+ * leaves every field unset. */
436
+ function readNodeExample(node, consts, componentName, sourceFile) {
437
+ const resolved = resolve(node, consts, new Set());
438
+ let element;
439
+ if (ts.isJsxSelfClosingElement(resolved))
440
+ element = resolved;
441
+ else if (ts.isJsxElement(resolved))
442
+ element = resolved.openingElement;
443
+ if (!element || !ts.isIdentifier(element.tagName))
444
+ return {};
445
+ const tagText = element.tagName.text;
446
+ if (componentName !== undefined && tagText === componentName) {
447
+ const nodeAttributes = {};
448
+ for (const attribute of element.attributes.properties) {
449
+ // Skips a spread attribute ({...rest}) and a namespaced name (xml:lang) — unreadable,
450
+ // never a reason to reject the whole item.
451
+ if (!ts.isJsxAttribute(attribute) || !ts.isIdentifier(attribute.name))
452
+ continue;
453
+ nodeAttributes[attribute.name.text] = readJsxAttributeValue(attribute, consts);
454
+ }
455
+ return { nodeDirectComponent: tagText, nodeAttributes };
456
+ }
457
+ return { nodeStatefulWrapper: sameFileWrapperHasUseState(sourceFile, tagText) ? true : undefined };
458
+ }
459
+ /** One JSX attribute's value, as a `StaticAttributeValue`. A shorthand attribute (`disabled`, no
460
+ * `initializer` at all — equivalent to `disabled={true}`) is a literal `true`. */
461
+ function readJsxAttributeValue(attribute, consts) {
462
+ if (!attribute.initializer)
463
+ return { kind: 'literal', value: true };
464
+ if (ts.isStringLiteral(attribute.initializer))
465
+ return { kind: 'literal', value: attribute.initializer.text };
466
+ if (!ts.isJsxExpression(attribute.initializer) || !attribute.initializer.expression)
467
+ return { kind: 'unknown' };
468
+ const resolved = resolve(attribute.initializer.expression, consts, new Set());
469
+ if (ts.isStringLiteral(resolved) || ts.isNoSubstitutionTemplateLiteral(resolved))
470
+ return { kind: 'literal', value: resolved.text };
471
+ if (ts.isNumericLiteral(resolved))
472
+ return { kind: 'literal', value: Number(resolved.text) };
473
+ if (resolved.kind === ts.SyntaxKind.TrueKeyword)
474
+ return { kind: 'literal', value: true };
475
+ if (resolved.kind === ts.SyntaxKind.FalseKeyword)
476
+ return { kind: 'literal', value: false };
477
+ if (ts.isIdentifier(resolved) && resolved.text === 'undefined')
478
+ return { kind: 'no-op-callback' };
479
+ if ((ts.isArrowFunction(resolved) || ts.isFunctionExpression(resolved)) && isEmptyFunctionBody(resolved.body)) {
480
+ return { kind: 'no-op-callback' };
481
+ }
482
+ return { kind: 'unknown' };
483
+ }
484
+ function isEmptyFunctionBody(body) {
485
+ return ts.isBlock(body) && body.statements.length === 0;
486
+ }
487
+ /** True only when `sourceFile` declares a top-level function (declaration, or a `const`/`let`
488
+ * initialized with a function/arrow expression) named `tagName` whose own body contains a call to
489
+ * a *source-backed* React `useState`/`React.useState` — a confidently-identified, genuinely
490
+ * stateful wrapper. Walking only that one function's own body (never the whole file) keeps this
491
+ * scoped the same way `cli/props.ts`'s composedOfCandidate reader scopes its own JSX walk to one
492
+ * implementation. */
493
+ function sameFileWrapperHasUseState(sourceFile, tagName) {
494
+ const bindings = reactUseStateBindings(sourceFile);
495
+ for (const statement of sourceFile.statements) {
496
+ let body;
497
+ if (ts.isFunctionDeclaration(statement) && statement.name?.text === tagName)
498
+ body = statement.body;
499
+ else if (ts.isVariableStatement(statement)) {
500
+ for (const declaration of statement.declarationList.declarations) {
501
+ if (ts.isIdentifier(declaration.name) && declaration.name.text === tagName && declaration.initializer &&
502
+ (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) {
503
+ body = declaration.initializer.body;
504
+ }
505
+ }
506
+ }
507
+ if (body && containsUseStateCall(body, bindings))
508
+ return true;
509
+ }
510
+ return false;
511
+ }
512
+ /** This file's own top-level `import ... from 'react'` bindings relevant to recognizing a genuine
513
+ * `useState` call — never a type-checker resolution, only the same-file import syntax, matching
514
+ * this reader's existing "never resolves imports across files" contract. `namedUseState` holds
515
+ * every local name bound to the real named `useState` export (an alias, e.g. `import { useState as
516
+ * useReactState }`, included); `reactNamespace` holds every local name bound to the whole `react`
517
+ * module (a default or `* as` import) that a `React.useState(...)` call could be written through.
518
+ * A locally-defined `useState` (no matching import) or an unrelated object's own `.useState` method
519
+ * binds to neither set and so is never counted — left uncheckable, never asserted stateful. */
520
+ function reactUseStateBindings(sourceFile) {
521
+ const namedUseState = new Set();
522
+ const reactNamespace = new Set();
523
+ for (const statement of sourceFile.statements) {
524
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== 'react')
525
+ continue;
526
+ const clause = statement.importClause;
527
+ if (!clause)
528
+ continue;
529
+ if (clause.name)
530
+ reactNamespace.add(clause.name.text);
531
+ if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings))
532
+ reactNamespace.add(clause.namedBindings.name.text);
533
+ else if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
534
+ for (const element of clause.namedBindings.elements) {
535
+ if ((element.propertyName ?? element.name).text === 'useState')
536
+ namedUseState.add(element.name.text);
537
+ }
538
+ }
539
+ }
540
+ return { namedUseState, reactNamespace };
541
+ }
542
+ function containsUseStateCall(node, bindings) {
543
+ let found = false;
544
+ const visit = (n) => {
545
+ if (found)
546
+ return;
547
+ if (ts.isCallExpression(n)) {
548
+ const callee = n.expression;
549
+ const isNamedUseState = ts.isIdentifier(callee) && bindings.namedUseState.has(callee.text);
550
+ const isReactNamespaceUseState = ts.isPropertyAccessExpression(callee) && callee.name.text === 'useState' &&
551
+ ts.isIdentifier(callee.expression) && bindings.reactNamespace.has(callee.expression.text);
552
+ if (isNamedUseState || isReactNamespaceUseState) {
553
+ found = true;
554
+ return;
555
+ }
556
+ }
557
+ ts.forEachChild(n, visit);
558
+ };
559
+ visit(node);
560
+ return found;
561
+ }
340
562
  function readLiteralProps(node, consts) {
341
563
  const resolved = resolve(node, consts, new Set());
342
564
  if (!ts.isObjectLiteralExpression(resolved))
@@ -365,14 +587,14 @@ function readLiteralProps(node, consts) {
365
587
  * literal — like `previewWidths`/`fullWidthLabel`, it isn't in the original literal-checkable
366
588
  * list, so a non-literal `itemsFill` is simply left unset. `maxColumns` does affect the geometry
367
589
  * `explain` reports, so it must be a literal integer from 1–5 or the page is not checkable. */
368
- function readSlotItems(prop, consts) {
590
+ function readSlotItems(prop, consts, componentName, sourceFile) {
369
591
  if (!prop)
370
592
  return { checkable: true };
371
593
  const resolved = resolve(prop.initializer, consts, new Set());
372
594
  const itemsProp = ts.isObjectLiteralExpression(resolved) ? findProp(resolved, 'items') : undefined;
373
595
  if (!itemsProp)
374
596
  return { checkable: false };
375
- const items = readListItems(itemsProp.initializer, consts);
597
+ const items = readListItems(itemsProp.initializer, consts, componentName, sourceFile);
376
598
  if (!items)
377
599
  return { checkable: false };
378
600
  const itemsFillProp = ts.isObjectLiteralExpression(resolved) ? findProp(resolved, 'itemsFill') : undefined;
@@ -15,6 +15,12 @@ export const PLACEMENT_MIN_SAVING = 120;
15
15
  export const PROPS_MIN_COLUMN_WIDTH = 360;
16
16
  /** Gap between the two Props columns (CATALOG_SPACE['2xl']). */
17
17
  export const PROPS_COLUMN_GAP = 32;
18
+ /** A reference column (Guidance, Quick reference, or Composition) is never narrower than this;
19
+ * below it, `referenceColumns` drops to fewer columns. */
20
+ export const REFERENCE_MIN_COLUMN_WIDTH = 280;
21
+ /** Gap between reference columns (CATALOG_SPACE['2xl'], the same token the card's columns already
22
+ * use). */
23
+ export const REFERENCE_COLUMN_GAP = 32;
18
24
  /** Smallest column or cell width per specimen size. Wide specimens are fixed at phone width. */
19
25
  export const COLUMN_MIN_WIDTH = { compact: 160, regular: 240, wide: 402 };
20
26
  /** `Array.isArray`'s built-in type guard narrows to a mutable `any[]`, which a `readonly T[]`
@@ -183,8 +189,19 @@ export function remainingStates(states, comparison) {
183
189
  export function slotSize(def, slot) {
184
190
  return def.specimenSize ?? (slot.itemsFill ? 'wide' : 'regular');
185
191
  }
186
- function slotItems(slot) {
187
- return slot.items.map((item) => ({ key: item.key, label: item.name, node: item.node, fill: item.fill || slot.itemsFill }));
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) {
202
+ // Nullish, not `||`: an item's own explicit `fill: false` must override an inherited
203
+ // `itemsFill: true`, which `false || slot.itemsFill` would silently discard.
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 }));
188
205
  }
189
206
  /** Preview widths, each capped at phone width. 'full' is kept for catalog chrome and token pages. */
190
207
  function previewWidths(requested, fallback) {
@@ -198,12 +215,14 @@ function groupStates(variants, states) {
198
215
  const variantKeys = new Set(variants.items.map((v) => v.key));
199
216
  if (!states.items.some((s) => s.group !== undefined && variantKeys.has(s.group)))
200
217
  return undefined;
201
- const fill = (item, slot) => item.fill || slot.itemsFill;
218
+ // Nullish, not `||` — same reasoning as `slotItems`: an explicit `fill: false` must win over an
219
+ // inherited `itemsFill: true`.
220
+ const fill = (item, slot) => item.fill ?? slot.itemsFill;
202
221
  const groups = variants.items.map((variant) => {
203
222
  const own = states.items.filter((s) => s.group === variant.key);
204
223
  const items = own.length > 0
205
- ? own.map((s) => ({ key: s.key, label: s.name, node: s.node, fill: fill(s, states) }))
206
- : [{ key: variant.key, label: variant.name, node: variant.node, fill: fill(variant, variants) }];
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 }];
207
226
  return { key: variant.key, label: variant.name, items };
208
227
  });
209
228
  const rest = states.items.filter((s) => s.group === undefined || !variantKeys.has(s.group));
@@ -250,8 +269,8 @@ export function presentationBlocks(def, options = {}) {
250
269
  kind: 'list',
251
270
  title: 'Variants',
252
271
  size: slotSize(def, def.variants),
253
- items: slotItems(def.variants),
254
- ...(def.variants.maxColumns !== undefined ? { maxColumns: def.variants.maxColumns } : {}),
272
+ items: slotItems(def.variants, 'variants'),
273
+ ...slotColumnCap(def.variants),
255
274
  });
256
275
  }
257
276
  else if (def.render) {
@@ -264,8 +283,8 @@ export function presentationBlocks(def, options = {}) {
264
283
  kind: 'list',
265
284
  title: secondary ? 'Other configurations' : 'States / configurations',
266
285
  size: slotSize(def, states),
267
- items: slotItems(states),
268
- ...(states.maxColumns !== undefined ? { maxColumns: states.maxColumns } : {}),
286
+ items: slotItems(states, 'states'),
287
+ ...slotColumnCap(states),
269
288
  });
270
289
  }
271
290
  if (blocks.length === 0 && !hide.variants && !hide.states) {
@@ -297,3 +316,18 @@ export function choosePlacement({ available, gap, first, second }) {
297
316
  export function propsColumns(count, width) {
298
317
  return count >= 4 && width >= 2 * PROPS_MIN_COLUMN_WIDTH + PROPS_COLUMN_GAP ? 2 : 1;
299
318
  }
319
+ /** How many columns the reference card's Guidance / Quick reference / Composition panel uses at a
320
+ * given measured inner card width: 3 only when there is confirmed composition to show as its own
321
+ * column AND the card is wide enough for three REFERENCE_MIN_COLUMN_WIDTH columns; 2 when wide
322
+ * enough for two (any composition then stays nested under Quick reference, as it always has been);
323
+ * 1 (stacked, full content in document order — nothing dropped) when even two columns wouldn't
324
+ * each keep their minimum. An unmeasured width (`<= 0`) stacks, so the first frame before layout
325
+ * settles never overflows. */
326
+ export function referenceColumns(width, hasComposition) {
327
+ const fits = (n) => width >= n * REFERENCE_MIN_COLUMN_WIDTH + (n - 1) * REFERENCE_COLUMN_GAP;
328
+ if (hasComposition && fits(3))
329
+ return 3;
330
+ if (fits(2))
331
+ return 2;
332
+ return 1;
333
+ }
@@ -0,0 +1,112 @@
1
+ import React, { useState } from 'react';
2
+ import { Platform, StyleSheet, Text, View } from 'react-native';
3
+ import { CATALOG_COLOR, CATALOG_TYPE } from './tokens';
4
+ import { isFiniteOverlaySize, overlayStageDimensions, resolveOverlayViewport, type OverlaySlot } from './overlayViewport';
5
+
6
+ function isWeb(): boolean {
7
+ return Platform.OS === 'web' && typeof window !== 'undefined';
8
+ }
9
+
10
+ export interface BoundedOverlayViewportProps {
11
+ /** This specimen's own page id — `SectionDef.id`. */
12
+ pageId: string;
13
+ /** Which block the demo wrapped here is authored in. */
14
+ slot: OverlaySlot;
15
+ /** That item's own key: `VariantExample.key` for `'variants'`/`'states'`, or
16
+ * `overlayViewport.ts`'s own `comparisonCellItemKey(rowKey, columnKey)` for a `'comparison'`
17
+ * cell — never inferred. */
18
+ itemKey: string;
19
+ /** `height` is the child document's exact usable viewport height, in CSS pixels — never
20
+ * renegotiated. `width` is only a MAXIMUM: this component adds no border or padding of its own
21
+ * (unlike `PhoneFrame`'s cosmetic hairline border), so this is the outer stage's widest possible
22
+ * size, but the stage still fills and shrinks to whatever its real owning specimen cell actually
23
+ * gives it at narrower widths — a fixed pixel width previously let the stage overflow a
24
+ * narrower owner and get silently clipped by it (see `overlayStageDimensions`). Both must be
25
+ * finite and strictly positive (`isFiniteOverlaySize`); an invalid value renders an honest
26
+ * unavailable state instead of an iframe sized to a meaningless bound. */
27
+ width: number;
28
+ height: number;
29
+ /** The real, unmodified demo — the same node that would otherwise render directly on the page. */
30
+ children: React.ReactNode;
31
+ }
32
+
33
+ /**
34
+ * An opt-in, bounded browsing context for a specimen whose demo opens a modal/sheet/portal that
35
+ * would otherwise escape its own card and cover the whole catalog page (RNW's Modal portals into
36
+ * `document.body`, so no styled wrapper or `overflow: hidden` can give it a different boundary —
37
+ * see the design's §5 "Bounded overlay viewport"). Opens a same-origin child document that boots
38
+ * the SAME app entry/module/provider tree this catalog page itself runs under, sized to
39
+ * `width`×`height`, so the demo's own overlay opens inside that rectangle instead of the outer
40
+ * document's body — the real production component, unmodified: no renderer is copied, no prop is
41
+ * mutated, no callback/source string is serialized or evaluated.
42
+ *
43
+ * Opt-in and additive: a page that never imports this renders exactly as it did before. Purely a
44
+ * documentation device, like `PhoneFrame` — a real app screen never frames its own overlays this
45
+ * way, since the actual device viewport already is the boundary.
46
+ *
47
+ * Browser-only containment: there is no native equivalent (React Native has no child-document
48
+ * boundary to open), so on a native runtime this renders `children` directly, with no boundary.
49
+ * Call sites must not read that fallback as native parity for the contained behavior.
50
+ */
51
+ export function BoundedOverlayViewport({ pageId, slot, itemKey, width, height, children }: BoundedOverlayViewportProps) {
52
+ const [status, setStatus] = useState<'loading' | 'ready' | 'unavailable'>('loading');
53
+ const web = isWeb();
54
+ const decision = resolveOverlayViewport({
55
+ address: { pageId, slot, itemKey },
56
+ isWeb: web,
57
+ currentSearch: web ? window.location.search : '',
58
+ pathname: web ? window.location.pathname : '',
59
+ });
60
+
61
+ // Not web, or this document IS the selected child for this exact address: render the real demo
62
+ // directly — on native there is no containment to offer, and in the selected child document
63
+ // framing again would recurse into another, identical child document forever.
64
+ if (decision.mode === 'inline') return <>{children}</>;
65
+
66
+ // An invalid bound (not finite, zero, negative — a measurement that hasn't settled yet, a
67
+ // miscomputed layout value, an author typo) must never reach the iframe below: an honest
68
+ // unavailable state, not a frame opened at some browser-decided fallback size.
69
+ if (!isFiniteOverlaySize(width, height)) {
70
+ return (
71
+ <View style={styles.stage}>
72
+ <Text style={styles.status}>This example is unavailable right now.</Text>
73
+ </View>
74
+ );
75
+ }
76
+
77
+ return (
78
+ <View style={[styles.stage, overlayStageDimensions(width, height)]}>
79
+ {status !== 'ready' && (
80
+ <Text style={styles.status}>{status === 'unavailable' ? 'This example is unavailable right now.' : 'Loading…'}</Text>
81
+ )}
82
+ {
83
+ // react-native-web has no `<Iframe>` primitive; this is the one place this component
84
+ // reaches past it for a genuine same-origin child document. `as unknown as string` only
85
+ // bypasses React Native's own JSX.IntrinsicElements typing (which never declares host-web
86
+ // tags) — the element itself, and every prop it's given, is still a plain, real iframe.
87
+ React.createElement('iframe' as unknown as string, {
88
+ src: decision.href,
89
+ title: `${pageId}: ${itemKey}`,
90
+ style: { ...StyleSheet.flatten(styles.frame), display: status === 'unavailable' ? 'none' : undefined },
91
+ onLoad: () => setStatus('ready'),
92
+ onError: () => setStatus('unavailable'),
93
+ })
94
+ }
95
+ </View>
96
+ );
97
+ }
98
+
99
+ const styles = StyleSheet.create({
100
+ stage: {
101
+ backgroundColor: CATALOG_COLOR.surfacePressed,
102
+ alignSelf: 'center',
103
+ alignItems: 'center',
104
+ justifyContent: 'center',
105
+ },
106
+ frame: {
107
+ width: '100%',
108
+ height: '100%',
109
+ borderWidth: 0,
110
+ },
111
+ status: { position: 'absolute', fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
112
+ });
@@ -6,6 +6,8 @@ import { CatalogSidebar } from './CatalogSidebar';
6
6
  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
+ import { findOverlayNode, overlayAddressFromSearch } from './overlayViewport';
10
+ import { SpecimenAddressProvider } from './SpecimenAddress';
9
11
  import type { NavGroup, PreviewWidths, SectionDef, UpdateNotice, VersionStatus } from './types';
10
12
 
11
13
  /** `active` can be a real page id, or the reserved update-page id — never both a generic `TId`
@@ -142,6 +144,15 @@ export function CatalogShell<TId extends string>({
142
144
  * **Update now** button when this is missing. */
143
145
  updateEndpoint?: { baseUrl: string; secret: string };
144
146
  }) {
147
+ // A `BoundedOverlayViewport`'s own child document (design §5): render ONLY the one authored
148
+ // node this document's URL addresses, with no sidebar/breadcrumb/title/pager — never the whole
149
+ // page, since that page's other examples (and this same example's own viewport wrapper, now
150
+ // recognizing itself as the selected address) would otherwise render alongside it for nothing.
151
+ // Read once per mount, like `active`'s own initial read below: this mode is only ever entered by
152
+ // a fresh document load, never a same-document transition.
153
+ const overlayAddress = useMemo(() => (isWeb() ? overlayAddressFromSearch(window.location.search) : undefined), []);
154
+ const overlayNode = useMemo(() => (overlayAddress ? findOverlayNode(sections, overlayAddress) : undefined), [overlayAddress, sections]);
155
+
145
156
  const sectionsById = useMemo(() => new Map(sections.map((def) => [def.id, def])), [sections]);
146
157
  const labels = useMemo(() => new Map(sections.filter((def) => def.title).map((def) => [def.id as string, def.title as string])), [sections]);
147
158
  const order = useMemo(() => orderedIds(groups, new Set(sectionsById.keys())), [groups, sectionsById]);
@@ -242,6 +253,17 @@ export function CatalogShell<TId extends string>({
242
253
  // the approved mockup's own `frame()`: the banner only ever renders when `!isPanel`).
243
254
  const showBanner = active !== UPDATE_PAGE_ID && shouldShowMajorBanner(effectiveUpdate, dismissedMajor);
244
255
 
256
+ if (overlayAddress) {
257
+ return (
258
+ <View style={styles.overlayDocumentRoot}>
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>
263
+ </View>
264
+ );
265
+ }
266
+
245
267
  return (
246
268
  <View style={styles.root}>
247
269
  <CatalogSidebar logo={appName} logoImageSource={logoImageSource} caption={title} groups={groups} labels={labels} active={active} onPress={select} footer={footer} />
@@ -284,6 +306,10 @@ export function CatalogShell<TId extends string>({
284
306
 
285
307
  const styles = StyleSheet.create({
286
308
  root: { flex: 1, flexDirection: 'row', backgroundColor: CATALOG_COLOR.pageBackground },
309
+ // A `BoundedOverlayViewport` child document's own root: no sidebar/chrome, just the one
310
+ // addressed node — sized by whatever that node itself renders, not the catalog's own layout.
311
+ overlayDocumentRoot: { flex: 1 },
312
+ overlayUnavailable: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.textMuted, padding: CATALOG_SPACE.lg },
287
313
  main: { flex: 1 },
288
314
  mainContent: {
289
315
  width: '100%',
@@ -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
 
@@ -65,8 +67,10 @@ export function ComparisonGrid({
65
67
  const cell = cells.get(cellKey(row.key, c.key));
66
68
  return (
67
69
  cell?.node !== undefined ? (
68
- <SpecimenSurface key={c.key} role="cell" surface={surface} style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
69
- <SpecimenContent fill={cell.fill}>{cell.node}</SpecimenContent>
70
+ <SpecimenSurface key={c.key} role="cell" surface={cell.surface ?? surface} style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
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));
@@ -47,8 +51,10 @@ export function ComparisonGroups({
47
51
  <View style={styles.caption}>
48
52
  <Text style={styles.captionText}>{item.label}</Text>
49
53
  </View>
50
- <SpecimenSurface surface={surface} style={styles.specimen}>
51
- <SpecimenContent fill={item.fill}>{item.node as React.ReactNode}</SpecimenContent>
54
+ <SpecimenSurface surface={item.surface ?? surface} style={styles.specimen}>
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
  );