@krapwoo/ds-viewer 0.4.6 → 0.4.7

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 (34) hide show
  1. package/README.md +100 -15
  2. package/dist/cli/doctor.js +137 -0
  3. package/dist/cli/explain.js +44 -6
  4. package/dist/cli/props.js +80 -1
  5. package/dist/cli/staticPage.js +228 -6
  6. package/dist/native/catalog/comparison.js +29 -4
  7. package/native/catalog/BoundedOverlayViewport.tsx +112 -0
  8. package/native/catalog/CatalogShell.tsx +22 -0
  9. package/native/catalog/ComparisonGrid.tsx +2 -2
  10. package/native/catalog/ComparisonGroups.tsx +2 -2
  11. package/native/catalog/ComparisonList.tsx +2 -2
  12. package/native/catalog/PhoneFrame.tsx +6 -0
  13. package/native/catalog/ReferenceDetails.tsx +75 -20
  14. package/native/catalog/SectionBlock.tsx +66 -4
  15. package/native/catalog/SpecimenSurface.tsx +18 -10
  16. package/native/catalog/comparison.ts +39 -5
  17. package/native/catalog/index.ts +4 -0
  18. package/native/catalog/overlayViewport.ts +156 -0
  19. package/native/catalog/pageApi.ts +6 -0
  20. package/native/catalog/specimenSurfaceStyle.ts +70 -1
  21. package/native/catalog/types.ts +42 -12
  22. package/package.json +1 -1
  23. package/starter-kit/components/Banner/Banner.catalog.tsx +3 -0
  24. package/starter-kit/components/Button/Button.catalog.tsx +3 -0
  25. package/starter-kit/components/Dropdown/Dropdown.catalog.tsx +6 -0
  26. package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +5 -0
  27. package/starter-kit/components/InputField/InputField.catalog.tsx +3 -0
  28. package/starter-kit/components/List/List.catalog.tsx +3 -0
  29. package/starter-kit/components/Pill/Pill.catalog.tsx +3 -0
  30. package/starter-kit/components/PillRow/PillRow.catalog.tsx +3 -0
  31. package/starter-kit/components/SearchField/SearchField.catalog.tsx +4 -0
  32. package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +3 -0
  33. package/starter-kit/components/TextArea/TextArea.catalog.tsx +3 -0
  34. package/starter-kit/components/Toast/Toast.catalog.tsx +7 -0
@@ -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[]`
@@ -184,7 +190,9 @@ export function slotSize(def, slot) {
184
190
  return def.specimenSize ?? (slot.itemsFill ? 'wide' : 'regular');
185
191
  }
186
192
  function slotItems(slot) {
187
- return slot.items.map((item) => ({ key: item.key, label: item.name, node: item.node, fill: item.fill || slot.itemsFill }));
193
+ // Nullish, not `||`: an item's own explicit `fill: false` must override an inherited
194
+ // `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 }));
188
196
  }
189
197
  /** Preview widths, each capped at phone width. 'full' is kept for catalog chrome and token pages. */
190
198
  function previewWidths(requested, fallback) {
@@ -198,12 +206,14 @@ function groupStates(variants, states) {
198
206
  const variantKeys = new Set(variants.items.map((v) => v.key));
199
207
  if (!states.items.some((s) => s.group !== undefined && variantKeys.has(s.group)))
200
208
  return undefined;
201
- const fill = (item, slot) => item.fill || slot.itemsFill;
209
+ // Nullish, not `||` — same reasoning as `slotItems`: an explicit `fill: false` must win over an
210
+ // inherited `itemsFill: true`.
211
+ const fill = (item, slot) => item.fill ?? slot.itemsFill;
202
212
  const groups = variants.items.map((variant) => {
203
213
  const own = states.items.filter((s) => s.group === variant.key);
204
214
  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) }];
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 }];
207
217
  return { key: variant.key, label: variant.name, items };
208
218
  });
209
219
  const rest = states.items.filter((s) => s.group === undefined || !variantKeys.has(s.group));
@@ -297,3 +307,18 @@ export function choosePlacement({ available, gap, first, second }) {
297
307
  export function propsColumns(count, width) {
298
308
  return count >= 4 && width >= 2 * PROPS_MIN_COLUMN_WIDTH + PROPS_COLUMN_GAP ? 2 : 1;
299
309
  }
310
+ /** How many columns the reference card's Guidance / Quick reference / Composition panel uses at a
311
+ * given measured inner card width: 3 only when there is confirmed composition to show as its own
312
+ * column AND the card is wide enough for three REFERENCE_MIN_COLUMN_WIDTH columns; 2 when wide
313
+ * enough for two (any composition then stays nested under Quick reference, as it always has been);
314
+ * 1 (stacked, full content in document order — nothing dropped) when even two columns wouldn't
315
+ * each keep their minimum. An unmeasured width (`<= 0`) stacks, so the first frame before layout
316
+ * settles never overflows. */
317
+ export function referenceColumns(width, hasComposition) {
318
+ const fits = (n) => width >= n * REFERENCE_MIN_COLUMN_WIDTH + (n - 1) * REFERENCE_COLUMN_GAP;
319
+ if (hasComposition && fits(3))
320
+ return 3;
321
+ if (fits(2))
322
+ return 2;
323
+ return 1;
324
+ }
@@ -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,7 @@ 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';
9
10
  import type { NavGroup, PreviewWidths, SectionDef, UpdateNotice, VersionStatus } from './types';
10
11
 
11
12
  /** `active` can be a real page id, or the reserved update-page id — never both a generic `TId`
@@ -142,6 +143,15 @@ export function CatalogShell<TId extends string>({
142
143
  * **Update now** button when this is missing. */
143
144
  updateEndpoint?: { baseUrl: string; secret: string };
144
145
  }) {
146
+ // A `BoundedOverlayViewport`'s own child document (design §5): render ONLY the one authored
147
+ // node this document's URL addresses, with no sidebar/breadcrumb/title/pager — never the whole
148
+ // page, since that page's other examples (and this same example's own viewport wrapper, now
149
+ // recognizing itself as the selected address) would otherwise render alongside it for nothing.
150
+ // Read once per mount, like `active`'s own initial read below: this mode is only ever entered by
151
+ // a fresh document load, never a same-document transition.
152
+ const overlayAddress = useMemo(() => (isWeb() ? overlayAddressFromSearch(window.location.search) : undefined), []);
153
+ const overlayNode = useMemo(() => (overlayAddress ? findOverlayNode(sections, overlayAddress) : undefined), [overlayAddress, sections]);
154
+
145
155
  const sectionsById = useMemo(() => new Map(sections.map((def) => [def.id, def])), [sections]);
146
156
  const labels = useMemo(() => new Map(sections.filter((def) => def.title).map((def) => [def.id as string, def.title as string])), [sections]);
147
157
  const order = useMemo(() => orderedIds(groups, new Set(sectionsById.keys())), [groups, sectionsById]);
@@ -242,6 +252,14 @@ export function CatalogShell<TId extends string>({
242
252
  // the approved mockup's own `frame()`: the banner only ever renders when `!isPanel`).
243
253
  const showBanner = active !== UPDATE_PAGE_ID && shouldShowMajorBanner(effectiveUpdate, dismissedMajor);
244
254
 
255
+ if (overlayAddress) {
256
+ return (
257
+ <View style={styles.overlayDocumentRoot}>
258
+ {overlayNode ?? <Text style={styles.overlayUnavailable}>This example is no longer available.</Text>}
259
+ </View>
260
+ );
261
+ }
262
+
245
263
  return (
246
264
  <View style={styles.root}>
247
265
  <CatalogSidebar logo={appName} logoImageSource={logoImageSource} caption={title} groups={groups} labels={labels} active={active} onPress={select} footer={footer} />
@@ -284,6 +302,10 @@ export function CatalogShell<TId extends string>({
284
302
 
285
303
  const styles = StyleSheet.create({
286
304
  root: { flex: 1, flexDirection: 'row', backgroundColor: CATALOG_COLOR.pageBackground },
305
+ // A `BoundedOverlayViewport` child document's own root: no sidebar/chrome, just the one
306
+ // addressed node — sized by whatever that node itself renders, not the catalog's own layout.
307
+ overlayDocumentRoot: { flex: 1 },
308
+ overlayUnavailable: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.textMuted, padding: CATALOG_SPACE.lg },
287
309
  main: { flex: 1 },
288
310
  mainContent: {
289
311
  width: '100%',
@@ -65,8 +65,8 @@ export function ComparisonGrid({
65
65
  const cell = cells.get(cellKey(row.key, c.key));
66
66
  return (
67
67
  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>
68
+ <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>
70
70
  </SpecimenSurface>
71
71
  ) : (
72
72
  <View key={c.key} role="cell" style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
@@ -47,8 +47,8 @@ export function ComparisonGroups({
47
47
  <View style={styles.caption}>
48
48
  <Text style={styles.captionText}>{item.label}</Text>
49
49
  </View>
50
- <SpecimenSurface surface={surface} style={styles.specimen}>
51
- <SpecimenContent fill={item.fill}>{item.node as React.ReactNode}</SpecimenContent>
50
+ <SpecimenSurface surface={item.surface ?? surface} style={styles.specimen}>
51
+ <SpecimenContent fill={item.fill} align={item.align}>{item.node as React.ReactNode}</SpecimenContent>
52
52
  </SpecimenSurface>
53
53
  </View>
54
54
  );
@@ -61,8 +61,8 @@ export function ComparisonList({
61
61
  <View style={styles.caption}>
62
62
  <Text style={styles.captionText}>{item.label}</Text>
63
63
  </View>
64
- <SpecimenSurface surface={surface} style={styles.specimen}>
65
- <SpecimenContent fill={item.fill}>{item.node as React.ReactNode}</SpecimenContent>
64
+ <SpecimenSurface surface={item.surface ?? surface} style={styles.specimen}>
65
+ <SpecimenContent fill={item.fill} align={item.align}>{item.node as React.ReactNode}</SpecimenContent>
66
66
  </SpecimenSurface>
67
67
  </View>
68
68
  );
@@ -16,6 +16,11 @@ const HEIGHT = 480;
16
16
  * `width: '100%'` capped at 280 (not a fixed width) — a fixed px width could exceed the card's real
17
17
  * content width once the surrounding column shrinks below its own max-width cap (it does at common
18
18
  * viewport sizes), overflowing past the card's edge since the card itself doesn't clip.
19
+ *
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`.
19
24
  */
20
25
  export function PhoneFrame({ children }: { children: React.ReactNode }) {
21
26
  return <View style={styles.frame}>{children}</View>;
@@ -33,5 +38,6 @@ const styles = StyleSheet.create({
33
38
  overflow: 'hidden',
34
39
  alignItems: 'center',
35
40
  justifyContent: 'center',
41
+ alignSelf: 'center',
36
42
  },
37
43
  });