@krapwoo/ds-viewer 0.4.9 → 0.6.0

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 (33) hide show
  1. package/README.md +74 -8
  2. package/dist/cli/doctor.js +92 -5
  3. package/dist/cli/explain.js +11 -20
  4. package/dist/cli/main.js +11 -2
  5. package/dist/cli/render.js +338 -0
  6. package/dist/cli/staticPage.js +27 -4
  7. package/dist/native/catalog/comparison.js +69 -30
  8. package/dist/native/catalog/tokens.js +8 -6
  9. package/native/catalog/CatalogButton.tsx +47 -0
  10. package/native/catalog/CatalogShell.tsx +29 -11
  11. package/native/catalog/CatalogSidebar.tsx +18 -6
  12. package/native/catalog/ComparisonGrid.tsx +9 -8
  13. package/native/catalog/ComparisonGroups.tsx +6 -5
  14. package/native/catalog/ComparisonList.tsx +12 -3
  15. package/native/catalog/OverlayDemo.tsx +52 -0
  16. package/native/catalog/PhoneScreen.tsx +51 -0
  17. package/native/catalog/SectionBlock.tsx +16 -11
  18. package/native/catalog/SpecimenSurface.tsx +4 -4
  19. package/native/catalog/TokenLayouts.tsx +14 -3
  20. package/native/catalog/UpdatePanel.tsx +4 -4
  21. package/native/catalog/comparison.ts +77 -33
  22. package/native/catalog/index.ts +4 -1
  23. package/native/catalog/overlayDemoState.ts +8 -0
  24. package/native/catalog/pageApi.ts +3 -0
  25. package/native/catalog/specimenSurfaceStyle.ts +11 -0
  26. package/native/catalog/tokens.ts +8 -6
  27. package/native/catalog/types.ts +10 -3
  28. package/package.json +1 -1
  29. package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +38 -48
  30. package/starter-kit/components/Dialog/Dialog.catalog.tsx +16 -16
  31. package/starter-kit/components/Toast/Toast.catalog.tsx +9 -10
  32. package/starter-kit/pages/ReportIssue.catalog.tsx +17 -13
  33. package/starter-kit/pages/SavedTrips.catalog.tsx +114 -119
@@ -0,0 +1,338 @@
1
+ // cli/render.ts — `doctor --render`: open every catalog page in a headless browser and report what
2
+ // only a real render shows (console errors, pages that fail to render, the viewer's own [Catalog]
3
+ // layout warnings). The browser is resolved from the project; ds-viewer never installs one.
4
+ import { spawn, spawnSync } from 'node:child_process';
5
+ import { createRequire } from 'node:module';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ const OVERFLOW = /is \d+px wide in a \d+px cell/;
9
+ /**
10
+ * Turns one page's console output into doctor findings. Console errors and a missing heading are
11
+ * errors. The viewer's own `[Catalog]` warnings are warnings: `render-layout` for an example that
12
+ * overflows its cell, `render-catalog-warning` for anything else. Other console noise (React Native
13
+ * web deprecations, logs) is ignored. `seen` is shared across the whole run, so a warning the
14
+ * catalog logs on every page (a duplicate id, an unknown `composedOf` name) is reported once.
15
+ */
16
+ export function issuesFromPage(page, messages, hasHeading, seen = new Set()) {
17
+ const ref = { page: page.id, ...(page.file ? { file: page.file } : {}) };
18
+ const issues = [];
19
+ if (!hasHeading) {
20
+ issues.push({ id: 'render-no-heading', severity: 'error', ...ref, message: 'The page rendered without its heading, so it probably failed to render.', fix: 'Open the page in `ds-viewer dev` and check the browser console.' });
21
+ }
22
+ const pageErrors = new Set();
23
+ for (const m of messages) {
24
+ const first = m.text.split('\n')[0];
25
+ if (m.type === 'error' || m.type === 'pageerror') {
26
+ if (pageErrors.has(first))
27
+ continue;
28
+ pageErrors.add(first);
29
+ issues.push({ id: 'render-error', severity: 'error', ...ref, message: `Console error while rendering: ${first}`, fix: 'Open the page in `ds-viewer dev` and fix the error in the browser console.' });
30
+ }
31
+ else if ((m.type === 'warn' || m.type === 'warning') && first.startsWith('[Catalog] ')) {
32
+ const text = first.slice('[Catalog] '.length);
33
+ if (seen.has(text))
34
+ continue;
35
+ seen.add(text);
36
+ issues.push(OVERFLOW.test(text)
37
+ ? { id: 'render-layout', severity: 'warning', ...ref, message: text, fix: 'Give the page a wider specimenSize; see the README page guide.' }
38
+ : { id: 'render-catalog-warning', severity: 'warning', ...ref, message: text, fix: 'Follow the message; the static doctor rule of the same name has the details.' });
39
+ }
40
+ }
41
+ return issues;
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
+ }
122
+ /** The project's own puppeteer, or `undefined` when it isn't installed. */
123
+ export function loadProjectPuppeteer(projectRoot) {
124
+ try {
125
+ return createRequire(path.join(path.resolve(projectRoot), 'package.json'))('puppeteer');
126
+ }
127
+ catch {
128
+ return undefined;
129
+ }
130
+ }
131
+ export const RENDER_UNAVAILABLE = {
132
+ id: 'render-unavailable',
133
+ severity: 'error',
134
+ message: "`doctor --render` needs a headless browser, and puppeteer isn't installed in this project.",
135
+ fix: 'npm install --save-dev puppeteer (then run doctor --render again).',
136
+ };
137
+ const DEV_START_TIMEOUT_MS = 120_000;
138
+ const FIRST_LOAD_TIMEOUT_MS = 300_000;
139
+ const PAGE_TIMEOUT_MS = 120_000;
140
+ const SETTLE_MS = 900;
141
+ /** Stops `dev` and everything it started (Expo/Metro): the whole process tree on Windows, the
142
+ * whole process group elsewhere (Expo stops through dev's own signal handler). Releases the pipes
143
+ * so this process can exit. `os` is injectable for tests. */
144
+ export function stopDev(child, os = {}) {
145
+ const platform = os.platform ?? process.platform;
146
+ if (child.pid) {
147
+ try {
148
+ if (platform === 'win32')
149
+ (os.spawnSync ?? spawnSync)('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
150
+ else
151
+ (os.kill ?? ((pid, signal) => process.kill(pid, signal)))(-child.pid, 'SIGTERM');
152
+ }
153
+ catch {
154
+ // Already exited.
155
+ }
156
+ }
157
+ child.stdout?.destroy();
158
+ child.stderr?.destroy();
159
+ child.unref();
160
+ }
161
+ /** The last few KB of dev's output, so a failed start can say why. */
162
+ function outputTail() {
163
+ let tail = '';
164
+ return {
165
+ push: (data) => {
166
+ tail = (tail + String(data)).slice(-4000);
167
+ },
168
+ text: () => {
169
+ const lines = tail.trim().split('\n').slice(-8).join('\n');
170
+ return lines ? `\n${lines}` : '';
171
+ },
172
+ };
173
+ }
174
+ /** Starts the project's own `dev` in its own process group. `onSpawn` receives the child at once,
175
+ * so a signal during startup can still stop it. */
176
+ function startDev(projectRoot, onSpawn) {
177
+ const main = path.join(path.dirname(fileURLToPath(import.meta.url)), 'main.js');
178
+ return new Promise((resolve, reject) => {
179
+ const child = spawn(process.execPath, [main, 'dev'], {
180
+ cwd: projectRoot,
181
+ env: { ...process.env, CI: '1', DS_VIEWER_NO_UPDATE_CHECK: '1' },
182
+ detached: process.platform !== 'win32',
183
+ stdio: ['ignore', 'pipe', 'pipe'],
184
+ });
185
+ onSpawn(child);
186
+ const output = outputTail();
187
+ let done = false;
188
+ const fail = (message) => {
189
+ if (done)
190
+ return;
191
+ done = true;
192
+ clearTimeout(timer);
193
+ stopDev(child);
194
+ reject(new Error(`${message}${output.text()}`));
195
+ };
196
+ const timer = setTimeout(() => fail('Timed out waiting for ds-viewer dev to start.'), DEV_START_TIMEOUT_MS);
197
+ // Read continuously (so Metro's output never fills the pipe) and keep the tail for errors.
198
+ child.stderr?.on('data', output.push);
199
+ child.stdout?.on('data', (data) => {
200
+ output.push(data);
201
+ const match = String(data).match(/Starting the catalog at http:\/\/localhost:(\d+)/);
202
+ if (match && !done) {
203
+ done = true;
204
+ clearTimeout(timer);
205
+ resolve({ child, port: Number(match[1]) });
206
+ }
207
+ });
208
+ child.on('exit', (code) => fail(`ds-viewer dev exited early (code ${code}).`));
209
+ });
210
+ }
211
+ const SIGNAL_EXIT_CODES = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 };
212
+ /** A sidebar href's page id; a malformed escape falls back to the raw text (like `idFromHash`). */
213
+ function idFromHref(href) {
214
+ const raw = href.slice(1);
215
+ try {
216
+ return decodeURIComponent(raw);
217
+ }
218
+ catch {
219
+ return raw;
220
+ }
221
+ }
222
+ /** Opens every sidebar page and returns its findings. `files` maps page ids to project-relative
223
+ * page files, for the findings' `file`. `log` receives progress lines (stderr in the CLI). `deps`
224
+ * replaces the browser, dev process control and exit for tests. */
225
+ export async function renderCheck(projectRoot, options = {}) {
226
+ projectRoot = path.resolve(projectRoot);
227
+ const deps = options.deps ?? {};
228
+ const puppeteer = (deps.puppeteer ?? loadProjectPuppeteer(projectRoot));
229
+ if (!puppeteer)
230
+ return [RENDER_UNAVAILABLE];
231
+ const stop = deps.stopDev ?? ((child) => stopDev(child));
232
+ const exit = deps.exit ?? ((code) => process.exit(code));
233
+ const log = options.log ?? (() => { });
234
+ // Set as soon as dev is spawned, before it's ready, so a signal during startup still stops it.
235
+ let devChild;
236
+ let port = 0;
237
+ let browser;
238
+ // Ctrl-C or a CI cancel must still stop Metro: `dev` runs in its own process group, so the
239
+ // terminal's signal never reaches it. Registered before the browser launches, so it runs ahead
240
+ // of puppeteer's own handler.
241
+ const handlers = Object.keys(SIGNAL_EXIT_CODES).map((signal) => {
242
+ const handler = () => {
243
+ if (devChild)
244
+ stop(devChild);
245
+ exit(SIGNAL_EXIT_CODES[signal]);
246
+ };
247
+ process.on(signal, handler);
248
+ return [signal, handler];
249
+ });
250
+ try {
251
+ // The browser first: a missing browser download shouldn't start Metro for nothing.
252
+ try {
253
+ browser = await puppeteer.launch({ headless: true });
254
+ }
255
+ catch (error) {
256
+ return [{ ...RENDER_UNAVAILABLE, message: `Couldn't start the headless browser: ${error.message.split('\n')[0]}`, fix: 'npx puppeteer browsers install chrome' }];
257
+ }
258
+ ({ port } = await (deps.startDev ?? startDev)(projectRoot, (child) => {
259
+ devChild = child;
260
+ }));
261
+ const home = await browser.newPage();
262
+ const deadline = Date.now() + FIRST_LOAD_TIMEOUT_MS;
263
+ for (;;) {
264
+ try {
265
+ await home.goto(`http://localhost:${port}`, { waitUntil: 'networkidle0', timeout: FIRST_LOAD_TIMEOUT_MS });
266
+ break;
267
+ }
268
+ catch (error) {
269
+ if (!String(error).includes('ERR_CONNECTION_REFUSED') || Date.now() > deadline)
270
+ throw error;
271
+ await new Promise((r) => setTimeout(r, 1000));
272
+ }
273
+ }
274
+ // Runs in the browser; `document` is typed loosely because the CLI compiles without DOM types.
275
+ const hrefs = await home.evaluate(() => {
276
+ const doc = globalThis.document;
277
+ return [...new Set(Array.from(doc.querySelectorAll('a[href^="#"]')).map((a) => a.getAttribute('href') ?? ''))];
278
+ });
279
+ await home.close();
280
+ const ids = hrefs.map(idFromHref).filter(Boolean);
281
+ if (ids.length === 0) {
282
+ return [{ id: 'render-failed', severity: 'error', message: 'The catalog rendered no sidebar links, so it probably failed to render at all.', fix: 'Run `ds-viewer dev` and check the browser console.' }];
283
+ }
284
+ log(`Rendering ${ids.length} pages…`);
285
+ const seen = new Set();
286
+ const issues = [];
287
+ for (const id of ids) {
288
+ // One page failing (a navigation timeout) is that page's finding; the others still run.
289
+ let page;
290
+ try {
291
+ page = await browser.newPage();
292
+ await page.setViewport({ width: 1280, height: 800 });
293
+ const messages = [];
294
+ page.on('console', (m) => messages.push({ type: m.type(), text: m.text() }));
295
+ page.on('pageerror', (e) => messages.push({ type: 'pageerror', text: String(e) }));
296
+ await page.goto(`http://localhost:${port}/#${encodeURIComponent(id)}`, { waitUntil: 'networkidle0', timeout: PAGE_TIMEOUT_MS });
297
+ await new Promise((r) => setTimeout(r, options.settleMs ?? SETTLE_MS));
298
+ const hasHeading = await page.evaluate(() => {
299
+ const doc = globalThis.document;
300
+ return Boolean(doc.querySelector('[role="heading"]')?.textContent?.trim());
301
+ });
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));
315
+ await page.close();
316
+ }
317
+ catch (error) {
318
+ // A stuck page's tab is closed, so it doesn't keep loading next to the remaining pages.
319
+ await page?.close().catch(() => { });
320
+ const file = options.files?.get(id);
321
+ issues.push({ id: 'render-error', severity: 'error', page: id, ...(file ? { file } : {}), message: `Couldn't open the page: ${error.message.split('\n')[0]}`, fix: 'Open the page in `ds-viewer dev`; it may be very slow to load or stuck.' });
322
+ }
323
+ }
324
+ log(`Checked ${ids.length} pages.`);
325
+ return issues;
326
+ }
327
+ catch (error) {
328
+ return [{ id: 'render-failed', severity: 'error', message: `Couldn't render the catalog: ${error.message}`, fix: "Run `ds-viewer dev` to see why the catalog doesn't start." }];
329
+ }
330
+ finally {
331
+ for (const [signal, handler] of handlers)
332
+ process.off(signal, handler);
333
+ if (browser)
334
+ await browser.close().catch(() => { });
335
+ if (devChild)
336
+ stop(devChild);
337
+ }
338
+ }
@@ -42,6 +42,7 @@ function readStaticPage(file) {
42
42
  }
43
43
  const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TSX);
44
44
  const imports = importedNames(sourceFile, '@krapwoo/ds-viewer');
45
+ const viewerImports = [...new Set(imports.values())];
45
46
  const defineCatalogPageNames = localNamesFor(imports, 'defineCatalogPage');
46
47
  const gridNames = localNamesFor(imports, 'grid');
47
48
  const call = defaultExportCall(sourceFile);
@@ -49,13 +50,13 @@ function readStaticPage(file) {
49
50
  // Not a recognized `defineCatalogPage(...)` default export — e.g. a hand-written object, or a
50
51
  // shadowed/renamed import this reader can't trace. Not a parse error: the file is valid
51
52
  // TypeScript, just not literal-checkable.
52
- return { file, checkable: false };
53
+ return { file, checkable: false, viewerImports };
53
54
  }
54
55
  const consts = topLevelConsts(sourceFile);
55
56
  const pageObject = resolve(call.arguments[0], consts, new Set());
56
57
  if (!ts.isObjectLiteralExpression(pageObject))
57
- return { file, checkable: false };
58
- const page = { file, checkable: true };
58
+ return { file, checkable: false, viewerImports };
59
+ const page = { file, checkable: true, viewerImports };
59
60
  const idProp = findProp(pageObject, 'id');
60
61
  const componentProp = findProp(pageObject, 'component');
61
62
  const groupProp = findProp(pageObject, 'group');
@@ -169,6 +170,23 @@ function readStaticPage(file) {
169
170
  page.intentionalStaticPreviewReason = stringLiteral(resolve(reasonProp.initializer, consts, new Set()));
170
171
  }
171
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
+ }
172
190
  return page;
173
191
  }
174
192
  /** A literal array of `ComposedOfEntry`-shaped object literals, read down to just `component`
@@ -193,16 +211,21 @@ function readComposedOf(node, consts) {
193
211
  return entries;
194
212
  }
195
213
  /** Local name -> imported name, for every named import from `moduleSpecifier`. */
196
- function importedNames(sourceFile, moduleSpecifier) {
214
+ export function importedNames(sourceFile, moduleSpecifier) {
197
215
  const names = new Map();
198
216
  for (const statement of sourceFile.statements) {
199
217
  if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== moduleSpecifier) {
200
218
  continue;
201
219
  }
220
+ // Type-only imports bring no runtime value (`import type { X }`, `import { type X }`).
221
+ if (statement.importClause?.isTypeOnly)
222
+ continue;
202
223
  const bindings = statement.importClause?.namedBindings;
203
224
  if (!bindings || !ts.isNamedImports(bindings))
204
225
  continue;
205
226
  for (const element of bindings.elements) {
227
+ if (element.isTypeOnly)
228
+ continue;
206
229
  names.set(element.name.text, element.propertyName ? element.propertyName.text : element.name.text);
207
230
  }
208
231
  }
@@ -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.',
@@ -0,0 +1,47 @@
1
+ import React, { forwardRef, useState } from 'react';
2
+ import { Pressable, StyleSheet, Text, type View } from 'react-native';
3
+ import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
4
+
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. */
7
+ export const CatalogButton = forwardRef<View, {
8
+ label: string;
9
+ onPress: () => void;
10
+ variant?: 'primary' | 'secondary';
11
+ accessibilityLabel?: string;
12
+ }>(function CatalogButton({ label, onPress, variant = 'primary', accessibilityLabel }, ref) {
13
+ const [focused, setFocused] = useState(false);
14
+ const primary = variant === 'primary';
15
+ return (
16
+ <Pressable
17
+ ref={ref}
18
+ onPress={onPress}
19
+ onFocus={() => setFocused(true)}
20
+ onBlur={() => setFocused(false)}
21
+ accessibilityRole="button"
22
+ accessibilityLabel={accessibilityLabel ?? label}
23
+ style={({ pressed }) => [
24
+ styles.base,
25
+ primary ? styles.primary : styles.secondary,
26
+ pressed && (primary ? styles.primaryPressed : styles.secondaryPressed),
27
+ focused && styles.focus,
28
+ ]}
29
+ >
30
+ <Text style={[styles.label, primary ? styles.labelPrimary : styles.labelSecondary]}>{label}</Text>
31
+ </Pressable>
32
+ );
33
+ });
34
+
35
+ const styles = StyleSheet.create({
36
+ // A rounded rectangle (owner decision 2026-10-11); primary in the catalog's accent blue.
37
+ base: { height: CATALOG_LAYOUT.controlSize, paddingHorizontal: 18, borderRadius: CATALOG_RADIUS.control, alignItems: 'center', justifyContent: 'center', alignSelf: 'center' },
38
+ primary: { backgroundColor: CATALOG_COLOR.accent },
39
+ primaryPressed: { opacity: 0.85 },
40
+ secondary: { borderWidth: 1, borderColor: CATALOG_COLOR.borderStrong, backgroundColor: CATALOG_COLOR.surface },
41
+ secondaryPressed: { backgroundColor: CATALOG_COLOR.surfacePressed },
42
+ // Same ring as the sidebar's: an outline, so focusing never shifts the layout.
43
+ focus: { outlineWidth: CATALOG_LAYOUT.focusRingWidth, outlineStyle: 'solid', outlineColor: CATALOG_COLOR.focusRing } as object,
44
+ label: { fontSize: CATALOG_TYPE.md, fontWeight: '700' },
45
+ labelPrimary: { color: '#ffffff' },
46
+ labelSecondary: { color: CATALOG_COLOR.text },
47
+ });