@krapwoo/ds-viewer 0.6.11 → 0.8.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.
package/dist/cli/main.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import path from 'node:path';
3
+ import { readFileSync } from 'node:fs';
3
4
  import { fileURLToPath } from 'node:url';
4
5
  import { resolveConfig } from "./config.js";
5
6
  import { initExistingProject, initNewProject, promptInitMode } from "./init.js";
@@ -9,6 +10,7 @@ import { formatHuman, runDoctor, toDoctorJson, withExtraIssues } from "./doctor.
9
10
  import { renderCheck } from "./render.js";
10
11
  import { RENDER_WORKSPACE_DIR } from "./types.js";
11
12
  import { explainPage, formatExplain, parseHeights } from "./explain.js";
13
+ import { comparableWidths, diffLayouts, formatLayoutDiff, layoutSnapshot } from "./layout.js";
12
14
  import { readOwnVersion } from "./packageVersion.js";
13
15
  import { checkForUpdate, defaultPreferencesDir, readPersonalAutoCheck, resolveAutoCheck } from "./updateCheck.js";
14
16
  import { runKitDiffCommand } from "./kitDiff.js";
@@ -34,10 +36,17 @@ Commands:
34
36
  --json print the machine-readable report instead
35
37
  --ci exit 1 when any error was found (never on a warning alone)
36
38
  --render also open every page in a headless browser (needs puppeteer)
39
+ --port <n> pin the render server's port (with --render), for when something
40
+ else already holds the range it would pick from
37
41
  explain <Page>
38
42
  Print why a page's specimens are laid out the way they are
39
43
  --heights <first>,<second> check side-by-side placement with these measured heights
40
44
  --json print the machine-readable report instead
45
+ --all every page's geometry as JSON, for layout-diff
46
+ layout-diff <before.json> <after.json>
47
+ What changed about every page's layout between two explain --all snapshots, so a
48
+ viewer upgrade that reshapes your catalog is visible rather than silent
49
+ --json print the changes as JSON
41
50
  kit diff <Component>
42
51
  Show the user's kit file(s) for <Component> against the installed kit's copy
43
52
  migrate --from <version>
@@ -128,7 +137,23 @@ export async function runDoctorCommand(projectRoot, rest, options = {}) {
128
137
  // Progress goes to stderr, so `--json` output stays pure JSON.
129
138
  if (rest.includes('--render')) {
130
139
  const files = result.pageFiles;
131
- const rendered = await (options.renderCheck ?? renderCheck)(config.projectRoot, { files, log: (line) => process.stderr.write(`${line}\n`) });
140
+ // `--port <n>`: pin the render server instead of taking the first free port from 5181. Needed
141
+ // when something else already holds that range (another project's catalog, a second `dev`),
142
+ // because the check cannot tell a stranger's server on its port from its own and would report
143
+ // that server's pages as this project's failures.
144
+ const portFlag = rest.indexOf('--port');
145
+ let renderPort;
146
+ if (portFlag !== -1) {
147
+ const raw = rest[portFlag + 1];
148
+ renderPort = Number(raw);
149
+ if (!raw || !Number.isInteger(renderPort) || renderPort < 1 || renderPort > 65535) {
150
+ // Refused rather than quietly falling back to a port it picked itself: a check that
151
+ // ignores the port you gave it would render against whatever else is there and report a
152
+ // stranger's pages as this project's failures.
153
+ return { output: `doctor --render --port needs a port between 1 and 65535, not ${raw ?? '(nothing)'}.`, exitCode: 1 };
154
+ }
155
+ }
156
+ const rendered = await (options.renderCheck ?? renderCheck)(config.projectRoot, { files, log: (line) => process.stderr.write(`${line}\n`), ...(renderPort ? { port: renderPort } : {}) });
132
157
  result = withExtraIssues(result, rendered, Boolean(config.doctor?.strict));
133
158
  }
134
159
  // Design §4 "`--ci`: ... no update check." — `--ci` skips this whole block even when
@@ -153,8 +178,21 @@ export async function runDoctorCommand(projectRoot, rest, options = {}) {
153
178
  }
154
179
  /** Same "testable without spawning" shape as `runDoctorCommand` above. */
155
180
  export function runExplainCommand(projectRoot, pageId, rest) {
181
+ // `--all`: every page's geometry as one JSON document, for `layout-diff` to compare across
182
+ // versions. It takes no page id, and it is JSON only — the whole point is a machine-readable
183
+ // artifact, and a prose dump of 50 pages would be read by nobody.
184
+ if (pageId === '--all' || rest.includes('--all')) {
185
+ const config = resolveConfig(projectRoot);
186
+ const version = readOwnVersion(path.dirname(fileURLToPath(import.meta.url)));
187
+ try {
188
+ return { output: JSON.stringify(layoutSnapshot(config, version), null, 2), exitCode: 0 };
189
+ }
190
+ catch (error) {
191
+ return { output: error.message, exitCode: 1 };
192
+ }
193
+ }
156
194
  if (!pageId)
157
- return { output: 'Usage: ds-viewer explain <Page> [--heights <first>,<second>] [--json]', exitCode: 1 };
195
+ return { output: 'Usage: ds-viewer explain <Page> [--heights <first>,<second>] [--json], or explain --all for every page as JSON', exitCode: 1 };
158
196
  const heightsIndex = rest.indexOf('--heights');
159
197
  const heightsRaw = heightsIndex !== -1 ? rest[heightsIndex + 1] : undefined;
160
198
  const heights = heightsRaw ? parseHeights(heightsRaw) : undefined;
@@ -167,6 +205,104 @@ export function runExplainCommand(projectRoot, pageId, rest) {
167
205
  return { output: `No page named "${pageId}" was found.`, exitCode: 1 };
168
206
  return { output: rest.includes('--json') ? JSON.stringify(result, null, 2) : formatExplain(result), exitCode: 0 };
169
207
  }
208
+ function isFiniteNumber(value) {
209
+ return typeof value === 'number' && Number.isFinite(value);
210
+ }
211
+ function isGeometry(value) {
212
+ if (!value || typeof value !== 'object')
213
+ return false;
214
+ const geometry = value;
215
+ return Number.isInteger(geometry.columns) && geometry.columns >= 0
216
+ && Number.isInteger(geometry.rows) && geometry.rows >= 0
217
+ && Number.isInteger(geometry.fillers) && geometry.fillers >= 0;
218
+ }
219
+ function isLayoutSnapshot(value) {
220
+ if (!value || typeof value !== 'object')
221
+ return false;
222
+ const snapshot = value;
223
+ if (typeof snapshot.version !== 'string' || !isFiniteNumber(snapshot.width) || snapshot.width <= 0 || !Array.isArray(snapshot.pages))
224
+ return false;
225
+ const ids = new Set();
226
+ for (const entry of snapshot.pages) {
227
+ if (!entry || typeof entry !== 'object')
228
+ return false;
229
+ const page = entry;
230
+ if (typeof page.page !== 'string' || page.page.length === 0 || ids.has(page.page) || !Array.isArray(page.blocks))
231
+ return false;
232
+ ids.add(page.page);
233
+ if (page.sideEligible !== undefined && typeof page.sideEligible !== 'boolean')
234
+ return false;
235
+ if (page.notCheckable !== undefined && page.notCheckable !== true)
236
+ return false;
237
+ for (const entryBlock of page.blocks) {
238
+ if (!entryBlock || typeof entryBlock !== 'object')
239
+ return false;
240
+ const block = entryBlock;
241
+ if (typeof block.title !== 'string' || !['grid', 'grouped', 'list', 'preview', 'tokenSections', 'empty'].includes(String(block.kind)))
242
+ return false;
243
+ if (!(block.minWidth === null || isFiniteNumber(block.minWidth)))
244
+ return false;
245
+ if (block.items !== undefined && (!Number.isInteger(block.items) || block.items < 0))
246
+ return false;
247
+ if (block.size !== undefined && typeof block.size !== 'string')
248
+ return false;
249
+ if (block.full !== undefined && !isGeometry(block.full))
250
+ return false;
251
+ if (block.half !== undefined && !isGeometry(block.half))
252
+ return false;
253
+ }
254
+ }
255
+ return true;
256
+ }
257
+ /** `layout-diff <before.json> <after.json>`: what a version changed about every page's geometry. */
258
+ export function runLayoutDiffCommand(args, deps = {}) {
259
+ const [beforePath, afterPath] = args.filter((a) => !a.startsWith('--'));
260
+ if (!beforePath || !afterPath) {
261
+ return { output: 'Usage: ds-viewer layout-diff <before.json> <after.json>\n\nBoth are written by `ds-viewer explain --all`.', exitCode: 1 };
262
+ }
263
+ const read = deps.readFile ?? ((file) => readFileSync(file, 'utf8'));
264
+ const load = (file) => {
265
+ let raw;
266
+ try {
267
+ raw = read(file);
268
+ }
269
+ catch {
270
+ return `Could not read ${file}.`;
271
+ }
272
+ try {
273
+ const parsed = JSON.parse(raw);
274
+ // Refuse plausible-but-incomplete JSON before the diff touches it. Treating an absent block
275
+ // array as empty reports invented removals; dereferencing it reports a raw TypeError instead
276
+ // of telling the user they selected the wrong file.
277
+ if (!isLayoutSnapshot(parsed)) {
278
+ return `${file} is not a layout snapshot. Write one with \`ds-viewer explain --all\`.`;
279
+ }
280
+ return parsed;
281
+ }
282
+ catch {
283
+ return `${file} is not valid JSON.`;
284
+ }
285
+ };
286
+ const before = load(beforePath);
287
+ if (typeof before === 'string')
288
+ return { output: before, exitCode: 1 };
289
+ const after = load(afterPath);
290
+ if (typeof after === 'string')
291
+ return { output: after, exitCode: 1 };
292
+ // Before diffing, not only before printing: the guard used to live in `formatLayoutDiff`, so
293
+ // `--json` — the path most likely to be automated in CI — returned a confident list of changes
294
+ // computed across incomparable widths.
295
+ if (!comparableWidths(before, after)) {
296
+ return {
297
+ output: `These snapshots were taken at different widths (${before.width}px and ${after.width}px), so their geometry is not comparable. The two viewer versions use different catalog content widths; compare releases whose snapshot widths match.`,
298
+ exitCode: 1,
299
+ };
300
+ }
301
+ const changes = diffLayouts(before, after);
302
+ if (args.includes('--json'))
303
+ return { output: JSON.stringify(changes, null, 2), exitCode: 0 };
304
+ return { output: formatLayoutDiff(before, after, changes), exitCode: 0 };
305
+ }
170
306
  async function main() {
171
307
  const [command, ...rest] = process.argv.slice(2);
172
308
  const projectRoot = process.cwd();
@@ -201,6 +337,13 @@ async function main() {
201
337
  process.exitCode = exitCode;
202
338
  return;
203
339
  }
340
+ if (command === 'layout-diff') {
341
+ const { output, exitCode } = runLayoutDiffCommand(rest);
342
+ console.log(output);
343
+ if (exitCode)
344
+ process.exitCode = exitCode;
345
+ return;
346
+ }
204
347
  if (command === 'explain') {
205
348
  const [pageId, ...explainRest] = rest;
206
349
  const { output, exitCode } = runExplainCommand(projectRoot, pageId, explainRest);
@@ -121,6 +121,79 @@ export function identicalVariantIssues(page, pairs) {
121
121
  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.",
122
122
  }));
123
123
  }
124
+ /**
125
+ * Runs in the browser: finds specimen content that paints outside its cell and cannot be reached.
126
+ *
127
+ * A cell is the width the page gives an example. Content wider than it is either clipped — the
128
+ * reader sees a cut-off component and no indication anything is missing — or drawn over its
129
+ * neighbour. Either way the page is showing something other than the component.
130
+ *
131
+ * Scrollable content is deliberately not reported, and so is content a component clips itself:
132
+ * both are decisions the specimen made, not width the page failed to give it. `PillRow`'s "Many
133
+ * pills (scrollable)" paints 220px outside its cell at 1280 and is correct; a shimmer sweep or a
134
+ * carousel that hides its own overflow is equally deliberate.
135
+ *
136
+ * The containment walk stops at the cell. Looking past it was wrong: `ComparisonGroups` wraps every
137
+ * grouped card in a horizontal `ScrollView`, which react-native-web renders as `overflow-x: auto`,
138
+ * so a walk to the document root found that scroller above every grouped cell and exempted all of
139
+ * them — the rule covered plain list blocks only, while its calibration claimed the whole corpus.
140
+ * What matters is whether the *specimen* contains its own overflow, which is a question about the
141
+ * ancestors inside the cell.
142
+ *
143
+ * Only horizontal crossings are measured. A cell's width is what the page allocates and what
144
+ * `specimenSize` controls; its height grows with content.
145
+ */
146
+ export const CLIPPED_SPECIMEN_SCRIPT = `(() => {
147
+ const doc = document;
148
+ const CONTAINS = /^(auto|scroll|hidden|clip)$/;
149
+ const out = [];
150
+ for (const card of doc.querySelectorAll('[role=list][aria-label]')) {
151
+ if (!/^[^:]+: .+/.test(card.getAttribute('aria-label') || '')) continue;
152
+ for (const cell of card.querySelectorAll('[role=listitem]')) {
153
+ if (cell.closest('[role=list]') !== card) continue;
154
+ const box = cell.getBoundingClientRect();
155
+ if (box.width < 2) continue;
156
+ let worst = 0;
157
+ let side = 'right';
158
+ for (const el of cell.querySelectorAll('*')) {
159
+ const r = el.getBoundingClientRect();
160
+ if (r.width < 2 || r.height < 2) continue;
161
+ const over = Math.max(box.left - r.left, r.right - box.right);
162
+ if (over <= worst) continue;
163
+ // Invisible content, including anything inside an invisible or offscreen wrapper: an exit
164
+ // animation mid-flight, or a visually-hidden node parked at a large negative offset.
165
+ let hidden = false;
166
+ let contained = false;
167
+ for (let p = el; p && p !== cell; p = p.parentElement) {
168
+ const style = getComputedStyle(p);
169
+ if (style.visibility === 'hidden' || style.opacity === '0' || style.display === 'none') { hidden = true; break; }
170
+ // Anything between the content and the cell that handles its own overflow — scrolling it
171
+ // or clipping it — is the specimen's own decision, not the page denying it width.
172
+ if (p !== el && CONTAINS.test(style.overflowX)) { contained = true; break; }
173
+ }
174
+ if (hidden || contained) continue;
175
+ worst = over;
176
+ side = box.left - r.left >= r.right - box.right ? 'left' : 'right';
177
+ }
178
+ if (worst > 1) {
179
+ const caption = cell.children[0] ? (cell.children[0].textContent || '') : '';
180
+ out.push({ name: caption.trim(), overflow: Math.round(worst), side });
181
+ }
182
+ }
183
+ }
184
+ return out;
185
+ })()`;
186
+ /** Turns the browser's findings into doctor issues, one per clipped example. */
187
+ export function clippedSpecimenIssues(page, clipped) {
188
+ const ref = { page: page.id, ...(page.file ? { file: page.file } : {}) };
189
+ return clipped.map((c) => ({
190
+ id: 'specimen-clipped',
191
+ severity: 'warning',
192
+ ...ref,
193
+ message: `${c.name ? `"${c.name}"` : 'An example'} paints ${c.overflow}px past the ${c.side} of its cell, where it is cut off rather than scrollable.`,
194
+ fix: 'Give the page a wider specimenSize, stack the blocks so the example gets the full row, or drop `itemsFill` if the example is a small instance that should sit centered.',
195
+ }));
196
+ }
124
197
  /** The project's own puppeteer, or `undefined` when it isn't installed. */
125
198
  export function loadProjectPuppeteer(projectRoot) {
126
199
  try {
@@ -195,14 +268,14 @@ async function waitForServer(port, timeoutMs = DEV_START_TIMEOUT_MS) {
195
268
  await new Promise((resolve) => setTimeout(resolve, 250));
196
269
  }
197
270
  }
198
- function startDev(projectRoot, onSpawn) {
271
+ function startDev(projectRoot, onSpawn, pinnedPort) {
199
272
  const main = path.join(path.dirname(fileURLToPath(import.meta.url)), 'main.js');
200
273
  return new Promise((resolve, reject) => {
201
274
  // Its own workspace directory and no update endpoint: this server exists to render pages, and
202
275
  // every file it generates would otherwise overwrite the ones an already-running `dev` is
203
276
  // serving — including that developer's endpoint, pointing their open catalog at a port that
204
277
  // dies with this check.
205
- const child = spawn(process.execPath, [main, 'dev', '--no-update-endpoint', '--workspace-dir', RENDER_WORKSPACE_DIR], {
278
+ const child = spawn(process.execPath, [main, 'dev', '--no-update-endpoint', '--workspace-dir', RENDER_WORKSPACE_DIR, ...(pinnedPort ? ['--port', String(pinnedPort)] : [])], {
206
279
  cwd: projectRoot,
207
280
  env: { ...process.env, CI: '1', DS_VIEWER_NO_UPDATE_CHECK: '1' },
208
281
  detached: process.platform !== 'win32',
@@ -289,7 +362,7 @@ export async function renderCheck(projectRoot, options = {}) {
289
362
  }
290
363
  ({ port } = await (deps.startDev ?? startDev)(projectRoot, (child) => {
291
364
  devChild = child;
292
- }));
365
+ }, options.port));
293
366
  const home = await browser.newPage();
294
367
  const deadline = Date.now() + FIRST_LOAD_TIMEOUT_MS;
295
368
  for (;;) {
@@ -352,6 +425,17 @@ export async function renderCheck(projectRoot, options = {}) {
352
425
  }
353
426
  if (Array.isArray(identical))
354
427
  issues.push(...identicalVariantIssues({ id, file: options.files?.get(id) }, identical));
428
+ // Advisory for the same reason as the pass above, and kept separate so one failing script
429
+ // does not cost the other its findings.
430
+ let clipped;
431
+ try {
432
+ clipped = (await page.evaluate(CLIPPED_SPECIMEN_SCRIPT));
433
+ }
434
+ catch {
435
+ clipped = undefined;
436
+ }
437
+ if (Array.isArray(clipped))
438
+ issues.push(...clippedSpecimenIssues({ id, file: options.files?.get(id) }, clipped));
355
439
  await page.close();
356
440
  }
357
441
  catch (error) {
@@ -337,16 +337,49 @@ function heightAtWidth(block, width, available) {
337
337
  const rows = listGeometry(block.itemCount, width, block.size, block.maxColumns).rows;
338
338
  return (block.height / Math.max(1, stackedRows)) * rows;
339
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. */
340
+ /** Whether a block still fits on a single row in half the width. Deliberately stricter than "the
341
+ * same number of rows as a full row would give": a column cap can already force a block to wrap
342
+ * at full width, and two wrapped rows staying two wrapped rows is not a reason to halve every
343
+ * specimen on the page. One row is the case where sharing genuinely costs nothing. */
344
+ export function fitsOneRow(block, half) {
345
+ if (block.kind !== 'list' || !block.itemCount || !block.size)
346
+ return true;
347
+ return listGeometry(block.itemCount, half, block.size, block.maxColumns).rows <= 1;
348
+ }
349
+ /** Side by side or stacked, for a page with exactly two blocks.
350
+ *
351
+ * **Stacked is the default.** A page reads top to bottom, each block gets the whole row, and every
352
+ * specimen keeps the width its component is actually used at. Side by side halves that width, so
353
+ * it has to earn the space rather than be taken whenever it happens to save height — which is how
354
+ * `Banner` ended up showing five full-width banners in a 462px column.
355
+ *
356
+ * Two things earn it:
357
+ *
358
+ * - **A single tall specimen.** One specimen taller than the column is wide leaves a long empty
359
+ * strip beside it (an open `Dropdown` is 738px tall in a 462px column), and the other block
360
+ * fits there for free.
361
+ * - **Both still fit one row.** Specimens small enough that each block is a single row in half
362
+ * the width lose nothing by sharing it.
363
+ *
364
+ * Both still require each block to keep its minimum width in half a row, and the result to save
365
+ * at least `PLACEMENT_MIN_SAVING` of height — side by side that saves nothing is just a narrower
366
+ * page. */
344
367
  export function choosePlacement({ available, gap, first, second }) {
345
368
  const half = Math.floor((available - gap) / 2);
346
369
  if (half <= 0)
347
370
  return 'stacked';
348
371
  if ((first.minWidth ?? Infinity) > half || (second.minWidth ?? Infinity) > half)
349
372
  return 'stacked';
373
+ // Measured at the width it would have: a specimen is "tall" when it runs longer than the column
374
+ // it would sit in, which is what leaves room beside it.
375
+ // Either block can be the tall one. Checking `first` alone made placement depend on the order the
376
+ // page happens to declare its blocks in, so the same two blocks laid out variants-then-states and
377
+ // states-then-variants got different answers — and the rule says "a single specimen taller than
378
+ // its column", which says nothing about order.
379
+ const singleTallSpecimen = (first.itemCount === 1 && first.height > half) || (second.itemCount === 1 && second.height > half);
380
+ const bothFitOneRow = fitsOneRow(first, half) && fitsOneRow(second, half);
381
+ if (!singleTallSpecimen && !bothFitOneRow)
382
+ return 'stacked';
350
383
  const sideHeight = Math.max(heightAtWidth(first, half, available), heightAtWidth(second, half, available));
351
384
  const stackedHeight = first.height + gap + second.height;
352
385
  return sideHeight <= stackedHeight - PLACEMENT_MIN_SAVING ? 'side' : 'stacked';
@@ -1,5 +1,5 @@
1
1
  import { View, Text, StyleSheet } from 'react-native';
2
- import { TokenRow } from './TokenRow';
2
+ import { TokenRow, TokenRowGroup } from './TokenRow';
3
3
  import { CATALOG_TYPE, CATALOG_COLOR, CATALOG_SPACE, CATALOG_RADIUS } from './tokens';
4
4
 
5
5
  /**
@@ -18,11 +18,11 @@ export function SpacingScaleGallery<TStep extends string | number>({
18
18
  useNotes: Record<TStep, string>;
19
19
  }) {
20
20
  return (
21
- <View style={styles.tokenStack}>
21
+ <TokenRowGroup><View style={styles.tokenStack}>
22
22
  {steps.map((step, i) => {
23
23
  const px = values[step];
24
24
  return (
25
- <TokenRow key={String(step)} use={useNotes[step]} last={i === steps.length - 1}>
25
+ <TokenRow key={String(step)} use={useNotes[step]} last={i === steps.length - 1} notePlacement="right">
26
26
  <View style={styles.row}>
27
27
  <Text style={styles.label}>{String(step)}</Text>
28
28
  <View style={[styles.bar, { width: Math.max(px, 1) }]} />
@@ -31,7 +31,7 @@ export function SpacingScaleGallery<TStep extends string | number>({
31
31
  </TokenRow>
32
32
  );
33
33
  })}
34
- </View>
34
+ </View></TokenRowGroup>
35
35
  );
36
36
  }
37
37
 
@@ -1,18 +1,45 @@
1
- import React from 'react';
2
- import { View, Text, StyleSheet } from 'react-native';
1
+ import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
2
+ import { View, Text, StyleSheet, type LayoutChangeEvent } from 'react-native';
3
3
  import { CATALOG_TYPE, CATALOG_COLOR, CATALOG_SPACE } from './tokens';
4
4
 
5
+ type Lane = { width: number; report: (width: number) => void };
6
+
7
+ const LaneContext = createContext<Lane | undefined>(undefined);
8
+
5
9
  /**
6
- * Wraps one token's rendered example with a grounded "when to use this" note beneath it — the
7
- * shared shape behind every token-gallery row (Spacing, Type Scale, …) in both the starter kit's
8
- * own token pages and the catalog framework's own `kit-host/viewer-pages/` pages. The row's own
9
- * content (a spacing bar, a type sample, …) is
10
- * freeform `children`; only the value-plus-use-note stacking is standardized here.
10
+ * Gives a stack of `notePlacement="right"` rows one shared sample column, measured from the widest
11
+ * sample actually in the stack.
12
+ *
13
+ * A fixed column has to be wide enough for the biggest sample a page might hold, which strands
14
+ * every smaller one: a `normal 400` weight sample is ~110px, and in a column sized for an 88px
15
+ * display sample its note sat 700px away from it. Measuring instead means the Font page's column
16
+ * is as wide as a font weight, and the Type scale's is as wide as `display` — each page tight to
17
+ * its own content, with the notes still aligned in a single column to read down.
11
18
  *
12
- * Draws a 1px `CATALOG_COLOR.border` bottom divider by default (the same divider PropsTable's rows use) so a stack of TokenRows
13
- * reads as a list, not a loose pile of paragraphs. Pass `last` on the final row in a stack to drop
14
- * the divider, matching PropsTable's own `rowLast` convention. Balanced `CATALOG_SPACE.md` padding
15
- * sits above and below both the content and its use-note, so a stack of rows breathes evenly
19
+ * The width only ever grows, so it settles after the first layout: once the column is the widest
20
+ * natural sample, no sample can report wider than it and nothing re-measures. Rows sit at their
21
+ * natural width for that first frame.
22
+ */
23
+ export function TokenRowGroup({ children }: { children: React.ReactNode }) {
24
+ const [width, setWidth] = useState(0);
25
+ const report = useCallback((next: number) => {
26
+ setWidth((current) => (next > current ? next : current));
27
+ }, []);
28
+ const value = useMemo(() => ({ width, report }), [width, report]);
29
+ return <LaneContext.Provider value={value}>{children}</LaneContext.Provider>;
30
+ }
31
+
32
+ /**
33
+ * Wraps one token's rendered example with a grounded "when to use this" note — the shared shape
34
+ * behind every token-gallery row (Spacing, Type Scale, …) in both the starter kit's own token pages
35
+ * and the catalog framework's own `viewer-pages/` pages. The row's own content (a spacing bar, a
36
+ * type sample, …) is freeform `children`; only the value-plus-use-note arrangement is standardized
37
+ * here.
38
+ *
39
+ * Draws a 1px `CATALOG_COLOR.border` bottom divider by default (the same divider PropsTable's rows
40
+ * use) so a stack of TokenRows reads as a list, not a loose pile of paragraphs. Pass `last` on the
41
+ * final row in a stack to drop the divider, matching PropsTable's own `rowLast` convention.
42
+ * Balanced `CATALOG_SPACE.md` padding sits above and below, so a stack of rows breathes evenly
16
43
  * instead of only the divider-adjacent gap existing.
17
44
  */
18
45
  export function TokenRow({
@@ -25,15 +52,36 @@ export function TokenRow({
25
52
  children: React.ReactNode;
26
53
  use: string;
27
54
  last?: boolean;
28
- /** `'right'` puts the note beside a narrow sample (icon sizes, spacing, control heights) so each
29
- * token is one line; the sample keeps `sampleWidth` and the note takes the rest. */
55
+ /** `'right'` puts the note beside the sample (font weights, spacing, type sizes) so each token is
56
+ * one line. Inside a `TokenRowGroup` the sample column is measured from the widest sample in the
57
+ * stack; a `TokenRowGroup`'s measured column wins when there is one, and `sampleWidth`
58
+ * (default 280) is the fallback for a row standing on its own. Keeping the default matters for
59
+ * rows outside a group: without it every row sizes to its own sample and the notes stop lining
60
+ * up in a column, which is the thing `notePlacement="right"` exists to do. */
30
61
  notePlacement?: 'below' | 'right';
31
62
  sampleWidth?: number;
32
63
  }) {
64
+ const lane = useContext(LaneContext);
65
+
33
66
  if (notePlacement === 'right') {
67
+ const measured = lane && lane.width > 0 ? lane.width : undefined;
68
+ // Inside a group the lane governs, and only the lane: falling back to `sampleWidth` on the
69
+ // first frame would pin the sample to 280 before anything is measured, and the measurement
70
+ // taken through that constraint can never report wider than 280 — capping a lane that exists
71
+ // precisely to grow to the widest sample (a type scale's `display` needs ~470).
72
+ // `sampleWidth` is for a row standing on its own, where there is nothing to measure against.
73
+ const column = lane ? measured : sampleWidth;
74
+ const onLayout = lane
75
+ ? (event: LayoutChangeEvent) => lane.report(Math.ceil(event.nativeEvent.layout.width))
76
+ : undefined;
34
77
  return (
35
78
  <View style={[styles.itemRight, !last && styles.itemDivider]}>
36
- <View style={{ width: sampleWidth, flexShrink: 0 }}>{children}</View>
79
+ {/* While measuring, the inner view sits at its natural width so it reports what the sample
80
+ actually takes. Standing alone there is nothing to report, so it stretches to the column
81
+ instead — otherwise a sample sized as a percentage would collapse to nothing. */}
82
+ <View style={[styles.sample, column !== undefined && { width: column }]}>
83
+ <View style={onLayout ? styles.natural : undefined} onLayout={onLayout}>{children}</View>
84
+ </View>
37
85
  {use ? <Text style={[styles.use, styles.useRight]}>{use}</Text> : null}
38
86
  </View>
39
87
  );
@@ -49,6 +97,8 @@ export function TokenRow({
49
97
  const styles = StyleSheet.create({
50
98
  item: { gap: CATALOG_SPACE.xs, paddingVertical: CATALOG_SPACE.md },
51
99
  itemRight: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.xl, paddingVertical: CATALOG_SPACE.md },
100
+ sample: { flexShrink: 0 },
101
+ natural: { alignSelf: 'flex-start' },
52
102
  useRight: { flex: 1 },
53
103
  itemDivider: {
54
104
  borderBottomWidth: 1,
@@ -1,5 +1,5 @@
1
1
  import { View, Text, StyleSheet, type TextStyle } from 'react-native';
2
- import { TokenRow } from './TokenRow';
2
+ import { TokenRow, TokenRowGroup } from './TokenRow';
3
3
  import { CATALOG_TYPE, CATALOG_COLOR, CATALOG_SPACE } from './tokens';
4
4
 
5
5
  /**
@@ -22,9 +22,9 @@ export function TypeScaleGallery<TStep extends string>({
22
22
  useNotes: Record<TStep, string>;
23
23
  }) {
24
24
  return (
25
- <View style={styles.tokenStack}>
25
+ <TokenRowGroup><View style={styles.tokenStack}>
26
26
  {steps.map((step, i) => (
27
- <TokenRow key={String(step)} use={useNotes[step]} last={i === steps.length - 1}>
27
+ <TokenRow key={String(step)} use={useNotes[step]} last={i === steps.length - 1} notePlacement="right">
28
28
  <View style={styles.row}>
29
29
  <Text style={[sampleStyle(step), styles.sample]} numberOfLines={1}>
30
30
  {String(step)}
@@ -33,7 +33,7 @@ export function TypeScaleGallery<TStep extends string>({
33
33
  </View>
34
34
  </TokenRow>
35
35
  ))}
36
- </View>
36
+ </View></TokenRowGroup>
37
37
  );
38
38
  }
39
39
 
@@ -437,14 +437,48 @@ function heightAtWidth(block: PlacementBlock, width: number, available: number):
437
437
  return (block.height / Math.max(1, stackedRows)) * rows;
438
438
  }
439
439
 
440
- /** Side by side or stacked, for a page with exactly two blocks. Side by side splits the row into
441
- * two equal columns and each block fills its own, so it is only possible when both blocks keep
442
- * their minimum width in half a row; it is only taken when it saves at least PLACEMENT_MIN_SAVING
443
- * against stacking, measuring each block at the width it would actually have. */
440
+ /** Whether a block still fits on a single row in half the width. Deliberately stricter than "the
441
+ * same number of rows as a full row would give": a column cap can already force a block to wrap
442
+ * at full width, and two wrapped rows staying two wrapped rows is not a reason to halve every
443
+ * specimen on the page. One row is the case where sharing genuinely costs nothing. */
444
+ export function fitsOneRow(block: PlacementBlock, half: number): boolean {
445
+ if (block.kind !== 'list' || !block.itemCount || !block.size) return true;
446
+ return listGeometry(block.itemCount, half, block.size, block.maxColumns).rows <= 1;
447
+ }
448
+
449
+ /** Side by side or stacked, for a page with exactly two blocks.
450
+ *
451
+ * **Stacked is the default.** A page reads top to bottom, each block gets the whole row, and every
452
+ * specimen keeps the width its component is actually used at. Side by side halves that width, so
453
+ * it has to earn the space rather than be taken whenever it happens to save height — which is how
454
+ * `Banner` ended up showing five full-width banners in a 462px column.
455
+ *
456
+ * Two things earn it:
457
+ *
458
+ * - **A single tall specimen.** One specimen taller than the column is wide leaves a long empty
459
+ * strip beside it (an open `Dropdown` is 738px tall in a 462px column), and the other block
460
+ * fits there for free.
461
+ * - **Both still fit one row.** Specimens small enough that each block is a single row in half
462
+ * the width lose nothing by sharing it.
463
+ *
464
+ * Both still require each block to keep its minimum width in half a row, and the result to save
465
+ * at least `PLACEMENT_MIN_SAVING` of height — side by side that saves nothing is just a narrower
466
+ * page. */
444
467
  export function choosePlacement({ available, gap, first, second }: { available: number; gap: number; first: PlacementBlock; second: PlacementBlock }): 'side' | 'stacked' {
445
468
  const half = Math.floor((available - gap) / 2);
446
469
  if (half <= 0) return 'stacked';
447
470
  if ((first.minWidth ?? Infinity) > half || (second.minWidth ?? Infinity) > half) return 'stacked';
471
+
472
+ // Measured at the width it would have: a specimen is "tall" when it runs longer than the column
473
+ // it would sit in, which is what leaves room beside it.
474
+ // Either block can be the tall one. Checking `first` alone made placement depend on the order the
475
+ // page happens to declare its blocks in, so the same two blocks laid out variants-then-states and
476
+ // states-then-variants got different answers — and the rule says "a single specimen taller than
477
+ // its column", which says nothing about order.
478
+ const singleTallSpecimen = (first.itemCount === 1 && first.height > half) || (second.itemCount === 1 && second.height > half);
479
+ const bothFitOneRow = fitsOneRow(first, half) && fitsOneRow(second, half);
480
+ if (!singleTallSpecimen && !bothFitOneRow) return 'stacked';
481
+
448
482
  const sideHeight = Math.max(heightAtWidth(first, half, available), heightAtWidth(second, half, available));
449
483
  const stackedHeight = first.height + gap + second.height;
450
484
  return sideHeight <= stackedHeight - PLACEMENT_MIN_SAVING ? 'side' : 'stacked';
@@ -23,7 +23,7 @@ export { ComparisonList } from './ComparisonList';
23
23
  export { ReferenceDetails } from './ReferenceDetails';
24
24
  export { PropsTable } from './PropsTable';
25
25
  export { VariantGroup } from './VariantGroup';
26
- export { TokenRow } from './TokenRow';
26
+ export { TokenRow, TokenRowGroup } from './TokenRow';
27
27
  export { MotionSpecimen } from './MotionSpecimen';
28
28
  export type { MotionSpecimenProps, MotionSpecimenSpringConfig } from './MotionSpecimen';
29
29
  export { SpecimenSurface } from './SpecimenSurface';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krapwoo/ds-viewer",
3
- "version": "0.6.11",
3
+ "version": "0.8.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A browsable, always-current catalog for an Expo / React Native design system.",
@@ -51,7 +51,10 @@ export default defineCatalogPage({
51
51
  ],
52
52
  },
53
53
  states: {
54
- itemsFill: true,
54
+ // No `itemsFill`: these specimens are a small icon trigger with a floating bubble, not a
55
+ // block-level component that wants the row's width. Filling pins the trigger to the cell's
56
+ // left edge (a filled specimen aligns to start, which is right for a Banner and wrong here),
57
+ // and the bubble — which centres on its trigger — then overflows the cell and is clipped.
55
58
  items: [
56
59
  {
57
60
  key: 'bottom',