@krapwoo/ds-viewer 0.5.0 → 0.6.1

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.
package/README.md CHANGED
@@ -175,13 +175,18 @@ when the card is too narrow even for two. The specimens use one of four layouts:
175
175
  - **Preview** — `render()` content. Component previews render at phone width (402px), optionally
176
176
  with more widths such as a 320px small phone; token galleries and catalog chrome stay full width.
177
177
 
178
- When a page has two blocks, the catalog places them side by side if that makes the page at least
179
- 120px shorter (e.g. Dropdown), and stacks them otherwise. A block with nothing to show is omitted.
180
-
181
- Grid columns and list cells are at most 402px wide with 16px padding. A page's `specimenSize`
182
- (`compact` 160px, `regular` 240px, `wide` 402px) sets the minimum width; full-width (`itemsFill`)
183
- slots default to `wide`. The catalog's own look comes from `native/catalog/tokens.ts`, never from
184
- the host app's tokens.
178
+ When a page has two blocks, the catalog places them side by side — the row split into two equal
179
+ columns, each block filling its own — if both keep their minimum width there and it makes the page
180
+ at least 120px shorter (e.g. Dropdown); otherwise they stack, each filling the whole row. A block
181
+ with nothing to show is omitted.
182
+
183
+ Every card fills the column it is given, and its columns or cells share that width equally: a
184
+ two-column grid is as wide as a five-column one, and a two-item list is as wide as a six-item one.
185
+ Phone width (402px, 370px of content) caps the specimen inside a cell, not the cell. A page's
186
+ `specimenSize` (`compact` 160px, `regular` 240px, `wide` 402px) sets the minimum column width — a
187
+ card narrower than its minimum scrolls inside itself rather than squeezing its cells. Full-width
188
+ (`itemsFill`) slots default to `wide`. The catalog's own look comes from `native/catalog/tokens.ts`,
189
+ never from the host app's tokens.
185
190
 
186
191
  ## Adding a component (the recipe)
187
192
 
@@ -201,7 +206,9 @@ actually apply:
201
206
  combination does not exist. Never multiply `variants` by `states` to fill it. Keep the column axis
202
207
  within what fits a 1280px laptop: 5 compact, 3 regular, or 2 wide columns. `states` items whose
203
208
  key matches a row or column key are not repeated below the grid.
204
- - `specimenSize: 'compact' | 'regular' | 'wide'` — the page's specimen width class.
209
+ - `specimenSize: 'compact' | 'regular' | 'wide' | 'page'` — the page's specimen width class. `'page'`
210
+ is for components wider than a phone (a whole screen layout, a catalog page): cells are at least
211
+ 512px and share the row, with no 402px cap.
205
212
  - `specimenSurface: 'auto' | 'neutral' | 'white' | 'dark' | 'transparent'` — the fill of each
206
213
  example's cell (list, grouped and grid cells, and a non-token `render()` preview). Defaults to
207
214
  `'auto'`: after the page draws, a cell turns light gray only when its own example would vanish
@@ -481,6 +488,17 @@ Button
481
488
  or `x`/`onXChange`) but every example that renders it passes a fixed value with no working
482
489
  callback. Add `intentionalStaticPreview: { reason: '...' }` to the page to suppress it when the
483
490
  static examples are deliberate.
491
+ - `preview-only-component-page` — a component page's only examples are a free-form `render()`
492
+ preview, so nothing is named and the checks above have no example to read. Give the page
493
+ `variants` (and `states`, where they differ) naming each real configuration. Token galleries
494
+ and a declared `intentionalStaticPreview` are exempt; `render()` stays right for a token
495
+ gallery or a whole-page layout.
496
+ - `internal-prop-unknown` — `internalProps` names something that is not a prop of the component,
497
+ so the exemption it was written for is not in effect. Correct the spelling or delete the entry.
498
+ A page's `internalProps: { size: 'why' }` exempts a named prop from `option-not-covered` — for
499
+ plumbing a parent passes (a layout size, an internal surface kind) rather than a value the
500
+ reader picks. It needs a reason per prop, so the exemption is a visible declaration in the page;
501
+ when the reader really does choose the value, tag an example with `props` instead.
484
502
  The analyzer follows supported aliases and resolved exports, but uncertain wrappers or
485
503
  dynamically-constructed references remain uncheckable rather than being guessed at. A
486
504
  declared `intentionalStaticPreview` is taken as the author's own call, not independently
@@ -498,6 +516,11 @@ and adds what only a real render shows:
498
516
  - `render-error` / `render-no-heading` (errors): a console error, or a page that didn't render.
499
517
  - `render-layout` (warning): an example wider than its cell (the viewer's own check), with the
500
518
  page and file. `render-catalog-warning` (warning): any other `[Catalog]` warning, reported once.
519
+ - `identical-variants` (warning): two examples in the same card whose specimens render identically,
520
+ so the difference their names claim isn't visible — usually a cell too narrow to show it, or a
521
+ prop that was never wired up. The fingerprint reads each specimen's whole subtree (including a
522
+ PhoneFrame's child document and a field's value) but not the caption carrying the variant's own
523
+ name, so two examples differing only in colour are not reported.
501
524
  - `render-unavailable` / `render-failed` (errors): no browser, or the catalog didn't start.
502
525
 
503
526
  It needs `puppeteer` in your project (`npm install --save-dev puppeteer`); ds-viewer doesn't
@@ -279,6 +279,22 @@ function findControlledPair(component) {
279
279
  * - no example directly renders the component at all, or any direct example's value/callback is
280
280
  * `'unknown'` — genuinely uncheckable, and "uncheckable" must never be reported as "inert".
281
281
  * Never a newly-fatal gate: always a warning, same as every other advisory in this file. */
282
+ /** A component page whose only examples are a free-form `render()` preview: nothing is named, so
283
+ * the reader sees one picture instead of the component's real variants and states, and several
284
+ * other rules here (`missing-working-preview`, the option-coverage rules) are skipped because
285
+ * they have no named example to read. A token gallery is exempt — raw token data has no variants
286
+ * — and so is a page that says `intentionalStaticPreview` with a reason. */
287
+ function checkPreviewOnlyPages(pages) {
288
+ return pages
289
+ .filter((page) => page.component !== undefined && page.checkable && page.hasRender === true && !page.tokenGallery
290
+ && !page.intentionalStaticPreviewReason
291
+ && !page.comparison && !page.variantsItems?.length && !page.statesItems?.length && !page.tokenSectionTitles?.length)
292
+ .map((page) => issue({
293
+ id: 'preview-only-component-page', severity: 'warning', ...pageRef(page),
294
+ message: `"${page.component}" is documented by a render() preview alone, with no named variants, states or comparison.`,
295
+ fix: 'Give the page variants (and states, where they differ) naming each real configuration, so every example has a caption and the other checks can read them. Keep render() for token galleries and whole-page layouts.',
296
+ }));
297
+ }
282
298
  function checkMissingWorkingPreview(pages, components) {
283
299
  const componentsByName = new Map(components.map((c) => [c.name, c]));
284
300
  const issues = [];
@@ -409,8 +425,25 @@ function checkComponentPages(pages, components) {
409
425
  }));
410
426
  }
411
427
  }
428
+ // Props the page declares as plumbing the reader never picks (with a reason) are not expected
429
+ // to have an example per option; see `internalProps`.
430
+ const internalProps = new Set(page.internalProps ?? []);
431
+ // A misspelled key silently exempts nothing, so the author sees the warnings it was meant to
432
+ // settle and no reason why — the same reasoning as propNotes' own key check.
433
+ for (const name of internalProps) {
434
+ // `component.props` deliberately omits a node_modules-declared prop (cli/props.ts), so an
435
+ // inherited name such as `autoCapitalize` is expected to be absent rather than misspelled —
436
+ // the same exemption the tagged-item check above makes.
437
+ if (!propsByName.has(name) && component.inheritedFrom.length === 0) {
438
+ issues.push(issue({
439
+ id: 'internal-prop-unknown', severity: 'warning', ...ref,
440
+ message: `internalProps names "${name}", which is not a prop of ${component.name}.`,
441
+ fix: `Correct the spelling, or delete the "${name}" entry — it exempts nothing as written.`,
442
+ }));
443
+ }
444
+ }
412
445
  for (const prop of component.props) {
413
- if (!prop.options)
446
+ if (!prop.options || internalProps.has(prop.name))
414
447
  continue;
415
448
  const covered = new Set();
416
449
  if (page.comparison) {
@@ -456,10 +489,15 @@ function checkComponentPages(pages, components) {
456
489
  fix: 'Add a one-sentence whenToUse note disambiguating this component from its closest look-alike(s), or omit it only when there genuinely is none.',
457
490
  }));
458
491
  }
459
- if (!page.tokenGallery && page.hasA11y === false && !page.hidesAccessibility) {
492
+ // `hide: { accessibility: true }` is a display choice, not an exemption from documenting the
493
+ // component: hiding the section only removes the visible placeholder, so without this the gap
494
+ // becomes invisible to both the reader and the doctor.
495
+ if (!page.tokenGallery && page.hasA11y === false) {
460
496
  issues.push(issue({
461
497
  id: 'missing-guidance-or-a11y', severity: 'warning', ...ref,
462
- message: 'No "a11y" notes documented.',
498
+ message: page.hidesAccessibility
499
+ ? 'No "a11y" notes documented, and the page hides the accessibility section.'
500
+ : 'No "a11y" notes documented.',
463
501
  fix: 'Add a one-sentence a11y note, grounded in the component\'s real behavior — even "no explicit handling beyond defaults" is better than leaving it blank.',
464
502
  }));
465
503
  }
@@ -480,6 +518,7 @@ export function collectIssues(pages, components) {
480
518
  ...checkComponentPages(pages, components),
481
519
  ...checkMissingComposition(pages, components),
482
520
  ...checkMissingWorkingPreview(pages, components),
521
+ ...checkPreviewOnlyPages(pages),
483
522
  ];
484
523
  }
485
524
  /** Issues that aren't tied to one page: `sync`'s own "Could not read" warnings, and a configured
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import { readStaticPages } from "./staticPage.js";
3
3
  import { discoverAllPageFiles, resolvePageId } from "./pageIndex.js";
4
4
  import { resolveGlob } from "./glob.js";
5
- import { axisItems, choosePlacement, gridColumnLimit, listGeometry, presentationBlocks, MATRIX_LAYOUT, } from "../native/catalog/comparison.js";
5
+ import { axisItems, blockMinWidth, choosePlacement, gridColumnLimit, listGeometry, presentationBlocks, MATRIX_LAYOUT, } from "../native/catalog/comparison.js";
6
6
  import { CATALOG_LAYOUT } from "../native/catalog/tokens.js";
7
7
  import { sectionColumns } from "../native/catalog/tokenLayout.js";
8
8
  function placeholderItem(item) {
@@ -113,23 +113,14 @@ function explainBlock(block) {
113
113
  }
114
114
  return { kind: 'empty', title: block.title, reason: block.message };
115
115
  }
116
- /** Mirrors `native/catalog/SectionBlock.tsx`'s own `blockWidth` helper: a list block's width is its
117
- * own natural, content-hugging width (`listGeometry`'s `containerWidth`) — the real viewer only
118
- * ever moves a *second* block beside a *first* one when that first block doesn't already fill the
119
- * whole row, so `choosePlacement` only reads `first.width` (never `second.width`) — but it's
120
- * computed uniformly here for either role, since setting it on `second` is simply unused, not wrong. */
121
- function toPlacementBlock(block, height, available) {
122
- if (block.kind === 'list') {
123
- return {
124
- kind: 'list',
125
- height,
126
- itemCount: block.items.length,
127
- size: block.size,
128
- maxColumns: block.maxColumns,
129
- width: listGeometry(block.items.length, available, block.size, block.maxColumns).containerWidth,
130
- };
131
- }
132
- return { kind: block.kind, height };
116
+ /** Mirrors `native/catalog/SectionBlock.tsx`'s own `placementBlock` helper: side by side splits the
117
+ * row in two and each block fills its half, so the decision needs each block's narrowest width
118
+ * plus, for a list, the geometry that predicts its height in half a row. */
119
+ function toPlacementBlock(block, height) {
120
+ const base = { kind: block.kind, height, minWidth: blockMinWidth(block) };
121
+ return block.kind === 'list'
122
+ ? { ...base, itemCount: block.items.length, size: block.size, maxColumns: block.maxColumns }
123
+ : base;
133
124
  }
134
125
  /** Finds `pageId` among every page `cli/doctor.ts`'s own `discoverAllPageFiles` would find, builds
135
126
  * its throwaway `SectionDef`, and asks the viewer's own pure layout functions for each block's
@@ -163,8 +154,8 @@ export function explainPage(config, pageId, options = {}) {
163
154
  const decision = choosePlacement({
164
155
  available: MATRIX_LAYOUT.laptopContentWidth,
165
156
  gap: CATALOG_LAYOUT.blockGap,
166
- first: toPlacementBlock(blocks[0], firstHeight, MATRIX_LAYOUT.laptopContentWidth),
167
- second: toPlacementBlock(blocks[1], secondHeight, MATRIX_LAYOUT.laptopContentWidth),
157
+ first: toPlacementBlock(blocks[0], firstHeight),
158
+ second: toPlacementBlock(blocks[1], secondHeight),
168
159
  });
169
160
  placement = {
170
161
  decision,
@@ -40,6 +40,85 @@ export function issuesFromPage(page, messages, hasHeading, seen = new Set()) {
40
40
  }
41
41
  return issues;
42
42
  }
43
+ /**
44
+ * Runs in the browser: finds any two examples in the same list card whose specimens render
45
+ * identically. A variant's name is a claim that something differs; two cells that render the same
46
+ * teach the reader nothing and usually mean the difference needs a wider cell (a column count in a
47
+ * cell too narrow to show it) or was never wired up.
48
+ *
49
+ * The fingerprint covers each specimen's whole subtree — tag, box size, colours, radius and text —
50
+ * but never the caption strip that carries the variant's own name, which always differs. Colour is
51
+ * part of it, so a "default vs disabled" pair that differs only in tone is not reported.
52
+ */
53
+ export const IDENTICAL_VARIANTS_SCRIPT = `(() => {
54
+ const doc = document;
55
+ const fingerprint = (root) => {
56
+ const out = [];
57
+ const base = root.getBoundingClientRect();
58
+ const walk = (el) => {
59
+ // PhoneFrame renders its example into a same-origin child document, so without reaching in
60
+ // every phone looks like an identical empty box.
61
+ if (el.tagName === 'IFRAME') {
62
+ try {
63
+ const body = el.contentDocument && el.contentDocument.body;
64
+ if (body) { out.push('IFRAME'); walk(body); return; }
65
+ } catch (error) { out.push('IFRAME|unreadable'); return; }
66
+ }
67
+ const box = el.getBoundingClientRect();
68
+ const style = getComputedStyle(el);
69
+ out.push([
70
+ el.tagName,
71
+ Math.round(box.width), Math.round(box.height),
72
+ Math.round(box.left - base.left), Math.round(box.top - base.top),
73
+ style.color, style.backgroundColor, style.borderColor, style.borderRadius,
74
+ style.fontSize, style.fontWeight, style.opacity, style.visibility,
75
+ // An icon page's variants are same-sized SVGs that differ only in geometry; a card page's
76
+ // in shadow; a text page's in style, decoration or alignment.
77
+ style.fill, style.stroke, style.boxShadow, style.fontStyle, style.textDecorationLine, style.textAlign,
78
+ el.getAttribute && (el.getAttribute('d') || el.getAttribute('points') || ''),
79
+ el.currentSrc || (el.getAttribute && el.getAttribute('src')) || '',
80
+ el.children.length === 0 ? (el.textContent || '') : '',
81
+ // A field's visible text is its value, not its textContent, which is always empty.
82
+ el.value === undefined ? '' : String(el.value),
83
+ el.placeholder || '', el.disabled ? 'disabled' : '',
84
+ ].join('|'));
85
+ for (const child of el.children) walk(child);
86
+ };
87
+ walk(root);
88
+ return out.join('\\n');
89
+ };
90
+ const pairs = [];
91
+ for (const card of doc.querySelectorAll('[role=list][aria-label]')) {
92
+ // ComparisonList and ComparisonGroups label their card "<page id>: <block title>". Anything
93
+ // else with role=list is a consumer's own component inside a specimen, not a card of examples.
94
+ if (!/^[^:]+: .+/.test(card.getAttribute('aria-label') || '')) continue;
95
+ const cells = [...card.querySelectorAll('[role=listitem]')].filter((c) => c.closest('[role=list]') === card);
96
+ const byPrint = new Map();
97
+ for (const cell of cells) {
98
+ // children[0] is the caption strip (the variant's own name); the specimen follows it.
99
+ const specimen = cell.children.length > 1 ? cell.children[1] : cell.children[0];
100
+ if (!specimen) continue;
101
+ const name = (cell.children[0] ? cell.children[0].textContent : '') || '';
102
+ const print = fingerprint(specimen);
103
+ if (!print.trim()) continue;
104
+ const first = byPrint.get(print);
105
+ if (first === undefined) byPrint.set(print, name);
106
+ else pairs.push({ card: card.getAttribute('aria-label') || '', names: [first, name] });
107
+ }
108
+ }
109
+ return pairs;
110
+ })()`;
111
+ /** Turns the browser's findings into doctor issues, one per identical pair. */
112
+ export function identicalVariantIssues(page, pairs) {
113
+ const ref = { page: page.id, ...(page.file ? { file: page.file } : {}) };
114
+ return pairs.map((pair) => ({
115
+ id: 'identical-variants',
116
+ severity: 'warning',
117
+ ...ref,
118
+ message: `"${pair.names[0]}" and "${pair.names[1]}" render identically in ${pair.card || 'the same card'}.`,
119
+ fix: "Make the difference visible — a wider specimenSize when the cell is too narrow to show it, or the prop the name claims — or drop one of the two examples.",
120
+ }));
121
+ }
43
122
  /** The project's own puppeteer, or `undefined` when it isn't installed. */
44
123
  export function loadProjectPuppeteer(projectRoot) {
45
124
  try {
@@ -221,6 +300,18 @@ export async function renderCheck(projectRoot, options = {}) {
221
300
  return Boolean(doc.querySelector('[role="heading"]')?.textContent?.trim());
222
301
  });
223
302
  issues.push(...issuesFromPage({ id, file: options.files?.get(id) }, messages, hasHeading, seen));
303
+ // Advisory: a script failure (a detached child document mid-navigation, an element whose
304
+ // `value` getter throws) must not become this page's render-error, so it has its own catch
305
+ // rather than falling into the one below.
306
+ let identical;
307
+ try {
308
+ identical = (await page.evaluate(IDENTICAL_VARIANTS_SCRIPT));
309
+ }
310
+ catch {
311
+ identical = undefined;
312
+ }
313
+ if (Array.isArray(identical))
314
+ issues.push(...identicalVariantIssues({ id, file: options.files?.get(id) }, identical));
224
315
  await page.close();
225
316
  }
226
317
  catch (error) {
@@ -170,6 +170,23 @@ function readStaticPage(file) {
170
170
  page.intentionalStaticPreviewReason = stringLiteral(resolve(reasonProp.initializer, consts, new Set()));
171
171
  }
172
172
  }
173
+ const internalPropsProp = findProp(pageObject, 'internalProps');
174
+ if (internalPropsProp) {
175
+ const resolved = resolve(internalPropsProp.initializer, consts, new Set());
176
+ if (ts.isObjectLiteralExpression(resolved)) {
177
+ const names = [];
178
+ for (const prop of resolved.properties) {
179
+ if (!ts.isPropertyAssignment(prop))
180
+ continue;
181
+ const name = ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name) ? prop.name.text : undefined;
182
+ const reason = stringLiteral(resolve(prop.initializer, consts, new Set()));
183
+ if (name && reason)
184
+ names.push(name);
185
+ }
186
+ if (names.length > 0)
187
+ page.internalProps = names;
188
+ }
189
+ }
173
190
  return page;
174
191
  }
175
192
  /** A literal array of `ComposedOfEntry`-shaped object literals, read down to just `component`
@@ -2,7 +2,6 @@
2
2
  * content width is a 1280px viewport minus the 264px sidebar and 32px side padding. */
3
3
  export const MATRIX_LAYOUT = {
4
4
  rowHeaderWidth: 120,
5
- columnMaxWidth: 402,
6
5
  cellPadding: 16,
7
6
  rowMinHeight: 150,
8
7
  laptopContentWidth: 952,
@@ -21,8 +20,9 @@ export const REFERENCE_MIN_COLUMN_WIDTH = 280;
21
20
  /** Gap between reference columns (CATALOG_SPACE['2xl'], the same token the card's columns already
22
21
  * use). */
23
22
  export const REFERENCE_COLUMN_GAP = 32;
24
- /** Smallest column or cell width per specimen size. Wide specimens are fixed at phone width. */
25
- export const COLUMN_MIN_WIDTH = { compact: 160, regular: 240, wide: 402 };
23
+ /** Smallest column or cell width per specimen size. `wide` is phone width as a minimum, not a
24
+ * maximum: cells share their container equally above it. */
25
+ export const COLUMN_MIN_WIDTH = { compact: 160, regular: 240, wide: 402, page: 512 };
26
26
  /** `Array.isArray`'s built-in type guard narrows to a mutable `any[]`, which a `readonly T[]`
27
27
  * union member is never assignable to — so the `else` branch of `Array.isArray(axis) ? ... :
28
28
  * ...` keeps `axis`'s full union type instead of narrowing away the array member. This explicit
@@ -71,15 +71,15 @@ export function grid(rowLabel, columnLabel, rows, columns, cell, size) {
71
71
  export function gridColumnLimit(size) {
72
72
  return Math.floor((MATRIX_LAYOUT.laptopContentWidth - MATRIX_LAYOUT.rowHeaderWidth) / COLUMN_MIN_WIDTH[size]);
73
73
  }
74
- export function gridWidthBounds(columnCount, size) {
75
- const n = Math.max(0, columnCount);
76
- return {
77
- minWidth: MATRIX_LAYOUT.rowHeaderWidth + n * COLUMN_MIN_WIDTH[size],
78
- maxWidth: MATRIX_LAYOUT.rowHeaderWidth + n * MATRIX_LAYOUT.columnMaxWidth,
79
- };
74
+ /** The narrowest a grid card may be: the row header plus every column at its minimum. A card wider
75
+ * than its container scrolls inside itself; above this width the columns share the container
76
+ * equally, because the phone-width cap belongs to the specimen inside a cell, not to the column. */
77
+ export function gridMinWidth(columnCount, size) {
78
+ return MATRIX_LAYOUT.rowHeaderWidth + Math.max(0, columnCount) * COLUMN_MIN_WIDTH[size];
80
79
  }
81
80
  /** Balanced wrapping for a one-axis list inside a single bordered card (1px outer border). Each
82
- * cell's 1px divider sits inside its own width, so no cell is ever wider than 402px. Uses as few
81
+ * cell's 1px divider sits inside its own width. Cells share the container equally — phone width
82
+ * caps the specimen inside a cell, not the cell. Uses as few
83
83
  * rows as fit, then spreads items evenly across them so a short last row leaves as little empty
84
84
  * space as possible. `fillers` blank cells complete the last row. `columnCap` (from
85
85
  * `VariantSlot.maxColumns`) only ever lowers the natural, width-based column count — a narrow
@@ -96,7 +96,7 @@ export function listGeometry(itemCount, availableWidth, size, columnCap) {
96
96
  const rows = Math.ceil(itemCount / maxColumns);
97
97
  const columns = Math.ceil(itemCount / rows);
98
98
  const fitted = Math.floor(inner / columns);
99
- const cellWidth = size === 'wide' ? MATRIX_LAYOUT.columnMaxWidth : Math.max(min, Math.min(MATRIX_LAYOUT.columnMaxWidth, fitted));
99
+ const cellWidth = Math.max(min, fitted);
100
100
  return {
101
101
  columns,
102
102
  rows,
@@ -198,10 +198,11 @@ function slotColumnCap(slot) {
198
198
  return { maxColumns: slot.maxColumns };
199
199
  return slot.itemsFill ? { maxColumns: FILLED_SLOT_MAX_COLUMNS } : {};
200
200
  }
201
- function slotItems(slot, from) {
201
+ function slotItems(slot, from, fillByDefault = false) {
202
202
  // Nullish, not `||`: an item's own explicit `fill: false` must override an inherited
203
203
  // `itemsFill: true`, which `false || slot.itemsFill` would silently discard.
204
- return slot.items.map((item) => ({ key: item.key, slot: from, label: item.name, node: item.node, fill: item.fill ?? slot.itemsFill, surface: item.surface, align: item.align }));
204
+ // Page-width examples (layouts wider than a phone) fill their cell unless they say otherwise.
205
+ return slot.items.map((item) => ({ key: item.key, slot: from, label: item.name, node: item.node, fill: item.fill ?? slot.itemsFill ?? (fillByDefault || undefined), surface: item.surface, align: item.align }));
205
206
  }
206
207
  /** Preview widths, each capped at phone width. 'full' is kept for catalog chrome and token pages. */
207
208
  function previewWidths(requested, fallback) {
@@ -269,12 +270,12 @@ export function presentationBlocks(def, options = {}) {
269
270
  kind: 'list',
270
271
  title: 'Variants',
271
272
  size: slotSize(def, def.variants),
272
- items: slotItems(def.variants, 'variants'),
273
+ items: slotItems(def.variants, 'variants', def.specimenSize === 'page'),
273
274
  ...slotColumnCap(def.variants),
274
275
  });
275
276
  }
276
277
  else if (def.render) {
277
- blocks.push({ kind: 'preview', title: 'Preview', widths: previewWidths(def.previewWidths, options.defaultPreviewWidths) });
278
+ blocks.push({ kind: 'preview', title: 'Preview', widths: previewWidths(def.previewWidths, options.defaultPreviewWidths), ...(def.previewLayout ? { layout: def.previewLayout } : {}) });
278
279
  }
279
280
  }
280
281
  if (states) {
@@ -283,7 +284,7 @@ export function presentationBlocks(def, options = {}) {
283
284
  kind: 'list',
284
285
  title: secondary ? 'Other configurations' : 'States / configurations',
285
286
  size: slotSize(def, states),
286
- items: slotItems(states, 'states'),
287
+ items: slotItems(states, 'states', def.specimenSize === 'page'),
287
288
  ...slotColumnCap(states),
288
289
  });
289
290
  }
@@ -292,23 +293,61 @@ export function presentationBlocks(def, options = {}) {
292
293
  }
293
294
  return blocks;
294
295
  }
295
- /** Side by side or stacked, for a page with exactly two blocks. Tries both and keeps the shorter;
296
- * stays stacked unless side by side saves at least PLACEMENT_MIN_SAVING. Only a list can move
297
- * beside a first block that hugs its content, and only when its cells keep their minimum width. */
296
+ /** The narrowest a block renders at. Every card fills the column it is given, so this is a floor,
297
+ * not the width it will take: below it a grid scrolls inside itself and a list would push its
298
+ * cells under their minimum. `Infinity` means the block can only have a whole row. */
299
+ /** What a preview card adds around its frames: `padding` (CATALOG_SPACE.xl) and a 1px border on
300
+ * each side. `blockMinWidth` must add it, or a preview is offered a half-row that fits its frame
301
+ * only after the card has taken its share, and the frame silently renders under phone width.
302
+ * SectionBlock's `previewCard` style is built from these, so the two cannot drift. */
303
+ export const PREVIEW_CARD_CHROME = { padding: 24, border: 1 };
304
+ /** The same for a `previewLayout: 'table'` card: no outer padding, a 1px border, and each cell
305
+ * carries `cellPadding` on both sides of its frame. */
306
+ export const TABLE_PREVIEW_CHROME = { padding: MATRIX_LAYOUT.cellPadding, border: 1 };
307
+ /** Total horizontal chrome for one preview frame in either card. */
308
+ export function previewChrome(layout) {
309
+ const chrome = layout === 'table' ? TABLE_PREVIEW_CHROME : PREVIEW_CARD_CHROME;
310
+ return 2 * chrome.padding + 2 * chrome.border;
311
+ }
312
+ export function blockMinWidth(block) {
313
+ switch (block.kind) {
314
+ case 'grid':
315
+ return gridMinWidth(axisItems(block.comparison.columns).length, block.size);
316
+ case 'grouped':
317
+ return MATRIX_LAYOUT.rowHeaderWidth + Math.max(1, ...block.groups.map((g) => g.items.length)) * COLUMN_MIN_WIDTH[block.size] + 2;
318
+ case 'list':
319
+ // One column: a list always wraps further rather than going below its size's minimum.
320
+ return COLUMN_MIN_WIDTH[block.size] + 2;
321
+ case 'preview':
322
+ // A 'full' preview is the row. Otherwise the *widest* frame sets the minimum: the frames wrap,
323
+ // so each one sits on its own row and `maxWidth: '100%'` would squeeze any frame wider than
324
+ // the card. The card's own chrome counts too — a frame keeps its width only if the row also
325
+ // covers the padding and border around it.
326
+ return block.widths === 'full' ? Infinity : Math.max(...block.widths) + previewChrome(block.layout);
327
+ default:
328
+ return Infinity;
329
+ }
330
+ }
331
+ /** The height a block takes at `width`, from the height measured while stacked at `available`.
332
+ * Only a list reflows: its rows change with the width, and every row is the same height. */
333
+ function heightAtWidth(block, width, available) {
334
+ if (block.kind !== 'list' || !block.itemCount || !block.size)
335
+ return block.height;
336
+ const stackedRows = listGeometry(block.itemCount, available, block.size, block.maxColumns).rows;
337
+ const rows = listGeometry(block.itemCount, width, block.size, block.maxColumns).rows;
338
+ return (block.height / Math.max(1, stackedRows)) * rows;
339
+ }
340
+ /** Side by side or stacked, for a page with exactly two blocks. Side by side splits the row into
341
+ * two equal columns and each block fills its own, so it is only possible when both blocks keep
342
+ * their minimum width in half a row; it is only taken when it saves at least PLACEMENT_MIN_SAVING
343
+ * against stacking, measuring each block at the width it would actually have. */
298
344
  export function choosePlacement({ available, gap, first, second }) {
299
- if (second.kind !== 'list' || !second.itemCount || !second.size)
300
- return 'stacked';
301
- const firstWidth = first.width ?? available;
302
- if (firstWidth > available * 0.7)
345
+ const half = Math.floor((available - gap) / 2);
346
+ if (half <= 0)
303
347
  return 'stacked';
304
- const room = available - firstWidth - gap;
305
- const minCell = second.size === 'wide' ? MATRIX_LAYOUT.columnMaxWidth : COLUMN_MIN_WIDTH[second.size];
306
- if (room < minCell + 2)
348
+ if ((first.minWidth ?? Infinity) > half || (second.minWidth ?? Infinity) > half)
307
349
  return 'stacked';
308
- const stackedRows = listGeometry(second.itemCount, available, second.size, second.maxColumns).rows;
309
- const sideRows = listGeometry(second.itemCount, room, second.size, second.maxColumns).rows;
310
- const rowHeight = second.height / Math.max(1, stackedRows);
311
- const sideHeight = Math.max(first.height, sideRows * rowHeight);
350
+ const sideHeight = Math.max(heightAtWidth(first, half, available), heightAtWidth(second, half, available));
312
351
  const stackedHeight = first.height + gap + second.height;
313
352
  return sideHeight <= stackedHeight - PLACEMENT_MIN_SAVING ? 'side' : 'stacked';
314
353
  }
@@ -10,15 +10,17 @@
10
10
  * (docs/design/2026-10-06-catalog-studio-matrix-*.html), with one deliberate exception:
11
11
  * `textMuted` stays #666666 because the references' #777777 fails WCAG AA contrast.
12
12
  */
13
+ /** Owner decision 2026-10-11: only 10, 12, 14, 16, 18, 24, 28, 32 and 40. Named roles share a
14
+ * step (tableHeader and panelHeading are 12, brand is 18). */
13
15
  export const CATALOG_TYPE = {
14
16
  xs: 10,
15
- tableHeader: 11,
17
+ tableHeader: 12,
16
18
  sm: 12,
17
- panelHeading: 13,
19
+ panelHeading: 12,
18
20
  md: 14,
19
21
  lg: 16,
20
- brand: 17,
21
- xl: 20,
22
+ brand: 18,
23
+ xl: 18,
22
24
  '2xl': 24,
23
25
  pageTitle: 28,
24
26
  '3xl': 32,
@@ -76,8 +78,8 @@ export const CATALOG_TYPE_USE = {
76
78
  md: 'Default body text: descriptions, nav links, grid row headers, quick-reference rows, the filter field.',
77
79
  lg: 'Pager arrows and the filter field\'s clear (×) glyph.',
78
80
  brand: 'The app name at the top of the sidebar.',
79
- xl: 'Reserved — no current consumer.',
80
- '2xl': 'Reserved — no current consumer.',
81
+ xl: "The major-update banner's close (×) glyph.",
82
+ '2xl': "The update page's version numbers and the arrow between them.",
81
83
  pageTitle: 'The selected page\'s title.',
82
84
  '3xl': 'Reserved — no current consumer.',
83
85
  '4xl': 'Reserved — no current consumer.',
@@ -111,6 +113,10 @@ export const CATALOG_COLOR = {
111
113
  surfaceMuted: '#fafafa',
112
114
  /** Hover and pressed feedback on nav rows and buttons. */
113
115
  surfacePressed: '#f1f3f8',
116
+ /** A disabled control's surface, and its label. Approved with the update page's buttons
117
+ * (docs/design/2026-10-10-ds-viewer-update-page-always-approved.html). */
118
+ controlDisabled: '#e6e6e6',
119
+ controlDisabledText: '#8a8a8a',
114
120
  /** An example's cell when the example itself is white (`specimenSurface`), darker than surfaceMuted so a white card's edges show. */
115
121
  specimenStage: '#f2f2f2',
116
122
  pageBackground: '#f6f6f4',
@@ -1,46 +1,85 @@
1
1
  import React, { forwardRef, useState } from 'react';
2
- import { Pressable, StyleSheet, Text, type View } from 'react-native';
3
- import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_TYPE } from './tokens';
2
+ import { ActivityIndicator, Pressable, StyleSheet, Text, type View } from 'react-native';
3
+ import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
4
4
 
5
- /** The viewer's own button, for catalog chrome and demo triggers (not an app component). Same look
6
- * as the update page's buttons. */
5
+ /** The viewer's own button, for catalog chrome and demo triggers (not an app component): a rounded
6
+ * rectangle, primary in the accent blue, secondary outlined. `disabled` greys it and stops the
7
+ * press; `busy` keeps its own colours while the action it started is still running, with a
8
+ * spinner before the label. */
7
9
  export const CatalogButton = forwardRef<View, {
8
10
  label: string;
9
- onPress: () => void;
11
+ /** Optional, because a permanently unavailable button still renders (see `disabled`). */
12
+ onPress?: () => void;
10
13
  variant?: 'primary' | 'secondary';
14
+ /** Not pressable, and visibly unavailable. */
15
+ disabled?: boolean;
16
+ /** Pressed already, still working: a spinner and a muted label. Also stops a second press. */
17
+ busy?: boolean;
11
18
  accessibilityLabel?: string;
12
- }>(function CatalogButton({ label, onPress, variant = 'primary', accessibilityLabel }, ref) {
19
+ }>(function CatalogButton({ label, onPress, variant = 'primary', disabled = false, busy = false, accessibilityLabel }, ref) {
13
20
  const [focused, setFocused] = useState(false);
14
21
  const primary = variant === 'primary';
22
+ // Busy stops the press like disabled does — the action is already running — but keeps the
23
+ // button's own colours, because it is working rather than unavailable.
24
+ const off = disabled || busy;
15
25
  return (
16
26
  <Pressable
17
27
  ref={ref}
18
- onPress={onPress}
28
+ onPress={off ? undefined : onPress}
29
+ disabled={off}
19
30
  onFocus={() => setFocused(true)}
20
31
  onBlur={() => setFocused(false)}
21
32
  accessibilityRole="button"
22
33
  accessibilityLabel={accessibilityLabel ?? label}
34
+ accessibilityState={{ disabled: off, busy }}
23
35
  style={({ pressed }) => [
24
36
  styles.base,
25
37
  primary ? styles.primary : styles.secondary,
26
- pressed && (primary ? styles.primaryPressed : styles.secondaryPressed),
38
+ pressed && !off && (primary ? styles.primaryPressed : styles.secondaryPressed),
39
+ disabled && styles.disabled,
27
40
  focused && styles.focus,
28
41
  ]}
29
42
  >
30
- <Text style={[styles.label, primary ? styles.labelPrimary : styles.labelSecondary]}>{label}</Text>
43
+ {busy ? <ActivityIndicator size="small" color={CATALOG_COLOR.textMuted} /> : null}
44
+ <Text
45
+ style={[
46
+ styles.label,
47
+ primary ? styles.labelPrimary : styles.labelSecondary,
48
+ busy && styles.labelBusy,
49
+ disabled && styles.labelDisabled,
50
+ ]}
51
+ >
52
+ {label}
53
+ </Text>
31
54
  </Pressable>
32
55
  );
33
56
  });
34
57
 
35
58
  const styles = StyleSheet.create({
36
- base: { height: CATALOG_LAYOUT.controlSize, paddingHorizontal: 18, borderRadius: 22, alignItems: 'center', justifyContent: 'center', alignSelf: 'center' },
37
- primary: { backgroundColor: CATALOG_COLOR.text },
59
+ // A rounded rectangle (owner decision 2026-10-11); primary in the catalog's accent blue.
60
+ // A row, so `busy` can put a spinner before the label without the label moving.
61
+ base: {
62
+ height: CATALOG_LAYOUT.controlSize,
63
+ paddingHorizontal: 18,
64
+ borderRadius: CATALOG_RADIUS.control,
65
+ flexDirection: 'row',
66
+ gap: CATALOG_SPACE.sm,
67
+ alignItems: 'center',
68
+ justifyContent: 'center',
69
+ alignSelf: 'center',
70
+ },
71
+ primary: { backgroundColor: CATALOG_COLOR.accent },
38
72
  primaryPressed: { opacity: 0.85 },
39
73
  secondary: { borderWidth: 1, borderColor: CATALOG_COLOR.borderStrong, backgroundColor: CATALOG_COLOR.surface },
40
74
  secondaryPressed: { backgroundColor: CATALOG_COLOR.surfacePressed },
75
+ // One flat grey for both variants, so a disabled primary and a disabled secondary read the same:
76
+ // what stops the press is the state, not which button it is.
77
+ disabled: { backgroundColor: CATALOG_COLOR.controlDisabled, borderColor: CATALOG_COLOR.controlDisabled },
41
78
  // Same ring as the sidebar's: an outline, so focusing never shifts the layout.
42
79
  focus: { outlineWidth: CATALOG_LAYOUT.focusRingWidth, outlineStyle: 'solid', outlineColor: CATALOG_COLOR.focusRing } as object,
43
80
  label: { fontSize: CATALOG_TYPE.md, fontWeight: '700' },
44
81
  labelPrimary: { color: '#ffffff' },
45
82
  labelSecondary: { color: CATALOG_COLOR.text },
83
+ labelBusy: { color: CATALOG_COLOR.textMuted },
84
+ labelDisabled: { color: CATALOG_COLOR.controlDisabledText },
46
85
  });