@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
@@ -1,9 +1,9 @@
1
1
  import React, { useState } from 'react';
2
- import { View, Text, StyleSheet, type LayoutChangeEvent } from 'react-native';
2
+ import { View, Text, StyleSheet, type LayoutChangeEvent, type TextStyle } from 'react-native';
3
3
  import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
4
4
  import { PropsTable } from './PropsTable';
5
- import { propsColumns } from './comparison';
6
- import type { SectionDef } from './types';
5
+ import { propsColumns, referenceColumns } from './comparison';
6
+ import type { ComposedOfEntry, SectionDef } from './types';
7
7
 
8
8
  // react-native-web reads `aria-level`; React Native's prop types do not declare it.
9
9
  const HEADING_LEVEL_2 = { 'aria-level': 2 } as Record<string, unknown>;
@@ -40,13 +40,51 @@ function PropsBox<TId extends string>({ def }: { def: SectionDef<TId> }) {
40
40
  );
41
41
  }
42
42
 
43
+ // `built-in` is composition's assumed default relationship, so spelling it out reads redundantly
44
+ // (e.g. "TrainArrivalRemainingTime · built-in"); `slot`/`related` still need their own suffix to
45
+ // stay distinguishable from a plain built-in entry.
46
+ function compositionLabel(entry: ComposedOfEntry): string {
47
+ return entry.relationship === 'built-in' ? entry.component : `${entry.component} · ${entry.relationship}`;
48
+ }
49
+
50
+ function CompositionFacts({ composedOf }: { composedOf: ComposedOfEntry[] }) {
51
+ return (
52
+ <>
53
+ {composedOf.map((entry, i) => (
54
+ <Fact key={`${entry.component}-${entry.relationship}-${i}`} label={compositionLabel(entry)} value={entry.role} />
55
+ ))}
56
+ </>
57
+ );
58
+ }
59
+
43
60
  /**
44
- * Always-visible reference below a page's specimens: a card with Guidance and Quick reference
45
- * (source path, accessibility), then Props in their own box. Token galleries show only Quick
46
- * reference with the source path. No collapse control by design.
61
+ * Always-visible reference below a page's specimens: a card with Guidance, Quick reference
62
+ * (source path, accessibility), and — only when the page has confirmed composition to disclose —
63
+ * Composition, then Props in their own box. Token galleries show only Quick reference with the
64
+ * source path. No collapse control by design.
65
+ *
66
+ * Guidance/Quick reference/Composition lay out from the card's own measured INNER width
67
+ * (`referenceColumns`), not viewport width alone: three columns only when there is confirmed
68
+ * composition and the card is wide enough for all three (Composition gets its own column only
69
+ * then — otherwise it stays nested under Quick reference, as it always has); two when there's
70
+ * room for Guidance and Quick reference side by side; one (stacked, full document order, nothing
71
+ * dropped) when the card is too narrow even for two. `minWidth: 0` on every column lets its facts
72
+ * and source path actually shrink and wrap at that width instead of pushing the card wider than
73
+ * its own container.
74
+ *
75
+ * `onLayout` sits on the `columns` row itself, not the padded/bordered `card` around it:
76
+ * `referenceColumns` fits each column against `REFERENCE_MIN_COLUMN_WIDTH`, an INNER measurement —
77
+ * the card's own padding (`panelPadding`) and 1px border never belong in that budget. Measuring the
78
+ * outer card previously fed `referenceColumns` an outer width up to ~42px wider than what the three
79
+ * columns' content actually has, which can report a column count the inner content doesn't fit.
47
80
  */
48
81
  export function ReferenceDetails<TId extends string>({ def }: { def: SectionDef<TId> }) {
49
82
  const hide = def.hide ?? {};
83
+ const [width, setWidth] = useState(0);
84
+ const onLayout = (event: LayoutChangeEvent) => {
85
+ const next = Math.round(event.nativeEvent.layout.width);
86
+ if (next > 0 && next !== width) setWidth(next);
87
+ };
50
88
  if (def.tokenGallery) {
51
89
  return (
52
90
  <View style={styles.card}>
@@ -55,27 +93,37 @@ export function ReferenceDetails<TId extends string>({ def }: { def: SectionDef<
55
93
  </View>
56
94
  );
57
95
  }
96
+ const hasComposition = Boolean(def.composedOf && def.composedOf.length > 0);
97
+ const columns = referenceColumns(width, hasComposition);
98
+ const stacked = columns === 1;
99
+ const compositionIsOwnColumn = columns === 3 && hasComposition;
100
+ const columnStyle = stacked ? styles.columnStacked : styles.column;
101
+
58
102
  return (
59
103
  <View style={styles.stack}>
60
104
  <View style={styles.card}>
61
- <View style={styles.columns}>
62
- <View style={styles.column}>
105
+ <View style={[styles.columns, stacked && styles.columnsStacked]} onLayout={onLayout}>
106
+ <View style={columnStyle}>
63
107
  <Text role="heading" {...HEADING_LEVEL_2} style={styles.heading}>Guidance</Text>
64
108
  <Text style={styles.body}>{def.whenToUse ?? 'No usage guidance documented.'}</Text>
65
109
  </View>
66
- <View style={styles.column}>
110
+ <View style={columnStyle}>
67
111
  <Text role="heading" {...HEADING_LEVEL_2} style={styles.heading}>Quick reference</Text>
68
112
  {def.path && <Fact label="Source" value={def.path} mono />}
69
113
  {!hide.accessibility && <Fact label="Accessibility" value={def.a11y ?? 'No accessibility notes documented.'} />}
70
- {def.composedOf && def.composedOf.length > 0 && (
114
+ {!compositionIsOwnColumn && hasComposition && def.composedOf && (
71
115
  <>
72
116
  <Text style={styles.subheading}>Composition</Text>
73
- {def.composedOf.map((entry, i) => (
74
- <Fact key={`${entry.component}-${entry.relationship}-${i}`} label={`${entry.component} · ${entry.relationship}`} value={entry.role} />
75
- ))}
117
+ <CompositionFacts composedOf={def.composedOf} />
76
118
  </>
77
119
  )}
78
120
  </View>
121
+ {compositionIsOwnColumn && def.composedOf && (
122
+ <View style={columnStyle}>
123
+ <Text role="heading" {...HEADING_LEVEL_2} style={styles.heading}>Composition</Text>
124
+ <CompositionFacts composedOf={def.composedOf} />
125
+ </View>
126
+ )}
79
127
  </View>
80
128
  </View>
81
129
  {!hide.props && <PropsBox def={def} />}
@@ -93,7 +141,9 @@ const styles = StyleSheet.create({
93
141
  padding: CATALOG_LAYOUT.panelPadding,
94
142
  },
95
143
  columns: { flexDirection: 'row', gap: CATALOG_SPACE['2xl'] },
96
- column: { flex: 1 },
144
+ columnsStacked: { flexDirection: 'column' },
145
+ column: { flex: 1, minWidth: 0 },
146
+ columnStacked: { minWidth: 0 },
97
147
  heading: {
98
148
  fontSize: CATALOG_TYPE.panelHeading,
99
149
  fontWeight: '700',
@@ -111,15 +161,20 @@ const styles = StyleSheet.create({
111
161
  marginBottom: CATALOG_SPACE.xs,
112
162
  },
113
163
  fact: {
114
- flexDirection: 'row',
115
- justifyContent: 'space-between',
116
- gap: CATALOG_SPACE.lg,
164
+ flexDirection: 'column',
165
+ gap: CATALOG_SPACE.xs,
117
166
  paddingVertical: CATALOG_LAYOUT.factPaddingY,
118
167
  borderBottomWidth: 1,
119
168
  borderBottomColor: CATALOG_COLOR.borderSubtle,
120
169
  },
121
- factLabel: { fontSize: CATALOG_TYPE.md, fontWeight: '700', color: CATALOG_COLOR.text },
122
- factValue: { flex: 1, fontSize: CATALOG_TYPE.md, lineHeight: 20, textAlign: 'right', color: CATALOG_COLOR.textMuted },
123
- mono: { fontFamily: CATALOG_COLOR.code },
170
+ factLabel: { flexShrink: 1, minWidth: 0, fontSize: CATALOG_TYPE.md, fontWeight: '700', color: CATALOG_COLOR.text },
171
+ factValue: { minWidth: 0, fontSize: CATALOG_TYPE.md, lineHeight: 20, textAlign: 'left', color: CATALOG_COLOR.textMuted },
172
+ // `wordBreak` isn't in the installed `TextStyle` type (RN's own, not RNW's — RNW's own
173
+ // `Text/types.js` declares it; a browser applies any valid CSS property name it recognizes
174
+ // regardless), so a bare literal here would report an unknown property under `--noEmit` even
175
+ // though it genuinely works: a long unbroken token (a source path with no spaces, e.g.
176
+ // `AnimatedChevron.tsx`) has no space for the browser's default line-breaking to use, so without
177
+ // this it stays one unbroken run and overflows a narrow card instead of wrapping at its '/'s.
178
+ mono: { fontFamily: CATALOG_COLOR.code, wordBreak: 'break-word' } as TextStyle,
124
179
  empty: { fontSize: CATALOG_TYPE.sm, fontStyle: 'italic', color: CATALOG_COLOR.textMuted },
125
180
  });
@@ -8,7 +8,7 @@ import { ReferenceDetails } from './ReferenceDetails';
8
8
  import { SpecimenSurface } from './SpecimenSurface';
9
9
  import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
10
10
  import { TokenSections } from './TokenLayouts';
11
- import { choosePlacement, listGeometry, presentationBlocks, type PresentationBlock } from './comparison';
11
+ import { MATRIX_LAYOUT, choosePlacement, listGeometry, presentationBlocks, type PresentationBlock } from './comparison';
12
12
  import type { PreviewWidths, SectionDef, SpecimenSurfaceKind } from './types';
13
13
 
14
14
  // Matches a quoted-string-literal union type, e.g. "'primary' | 'secondary' | 'tertiary'" — anything
@@ -103,11 +103,14 @@ function frameLabel(width: number): string {
103
103
 
104
104
  /** A component preview: one frame per width (each at most phone width), or full width for catalog
105
105
  * chrome and token galleries. Every frame is its own live instance with its own state. `surface`
106
- * is omitted for token galleries, which keep their current, unstaged presentation. */
107
- function Preview({ render, widths, surface }: { render: () => React.ReactNode; widths: PreviewWidths; surface?: SpecimenSurfaceKind }) {
106
+ * is omitted for token galleries, which keep their current, unstaged presentation. `layout: 'table'`
107
+ * (only ever set for a numeric-width preview — see `presentationBlocks`) renders `TablePreview`
108
+ * instead; omitted/`'frames'` keeps this exact, unchanged presentation. */
109
+ function Preview({ render, widths, surface, layout }: { render: () => React.ReactNode; widths: PreviewWidths; surface?: SpecimenSurfaceKind; layout?: 'table' }) {
108
110
  // Token galleries pass no surface: their card stays white.
109
111
  const kind = surface ?? 'transparent';
110
112
  if (widths === 'full') return <SpecimenSurface surface={kind} style={styles.previewCard}>{render()}</SpecimenSurface>;
113
+ if (layout === 'table') return <TablePreview render={render} widths={widths} surface={kind} />;
111
114
  return (
112
115
  <View style={styles.previewCard}>
113
116
  <View style={styles.frames}>
@@ -128,6 +131,39 @@ function Preview({ render, widths, surface }: { render: () => React.ReactNode; w
128
131
  );
129
132
  }
130
133
 
134
+ /** `layout: 'table'` presentation for a numeric-width Preview: every width shares ONE
135
+ * `ComparisonList`-style card (same caption typography, muted caption strip, border/radius/surface
136
+ * tokens) instead of each width getting its own bordered frame-card. Each width keeps its own
137
+ * caption strip directly above its own live body, in a single flex-wrapped row — one stable parent
138
+ * with keyed cells, so a viewport resize that wraps the row to stacked columns reflows in place
139
+ * rather than remounting any demo. A cell's body carries the same `MATRIX_LAYOUT.cellPadding`
140
+ * `ComparisonList`'s own cells use; the requested width instead reaches the demo untouched through
141
+ * an inner width-qualified viewport nested inside that padded body (capped only by
142
+ * `maxWidth: '100%'`, the same cap `Preview`'s own frames already use), so a cell's `flexBasis`
143
+ * reserving `width + 2 * cellPadding` leaves the padded body exactly enough room for it, with no
144
+ * blank trailing slack. The row's cells share 1px gutters (a `gap` on a bordered backing) for
145
+ * dividers, in both the side-by-side and stacked-wrap arrangements. */
146
+ function TablePreview({ render, widths, surface }: { render: () => React.ReactNode; widths: readonly number[]; surface: SpecimenSurfaceKind }) {
147
+ return (
148
+ <View style={styles.tableCard}>
149
+ <View style={styles.tableRow}>
150
+ {widths.map((width, i) => (
151
+ <View key={`${width}-${i}`} style={[styles.tableCell, { flexBasis: width + 2 * MATRIX_LAYOUT.cellPadding }]}>
152
+ <View style={styles.tableCaption}>
153
+ <Text style={styles.tableCaptionText}>{frameLabel(width)}</Text>
154
+ </View>
155
+ <SpecimenSurface surface={surface} style={styles.tableBody}>
156
+ <View role="group" aria-label={frameLabel(width)} style={[styles.tableBodyViewport, { width }]}>
157
+ {render()}
158
+ </View>
159
+ </SpecimenSurface>
160
+ </View>
161
+ ))}
162
+ </View>
163
+ </View>
164
+ );
165
+ }
166
+
131
167
  function BlockContent<TId extends string>({ block, def, surface }: { block: PresentationBlock; def: SectionDef<TId>; surface: SpecimenSurfaceKind }) {
132
168
  switch (block.kind) {
133
169
  case 'grid':
@@ -139,7 +175,9 @@ function BlockContent<TId extends string>({ block, def, surface }: { block: Pres
139
175
  case 'preview':
140
176
  // A token gallery's `preview` block shows full-width raw token data, not a component
141
177
  // specimen — it keeps its current, unstaged presentation.
142
- return def.render ? <Preview render={def.render} widths={block.widths} surface={def.tokenGallery ? undefined : surface} /> : null;
178
+ // `Preview`'s own `widths === 'full'` check already excludes a full-width or token-gallery
179
+ // preview from ever reaching the table layout, regardless of `def.previewLayout`.
180
+ return def.render ? <Preview render={def.render} widths={block.widths} surface={def.tokenGallery ? undefined : surface} layout={def.previewLayout === 'table' ? 'table' : undefined} /> : null;
143
181
  case 'tokenSections':
144
182
  return <TokenSections sections={block.sections} columns={block.columns} />;
145
183
  default:
@@ -356,5 +394,29 @@ const styles = StyleSheet.create({
356
394
  padding: CATALOG_SPACE.xl,
357
395
  gap: CATALOG_SPACE.md,
358
396
  },
397
+ // `ComparisonList`'s own card/caption tokens, reused so a 'table' preview reads as the same shared
398
+ // surface — no outer `previewCard` padding/chrome around it (that card provides its own).
399
+ tableCard: {
400
+ backgroundColor: CATALOG_COLOR.surface,
401
+ borderWidth: 1,
402
+ borderColor: CATALOG_COLOR.borderStrong,
403
+ borderRadius: CATALOG_RADIUS.card,
404
+ overflow: 'hidden',
405
+ },
406
+ // 1px gutters between cells: a gap over a bordered backing, so dividers read correctly whether
407
+ // cells sit side by side or wrap into stacked rows.
408
+ tableRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 1, backgroundColor: CATALOG_COLOR.border },
409
+ tableCell: { backgroundColor: CATALOG_COLOR.surface, flexGrow: 1, minWidth: 0, maxWidth: '100%' },
410
+ tableCaption: {
411
+ padding: MATRIX_LAYOUT.cellPadding,
412
+ backgroundColor: CATALOG_COLOR.surfaceMuted,
413
+ borderBottomWidth: 1,
414
+ borderBottomColor: CATALOG_COLOR.border,
415
+ },
416
+ tableCaptionText: { fontSize: CATALOG_TYPE.tableHeader, fontWeight: '800', letterSpacing: 0.44, color: CATALOG_COLOR.text },
417
+ // Same cell padding `ComparisonList` uses: the requested width instead reaches the live demo
418
+ // untouched through `tableBodyViewport`, the inner width-qualified group nested inside this body.
419
+ tableBody: { padding: MATRIX_LAYOUT.cellPadding, flexGrow: 1, alignItems: 'center', justifyContent: 'flex-start' },
420
+ tableBodyViewport: { maxWidth: '100%' },
359
421
  emptyText: { fontSize: CATALOG_TYPE.sm, fontStyle: 'italic', color: CATALOG_COLOR.textMuted },
360
422
  });
@@ -1,9 +1,9 @@
1
1
  import React, { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { View, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
3
3
  import { CATALOG_COLOR } from './tokens';
4
- import { DEFAULT_SPECIMEN_SURFACE, SPECIMEN_SURFACE_WIDTH_STYLE } from './specimenSurfaceStyle';
4
+ import { DEFAULT_SPECIMEN_SURFACE, SPECIMEN_SURFACE_WIDTH_STYLE, specimenContentAlignItems, specimenContentBoundedStyle } from './specimenSurfaceStyle';
5
5
  import { blendsIntoCell, measurePaintedBoxes } from './specimenFill';
6
- import type { SpecimenSurfaceKind } from './types';
6
+ import type { SpecimenAlign, SpecimenSurfaceKind } from './types';
7
7
 
8
8
  type Browser = {
9
9
  getComputedStyle?: (element: unknown) => never;
@@ -65,9 +65,22 @@ export function SpecimenSurface({
65
65
  }
66
66
 
67
67
  /** Wraps one example inside its cell: full cell width (so a percentage-width example resolves
68
- * against the cell), centered unless the example fills the cell. */
69
- export function SpecimenContent({ fill, children }: { fill?: boolean; children: React.ReactNode }) {
70
- return <View style={fill ? styles.fill : styles.center}>{children}</View>;
68
+ * against the cell). `fill` changes whether the example itself also stretches to that width
69
+ * (default) or keeps its own content size — a non-`fill` specimen centers by default; a `fill`
70
+ * one stretches by default instead, preserving an auto-sized full-width child (see
71
+ * `specimenContentAlignItems`). `align` always overrides that default explicitly.
72
+ *
73
+ * A non-`fill` specimen uses a row main axis (`specimenContentBoundedStyle`) so `justifyContent`
74
+ * positions it horizontally — a column + `alignItems` left the specimen's own root `alignSelf`
75
+ * (e.g. Badge's `alignSelf: 'flex-start'`) free to override that positioning outright, since
76
+ * `alignItems` only sets a child's *default* `alignSelf`. `fill` keeps the existing column +
77
+ * `alignItems` stretch behavior, unchanged. */
78
+ export function SpecimenContent({ fill, align, children }: { fill?: boolean; align?: SpecimenAlign; children: React.ReactNode }) {
79
+ if (fill) {
80
+ const alignItems = specimenContentAlignItems(fill, align);
81
+ return <View style={[SPECIMEN_SURFACE_WIDTH_STYLE, { alignItems }]}>{children}</View>;
82
+ }
83
+ return <View style={specimenContentBoundedStyle(align)}>{children}</View>;
71
84
  }
72
85
 
73
86
  // 'dark' reuses CATALOG_COLOR.text (the catalog's own darkest token); there is no dark-surface token.
@@ -77,8 +90,3 @@ const FILL = StyleSheet.create({
77
90
  dark: { backgroundColor: CATALOG_COLOR.text },
78
91
  transparent: {},
79
92
  });
80
-
81
- const styles = StyleSheet.create({
82
- center: { ...SPECIMEN_SURFACE_WIDTH_STYLE, alignItems: 'center' },
83
- fill: { ...SPECIMEN_SURFACE_WIDTH_STYLE },
84
- });
@@ -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
 
@@ -222,6 +232,12 @@ export interface ListItem {
222
232
  label: string;
223
233
  node: unknown;
224
234
  fill?: boolean;
235
+ /** This item's own `specimenSurface` override — see `SpecimenPresentation`. Omit to inherit the
236
+ * surface already in effect (the slot's/page's `specimenSurface`, else `'auto'`). */
237
+ surface?: SpecimenSurfaceKind;
238
+ /** This item's own cross-axis alignment override — see `SpecimenPresentation.align`. Omit to
239
+ * inherit the established default (centered, or stretched when `fill` is set). */
240
+ align?: SpecimenAlign;
225
241
  }
226
242
 
227
243
  export interface ListGroup {
@@ -250,7 +266,9 @@ export function slotSize<TId extends string>(def: SectionDef<TId>, slot: Variant
250
266
  }
251
267
 
252
268
  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 }));
269
+ // Nullish, not `||`: an item's own explicit `fill: false` must override an inherited
270
+ // `itemsFill: true`, which `false || slot.itemsFill` would silently discard.
271
+ 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 }));
254
272
  }
255
273
 
256
274
  /** Preview widths, each capped at phone width. 'full' is kept for catalog chrome and token pages. */
@@ -265,12 +283,14 @@ function previewWidths(requested: PreviewWidths | undefined, fallback: PreviewWi
265
283
  function groupStates(variants: VariantSlot, states: VariantSlot): { groups: ListGroup[]; leftovers: VariantSlot | undefined } | undefined {
266
284
  const variantKeys = new Set(variants.items.map((v) => v.key));
267
285
  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;
286
+ // Nullish, not `||` — same reasoning as `slotItems`: an explicit `fill: false` must win over an
287
+ // inherited `itemsFill: true`.
288
+ const fill = (item: VariantSlot['items'][number], slot: VariantSlot) => item.fill ?? slot.itemsFill;
269
289
  const groups = variants.items.map((variant) => {
270
290
  const own = states.items.filter((s) => s.group === variant.key);
271
291
  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) }];
292
+ ? own.map((s) => ({ key: s.key, label: s.name, node: s.node, fill: fill(s, states), surface: s.surface, align: s.align }))
293
+ : [{ key: variant.key, label: variant.name, node: variant.node, fill: fill(variant, variants), surface: variant.surface, align: variant.align }];
274
294
  return { key: variant.key, label: variant.name, items };
275
295
  });
276
296
  const rest = states.items.filter((s) => s.group === undefined || !variantKeys.has(s.group));
@@ -377,3 +397,17 @@ export function choosePlacement({ available, gap, first, second }: { available:
377
397
  export function propsColumns(count: number, width: number): 1 | 2 {
378
398
  return count >= 4 && width >= 2 * PROPS_MIN_COLUMN_WIDTH + PROPS_COLUMN_GAP ? 2 : 1;
379
399
  }
400
+
401
+ /** How many columns the reference card's Guidance / Quick reference / Composition panel uses at a
402
+ * given measured inner card width: 3 only when there is confirmed composition to show as its own
403
+ * column AND the card is wide enough for three REFERENCE_MIN_COLUMN_WIDTH columns; 2 when wide
404
+ * enough for two (any composition then stays nested under Quick reference, as it always has been);
405
+ * 1 (stacked, full content in document order — nothing dropped) when even two columns wouldn't
406
+ * each keep their minimum. An unmeasured width (`<= 0`) stacks, so the first frame before layout
407
+ * settles never overflows. */
408
+ export function referenceColumns(width: number, hasComposition: boolean): 1 | 2 | 3 {
409
+ const fits = (n: number) => width >= n * REFERENCE_MIN_COLUMN_WIDTH + (n - 1) * REFERENCE_COLUMN_GAP;
410
+ if (hasComposition && fits(3)) return 3;
411
+ if (fits(2)) return 2;
412
+ return 1;
413
+ }
@@ -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,156 @@
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';
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'];
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
+ // Matched by recomputing each cell's own canonical key, never by splitting `address.itemKey` —
121
+ // a row/column key may itself contain any delimiter this module could pick.
122
+ return def.comparison?.cells.find((cell) => comparisonCellItemKey(cell.rowKey, cell.columnKey) === address.itemKey)?.node;
123
+ }
124
+
125
+ /** The canonical, unambiguous `itemKey` for one comparison cell's row+column — this module's own
126
+ * counterpart to `comparison.ts`'s `cellKey` (not imported from there: a VALUE import between two
127
+ * pure `.ts` logic modules here can't carry the `.ts` extension that both Node's direct test run
128
+ * and `kit-host`'s bundler-mode typechecker require/reject at once — `import type` is exempt
129
+ * since it is fully erased, which is why `./types.ts` above is fine). Exported so a `.tsx` call
130
+ * site building a `'comparison'` `OverlayAddress` constructs the exact same key this module looks
131
+ * it up by — a plain `"${rowKey}:${columnKey}"` join is ambiguous whenever a row or column key
132
+ * contains a colon itself. */
133
+ export function comparisonCellItemKey(rowKey: string, columnKey: string): string {
134
+ return `${rowKey}${columnKey}`;
135
+ }
136
+
137
+ /** Usable CSS pixel dimensions for a bounded overlay viewport: finite and strictly positive.
138
+ * `NaN`/`Infinity`/zero/negative (a measurement that hasn't settled yet, a miscomputed layout
139
+ * value, an author typo) must never reach `BoundedOverlayViewport`'s iframe — an iframe sized to
140
+ * a meaningless bound is worse than no iframe, since it still renders (and can still mount the
141
+ * wrapped demo's overlay) at some browser-decided fallback size instead of honestly reporting
142
+ * unavailable. */
143
+ export function isFiniteOverlaySize(width: number, height: number): boolean {
144
+ return Number.isFinite(width) && width > 0 && Number.isFinite(height) && height > 0;
145
+ }
146
+
147
+ /** The framed stage's own box dimensions. `height` is exact — the production overlay's full
148
+ * usable height is never renegotiated by its owner. `width` is only a MAXIMUM: `'100%'` lets the
149
+ * stage fill and shrink to whatever its real owning specimen cell actually gives it, capped at
150
+ * `width` when the owner has at least that much room. A fixed pixel width here previously let the
151
+ * stage overflow a narrower owner (e.g. a 240px comparison cell at a narrow browser width) while
152
+ * that owner's own `overflow: hidden` silently clipped the excess — a passing-looking but false
153
+ * containment, not the honest unavailable/shrink this design requires. */
154
+ export function overlayStageDimensions(width: number, height: number): { width: '100%'; maxWidth: number; height: number } {
155
+ return { width: '100%', maxWidth: width, height };
156
+ }
@@ -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