@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
@@ -8,7 +8,9 @@
8
8
  * It also decides whether two blocks sit side by side (choosePlacement) and how many columns the
9
9
  * Props box uses (propsColumns). ComparisonGrid, ComparisonGroups, and ComparisonList render it.
10
10
  */
11
- import type { ComparisonAxisItem, ComparisonDef, GridAxis, PreviewWidths, SectionDef, SpecimenSize, TokenSection, VariantSlot } from './types.ts';
11
+ import type {
12
+ ComparisonAxisItem, ComparisonDef, GridAxis, PreviewWidths, SectionDef, SpecimenAlign, SpecimenSize, SpecimenSurfaceKind, TokenSection, VariantSlot,
13
+ } from './types.ts';
12
14
  import type React from 'react';
13
15
 
14
16
  /** Approved geometry. `cellPadding` equals CATALOG_SPACE.lg (asserted in tests). The laptop
@@ -33,6 +35,14 @@ export const PROPS_MIN_COLUMN_WIDTH = 360;
33
35
  /** Gap between the two Props columns (CATALOG_SPACE['2xl']). */
34
36
  export const PROPS_COLUMN_GAP = 32;
35
37
 
38
+ /** A reference column (Guidance, Quick reference, or Composition) is never narrower than this;
39
+ * below it, `referenceColumns` drops to fewer columns. */
40
+ export const REFERENCE_MIN_COLUMN_WIDTH = 280;
41
+
42
+ /** Gap between reference columns (CATALOG_SPACE['2xl'], the same token the card's columns already
43
+ * use). */
44
+ export const REFERENCE_COLUMN_GAP = 32;
45
+
36
46
  /** Smallest column or cell width per specimen size. Wide specimens are fixed at phone width. */
37
47
  export const COLUMN_MIN_WIDTH: Record<SpecimenSize, number> = { compact: 160, regular: 240, wide: 402 };
38
48
 
@@ -219,9 +229,18 @@ export function remainingStates(states: VariantSlot | undefined, comparison: Com
219
229
 
220
230
  export interface ListItem {
221
231
  key: string;
232
+ /** Which authored slot this item came from, so a device frame inside it can address it
233
+ * (`overlayViewport.ts`). */
234
+ slot?: 'variants' | 'states';
222
235
  label: string;
223
236
  node: unknown;
224
237
  fill?: boolean;
238
+ /** This item's own `specimenSurface` override — see `SpecimenPresentation`. Omit to inherit the
239
+ * surface already in effect (the slot's/page's `specimenSurface`, else `'auto'`). */
240
+ surface?: SpecimenSurfaceKind;
241
+ /** This item's own cross-axis alignment override — see `SpecimenPresentation.align`. Omit to
242
+ * inherit the established default (centered, or stretched when `fill` is set). */
243
+ align?: SpecimenAlign;
225
244
  }
226
245
 
227
246
  export interface ListGroup {
@@ -249,8 +268,20 @@ export function slotSize<TId extends string>(def: SectionDef<TId>, slot: Variant
249
268
  return def.specimenSize ?? (slot.itemsFill ? 'wide' : 'regular');
250
269
  }
251
270
 
252
- function slotItems(slot: VariantSlot): ListItem[] {
253
- return slot.items.map((item) => ({ key: item.key, label: item.name, node: item.node, fill: item.fill || slot.itemsFill }));
271
+ /** Full-width (filled) examples are block components (cards, banners, rows): narrower than about a
272
+ * third of a laptop's content width they stop reading as themselves, so they wrap at most 3
273
+ * across unless the page sets its own `maxColumns`. Small centred examples wrap freely. */
274
+ export const FILLED_SLOT_MAX_COLUMNS = 3;
275
+
276
+ function slotColumnCap(slot: VariantSlot): { maxColumns?: 1 | 2 | 3 | 4 | 5 } {
277
+ if (slot.maxColumns !== undefined) return { maxColumns: slot.maxColumns };
278
+ return slot.itemsFill ? { maxColumns: FILLED_SLOT_MAX_COLUMNS } : {};
279
+ }
280
+
281
+ function slotItems(slot: VariantSlot, from: 'variants' | 'states'): ListItem[] {
282
+ // Nullish, not `||`: an item's own explicit `fill: false` must override an inherited
283
+ // `itemsFill: true`, which `false || slot.itemsFill` would silently discard.
284
+ return slot.items.map((item) => ({ key: item.key, slot: from, label: item.name, node: item.node, fill: item.fill ?? slot.itemsFill, surface: item.surface, align: item.align }));
254
285
  }
255
286
 
256
287
  /** Preview widths, each capped at phone width. 'full' is kept for catalog chrome and token pages. */
@@ -265,12 +296,14 @@ function previewWidths(requested: PreviewWidths | undefined, fallback: PreviewWi
265
296
  function groupStates(variants: VariantSlot, states: VariantSlot): { groups: ListGroup[]; leftovers: VariantSlot | undefined } | undefined {
266
297
  const variantKeys = new Set(variants.items.map((v) => v.key));
267
298
  if (!states.items.some((s) => s.group !== undefined && variantKeys.has(s.group))) return undefined;
268
- const fill = (item: VariantSlot['items'][number], slot: VariantSlot) => item.fill || slot.itemsFill;
299
+ // Nullish, not `||` — same reasoning as `slotItems`: an explicit `fill: false` must win over an
300
+ // inherited `itemsFill: true`.
301
+ const fill = (item: VariantSlot['items'][number], slot: VariantSlot) => item.fill ?? slot.itemsFill;
269
302
  const groups = variants.items.map((variant) => {
270
303
  const own = states.items.filter((s) => s.group === variant.key);
271
304
  const items = own.length > 0
272
- ? own.map((s) => ({ key: s.key, label: s.name, node: s.node, fill: fill(s, states) }))
273
- : [{ key: variant.key, label: variant.name, node: variant.node, fill: fill(variant, variants) }];
305
+ ? own.map((s) => ({ key: s.key, slot: 'states' as const, label: s.name, node: s.node, fill: fill(s, states), surface: s.surface, align: s.align }))
306
+ : [{ key: variant.key, slot: 'variants' as const, label: variant.name, node: variant.node, fill: fill(variant, variants), surface: variant.surface, align: variant.align }];
274
307
  return { key: variant.key, label: variant.name, items };
275
308
  });
276
309
  const rest = states.items.filter((s) => s.group === undefined || !variantKeys.has(s.group));
@@ -317,8 +350,8 @@ export function presentationBlocks<TId extends string>(def: SectionDef<TId>, opt
317
350
  kind: 'list',
318
351
  title: 'Variants',
319
352
  size: slotSize(def, def.variants),
320
- items: slotItems(def.variants),
321
- ...(def.variants.maxColumns !== undefined ? { maxColumns: def.variants.maxColumns } : {}),
353
+ items: slotItems(def.variants, 'variants'),
354
+ ...slotColumnCap(def.variants),
322
355
  });
323
356
  } else if (def.render) {
324
357
  blocks.push({ kind: 'preview', title: 'Preview', widths: previewWidths(def.previewWidths, options.defaultPreviewWidths) });
@@ -331,8 +364,8 @@ export function presentationBlocks<TId extends string>(def: SectionDef<TId>, opt
331
364
  kind: 'list',
332
365
  title: secondary ? 'Other configurations' : 'States / configurations',
333
366
  size: slotSize(def, states),
334
- items: slotItems(states),
335
- ...(states.maxColumns !== undefined ? { maxColumns: states.maxColumns } : {}),
367
+ items: slotItems(states, 'states'),
368
+ ...slotColumnCap(states),
336
369
  });
337
370
  }
338
371
 
@@ -377,3 +410,17 @@ export function choosePlacement({ available, gap, first, second }: { available:
377
410
  export function propsColumns(count: number, width: number): 1 | 2 {
378
411
  return count >= 4 && width >= 2 * PROPS_MIN_COLUMN_WIDTH + PROPS_COLUMN_GAP ? 2 : 1;
379
412
  }
413
+
414
+ /** How many columns the reference card's Guidance / Quick reference / Composition panel uses at a
415
+ * given measured inner card width: 3 only when there is confirmed composition to show as its own
416
+ * column AND the card is wide enough for three REFERENCE_MIN_COLUMN_WIDTH columns; 2 when wide
417
+ * enough for two (any composition then stays nested under Quick reference, as it always has been);
418
+ * 1 (stacked, full content in document order — nothing dropped) when even two columns wouldn't
419
+ * each keep their minimum. An unmeasured width (`<= 0`) stacks, so the first frame before layout
420
+ * settles never overflows. */
421
+ export function referenceColumns(width: number, hasComposition: boolean): 1 | 2 | 3 {
422
+ const fits = (n: number) => width >= n * REFERENCE_MIN_COLUMN_WIDTH + (n - 1) * REFERENCE_COLUMN_GAP;
423
+ if (hasComposition && fits(3)) return 3;
424
+ if (fits(2)) return 2;
425
+ return 1;
426
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The device every `PhoneFrame` shows: a real phone's viewport, in points, at the small end of
3
+ * current phones (iPhone SE, 3rd generation). Examples lay out at exactly these dimensions; a cell
4
+ * narrower than the device scales the whole viewport down instead of squeezing its layout. Kept
5
+ * free of runtime imports so `node --test` can check it.
6
+ */
7
+ export const DEVICE_FRAME = { name: 'iPhone SE', width: 375, height: 667 } as const;
8
+
9
+ /** How much to scale the device so it fits `availableWidth` (never enlarged). An unmeasured cell
10
+ * (0) renders at full size rather than collapsing. */
11
+ export function deviceFrameScale(availableWidth: number): number {
12
+ if (!(availableWidth > 0)) return 1;
13
+ return Math.min(1, availableWidth / DEVICE_FRAME.width);
14
+ }
@@ -31,6 +31,10 @@ export { TokenSections, TokenGrid, TokenTile } from './TokenLayouts';
31
31
  export { DividedStack } from './DividedStack';
32
32
  export { Swatch } from './Swatch';
33
33
  export { PhoneFrame } from './PhoneFrame';
34
+ export { BoundedOverlayViewport } from './BoundedOverlayViewport';
35
+ export type { BoundedOverlayViewportProps } from './BoundedOverlayViewport';
36
+ export { comparisonCellItemKey } from './overlayViewport';
37
+ export type { OverlayAddress, OverlaySlot } from './overlayViewport';
34
38
  export { SpacingScaleGallery } from './SpacingScaleGallery';
35
39
  export { TypeScaleGallery } from './TypeScaleGallery';
36
40
  export { buildComponentManifest } from './manifest';
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Pure logic behind `BoundedOverlayViewport.tsx` — no React/React Native imports, so it runs
3
+ * under Node's test runner the same way `catalogNavigation.ts` does for `CatalogShell`. Addresses
4
+ * one specific authored specimen, decides whether an instance should frame itself in a bounded
5
+ * child document or render inline, and (for `CatalogShell`'s own chromeless selected-document
6
+ * render) resolves an address back to the `node` it names.
7
+ */
8
+ import type React from 'react';
9
+ import type { SectionDef } from './types.ts';
10
+
11
+ /** Which block on a page an addressed specimen's `node` lives in — see `findOverlayNode`. */
12
+ export type OverlaySlot = 'variants' | 'states' | 'comparison' | 'preview';
13
+
14
+ /** Identifies exactly one authored specimen: a page id (`SectionDef.id`), which block it lives
15
+ * in, and that item's own key — `VariantExample.key` for `'variants'`/`'states'`, or this
16
+ * module's own `comparisonCellItemKey(rowKey, columnKey)` for a `'comparison'` cell (NOT an ad
17
+ * hoc `"<rowKey>:<columnKey>"` join — a plain colon join is ambiguous whenever a row or column
18
+ * key contains a colon itself). Always an existing author-assigned identifier, never inferred
19
+ * from pixels, source strings, or a DOM selector. */
20
+ export interface OverlayAddress {
21
+ pageId: string;
22
+ slot: OverlaySlot;
23
+ itemKey: string;
24
+ }
25
+
26
+ const OVERLAY_SLOTS: readonly OverlaySlot[] = ['variants', 'states', 'comparison', 'preview'];
27
+
28
+ /** `encodeURIComponent` leaves `.` untouched (it's an unreserved character), which would let a
29
+ * literal `.` inside a segment collide with the `.`-joined encoding below; escape it too so
30
+ * splitting on `.` is always safe. `decodeURIComponent` already reverses `%2E` with no special
31
+ * casing needed. */
32
+ function encodeSegment(segment: string): string {
33
+ return encodeURIComponent(segment).replace(/\./g, '%2E');
34
+ }
35
+
36
+ /** The query param a `BoundedOverlayViewport`'s child document is opened with — distinct from
37
+ * `CatalogShell`'s own page-routing hash (`#PageId`), which the child document's URL also keeps
38
+ * (see `overlayViewportHrefSuffix`) so the SAME app entry still resolves the right page/providers
39
+ * before the viewport component itself ever runs. */
40
+ export const OVERLAY_ADDRESS_PARAM = 'ds-viewer-overlay';
41
+
42
+ /** `pageId.slot.itemKey`, each segment percent-encoded — one URL-safe token for a query value. */
43
+ export function encodeOverlayAddress(address: OverlayAddress): string {
44
+ return [address.pageId, address.slot, address.itemKey].map(encodeSegment).join('.');
45
+ }
46
+
47
+ /** The inverse of `encodeOverlayAddress` — `undefined` for anything that isn't exactly three
48
+ * non-empty segments with a recognized slot: the same "malformed means absent" recovery
49
+ * `catalogNavigation.ts`'s `idFromHash` uses for an unknown page fragment, rather than guessing
50
+ * at a partial or corrupted address. */
51
+ export function decodeOverlayAddress(value: string): OverlayAddress | undefined {
52
+ const parts = value.split('.');
53
+ if (parts.length !== 3) return undefined;
54
+ let decoded: string[];
55
+ try {
56
+ decoded = parts.map((part) => decodeURIComponent(part));
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ const [pageId, slot, itemKey] = decoded;
61
+ if (!pageId || !itemKey) return undefined;
62
+ if (!OVERLAY_SLOTS.includes(slot as OverlaySlot)) return undefined;
63
+ return { pageId, slot: slot as OverlaySlot, itemKey };
64
+ }
65
+
66
+ /** Reads `OVERLAY_ADDRESS_PARAM` out of a URL search string (e.g. `window.location.search`);
67
+ * `undefined` when the param is absent or its value doesn't decode. A trailing `#fragment` is
68
+ * dropped first — `URLSearchParams` has no notion of a URL fragment, so without this a caller
69
+ * that (like `overlayViewportHrefSuffix`'s own combined query+hash string) passes one along
70
+ * would see it swallowed into the last param's value instead of ignored. */
71
+ export function overlayAddressFromSearch(search: string): OverlayAddress | undefined {
72
+ const raw = new URLSearchParams(search.split('#')[0]).get(OVERLAY_ADDRESS_PARAM);
73
+ return raw ? decodeOverlayAddress(raw) : undefined;
74
+ }
75
+
76
+ export function overlayAddressesEqual(a: OverlayAddress, b: OverlayAddress): boolean {
77
+ return a.pageId === b.pageId && a.slot === b.slot && a.itemKey === b.itemKey;
78
+ }
79
+
80
+ /** The query + hash suffix that boots the SAME app entry directly into this address's
81
+ * selected-document mode: the overlay param (so the `BoundedOverlayViewport` instance that
82
+ * matches this address renders its children inline instead of framing itself again) plus the
83
+ * page's own routing hash (so `CatalogShell`'s existing hash-based routing still resolves the
84
+ * right `SectionDef`/providers before `findOverlayNode` narrows further). */
85
+ export function overlayViewportHrefSuffix(address: OverlayAddress): string {
86
+ return `?${OVERLAY_ADDRESS_PARAM}=${encodeURIComponent(encodeOverlayAddress(address))}#${encodeURIComponent(address.pageId)}`;
87
+ }
88
+
89
+ /** `'inline'`: render the wrapped demo directly, no frame — either this isn't a web runtime (no
90
+ * same-origin child-document mechanism to offer; see `BoundedOverlayViewport`'s own doc comment)
91
+ * or `currentSearch` already names this exact address, meaning this instance IS the selected
92
+ * child document's own addressed specimen and must not frame itself again (recursion guard).
93
+ * `'frame'`: open `href` — the current pathname plus this address's query+hash — in a bounded
94
+ * child document. */
95
+ export type OverlayViewportDecision = { mode: 'inline' } | { mode: 'frame'; href: string };
96
+
97
+ export function resolveOverlayViewport(args: {
98
+ address: OverlayAddress;
99
+ isWeb: boolean;
100
+ /** The hosting document's current `location.search` (e.g. `window.location.search`). */
101
+ currentSearch: string;
102
+ /** The hosting document's current `location.pathname` — same pathname the child document loads,
103
+ * since this viewer is a single-page app whose page lives in the hash, not the path. */
104
+ pathname: string;
105
+ }): OverlayViewportDecision {
106
+ if (!args.isWeb) return { mode: 'inline' };
107
+ const current = overlayAddressFromSearch(args.currentSearch);
108
+ if (current && overlayAddressesEqual(current, args.address)) return { mode: 'inline' };
109
+ return { mode: 'frame', href: `${args.pathname}${overlayViewportHrefSuffix(args.address)}` };
110
+ }
111
+
112
+ /** The authored `node` an `OverlayAddress` points at, or `undefined` for a stale/unknown address
113
+ * (a removed example, a renamed key, a typo) — `CatalogShell`'s chromeless selected-document
114
+ * render shows an honest "unavailable" recovery rather than rendering nothing silently. */
115
+ export function findOverlayNode(sections: readonly SectionDef<string>[], address: OverlayAddress): React.ReactNode | undefined {
116
+ const def = sections.find((section) => section.id === address.pageId);
117
+ if (!def) return undefined;
118
+ if (address.slot === 'variants') return def.variants?.items.find((item) => item.key === address.itemKey)?.node;
119
+ if (address.slot === 'states') return def.states?.items.find((item) => item.key === address.itemKey)?.node;
120
+ // A `render()` page's preview (one per page; `itemKey` is always 'preview').
121
+ if (address.slot === 'preview') return def.render?.();
122
+ // Matched by recomputing each cell's own canonical key, never by splitting `address.itemKey` —
123
+ // a row/column key may itself contain any delimiter this module could pick.
124
+ return def.comparison?.cells.find((cell) => comparisonCellItemKey(cell.rowKey, cell.columnKey) === address.itemKey)?.node;
125
+ }
126
+
127
+ /** The canonical, unambiguous `itemKey` for one comparison cell's row+column — this module's own
128
+ * counterpart to `comparison.ts`'s `cellKey` (not imported from there: a VALUE import between two
129
+ * pure `.ts` logic modules here can't carry the `.ts` extension that both Node's direct test run
130
+ * and `kit-host`'s bundler-mode typechecker require/reject at once — `import type` is exempt
131
+ * since it is fully erased, which is why `./types.ts` above is fine). Exported so a `.tsx` call
132
+ * site building a `'comparison'` `OverlayAddress` constructs the exact same key this module looks
133
+ * it up by — a plain `"${rowKey}:${columnKey}"` join is ambiguous whenever a row or column key
134
+ * contains a colon itself. */
135
+ export function comparisonCellItemKey(rowKey: string, columnKey: string): string {
136
+ return `${rowKey}${columnKey}`;
137
+ }
138
+
139
+ /** Usable CSS pixel dimensions for a bounded overlay viewport: finite and strictly positive.
140
+ * `NaN`/`Infinity`/zero/negative (a measurement that hasn't settled yet, a miscomputed layout
141
+ * value, an author typo) must never reach `BoundedOverlayViewport`'s iframe — an iframe sized to
142
+ * a meaningless bound is worse than no iframe, since it still renders (and can still mount the
143
+ * wrapped demo's overlay) at some browser-decided fallback size instead of honestly reporting
144
+ * unavailable. */
145
+ export function isFiniteOverlaySize(width: number, height: number): boolean {
146
+ return Number.isFinite(width) && width > 0 && Number.isFinite(height) && height > 0;
147
+ }
148
+
149
+ /** The framed stage's own box dimensions. `height` is exact — the production overlay's full
150
+ * usable height is never renegotiated by its owner. `width` is only a MAXIMUM: `'100%'` lets the
151
+ * stage fill and shrink to whatever its real owning specimen cell actually gives it, capped at
152
+ * `width` when the owner has at least that much room. A fixed pixel width here previously let the
153
+ * stage overflow a narrower owner (e.g. a 240px comparison cell at a narrow browser width) while
154
+ * that owner's own `overflow: hidden` silently clipped the excess — a passing-looking but false
155
+ * containment, not the honest unavailable/shrink this design requires. */
156
+ export function overlayStageDimensions(width: number, height: number): { width: '100%'; maxWidth: number; height: number } {
157
+ return { width: '100%', maxWidth: width, height };
158
+ }
@@ -31,8 +31,12 @@ export interface CatalogPageInput<TId extends string = string> {
31
31
  specimenSize?: SpecimenSize;
32
32
  specimenSurface?: SpecimenSurfaceKind;
33
33
  previewWidths?: PreviewWidths;
34
+ /** See `SectionDef.previewLayout`. */
35
+ previewLayout?: 'frames' | 'table';
34
36
  hide?: SectionDef['hide'];
35
37
  composedOf?: ComposedOfEntry[];
38
+ /** See `SectionDef.intentionalStaticPreview`. */
39
+ intentionalStaticPreview?: { reason: string };
36
40
  /** Export name this page documents; defaults to `id` for component pages. */
37
41
  component?: string;
38
42
  /** Sidebar group label. */
@@ -168,8 +172,10 @@ export function buildCatalogSections<TId extends string>(
168
172
  specimenSize: page.specimenSize,
169
173
  specimenSurface: page.specimenSurface,
170
174
  previewWidths: page.previewWidths,
175
+ previewLayout: page.previewLayout,
171
176
  hide: component ? page.hide : { ...page.hide, props: true },
172
177
  composedOf: page.composedOf,
178
+ intentionalStaticPreview: page.intentionalStaticPreview,
173
179
  };
174
180
  });
175
181
 
@@ -1,4 +1,4 @@
1
- import type { SpecimenSurfaceKind } from './types.ts';
1
+ import type { SpecimenAlign, SpecimenSurfaceKind } from './types.ts';
2
2
 
3
3
  /**
4
4
  * The example wrapper's width policy inside a cell, kept free of any `react-native` import so
@@ -16,6 +16,129 @@ export const SPECIMEN_SURFACE_WIDTH_STYLE = {
16
16
  alignSelf: 'stretch',
17
17
  } as const;
18
18
 
19
+ /**
20
+ * Centers a non-`fill` specimen horizontally inside its (now full-width) wrapper — unchanged,
21
+ * bounded (non-`fill`) specimens stay centered by default. See `SpecimenSurface.tsx`'s
22
+ * `SpecimenContent`, the one place every presentation shape (list/grouped/grid) wraps a specimen
23
+ * before handing it to its cell.
24
+ */
25
+ export const SPECIMEN_CONTENT_CENTER_STYLE = {
26
+ ...SPECIMEN_SURFACE_WIDTH_STYLE,
27
+ alignItems: 'center',
28
+ } as const;
29
+
30
+ /**
31
+ * A `fill` specimen's content stretches instead of centering: `alignItems: 'center'` on this
32
+ * wrapper previously applied to `fill` specimens too, which collapses an auto-sized, already
33
+ * full-width child (e.g. NextTrainInfo's row) down to its own intrinsic content width instead of
34
+ * leaving it at the wrapper's full width — `alignItems: 'center'` resolves a child with no definite
35
+ * cross-axis size to its content size, the opposite of what `fill` promises. A `fill` specimen
36
+ * whose own size is capped below the wrapper's width (e.g. a phone-frame preview's `maxWidth`) is
37
+ * expected to center itself (`alignSelf: 'center'`, see `PhoneFrame.tsx`) rather than lean on this
38
+ * wrapper's `alignItems` — the same way `alignItems: 'stretch'` already behaves like `flex-start`,
39
+ * not an actual stretch, for any child with a definite cross-axis size (a set width/maxWidth) per
40
+ * the flexbox spec. An explicit `align` (see `specimenContentAlignItems`) still overrides this.
41
+ */
42
+ export const SPECIMEN_CONTENT_FILL_STYLE = {
43
+ ...SPECIMEN_SURFACE_WIDTH_STYLE,
44
+ alignItems: 'stretch',
45
+ } as const;
46
+
47
+ const ALIGN_ITEMS_BY_SPECIMEN_ALIGN: Record<SpecimenAlign, 'flex-start' | 'center' | 'flex-end'> = {
48
+ start: 'flex-start',
49
+ center: 'center',
50
+ end: 'flex-end',
51
+ };
52
+
53
+ /** The `SpecimenContent` wrapper's `alignItems`: an explicit `align` (`SpecimenPresentation.align`)
54
+ * always wins over `fill`. Omitted, `fill` decides the established default — `'stretch'` (preserve
55
+ * a full-width auto-sized child) when true, `'center'` (unchanged) otherwise. */
56
+ export function specimenContentAlignItems(fill: boolean | undefined, align: SpecimenAlign | undefined): 'flex-start' | 'center' | 'flex-end' | 'stretch' {
57
+ if (align !== undefined) return ALIGN_ITEMS_BY_SPECIMEN_ALIGN[align];
58
+ return fill ? 'stretch' : 'center';
59
+ }
60
+
61
+ /** A non-fill specimen's horizontal position, as `justifyContent` on `SpecimenContent`'s own row
62
+ * main axis rather than `alignItems` on a column: `alignItems` only sets a child's *default*
63
+ * `alignSelf`, which the specimen's own root can — and does, e.g. Badge's `alignSelf:
64
+ * 'flex-start'` — override outright, leaning the whole grid left regardless of the wrapper's
65
+ * intent. `justifyContent` positions along the main axis directly and cannot be overridden by a
66
+ * child's `alignSelf`, which only ever targets the cross axis (vertical, on a row). Reuses
67
+ * `ALIGN_ITEMS_BY_SPECIMEN_ALIGN`'s mapping — `flex-start`/`center`/`flex-end` are valid values
68
+ * for both `alignItems` and `justifyContent`. */
69
+ export function specimenContentJustifyContent(align: SpecimenAlign | undefined): 'flex-start' | 'center' | 'flex-end' {
70
+ return align !== undefined ? ALIGN_ITEMS_BY_SPECIMEN_ALIGN[align] : 'center';
71
+ }
72
+
73
+ /** The non-fill `SpecimenContent` wrapper's own style: still full-width (`SPECIMEN_SURFACE_WIDTH_STYLE`,
74
+ * so a percentage-width child keeps resolving against it), but a row main axis so
75
+ * `justifyContent` — not `alignItems` — decides horizontal placement (see
76
+ * `specimenContentJustifyContent`). Vertical cross-axis stays centered, as it already did. Only
77
+ * for non-`fill` specimens; `fill` keeps the existing column + `alignItems` stretch behavior
78
+ * (`specimenContentAlignItems`), unchanged. */
79
+ export function specimenContentBoundedStyle(align: SpecimenAlign | undefined) {
80
+ return {
81
+ ...SPECIMEN_SURFACE_WIDTH_STYLE,
82
+ flexDirection: 'row' as const,
83
+ justifyContent: specimenContentJustifyContent(align),
84
+ alignItems: 'center' as const,
85
+ };
86
+ }
87
+
19
88
  /** Each example's cell turns gray only when its example would vanish against white (see
20
89
  * `specimenFill.ts`). A page can force a fill with `specimenSurface`. */
21
90
  export const DEFAULT_SPECIMEN_SURFACE: SpecimenSurfaceKind = 'auto';
91
+
92
+ /** A `fill` example stretches to its cell, but one with its own fixed width (a 160-point skeleton
93
+ * bar) stays that width; it should then sit in the middle of the cell, not against its leading
94
+ * edge. True when the measured example is narrower than its wrapper (by more than rounding). */
95
+ export function shouldCenterFilledContent(contentWidth: number, wrapperWidth: number): boolean {
96
+ if (!(contentWidth > 0) || !(wrapperWidth > 0)) return false;
97
+ return contentWidth < wrapperWidth - 1;
98
+ }
99
+
100
+ /** The width of what's actually visible in an example: from the leftmost to the rightmost painted
101
+ * box (text, an icon, anything with a fill), ignoring transparent layout wrappers that may span
102
+ * the whole cell. */
103
+ export function paintedExtent(boxes: readonly { left: number; right: number; painted: boolean }[]): number {
104
+ let left = Infinity;
105
+ let right = -Infinity;
106
+ for (const box of boxes) {
107
+ if (!box.painted || box.right <= box.left) continue;
108
+ left = Math.min(left, box.left);
109
+ right = Math.max(right, box.right);
110
+ }
111
+ return Number.isFinite(left) ? right - left : 0;
112
+ }
113
+
114
+ /** True when an example is wider than its cell (beyond rounding): its edges are cut off or touch
115
+ * the neighbouring cell, so the page should use a wider `specimenSize`. */
116
+ export function overflowsCell(contentWidth: number, cellWidth: number): boolean {
117
+ return contentWidth > 0 && cellWidth > 0 && contentWidth > cellWidth + 1;
118
+ }
119
+
120
+ /** One element of an example as measured in the browser. `clips` is true for a container that
121
+ * hides or scrolls its overflow (a scrolling row, a device frame). */
122
+ export interface LayoutBox {
123
+ left: number;
124
+ right: number;
125
+ painted: boolean;
126
+ clips: boolean;
127
+ children: LayoutBox[];
128
+ }
129
+
130
+ /** The boxes a reader can actually see: a clipping container counts as its own box, and what it
131
+ * hides or scrolls inside it is skipped. */
132
+ export function visibleBoxes(root: LayoutBox): { left: number; right: number; painted: boolean }[] {
133
+ const out: { left: number; right: number; painted: boolean }[] = [];
134
+ const walk = (box: LayoutBox, isRoot: boolean) => {
135
+ if (!isRoot && box.clips) {
136
+ out.push({ left: box.left, right: box.right, painted: true });
137
+ return;
138
+ }
139
+ if (!isRoot) out.push({ left: box.left, right: box.right, painted: box.painted });
140
+ for (const child of box.children) walk(child, false);
141
+ };
142
+ walk(root, true);
143
+ return out;
144
+ }
@@ -12,21 +12,39 @@ export interface PropDef {
12
12
  desc: string;
13
13
  }
14
14
 
15
+ /** How a specimen's content is cross-axis aligned inside its (already full-width) wrapper —
16
+ * `start`/`center`/`end` map to `flex-start`/`center`/`flex-end`. Omit to inherit the established
17
+ * default: `center` when `fill` is false/omitted (unchanged), or the wrapper's own full-width
18
+ * stretch when `fill` is true — a capped-width `fill` specimen (e.g. a phone-frame preview) that
19
+ * still wants centering sets `align: 'center'` explicitly rather than relying on `fill` alone; see
20
+ * `specimenContentAlignItems` in `specimenSurfaceStyle.ts`. */
21
+ export type SpecimenAlign = 'start' | 'center' | 'end';
22
+
23
+ /** Per-specimen presentation, layered under whatever a slot or page already decides: this one
24
+ * specimen's own background (`surface`, overriding the slot's/page's `specimenSurface` — omit to
25
+ * inherit it), whether its wrapper stretches to the row's full width instead of shrinking to its
26
+ * own content width (`fill`, for a single wide-format instance, e.g. a `fullWidth` Button, sitting
27
+ * among otherwise compact, centered siblings — independent of a slot's own `itemsFill`, which
28
+ * applies to every item uniformly), and how its content cross-axis aligns inside that wrapper
29
+ * (`align` — see `SpecimenAlign`). Shared by `VariantExample` (list/grouped items) and
30
+ * `ComparisonCell` (grid cells) so the same knobs mean the same thing in every presentation shape
31
+ * ds-viewer renders a specimen in; an explicit `surface` here always bypasses `'auto'`'s own fill
32
+ * heuristic (`specimenFill.ts`), the same way a slot's/page's own explicit surface already does. */
33
+ export interface SpecimenPresentation {
34
+ /** @default false */
35
+ fill?: boolean;
36
+ surface?: SpecimenSurfaceKind;
37
+ align?: SpecimenAlign;
38
+ }
39
+
15
40
  /** One individual example inside a variant/state cluster — e.g. one Button instance. */
16
- export interface VariantExample {
41
+ export interface VariantExample extends SpecimenPresentation {
17
42
  /** React key; keep stable and unique within its group. */
18
43
  key: string;
19
44
  /** Shown as a small caption under this item — the actual variant/state value it demonstrates
20
45
  * (e.g. "Primary", "Icon-only"), not a generic label like "Example 1". */
21
46
  name: string;
22
47
  node: React.ReactNode;
23
- /** Stretch *this item's* wrapper to the row's full width, instead of shrinking to its own content
24
- * width — for a single wide-format instance (e.g. a `fullWidth` Button) sitting among otherwise
25
- * compact, centered siblings in the same slot. Without this, a `fullWidth`/stretch-based prop on
26
- * the instance itself has nothing to stretch into — its wrapper still shrinks to content, so the
27
- * instance renders at its natural size regardless of the prop. Independent of the slot's own
28
- * `itemsFill` (which applies to every item uniformly); this is a per-item override. @default false */
29
- fill?: boolean;
30
48
  /** Which real prop value(s) this instance demonstrates, e.g. `{ variant: 'primary' }` or
31
49
  * `{ size: 'large', disabled: true }` — mirrors the actual props passed to `node`. Optional and
32
50
  * additive: SectionBlock only cross-checks a section's enum props against this metadata once at
@@ -99,14 +117,13 @@ export interface ComparisonAxisItem {
99
117
  export type GridAxis = readonly ComparisonAxisItem[] | { prop: string; items: readonly ComparisonAxisItem[] };
100
118
 
101
119
  /** One authored row × column specimen. Provide exactly one of `node` (a real instance of the
102
- * documented component) or `unavailableReason` (the combination genuinely does not exist). */
103
- export interface ComparisonCell {
120
+ * documented component) or `unavailableReason` (the combination genuinely does not exist). See
121
+ * `SpecimenPresentation` for `fill`/`surface`. */
122
+ export interface ComparisonCell extends SpecimenPresentation {
104
123
  rowKey: string;
105
124
  columnKey: string;
106
125
  node?: React.ReactNode;
107
126
  unavailableReason?: string;
108
- /** Stretch this specimen to the cell width. @default false */
109
- fill?: boolean;
110
127
  }
111
128
 
112
129
  /** A grid of two props that combine freely. Every row × column pair is authored — never inferred
@@ -222,6 +239,13 @@ export interface SectionDef<TId extends string = string> {
222
239
  /** Widths for this page's `render()` preview, e.g. `[402, 320]` to add a small-phone example.
223
240
  * Defaults to the catalog's default (CatalogShell `defaultPreviewWidths`), else `[402]`. */
224
241
  previewWidths?: PreviewWidths;
242
+ /** Presentation for this page's numeric-width component Preview (guided intelligence,
243
+ * "Collapsible Preview table"): `'frames'` (default, unchanged) — one borderless-card frame per
244
+ * width, side by side. `'table'` — the widths share one `ComparisonList`-style card, each with
245
+ * its own muted caption strip above its live body; still an independent, stateful instance per
246
+ * width. Has no effect on a `'full'` preview width (catalog chrome) or a token gallery — those
247
+ * always render as `'frames'` regardless of this field. */
248
+ previewLayout?: 'frames' | 'table';
225
249
  /** Remove specific parts of the page — for sections with no meaningful states, props, or
226
250
  * accessibility story (e.g. a framework's own building-block pages). `variants` removes the
227
251
  * first specimen block (grid, Variants list, or Preview); `states` removes the States /
@@ -237,6 +261,12 @@ export interface SectionDef<TId extends string = string> {
237
261
  * Quick reference. See `ComposedOfEntry`. Omit when the component has no meaningful composition
238
262
  * to disclose (most leaf components). */
239
263
  composedOf?: ComposedOfEntry[];
264
+ /** Explicit author declaration (guided intelligence design §3) that this page's examples are
265
+ * intentionally static despite the documented component having a controlled state/event pair —
266
+ * suppresses `cli/doctor.ts`'s `missing-working-preview` advisory. `reason` is a short, bounded
267
+ * sentence explaining why (not free-form prose) — e.g. "Selection is already demonstrated by X's
268
+ * working preview." Omit when no such declaration applies; the advisory then runs normally. */
269
+ intentionalStaticPreview?: { reason: string };
240
270
  }
241
271
 
242
272
  /** A labeled group of section ids in the sidebar (e.g. "Components" vs "Tokens"). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krapwoo/ds-viewer",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A browsable, always-current catalog for an Expo / React Native design system.",
@@ -65,9 +65,10 @@
65
65
  "test": "node --experimental-strip-types --test 'cli/**/*.test.ts' 'config/**/*.test.ts' 'native/catalog/__tests__/*.test.ts'",
66
66
  "typecheck": "tsc -p tsconfig.json --noEmit",
67
67
  "kit:dev": "npm run build && cd kit-host && node node_modules/@krapwoo/ds-viewer/dist/cli/main.js dev",
68
- "check:catalog": "npm run build && node scripts/checkCatalogConsole.mjs kit-host",
69
- "check:doctor": "npm run build && cd kit-host && node node_modules/@krapwoo/ds-viewer/dist/cli/main.js doctor --ci",
70
- "check:types": "tsc --noEmit -p kit-host",
68
+ "viewer:dev": "npm run build && cd viewer-host && node node_modules/@krapwoo/ds-viewer/dist/cli/main.js dev",
69
+ "check:catalog": "npm run build && node scripts/checkCatalogConsole.mjs kit-host && node scripts/checkCatalogConsole.mjs viewer-host",
70
+ "check:doctor": "npm run build && cd kit-host && node node_modules/@krapwoo/ds-viewer/dist/cli/main.js doctor --ci && cd ../viewer-host && node node_modules/@krapwoo/ds-viewer/dist/cli/main.js doctor --ci",
71
+ "check:types": "tsc --noEmit -p kit-host && tsc --noEmit -p viewer-host",
71
72
  "release": "node scripts/release.mjs"
72
73
  }
73
74
  }
@@ -5,7 +5,7 @@ import { DS_SEMANTIC } from '../../tokens';
5
5
 
6
6
  export default defineCatalogPage({
7
7
  component: 'AnimatedChevron',
8
- group: 'Sub-Parts',
8
+ group: 'Components',
9
9
  description: 'A chevron that morphs between down (collapsed) and up (expanded) — flipping vertically in place instead of rotating through a sideways-pointing angle, and instead of swapping icons. The morph Banner\'s own collapsible header uses for its disclosure indicator. Not commonly used on its own; exported for building a custom disclosure/accordion toggle.',
10
10
  a11y: 'Purely decorative — no accessibility role of its own. Wrap it in an accessible parent (the way Banner\'s own header Pressable does) if the toggle needs to be announced.',
11
11
  variants: {
@@ -31,7 +31,7 @@ const AVATAR_COMPARISON = grid(
31
31
 
32
32
  export default defineCatalogPage({
33
33
  component: 'Avatar',
34
- group: 'Surfaces',
34
+ group: 'Components',
35
35
  specimenSize: 'compact',
36
36
  comparison: AVATAR_COMPARISON,
37
37
  description: 'A circular image, or an initials fallback on a solid fill when there\'s no image (or it fails to load).',
@@ -51,7 +51,7 @@ const BADGE_COMPARISON = grid(
51
51
 
52
52
  export default defineCatalogPage({
53
53
  component: 'Badge',
54
- group: 'Surfaces',
54
+ group: 'Components',
55
55
  specimenSize: 'compact',
56
56
  comparison: BADGE_COMPARISON,
57
57
  description: 'A small status chip — five semantic variants, with optional leading/trailing icons or icon-only.',
@@ -4,7 +4,11 @@ import { Banner } from './Banner';
4
4
 
5
5
  export default defineCatalogPage({
6
6
  component: 'Banner',
7
- group: 'Surfaces',
7
+ group: 'Components',
8
+ specimenSize: 'regular',
9
+ composedOf: [
10
+ { component: 'AnimatedChevron', role: "The expand/collapse chevron in the header of a collapsible banner.", relationship: 'built-in' },
11
+ ],
8
12
  description: 'An inline callout for status/announcements — five semantic variants, optional collapsible body, inline link, and action button.',
9
13
  whenToUse: "Persistent and in-flow, describing a standing condition about the screen's content. For a transient, self-contained event notification, use Toast instead.",
10
14
  a11y: 'When onPress/action is set the header/button are Pressables; the collapsible header toggles the description with a chevron affordance.',