@krapwoo/ds-viewer 0.6.10 → 0.7.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/README.md CHANGED
@@ -72,6 +72,29 @@ loser then serves a workspace that no longer describes it, which Expo reports as
72
72
  with `--workspace-dir .ds-viewer-second`. A lock left behind by a crashed `dev` never blocks the
73
73
  next one.
74
74
 
75
+ `.ds-viewer/` belongs to the running server, and it repairs it. If something outside the server
76
+ deletes the generated files while it is serving, it says so and writes them back, rather than
77
+ leaving Expo to report an unresolvable `main` field — which names your project's `package.json`, a
78
+ file that is fine and that you never touched:
79
+
80
+ ```
81
+ Something outside this server deleted package.json, app.json, tsconfig.json, metro.config.js,
82
+ entry.tsx from .ds-viewer/. Rewriting them — your project is fine, this is the catalog's own
83
+ generated workspace. If the catalog looks broken, reload the page.
84
+ Restarting Metro so the catalog picks the files back up — the page reloads itself in a few seconds.
85
+ ```
86
+
87
+ Metro is restarted as part of the repair, because rewriting the files is not enough on its own: it
88
+ keeps serving the module map it cached before they went missing, and answers every request with
89
+ `Unable to resolve "expo" from "entry.tsx"` until it starts again. The server stays up and the page
90
+ reloads itself.
91
+
92
+ The repair is capped at five per run: if something is deleting continuously, repairing forever
93
+ would only hide it, so past the cap the server stops and keeps reporting. If you ever see that
94
+ message, what else was running at the time would be useful on
95
+ [issue #41](https://github.com/krapwoo/design-system-viewer/issues/41) — the cause is not yet
96
+ identified.
97
+
75
98
  `ds-viewer.config.ts` fields: `name`, `logo` (optional — a path to an image asset, shown in the
76
99
  sidebar beside the name, or in place of it for a wide wordmark; validated at `sync`/`dev` time
77
100
  and bundled into the preview workspace), `components`, `exclude`, `tokens`, `pages`, `groupOrder`, `starterKit` (new-project only —
@@ -544,12 +567,28 @@ and adds what only a real render shows:
544
567
  prop that was never wired up. The fingerprint reads each specimen's whole subtree (including a
545
568
  PhoneFrame's child document and a field's value) but not the caption carrying the variant's own
546
569
  name, so two examples differing only in colour are not reported.
570
+ - `specimen-clipped` (warning): an example in a list card painting past the left or right of its
571
+ cell, where it is cut off rather than contained. Content the specimen holds itself is not
572
+ reported — anything inside something that scrolls (`auto`/`scroll`, so the reader can reach it)
573
+ or clips (`hidden`/`clip`) is treated as the specimen's own containment. That last one is a
574
+ trade-off worth knowing: a full-width component clipping its own corners hides a genuinely
575
+ too-narrow cell from this rule, so `hidden` buys quiet for carousels and shimmer sweeps at the
576
+ cost of that case. Only horizontal crossings in `role=list` cells are measured — a cell's width
577
+ is what the page allocates, while its height grows with content, and matrix (`ComparisonGrid`)
578
+ cells are not visited at all. Usually fixed with a wider `specimenSize`, by stacking the blocks so
579
+ the example gets the full row, or by dropping `itemsFill` from a small instance that should sit
580
+ centered.
547
581
  - `render-unavailable` / `render-failed` (errors): no browser, or the catalog didn't start.
548
582
 
549
583
  It needs `puppeteer` in your project (`npm install --save-dev puppeteer`); ds-viewer doesn't
550
584
  install a browser. It takes about two minutes for 50 pages and works with `--json` (progress
551
585
  goes to stderr), `--ci` and `doctor.strict`, so CI can run `npx ds-viewer doctor --render --ci`.
552
586
 
587
+ It starts its own catalog on the first free port from 5181. `--port <n>` pins it instead, which
588
+ you want when something else already holds that range — another project's catalog, or a second
589
+ `dev`. The check cannot tell a stranger's server on its port from its own, so a collision reports
590
+ that server's pages as your project's failures.
591
+
553
592
  It starts its own `dev` server, with `--no-update-endpoint` and its own generated directory
554
593
  (`.ds-viewer-render/`, gitignored by `init` alongside `.ds-viewer/`). Nothing it generates touches
555
594
  the workspace a running `dev` owns, so you can run it beside a catalog you already have open and
package/dist/cli/dev.js CHANGED
@@ -6,7 +6,7 @@ import { execFile } from 'node:child_process';
6
6
  import { promisify } from 'node:util';
7
7
  import { resolveConfig } from "./config.js";
8
8
  import { sync } from "./sync.js";
9
- import { clearDevLock, liveDevLock, workspacePath, writeDevLock, writeWorkspace } from "./workspace.js";
9
+ import { clearDevLock, liveDevLock, missingWorkspaceFiles, workspacePath, writeDevLock, writeWorkspace } from "./workspace.js";
10
10
  import { findFreePort } from "./port.js";
11
11
  import { globBaseFolder } from "./glob.js";
12
12
  import { buildVersionStatus, createVersionController, defaultPreferencesDir, readPersonalAutoCheck, resolveAutoCheck, runUpdateCheck, statusFromCheck, writeUpdateFile, writeVersionFile, } from "./updateCheck.js";
@@ -326,6 +326,42 @@ export async function performRestart(handoff, deps = {}) {
326
326
  * component/token/page changes to re-run `sync`. Metro's own HMR updates the rendered component
327
327
  * when its source changes, but not the generated props table or page list (Task 17 Step 4 relies
328
328
  * on this: editing `Button.tsx` must update its props table, not just hot-reload the component). */
329
+ /** How often a running `dev` checks that its own workspace still exists. Cheap (five `existsSync`
330
+ * calls), and slow enough to be invisible: the failure it covers is rare, and noticing within a
331
+ * few seconds is soon enough to repair before the next page load. */
332
+ const WORKSPACE_WATCH_INTERVAL_MS = 4000;
333
+ /** How many times one `dev` run will rewrite a workspace that keeps losing files. Past this,
334
+ * something is deleting continuously and repairing again would only hide it. */
335
+ const WORKSPACE_REPAIR_LIMIT = 5;
336
+ /** One pass of the workspace integrity check. Returns what it did, so the caller owns the repair
337
+ * budget and the tests can drive it without a timer.
338
+ *
339
+ * `updateEndpoint` is the caller's own, passed through unchanged: rebuilding it here from the
340
+ * Metro port would point the repaired catalog at the wrong server, silently breaking the update
341
+ * panel while telling the developer everything is fine. */
342
+ export function repairWorkspaceIfMissing(deps) {
343
+ const { config, updateEndpoint, versionStatus, sync, reclaimLock, log = console.warn } = deps;
344
+ const missing = missingWorkspaceFiles(config);
345
+ if (missing.length === 0)
346
+ return { missing, repaired: false };
347
+ log(`Something outside this server deleted ${missing.join(', ')} from ${config.workspaceDir ?? DEFAULT_WORKSPACE_DIR}/. ` +
348
+ "Rewriting them — your project is fine, this is the catalog's own generated workspace. " +
349
+ 'If the catalog looks broken, reload the page.');
350
+ writeWorkspace(config, updateEndpoint);
351
+ // `entry.tsx` imports both of these, so a rewrite that skips them resolves nothing. Both come
352
+ // from one status, the way `refreshUpdateFile` writes them, so a repair cannot leave
353
+ // `version.json` announcing an update that `update.json` denies.
354
+ const status = versionStatus();
355
+ if (!existsSync(workspacePath(config, 'update.json'))) {
356
+ const update = status.update ? { ...status.update, checkedAt: status.lastCheckedAt ?? new Date().toISOString() } : undefined;
357
+ writeUpdateFile(config.projectRoot, update, config.workspaceDir);
358
+ }
359
+ if (!existsSync(workspacePath(config, 'version.json')))
360
+ writeVersionFile(config.projectRoot, status, config.workspaceDir);
361
+ reclaimLock();
362
+ sync(config);
363
+ return { missing, repaired: true };
364
+ }
329
365
  export async function dev(initialConfig, devOptions = {}) {
330
366
  assertLocalInstall(initialConfig.projectRoot);
331
367
  const ownVersion = readOwnVersion(import.meta.dirname);
@@ -499,23 +535,122 @@ export async function dev(initialConfig, devOptions = {}) {
499
535
  for (const watcher of watchers) {
500
536
  watcher.on('error', (error) => console.warn(`File watcher error: ${error.message}`));
501
537
  }
502
- child = spawn('npx', ['expo', 'start', '--web', '--host', 'localhost', '--port', String(port)], {
538
+ const spawnMetro = () => spawn('npx', ['expo', 'start', '--web', '--host', 'localhost', '--port', String(port)], {
503
539
  cwd: workspace,
504
540
  stdio: 'inherit',
505
541
  shell: process.platform === 'win32',
506
542
  detached: true,
507
543
  });
508
- child.on('error', (error) => console.warn(`Could not start Expo: ${error.message}`));
509
- child.on('exit', (code) => {
510
- for (const watcher of watchers)
511
- watcher.close();
512
- endpointServer?.close();
513
- // Released so the next `dev` is not refused by a lock naming a process that has gone.
514
- // `liveDevLock` already ignores a dead pid, so this is tidiness, not correctness — it keeps a
515
- // stale file out of a directory a developer may well read.
516
- clearDevLock(config);
517
- process.exitCode = code ?? 0;
518
- });
544
+ // Set while this process is deliberately replacing Metro, so the exit handler below respawns it
545
+ // instead of running the shutdown path. Restoring the files is not enough on its own: Metro has
546
+ // already cached a module map that no longer matches the directory, and it answers every request
547
+ // with `Unable to resolve "expo" from "entry.tsx"` until it is restarted. Observed four times —
548
+ // the files came back and the catalog stayed blank.
549
+ let restartingMetro = false;
550
+ child = spawnMetro();
551
+ // This process owns its workspace: nothing else should remove the files that define it. When
552
+ // they vanish anyway, Metro reports it as `Cannot resolve entry file: the 'main' field defined
553
+ // in your package.json points to an unresolvable or non-existent path` — which names the
554
+ // *project's* package.json, a file that is fine and that the developer did not touch. The cause
555
+ // is still unidentified (issue #41), so this does not pretend to prevent it; it notices, says
556
+ // plainly what happened, and writes the files back, because `writeWorkspace` is idempotent and
557
+ // already knows how.
558
+ //
559
+ // Capped rather than endless: if something is deleting continuously, rewriting forever would
560
+ // hide it behind a repair loop. After the cap this stops repairing and keeps saying so, which
561
+ // leaves the evidence visible.
562
+ let repairs = 0;
563
+ const workspaceWatch = setInterval(() => {
564
+ if (watchersPaused)
565
+ return; // an update is rewriting the workspace on purpose.
566
+ if (repairs >= WORKSPACE_REPAIR_LIMIT) {
567
+ const missing = missingWorkspaceFiles(config);
568
+ if (missing.length > 0) {
569
+ console.warn(`The catalog workspace is still losing files (missing ${missing.join(', ')}). Not rewriting them again — ` +
570
+ 'something outside this server is deleting them. Please report what else was running at https://github.com/krapwoo/design-system-viewer/issues/41');
571
+ }
572
+ return;
573
+ }
574
+ // Counted as an attempt, not a success: a workspace that cannot be rewritten (a permission
575
+ // error, a sync failure) would otherwise retry every four seconds forever.
576
+ if (missingWorkspaceFiles(config).length > 0)
577
+ repairs += 1;
578
+ try {
579
+ // `updateEndpoint` is passed through, never rebuilt: the endpoint listens on its own
580
+ // ephemeral port, so deriving one from Metro's port here would repair the files and break
581
+ // the update panel in the same breath.
582
+ const { repaired } = repairWorkspaceIfMissing({
583
+ config, updateEndpoint, versionStatus: version.getVersionStatus, sync,
584
+ // Only if no one else took it meanwhile: between losing the lock and this tick, a second
585
+ // `dev` can legitimately have started and be serving the directory. Overwriting its lock
586
+ // would leave two servers each believing they own it.
587
+ reclaimLock: () => {
588
+ const other = liveDevLock(config);
589
+ if (other && other.pid !== process.pid) {
590
+ console.warn(`Not reclaiming ${config.workspaceDir ?? DEFAULT_WORKSPACE_DIR}/: pid ${other.pid} owns it now.`);
591
+ return;
592
+ }
593
+ writeDevLock(config, { pid: process.pid, port });
594
+ },
595
+ });
596
+ // Rewritten files are not enough: Metro keeps serving the module map it cached before they
597
+ // went missing. Restarting it is the only thing that gets the catalog back, and the repair
598
+ // cap bounds how often this can happen.
599
+ if (repaired && !restartingMetro) {
600
+ console.warn('Restarting Metro so the catalog picks the files back up — the page reloads itself in a few seconds.');
601
+ restartingMetro = true;
602
+ killGroup(child, 'SIGTERM');
603
+ }
604
+ }
605
+ catch (error) {
606
+ console.warn(`Could not rewrite the workspace: ${error.message}`);
607
+ }
608
+ }, WORKSPACE_WATCH_INTERVAL_MS);
609
+ workspaceWatch.unref?.();
610
+ const bindChild = () => {
611
+ child.on('error', (error) => console.warn(`Could not start Expo: ${error.message}`));
612
+ child.on('exit', (code) => {
613
+ // A Metro we killed on purpose: respawn it on the same port rather than tearing the server
614
+ // down, once the port it held is actually free again.
615
+ if (restartingMetro) {
616
+ // The flag stays set for the whole restart, not just until this handler returns. Clearing
617
+ // it before the wait left a window where the repair timer could ask for another restart,
618
+ // find no live child to kill, and leave the flag set with nothing pending — after which
619
+ // the next genuine Metro crash would be mistaken for a deliberate restart and silently
620
+ // respawn, skipping the shutdown path that releases the watchers and the dev lock.
621
+ // `repairWorkspaceIfMissing`'s caller already checks this flag, so holding it also stops a
622
+ // second repair stacking a restart on top of this one.
623
+ //
624
+ // The port is not free the instant this child exits: `performRestart` waits for the same
625
+ // reason, and Errata 7c records that Metro listens on `[::1]`, so a naive probe reports
626
+ // free while it is still bound. Respawning early lets Expo find its own port taken and
627
+ // either prompt — giving up with "Skipping dev server" in a non-interactive shell — or move
628
+ // to another port, leaving the catalog where the dev lock does not name.
629
+ void waitForPortFree(port, defaultProbePortFree)
630
+ .catch(() => {
631
+ // Still spawn: a catalog that tried and failed to come back is recoverable, one that
632
+ // silently never tries is not. Expo reports the port conflict itself if there is one.
633
+ console.warn(`Port ${port} was still held after the wait; starting Metro anyway.`);
634
+ })
635
+ .then(() => {
636
+ child = spawnMetro();
637
+ bindChild();
638
+ restartingMetro = false;
639
+ });
640
+ return;
641
+ }
642
+ clearInterval(workspaceWatch);
643
+ for (const watcher of watchers)
644
+ watcher.close();
645
+ endpointServer?.close();
646
+ // Released so the next `dev` is not refused by a lock naming a process that has gone.
647
+ // `liveDevLock` already ignores a dead pid, so this is tidiness, not correctness — it keeps a
648
+ // stale file out of a directory a developer may well read.
649
+ clearDevLock(config);
650
+ process.exitCode = code ?? 0;
651
+ });
652
+ };
653
+ bindChild();
519
654
  // Node's default SIGTERM/SIGINT/SIGHUP behavior exits this process without touching its
520
655
  // children — left alone, a signal sent only to `dev`'s own pid (not its process group) would
521
656
  // leave the spawned `expo start` running, still bound to its port (Important finding, Fable
package/dist/cli/main.js CHANGED
@@ -34,6 +34,8 @@ Commands:
34
34
  --json print the machine-readable report instead
35
35
  --ci exit 1 when any error was found (never on a warning alone)
36
36
  --render also open every page in a headless browser (needs puppeteer)
37
+ --port <n> pin the render server's port (with --render), for when something
38
+ else already holds the range it would pick from
37
39
  explain <Page>
38
40
  Print why a page's specimens are laid out the way they are
39
41
  --heights <first>,<second> check side-by-side placement with these measured heights
@@ -128,7 +130,23 @@ export async function runDoctorCommand(projectRoot, rest, options = {}) {
128
130
  // Progress goes to stderr, so `--json` output stays pure JSON.
129
131
  if (rest.includes('--render')) {
130
132
  const files = result.pageFiles;
131
- const rendered = await (options.renderCheck ?? renderCheck)(config.projectRoot, { files, log: (line) => process.stderr.write(`${line}\n`) });
133
+ // `--port <n>`: pin the render server instead of taking the first free port from 5181. Needed
134
+ // when something else already holds that range (another project's catalog, a second `dev`),
135
+ // because the check cannot tell a stranger's server on its port from its own and would report
136
+ // that server's pages as this project's failures.
137
+ const portFlag = rest.indexOf('--port');
138
+ let renderPort;
139
+ if (portFlag !== -1) {
140
+ const raw = rest[portFlag + 1];
141
+ renderPort = Number(raw);
142
+ if (!raw || !Number.isInteger(renderPort) || renderPort < 1 || renderPort > 65535) {
143
+ // Refused rather than quietly falling back to a port it picked itself: a check that
144
+ // ignores the port you gave it would render against whatever else is there and report a
145
+ // stranger's pages as this project's failures.
146
+ return { output: `doctor --render --port needs a port between 1 and 65535, not ${raw ?? '(nothing)'}.`, exitCode: 1 };
147
+ }
148
+ }
149
+ const rendered = await (options.renderCheck ?? renderCheck)(config.projectRoot, { files, log: (line) => process.stderr.write(`${line}\n`), ...(renderPort ? { port: renderPort } : {}) });
132
150
  result = withExtraIssues(result, rendered, Boolean(config.doctor?.strict));
133
151
  }
134
152
  // Design §4 "`--ci`: ... no update check." — `--ci` skips this whole block even when
@@ -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) {
@@ -151,6 +151,18 @@ export function liveDevLock(config, ownPid = process.pid) {
151
151
  return undefined;
152
152
  return lock;
153
153
  }
154
+ /** The files `writeWorkspace` always writes: the ones that define the workspace to Expo and Metro.
155
+ * `babel.config.js` is conditional and the update files are written separately, so neither counts
156
+ * as missing here. */
157
+ export const WORKSPACE_DEFINING_FILES = ['package.json', 'app.json', 'tsconfig.json', 'metro.config.js', 'entry.tsx'];
158
+ /** Which of those a workspace has lost. A running `dev` owns these files and nothing else should
159
+ * remove them, so a non-empty answer means something outside this process deleted them — see
160
+ * issue #41, where the cause is still unidentified. Reported rather than diagnosed: the point is
161
+ * that the server can say what happened instead of leaving Expo to blame the project's own
162
+ * `package.json` with `Cannot resolve entry file`. */
163
+ export function missingWorkspaceFiles(config) {
164
+ return WORKSPACE_DEFINING_FILES.filter((name) => !existsSync(workspacePath(config, name)));
165
+ }
154
166
  /** Writes the self-contained Expo web workspace at `.ds-viewer/` (design §2 "The preview
155
167
  * workspace and `npx ds-viewer dev`"). Rewritten freely on every `dev` run — nothing here is
156
168
  * user-owned.
@@ -158,9 +170,9 @@ export function liveDevLock(config, ownPid = process.pid) {
158
170
  * `updateEndpoint` is the caller's own, inlined into `entry.tsx`. Without one (a render check's
159
171
  * throwaway server, `--no-update-endpoint`) the catalog is served with no endpoint at all, so it
160
172
  * makes no calls: the shell polls `/version` from every page, so inlining a *different* server's
161
- * endpoint here would have every page call it from the wrong origin. Two `dev` servers share this
162
- * one directory, so the last writer decides — see the README on running a render check beside an
163
- * open catalog. */
173
+ * endpoint here would have every page call it from the wrong origin. Each `dev` owns its own
174
+ * workspace directory (`dev-lock.json`), so a render check beside an open catalog writes to
175
+ * `.ds-viewer-render/` rather than this one — see the README. */
164
176
  export function writeWorkspace(config, updateEndpoint) {
165
177
  const workspace = workspacePath(config);
166
178
  mkdirSync(workspace, { recursive: true });
@@ -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
+ 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
+ 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.10",
3
+ "version": "0.7.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',
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import { View, Text, StyleSheet, Easing } from 'react-native';
3
- import { defineCatalogPage, DividedStack, VariantGroup, TokenRow, MotionSpecimen } from '@krapwoo/ds-viewer';
3
+ import { defineCatalogPage, TokenRow, TokenRowGroup, MotionSpecimen } from '@krapwoo/ds-viewer';
4
4
  import {
5
5
  DS_MOTION_DURATION,
6
6
  DS_MOTION_DURATION_STEPS,
@@ -26,98 +26,127 @@ const styles = StyleSheet.create({
26
26
  previewLabel: { minWidth: 64, ...DS_TYPOGRAPHY.labelSm, color: DS_SEMANTIC.text.regular },
27
27
  previewValue: { ...DS_TYPOGRAPHY.bodyXs, color: DS_SEMANTIC.text.muted },
28
28
  durationBar: { height: 12, borderRadius: DS_RADIUS.small, backgroundColor: DS_SEMANTIC.emphasis.info },
29
- // Spring config — a wrapping grid of key/value fields, since DS_MOTION_SPRING has 6 fields, too
30
- // many to lay out as a single previewRow.
31
- springGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: DS_SPACING[600] },
32
- springField: { width: 168, gap: DS_SPACING[100] },
33
29
  easingValue: { ...DS_TYPOGRAPHY.bodyXs, color: DS_SEMANTIC.text.muted, fontFamily: 'Menlo' },
30
+
31
+ // A spring is a config object, not a step on a scale: six named fields that only mean anything
32
+ // together. Laid out with the fields down the side and each spring token as its own column, so a
33
+ // second spring adds a column instead of repeating all six names, and `damping` reads across
34
+ // tokens on one line. The field vocabulary is fixed; the number of tokens is what grows.
35
+ springRow: {
36
+ flexDirection: 'row',
37
+ alignItems: 'flex-start',
38
+ gap: DS_SPACING[600],
39
+ paddingVertical: DS_SPACING[400],
40
+ borderBottomWidth: StyleSheet.hairlineWidth,
41
+ borderColor: DS_SEMANTIC.border.light,
42
+ },
43
+ springRowLast: { borderBottomWidth: 0 },
44
+ // Wide enough that `restDisplacementThreshold` sits on one line instead of breaking mid-word,
45
+ // which is what the previous fixed 168px field did to it.
46
+ springField: { width: 210, ...DS_TYPOGRAPHY.labelSm, color: DS_SEMANTIC.text.regular },
47
+ springCell: { flex: 1, minWidth: 130 },
48
+ springValue: { ...DS_TYPOGRAPHY.bodyXs, color: DS_SEMANTIC.text.regular },
49
+ springNote: { ...DS_TYPOGRAPHY.bodyXs, color: DS_SEMANTIC.text.muted },
34
50
  });
35
51
 
36
- function MotionGallery() {
37
- const maxDuration = Math.max(...Object.values(DS_MOTION_DURATION));
38
- const maxLoopDuration = Math.max(...Object.values(DS_MOTION_LOOP_DURATION));
52
+ /** Every spring token on the page. One today — the shape is what lets a second arrive without
53
+ * redesigning the section. */
54
+ const SPRINGS: Record<string, typeof DS_MOTION_SPRING> = { snappy: DS_MOTION_SPRING };
55
+ const SPRING_NOTES: Record<string, string> = { snappy: DS_MOTION_SPRING_USE };
56
+ const SPRING_FIELDS = Object.keys(DS_MOTION_SPRING) as (keyof typeof DS_MOTION_SPRING)[];
57
+
58
+ function SpringTable() {
59
+ const names = Object.keys(SPRINGS);
60
+ const cells = (render: (name: string) => React.ReactNode) =>
61
+ names.map((name) => (
62
+ <View key={name} style={styles.springCell}>
63
+ {render(name)}
64
+ </View>
65
+ ));
39
66
  return (
40
- <DividedStack>
41
- <VariantGroup name="Duration" desc="one-shot transitions — reach for base (240ms) first" align="left">
42
- <View style={styles.tokenStack}>
43
- {DS_MOTION_DURATION_STEPS.map((step, i) => (
44
- <TokenRow key={step} use={DS_MOTION_DURATION_USE[step]} last={i === DS_MOTION_DURATION_STEPS.length - 1}>
45
- <View style={styles.rowContent}>
46
- <View style={styles.previewRow}>
47
- <Text style={styles.previewLabel}>{step}</Text>
48
- <View style={[styles.durationBar, { width: (DS_MOTION_DURATION[step] / maxDuration) * 120 }]} />
49
- <Text style={styles.previewValue}>{DS_MOTION_DURATION[step]}ms</Text>
50
- </View>
51
- <MotionSpecimen
52
- kind="timing"
53
- duration={DS_MOTION_DURATION[step]}
54
- easing={Easing.bezier(...DS_MOTION_EASING.standard)}
55
- label={`${DS_MOTION_DURATION[step]}ms · standard easing`}
56
- />
57
- </View>
58
- </TokenRow>
59
- ))}
67
+ <View>
68
+ {/* A single spring needs no column header — the section title is already its name. */}
69
+ {names.length > 1 ? (
70
+ <View style={styles.springRow}>
71
+ <Text style={styles.springField}>field</Text>
72
+ {cells((name) => <Text style={styles.previewLabel}>{name}</Text>)}
60
73
  </View>
61
- </VariantGroup>
62
- <VariantGroup name="Loop duration" desc="continuous, indeterminate loops — a spinner or a shimmer pulse, not a one-shot transition" align="left">
63
- <View style={styles.tokenStack}>
64
- {DS_MOTION_LOOP_DURATION_STEPS.map((step, i) => (
65
- <TokenRow key={step} use={DS_MOTION_LOOP_DURATION_USE[step]} last={i === DS_MOTION_LOOP_DURATION_STEPS.length - 1}>
66
- <View style={styles.rowContent}>
67
- <View style={styles.previewRow}>
68
- <Text style={styles.previewLabel}>{step}</Text>
69
- <View style={[styles.durationBar, { width: (DS_MOTION_LOOP_DURATION[step] / maxLoopDuration) * 120 }]} />
70
- <Text style={styles.previewValue}>{DS_MOTION_LOOP_DURATION[step]}ms</Text>
71
- </View>
72
- <MotionSpecimen
73
- kind="timing"
74
- duration={DS_MOTION_LOOP_DURATION[step]}
75
- easing={Easing.bezier(...DS_MOTION_EASING.standard)}
76
- label={`${DS_MOTION_LOOP_DURATION[step]}ms · one cycle`}
77
- />
78
- </View>
79
- </TokenRow>
80
- ))}
74
+ ) : null}
75
+ {SPRING_FIELDS.map((field) => (
76
+ <View key={String(field)} style={styles.springRow}>
77
+ <Text style={styles.springField}>{String(field)}</Text>
78
+ {cells((name) => <Text style={styles.springValue}>{String(SPRINGS[name][field])}</Text>)}
81
79
  </View>
82
- </VariantGroup>
83
- <VariantGroup name="Easing" desc="cubic-bezier curves — convert with Easing.bezier(...DS_MOTION_EASING.x)" align="left">
84
- <View style={styles.tokenStack}>
85
- {DS_MOTION_EASING_STEPS.map((step, i) => (
86
- <TokenRow key={step} use={DS_MOTION_EASING_USE[step]} last={i === DS_MOTION_EASING_STEPS.length - 1}>
87
- <View style={styles.rowContent}>
88
- <View style={styles.previewRow}>
89
- <Text style={styles.previewLabel}>{step}</Text>
90
- <Text style={styles.easingValue}>cubic-bezier({DS_MOTION_EASING[step].join(', ')})</Text>
91
- </View>
92
- <MotionSpecimen
93
- kind="timing"
94
- duration={DS_MOTION_DURATION.base}
95
- easing={Easing.bezier(...DS_MOTION_EASING[step])}
96
- label={`${DS_MOTION_DURATION.base}ms · ${step}`}
97
- />
80
+ ))}
81
+ <View style={styles.springRow}>
82
+ <Text style={styles.springField}>preview</Text>
83
+ {cells((name) => (
84
+ <MotionSpecimen kind="spring" spring={SPRINGS[name]} label="Animated.spring(value, DS_MOTION_SPRING)" />
85
+ ))}
86
+ </View>
87
+ <View style={[styles.springRow, styles.springRowLast]}>
88
+ <Text style={styles.springField}>when to use</Text>
89
+ {cells((name) => <Text style={styles.springNote}>{SPRING_NOTES[name]}</Text>)}
90
+ </View>
91
+ </View>
92
+ );
93
+ }
94
+
95
+ /** A millisecond scale: a bar for the relative length, then the real animation at that duration. */
96
+ function DurationRows({ steps, values, notes, label }: {
97
+ steps: readonly string[];
98
+ values: Record<string, number>;
99
+ notes: Record<string, string>;
100
+ label: (ms: number) => string;
101
+ }) {
102
+ const max = Math.max(...steps.map((step) => values[step]));
103
+ return (
104
+ <TokenRowGroup>
105
+ <View style={styles.tokenStack}>
106
+ {steps.map((step, i) => (
107
+ <TokenRow key={step} use={notes[step]} last={i === steps.length - 1} notePlacement="right">
108
+ <View style={styles.rowContent}>
109
+ <View style={styles.previewRow}>
110
+ <Text style={styles.previewLabel}>{step}</Text>
111
+ <View style={[styles.durationBar, { width: (values[step] / max) * 120 }]} />
112
+ <Text style={styles.previewValue}>{values[step]}ms</Text>
98
113
  </View>
99
- </TokenRow>
100
- ))}
101
- </View>
102
- </VariantGroup>
103
- <VariantGroup name="Spring" desc="for Animated.spring(value, DS_MOTION_SPRING) — snap-point transitions, not fixed-duration timing">
104
- <View style={styles.tokenStack}>
105
- <TokenRow use={DS_MOTION_SPRING_USE} last>
114
+ <MotionSpecimen
115
+ kind="timing"
116
+ duration={values[step]}
117
+ easing={Easing.bezier(...DS_MOTION_EASING.standard)}
118
+ label={label(values[step])}
119
+ />
120
+ </View>
121
+ </TokenRow>
122
+ ))}
123
+ </View>
124
+ </TokenRowGroup>
125
+ );
126
+ }
127
+
128
+ function EasingRows() {
129
+ return (
130
+ <TokenRowGroup>
131
+ <View style={styles.tokenStack}>
132
+ {DS_MOTION_EASING_STEPS.map((step, i) => (
133
+ <TokenRow key={step} use={DS_MOTION_EASING_USE[step]} last={i === DS_MOTION_EASING_STEPS.length - 1} notePlacement="right">
106
134
  <View style={styles.rowContent}>
107
- <View style={styles.springGrid}>
108
- {(Object.entries(DS_MOTION_SPRING) as [string, number | boolean][]).map(([key, value]) => (
109
- <View key={key} style={styles.springField}>
110
- <Text style={styles.previewLabel}>{key}</Text>
111
- <Text style={styles.previewValue}>{String(value)}</Text>
112
- </View>
113
- ))}
135
+ <View style={styles.previewRow}>
136
+ <Text style={styles.previewLabel}>{step}</Text>
137
+ <Text style={styles.easingValue}>cubic-bezier({DS_MOTION_EASING[step].join(', ')})</Text>
114
138
  </View>
115
- <MotionSpecimen kind="spring" spring={DS_MOTION_SPRING} label="Animated.spring(value, DS_MOTION_SPRING)" />
139
+ <MotionSpecimen
140
+ kind="timing"
141
+ duration={DS_MOTION_DURATION.base}
142
+ easing={Easing.bezier(...DS_MOTION_EASING[step])}
143
+ label={`${DS_MOTION_DURATION.base}ms · ${step}`}
144
+ />
116
145
  </View>
117
146
  </TokenRow>
118
- </View>
119
- </VariantGroup>
120
- </DividedStack>
147
+ ))}
148
+ </View>
149
+ </TokenRowGroup>
121
150
  );
122
151
  }
123
152
 
@@ -125,5 +154,44 @@ export default defineCatalogPage({
125
154
  group: 'Tokens',
126
155
  description: 'Duration (one-shot transitions), loop duration (continuous, indeterminate loops like Loading/Shimmer), easing, and spring tokens for transitions/animations. Easings are cubic-bezier tuples, not Easing objects, so the token file stays free of any react-native import. The spring config is grounded in the metro-native app this template was extracted from, where the same values drive its BottomSheet\'s snap-point transitions.',
127
156
  tokenGallery: true,
128
- render: () => <MotionGallery />,
157
+ // A card per kind of token. Durations are a scale in milliseconds, easings are bezier curves and
158
+ // a spring is a config object: four different things that read as one long box when they share a
159
+ // single card. One column, because every section's rows want the full width.
160
+ tokenColumns: 1,
161
+ tokenSections: [
162
+ {
163
+ title: 'Duration',
164
+ desc: 'one-shot transitions — reach for base (240ms) first',
165
+ render: () => (
166
+ <DurationRows
167
+ steps={DS_MOTION_DURATION_STEPS}
168
+ values={DS_MOTION_DURATION}
169
+ notes={DS_MOTION_DURATION_USE}
170
+ label={(ms) => `${ms}ms · standard easing`}
171
+ />
172
+ ),
173
+ },
174
+ {
175
+ title: 'Loop duration',
176
+ desc: 'continuous, indeterminate loops — a spinner or a shimmer pulse, not a one-shot transition',
177
+ render: () => (
178
+ <DurationRows
179
+ steps={DS_MOTION_LOOP_DURATION_STEPS}
180
+ values={DS_MOTION_LOOP_DURATION}
181
+ notes={DS_MOTION_LOOP_DURATION_USE}
182
+ label={(ms) => `${ms}ms · one cycle`}
183
+ />
184
+ ),
185
+ },
186
+ {
187
+ title: 'Easing',
188
+ desc: 'cubic-bezier curves — convert with Easing.bezier(...DS_MOTION_EASING.x)',
189
+ render: () => <EasingRows />,
190
+ },
191
+ {
192
+ title: 'Spring',
193
+ desc: 'for Animated.spring(value, DS_MOTION_SPRING) — snap-point transitions, not fixed-duration timing',
194
+ render: () => <SpringTable />,
195
+ },
196
+ ],
129
197
  });
@@ -1,29 +1,33 @@
1
1
  import React from 'react';
2
- import { View, Text, StyleSheet } from 'react-native';
3
- import { defineCatalogPage, TokenRow } from '@krapwoo/ds-viewer';
4
- import { DS_RADIUS, DS_RADIUS_STEPS, DS_RADIUS_USE, DS_SEMANTIC, DS_SPACING, DS_TYPOGRAPHY } from '../tokens';
2
+ import { View, StyleSheet } from 'react-native';
3
+ import { defineCatalogPage, TokenGrid, TokenTile } from '@krapwoo/ds-viewer';
4
+ import { DS_RADIUS, DS_RADIUS_STEPS, DS_RADIUS_USE, DS_SEMANTIC } from '../tokens';
5
5
 
6
6
  const styles = StyleSheet.create({
7
- tokenStack: { gap: DS_SPACING[800] },
8
- previewRow: { flexDirection: 'row', alignItems: 'center', gap: DS_SPACING[600] },
9
- previewLabel: { minWidth: 64, ...DS_TYPOGRAPHY.labelSm, color: DS_SEMANTIC.text.regular },
10
- previewValue: { ...DS_TYPOGRAPHY.bodyXs, color: DS_SEMANTIC.text.muted },
11
- radiusBox: { width: 40, height: 40, backgroundColor: DS_SEMANTIC.emphasis.info, borderWidth: StyleSheet.hairlineWidth, borderColor: DS_SEMANTIC.border.light },
7
+ // Square, so the corner is read as a corner rather than as the end of a bar. Large enough that
8
+ // 4px and 8px are still telling apart at a glance.
9
+ radiusBox: {
10
+ width: 64,
11
+ height: 64,
12
+ backgroundColor: DS_SEMANTIC.emphasis.info,
13
+ borderWidth: StyleSheet.hairlineWidth,
14
+ borderColor: DS_SEMANTIC.border.light,
15
+ },
12
16
  });
13
17
 
14
18
  function RadiusGallery() {
15
19
  return (
16
- <View style={styles.tokenStack}>
17
- {DS_RADIUS_STEPS.map((step, i) => (
18
- <TokenRow key={step} use={DS_RADIUS_USE[step]} last={i === DS_RADIUS_STEPS.length - 1}>
19
- <View style={styles.previewRow}>
20
- <Text style={styles.previewLabel}>{step}</Text>
21
- <View style={[styles.radiusBox, { borderRadius: DS_RADIUS[step] }]} />
22
- <Text style={styles.previewValue}>{DS_RADIUS[step]}px</Text>
23
- </View>
24
- </TokenRow>
20
+ // Tiles, not rows: a radius preview is small and self-contained, so the steps belong side by
21
+ // side where `xs` can be compared against `large` directly — which is the only way to read a
22
+ // scale. A row each would put them 100px apart vertically and leave the right half of every
23
+ // row empty.
24
+ <TokenGrid minTileWidth={150}>
25
+ {DS_RADIUS_STEPS.map((step) => (
26
+ <TokenTile key={step} name={step} value={`${DS_RADIUS[step]}px`} note={DS_RADIUS_USE[step]}>
27
+ <View style={[styles.radiusBox, { borderRadius: DS_RADIUS[step] }]} />
28
+ </TokenTile>
25
29
  ))}
26
- </View>
30
+ </TokenGrid>
27
31
  );
28
32
  }
29
33
 
@@ -1,28 +1,40 @@
1
1
  import React from 'react';
2
- import { View, Text, StyleSheet } from 'react-native';
3
- import { defineCatalogPage, TokenRow } from '@krapwoo/ds-viewer';
4
- import { DS_RADIUS, DS_SEMANTIC, DS_SHADOW, DS_SHADOW_USE, DS_SPACING, DS_TYPOGRAPHY, type ShadowToken } from '../tokens';
2
+ import { View, StyleSheet } from 'react-native';
3
+ import { defineCatalogPage, TokenGrid, TokenTile } from '@krapwoo/ds-viewer';
4
+ import { DS_RADIUS, DS_SEMANTIC, DS_SHADOW, DS_SHADOW_USE, DS_SPACING, type ShadowToken } from '../tokens';
5
5
 
6
6
  const styles = StyleSheet.create({
7
- tokenStack: { gap: DS_SPACING[800] },
8
- previewRow: { flexDirection: 'row', alignItems: 'center', gap: DS_SPACING[600] },
9
- previewLabel: { minWidth: 64, ...DS_TYPOGRAPHY.labelSm, color: DS_SEMANTIC.text.regular },
10
- shadowBox: { width: 56, height: 40, borderRadius: DS_RADIUS.medium, backgroundColor: DS_SEMANTIC.surface.white },
7
+ // A shadow is cast *onto* something. On the catalog's own white card the elevated surface is
8
+ // white on white and the token reads as an empty space, so each tile gives it a tinted backdrop
9
+ // to fall on — the same reason a product screen sits a card on a page colour, not on paper.
10
+ stage: {
11
+ backgroundColor: DS_SEMANTIC.surface.main,
12
+ borderRadius: DS_RADIUS.small,
13
+ paddingVertical: DS_SPACING[800],
14
+ alignItems: 'center',
15
+ },
16
+ surface: {
17
+ width: 76,
18
+ height: 48,
19
+ borderRadius: DS_RADIUS.medium,
20
+ backgroundColor: DS_SEMANTIC.surface.white,
21
+ },
11
22
  });
12
23
 
13
24
  function ShadowGallery() {
14
25
  const steps = Object.keys(DS_SHADOW) as ShadowToken[];
15
26
  return (
16
- <View style={styles.tokenStack}>
17
- {steps.map((step, i) => (
18
- <TokenRow key={step} use={DS_SHADOW_USE[step]} last={i === steps.length - 1}>
19
- <View style={styles.previewRow}>
20
- <Text style={styles.previewLabel}>{step}</Text>
21
- <View style={[styles.shadowBox, DS_SHADOW[step]]} />
27
+ // Tiles: an elevation preview is small and self-contained, and seeing `resting` beside
28
+ // `bottomSheet` is the only way to judge how much stronger one lift is than another.
29
+ <TokenGrid minTileWidth={170}>
30
+ {steps.map((step) => (
31
+ <TokenTile key={step} name={step} note={DS_SHADOW_USE[step]}>
32
+ <View style={styles.stage}>
33
+ <View style={[styles.surface, DS_SHADOW[step]]} />
22
34
  </View>
23
- </TokenRow>
35
+ </TokenTile>
24
36
  ))}
25
- </View>
37
+ </TokenGrid>
26
38
  );
27
39
  }
28
40