@krapwoo/ds-viewer 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +5 -0
  2. package/dist/cli/dev.js +40 -22
  3. package/dist/cli/endpoint.js +13 -2
  4. package/dist/cli/init.js +8 -3
  5. package/dist/cli/main.js +21 -1
  6. package/dist/cli/render.js +43 -3
  7. package/dist/cli/sync.js +2 -1
  8. package/dist/cli/types.js +8 -1
  9. package/dist/cli/updateCheck.js +7 -5
  10. package/dist/cli/workspace.js +15 -2
  11. package/package.json +1 -1
  12. package/starter-kit/components/AnimatedChevron/AnimatedChevron.catalog.tsx +1 -0
  13. package/starter-kit/components/Avatar/Avatar.catalog.tsx +1 -0
  14. package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +55 -36
  15. package/starter-kit/components/ButtonGroup/ButtonGroup.catalog.tsx +1 -0
  16. package/starter-kit/components/Dialog/Dialog.catalog.tsx +60 -11
  17. package/starter-kit/components/Divider/Divider.catalog.tsx +1 -0
  18. package/starter-kit/components/Dock/Dock.catalog.tsx +1 -0
  19. package/starter-kit/components/FieldContainer/FieldContainer.catalog.tsx +1 -0
  20. package/starter-kit/components/InputClearButton/InputClearButton.catalog.tsx +1 -0
  21. package/starter-kit/components/List/List.catalog.tsx +1 -0
  22. package/starter-kit/components/Loading/Loading.catalog.tsx +1 -0
  23. package/starter-kit/components/ProgressDots/ProgressDots.catalog.tsx +1 -0
  24. package/starter-kit/components/SegmentedToggle/SegmentedToggle.catalog.tsx +38 -17
  25. package/starter-kit/components/Shimmer/Breathing.catalog.tsx +1 -0
  26. package/starter-kit/components/Surface/Surface.catalog.tsx +1 -0
  27. package/starter-kit/components/TextArea/TextArea.catalog.tsx +1 -0
  28. package/starter-kit/components/Tooltip/Tooltip.catalog.tsx +1 -0
  29. package/starter-kit/components/TopNav/TopNav.catalog.tsx +1 -0
  30. package/starter-kit/components/UnderlineTabs/UnderlineTabs.catalog.tsx +38 -16
package/README.md CHANGED
@@ -526,6 +526,11 @@ and adds what only a real render shows:
526
526
  It needs `puppeteer` in your project (`npm install --save-dev puppeteer`); ds-viewer doesn't
527
527
  install a browser. It takes about two minutes for 50 pages and works with `--json` (progress
528
528
  goes to stderr), `--ci` and `doctor.strict`, so CI can run `npx ds-viewer doctor --render --ci`.
529
+
530
+ It starts its own `dev` server, with `--no-update-endpoint` and its own generated directory
531
+ (`.ds-viewer-render/`, gitignored by `init` alongside `.ds-viewer/`). Nothing it generates touches
532
+ the workspace a running `dev` owns, so you can run it beside a catalog you already have open and
533
+ that catalog is unaffected — including its update page's actions.
529
534
  It stops the viewer it started, including on Ctrl-C (exit codes 130, 143 or 129 for SIGINT,
530
535
  SIGTERM or SIGHUP). On Windows it stops the viewer with `taskkill`; that path is unit-tested, not
531
536
  run on a real Windows machine.
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 { writeWorkspace } from "./workspace.js";
9
+ import { workspacePath, 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";
@@ -15,9 +15,10 @@ import { createUpdateEndpoint } from "./endpoint.js";
15
15
  import { buildUpdatePlan } from "./updatePlan.js";
16
16
  import { lastSummaryLine } from "./doctor.js";
17
17
  import { detectPackageManager, installUpgradeCommand, platformCommand } from "./packageManager.js";
18
+ import { DEFAULT_WORKSPACE_DIR, RENDER_WORKSPACE_DIR } from "./types.js";
18
19
  export class LocalInstallMissingError extends Error {
19
20
  }
20
- const IGNORED_WATCH_SEGMENTS = new Set(['.ds-viewer', 'node_modules', '.git']);
21
+ const IGNORED_WATCH_SEGMENTS = new Set([DEFAULT_WORKSPACE_DIR, RENDER_WORKSPACE_DIR, 'node_modules', '.git']);
21
22
  /** False for a changed path under `.ds-viewer/` (package-owned output — reacting to it would make
22
23
  * `dev`'s own `sync` retrigger itself), `node_modules/`, or `.git/`; true for everything else, so
23
24
  * `dev`'s watcher (below) only re-syncs on a project source change. */
@@ -58,8 +59,13 @@ export function watchTargetFolders(config) {
58
59
  * workspace until `dev` was restarted, because `writeWorkspace` was never called again with the
59
60
  * new value (controller end-to-end finding E1). Returns the freshly-resolved config so `dev` can
60
61
  * keep watching with it. */
61
- export function reloadWorkspace(projectRoot, updateEndpoint) {
62
- const config = resolveConfig(projectRoot);
62
+ export function reloadWorkspace(projectRoot, updateEndpoint,
63
+ // `resolveConfig` reads the user's file, which never names a workspace directory, so a run that
64
+ // owns a non-default one has to put it back — otherwise a config change moves every generated
65
+ // file into `.ds-viewer`, which is the collision this parameter exists to prevent.
66
+ workspaceDir) {
67
+ const resolved = resolveConfig(projectRoot);
68
+ const config = workspaceDir ? { ...resolved, workspaceDir } : resolved;
63
69
  sync(config);
64
70
  writeWorkspace(config, updateEndpoint);
65
71
  return config;
@@ -81,8 +87,8 @@ export async function refreshUpdateFile(config, ownVersion, options = {}) {
81
87
  ? statusFromCheck(ownVersion, autoCheck, { result: await injected(ownVersion), reached: true })
82
88
  : { current: ownVersion, autoCheck, lastOutcome: 'not-run', update: null }
83
89
  : await buildVersionStatus(ownVersion, autoCheck);
84
- writeUpdateFile(config.projectRoot, status.update ? { ...status.update, checkedAt: status.lastCheckedAt ?? new Date().toISOString() } : undefined);
85
- writeVersionFile(config.projectRoot, status);
90
+ writeUpdateFile(config.projectRoot, status.update ? { ...status.update, checkedAt: status.lastCheckedAt ?? new Date().toISOString() } : undefined, config.workspaceDir);
91
+ writeVersionFile(config.projectRoot, status, config.workspaceDir);
86
92
  return status;
87
93
  }
88
94
  // Promisified, not `execFileSync` — install/migrate/doctor each take real seconds, and this now
@@ -246,7 +252,7 @@ export async function performRestart(handoff, deps = {}) {
246
252
  try {
247
253
  for (const watcher of handoff.watchers)
248
254
  watcher.close();
249
- handoff.endpointServer.close();
255
+ handoff.endpointServer?.close();
250
256
  killGroup(handoff.child, 'SIGTERM');
251
257
  // Errata 7d: race the old child's exit against a 10s timer, then SIGKILL the group — a wedged
252
258
  // Expo/Metro process must never block the restart forever.
@@ -299,13 +305,15 @@ export async function performRestart(handoff, deps = {}) {
299
305
  export async function dev(initialConfig, devOptions = {}) {
300
306
  assertLocalInstall(initialConfig.projectRoot);
301
307
  const ownVersion = readOwnVersion(import.meta.dirname);
302
- let config = initialConfig;
308
+ // Carried on the config so every writer (sync, writeWorkspace, the update files) lands in this
309
+ // run's own directory, and so a config reload cannot silently drop it.
310
+ let config = devOptions.workspaceDir ? { ...initialConfig, workspaceDir: devOptions.workspaceDir } : initialConfig;
303
311
  const version = createVersionController({
304
312
  ownVersion,
305
313
  projectRoot: initialConfig.projectRoot,
306
314
  prefsDir: defaultPreferencesDir(),
307
315
  env: process.env,
308
- initial: await refreshUpdateFile(initialConfig, ownVersion),
316
+ initial: await refreshUpdateFile(config, ownVersion),
309
317
  getConfig: () => config,
310
318
  // **Check now** always asks npm, even with automatic checks off: you asked for this one check.
311
319
  // The result lives only in this process; `update.json` and `version.json` are rewritten at
@@ -316,7 +324,7 @@ export async function dev(initialConfig, devOptions = {}) {
316
324
  // Restart handoff step 2/4: a status file left by the *previous* `dev` process (right before it
317
325
  // exited to restart) is read once, then deleted — a plain refresh afterward shows "idle"/"no
318
326
  // update known" instead of replaying a stale success/failure forever.
319
- const statusFile = path.join(config.projectRoot, '.ds-viewer', 'update-status.json');
327
+ const statusFile = workspacePath(config, 'update-status.json');
320
328
  let updateStatus = { phase: 'idle' };
321
329
  if (existsSync(statusFile)) {
322
330
  try {
@@ -390,14 +398,24 @@ export async function dev(initialConfig, devOptions = {}) {
390
398
  checkNow: version.checkNow,
391
399
  setAutoCheck: version.setAutoCheck,
392
400
  };
393
- const endpointServer = createUpdateEndpoint(endpointDeps);
394
- await new Promise((resolve) => endpointServer.listen(0, '127.0.0.1', resolve));
395
- const endpointPort = endpointServer.address().port;
396
- // Printed for the same reason the Metro port above is printed: a developer (or Task 20's own
397
- // verification) checking the endpoint directly (e.g. with curl) needs its port — the secret
398
- // itself is never printed; the endpoint rejects anything that doesn't already have it.
399
- console.log(`Update endpoint listening on 127.0.0.1:${endpointPort}`);
400
- const updateEndpoint = { baseUrl: `http://127.0.0.1:${endpointPort}`, secret };
401
+ // A throwaway server (a render check's own `dev`) takes no update actions, and creating an
402
+ // endpoint would bake its short-lived port into the shared workspace — pointing an already-open
403
+ // catalog at a port that dies with this process. See `writeWorkspace`.
404
+ let updateEndpoint;
405
+ let endpointServer;
406
+ if (!devOptions.noUpdateEndpoint) {
407
+ endpointServer = createUpdateEndpoint(endpointDeps);
408
+ await new Promise((resolve) => endpointServer.listen(0, '127.0.0.1', resolve));
409
+ const endpointPort = endpointServer.address().port;
410
+ // Printed for the same reason the Metro port above is printed: a developer (or Task 20's own
411
+ // verification) checking the endpoint directly (e.g. with curl) needs its port — the secret
412
+ // itself is never printed; the endpoint rejects anything that doesn't already have it.
413
+ console.log(`Update endpoint listening on 127.0.0.1:${endpointPort}`);
414
+ updateEndpoint = { baseUrl: `http://127.0.0.1:${endpointPort}`, secret };
415
+ }
416
+ else {
417
+ console.log('Update endpoint disabled for this run (--no-update-endpoint).');
418
+ }
401
419
  const workspace = writeWorkspace(config, updateEndpoint);
402
420
  let debounceTimer;
403
421
  const onSourceChange = (_eventType, relativePath) => {
@@ -426,7 +444,7 @@ export async function dev(initialConfig, devOptions = {}) {
426
444
  clearTimeout(configDebounceTimer);
427
445
  configDebounceTimer = setTimeout(() => {
428
446
  try {
429
- config = reloadWorkspace(config.projectRoot, updateEndpoint);
447
+ config = reloadWorkspace(config.projectRoot, updateEndpoint, config.workspaceDir);
430
448
  version.refreshAutoCheck();
431
449
  }
432
450
  catch (error) {
@@ -451,7 +469,7 @@ export async function dev(initialConfig, devOptions = {}) {
451
469
  child.on('exit', (code) => {
452
470
  for (const watcher of watchers)
453
471
  watcher.close();
454
- endpointServer.close();
472
+ endpointServer?.close();
455
473
  process.exitCode = code ?? 0;
456
474
  });
457
475
  // Node's default SIGTERM/SIGINT/SIGHUP behavior exits this process without touching its
@@ -464,7 +482,7 @@ export async function dev(initialConfig, devOptions = {}) {
464
482
  process.on(signal, () => {
465
483
  for (const watcher of watchers)
466
484
  watcher.close();
467
- endpointServer.close();
485
+ endpointServer?.close();
468
486
  killGroup(child, signal);
469
487
  process.exit(0);
470
488
  });
@@ -476,7 +494,7 @@ export async function dev(initialConfig, devOptions = {}) {
476
494
  console.error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
477
495
  for (const watcher of watchers)
478
496
  watcher.close();
479
- endpointServer.close();
497
+ endpointServer?.close();
480
498
  killGroup(child, 'SIGTERM');
481
499
  process.exit(1);
482
500
  };
@@ -50,6 +50,17 @@ function sendJson(res, status, body, allowedOrigin) {
50
50
  res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': allowedOrigin });
51
51
  res.end(JSON.stringify(body));
52
52
  }
53
+ /** For a refusal the page is *allowed* to read: a wrong secret or an unknown route, where the
54
+ * Origin check has already passed. Without the allow-origin header the browser blocks the
55
+ * response and the page cannot tell "wrong secret" from "server not running" — both arrive as
56
+ * the same opaque network failure, which is how a stale secret looked like a dead server.
57
+ *
58
+ * A rejected or absent Origin deliberately gets no CORS headers at all (see the Origin checks
59
+ * below): a page from somewhere else is told nothing, not even which origin is allowed. */
60
+ function sendText(res, status, body, allowedOrigin) {
61
+ res.writeHead(status, { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': allowedOrigin });
62
+ res.end(body);
63
+ }
53
64
  /** Design §5 "Local endpoint safeguards", every one of them, in the exact order this plan's own
54
65
  * `spikes/cors-endpoint/` spike proved: Origin (including `OPTIONS`) → secret → route. Does not
55
66
  * call `.listen()` itself — Task 18's `dev.ts` owns the real port (`0` → OS-assigned, `127.0.0.1`
@@ -102,7 +113,7 @@ export function createUpdateEndpoint(deps) {
102
113
  return;
103
114
  }
104
115
  if (!secretMatches(req.headers[DS_VIEWER_SECRET_HEADER], deps.secret)) {
105
- res.writeHead(401).end('Missing or wrong secret.');
116
+ sendText(res, 401, 'Missing or wrong secret.', deps.allowedOrigin);
106
117
  return;
107
118
  }
108
119
  if (req.method === 'POST' && req.url === '/plan') {
@@ -245,6 +256,6 @@ export function createUpdateEndpoint(deps) {
245
256
  }
246
257
  return;
247
258
  }
248
- res.writeHead(404).end('Not found.');
259
+ sendText(res, 404, 'Not found.', deps.allowedOrigin);
249
260
  });
250
261
  }
package/dist/cli/init.js CHANGED
@@ -7,6 +7,7 @@ import { detectComponentFolders, detectTokenFiles } from "./detect.js";
7
7
  import { ensureAgentsFileSection } from "./agentsFile.js";
8
8
  import { ensureGithubAction } from "./githubAction.js";
9
9
  import { readOwnVersion } from "./packageVersion.js";
10
+ import { DEFAULT_WORKSPACE_DIR, RENDER_WORKSPACE_DIR } from "./types.js";
10
11
  async function promptConfirm(message) {
11
12
  // A closed, non-interactive stdin (CI, piped npx) would reject `question` with an AbortError;
12
13
  // say how to proceed instead.
@@ -129,11 +130,15 @@ function ensureSharedProjectFiles(projectRoot) {
129
130
  written.push(packageJsonPath);
130
131
  }
131
132
  const gitignorePath = path.join(projectRoot, '.gitignore');
132
- const gitignoreEntry = '.ds-viewer/';
133
+ // Both generated workspaces: the one `dev` owns, and the one `doctor --render`'s own server uses
134
+ // so it never writes the files `dev` is serving.
135
+ const gitignoreEntries = [`${DEFAULT_WORKSPACE_DIR}/`, `${RENDER_WORKSPACE_DIR}/`];
133
136
  const existingGitignore = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : '';
134
- if (!existingGitignore.split('\n').some((line) => line.replace(/\r$/, '') === gitignoreEntry)) {
137
+ const presentLines = new Set(existingGitignore.split('\n').map((line) => line.replace(/\r$/, '')));
138
+ const missing = gitignoreEntries.filter((entry) => !presentLines.has(entry));
139
+ if (missing.length > 0) {
135
140
  const separator = existingGitignore === '' || existingGitignore.endsWith('\n') ? '' : '\n';
136
- writeFileSync(gitignorePath, `${existingGitignore}${separator}${gitignoreEntry}\n`);
141
+ writeFileSync(gitignorePath, `${existingGitignore}${separator}${missing.join('\n')}\n`);
137
142
  written.push(gitignorePath);
138
143
  }
139
144
  written.push(...ensureAgentsFileSection(projectRoot));
package/dist/cli/main.js CHANGED
@@ -7,6 +7,7 @@ import { dev } from "./dev.js";
7
7
  import { sync } from "./sync.js";
8
8
  import { formatHuman, runDoctor, toDoctorJson, withExtraIssues } from "./doctor.js";
9
9
  import { renderCheck } from "./render.js";
10
+ import { RENDER_WORKSPACE_DIR } from "./types.js";
10
11
  import { explainPage, formatExplain, parseHeights } from "./explain.js";
11
12
  import { readOwnVersion } from "./packageVersion.js";
12
13
  import { checkForUpdate, defaultPreferencesDir, readPersonalAutoCheck, resolveAutoCheck } from "./updateCheck.js";
@@ -25,6 +26,10 @@ Commands:
25
26
  dev Sync, then serve the catalog on a local web address and keep it current as files change
26
27
  [--port <number>]
27
28
  Pin the catalog (and its update endpoint's restart handoff) to this port
29
+ [--no-update-endpoint]
30
+ Serve with no local update endpoint, leaving the one a running dev server recorded alone
31
+ [--workspace-dir <name>]
32
+ Generate into this directory instead of .ds-viewer, so two servers never share files
28
33
  doctor Check every page for drift and coverage gaps
29
34
  --json print the machine-readable report instead
30
35
  --ci exit 1 when any error was found (never on a warning alone)
@@ -243,7 +248,22 @@ async function main() {
243
248
  const portIndex = rest.indexOf('--port');
244
249
  const parsedPort = portIndex !== -1 ? Number(rest[portIndex + 1]) : undefined;
245
250
  const pinnedPort = parsedPort !== undefined && Number.isInteger(parsedPort) ? parsedPort : undefined;
246
- await dev(config, { pinnedPort });
251
+ // `--no-update-endpoint`: run the catalog without the local update endpoint, and leave the
252
+ // endpoint an already-running `dev` recorded in the shared workspace alone. `doctor --render`
253
+ // starts its own server this way.
254
+ const noUpdateEndpoint = rest.includes('--no-update-endpoint');
255
+ // `--workspace-dir <name>`: which generated directory this run owns, so a second server (a
256
+ // render check's own) never writes the files the first one is serving. A bare directory name
257
+ // only — anything with a path separator, or a parent reference, would let an argument write
258
+ // outside the project.
259
+ const dirIndex = rest.indexOf('--workspace-dir');
260
+ const requestedDir = dirIndex !== -1 ? rest[dirIndex + 1] : undefined;
261
+ if (requestedDir !== undefined && (requestedDir === '' || requestedDir !== path.basename(requestedDir) || requestedDir.startsWith('.') === false)) {
262
+ console.error(`--workspace-dir must be a single hidden directory name such as ${RENDER_WORKSPACE_DIR}.`);
263
+ process.exitCode = 1;
264
+ return;
265
+ }
266
+ await dev(config, { pinnedPort, noUpdateEndpoint, workspaceDir: requestedDir });
247
267
  return;
248
268
  }
249
269
  console.error(`Unknown command "${command}".\n\n${USAGE}`);
@@ -2,9 +2,11 @@
2
2
  // only a real render shows (console errors, pages that fail to render, the viewer's own [Catalog]
3
3
  // layout warnings). The browser is resolved from the project; ds-viewer never installs one.
4
4
  import { spawn, spawnSync } from 'node:child_process';
5
+ import { get as httpGet } from 'node:http';
5
6
  import { createRequire } from 'node:module';
6
7
  import path from 'node:path';
7
8
  import { fileURLToPath } from 'node:url';
9
+ import { RENDER_WORKSPACE_DIR } from "./types.js";
8
10
  const OVERFLOW = /is \d+px wide in a \d+px cell/;
9
11
  /**
10
12
  * Turns one page's console output into doctor findings. Console errors and a missing heading are
@@ -173,10 +175,34 @@ function outputTail() {
173
175
  }
174
176
  /** Starts the project's own `dev` in its own process group. `onSpawn` receives the child at once,
175
177
  * so a signal during startup can still stop it. */
178
+ /** Polls `http://localhost:<port>/` until it answers, so a caller never navigates to a port Metro
179
+ * has not bound yet. Any HTTP reply counts — the page's own content is checked afterwards. */
180
+ async function waitForServer(port, timeoutMs = DEV_START_TIMEOUT_MS) {
181
+ const deadline = Date.now() + timeoutMs;
182
+ for (;;) {
183
+ const answered = await new Promise((resolve) => {
184
+ const request = httpGet({ host: 'localhost', port, path: '/', timeout: 2000 }, () => resolve(true));
185
+ request.on('error', () => resolve(false));
186
+ request.on('timeout', () => {
187
+ request.destroy();
188
+ resolve(false);
189
+ });
190
+ });
191
+ if (answered)
192
+ return;
193
+ if (Date.now() > deadline)
194
+ throw new Error(`Timed out waiting for the catalog to answer on port ${port}.`);
195
+ await new Promise((resolve) => setTimeout(resolve, 250));
196
+ }
197
+ }
176
198
  function startDev(projectRoot, onSpawn) {
177
199
  const main = path.join(path.dirname(fileURLToPath(import.meta.url)), 'main.js');
178
200
  return new Promise((resolve, reject) => {
179
- const child = spawn(process.execPath, [main, 'dev'], {
201
+ // Its own workspace directory and no update endpoint: this server exists to render pages, and
202
+ // every file it generates would otherwise overwrite the ones an already-running `dev` is
203
+ // serving — including that developer's endpoint, pointing their open catalog at a port that
204
+ // dies with this check.
205
+ const child = spawn(process.execPath, [main, 'dev', '--no-update-endpoint', '--workspace-dir', RENDER_WORKSPACE_DIR], {
180
206
  cwd: projectRoot,
181
207
  env: { ...process.env, CI: '1', DS_VIEWER_NO_UPDATE_CHECK: '1' },
182
208
  detached: process.platform !== 'win32',
@@ -202,7 +228,13 @@ function startDev(projectRoot, onSpawn) {
202
228
  if (match && !done) {
203
229
  done = true;
204
230
  clearTimeout(timer);
205
- resolve({ child, port: Number(match[1]) });
231
+ // `dev` prints that line before it spawns Expo, so the port is known well before Metro is
232
+ // listening on it. Resolving here raced the bundler: the first navigation could land on a
233
+ // port nothing was bound to yet. Wait for the server to actually answer.
234
+ const port = Number(match[1]);
235
+ waitForServer(port)
236
+ .then(() => resolve({ child, port }))
237
+ .catch((error) => fail(`${error.message} `));
206
238
  }
207
239
  });
208
240
  child.on('exit', (code) => fail(`ds-viewer dev exited early (code ${code}).`));
@@ -272,10 +304,18 @@ export async function renderCheck(projectRoot, options = {}) {
272
304
  }
273
305
  }
274
306
  // Runs in the browser; `document` is typed loosely because the CLI compiles without DOM types.
275
- const hrefs = await home.evaluate(() => {
307
+ const readHrefs = () => home.evaluate(() => {
276
308
  const doc = globalThis.document;
277
309
  return [...new Set(Array.from(doc.querySelectorAll('a[href^="#"]')).map((a) => a.getAttribute('href') ?? ''))];
278
310
  });
311
+ // The server answers before the bundle has run, and a workspace Metro has never bundled before
312
+ // (a fresh `.ds-viewer-render`) is slow the first time. Keep asking until the sidebar is there,
313
+ // rather than reading once and calling an empty page a failed render.
314
+ let hrefs = await readHrefs();
315
+ while (hrefs.length === 0 && Date.now() < deadline) {
316
+ await new Promise((r) => setTimeout(r, 500));
317
+ hrefs = await readHrefs();
318
+ }
279
319
  await home.close();
280
320
  const ids = hrefs.map(idFromHref).filter(Boolean);
281
321
  if (ids.length === 0) {
package/dist/cli/sync.js CHANGED
@@ -4,13 +4,14 @@ import { globBaseFolder, resolveGlob } from "./glob.js";
4
4
  import { createComponentReader } from "./props.js";
5
5
  import { readTokenModules } from "./tokens.js";
6
6
  import { discoverPages, writePageIndex } from "./pageIndex.js";
7
+ import { workspacePath } from "./workspace.js";
7
8
  const GENERATED_INDEX = "export { default as components } from './components.json';\nexport { default as tokens } from './tokens.json';\n";
8
9
  /** Reads detection, props, and tokens from source, then writes `.ds-viewer/generated/`
9
10
  * (design §3 — "Output and speed": `components.json`, `tokens.json`, and the page import list,
10
11
  * sorted and deterministic). Nothing from the project is executed. `resolveOptions` is test-only
11
12
  * (see this task's Interfaces note) — production callers omit it. */
12
13
  export function sync(config, resolveOptions) {
13
- const generatedDir = path.join(config.projectRoot, '.ds-viewer', 'generated');
14
+ const generatedDir = workspacePath(config, 'generated');
14
15
  mkdirSync(generatedDir, { recursive: true });
15
16
  const excluded = new Set((config.exclude ?? []).flatMap((pattern) => resolveGlob(config.projectRoot, pattern)));
16
17
  const componentEntryFiles = config.components.flatMap((pattern) => resolveGlob(config.projectRoot, pattern)).filter((file) => !excluded.has(file));
package/dist/cli/types.js CHANGED
@@ -1 +1,8 @@
1
- export {};
1
+ /** `DsViewerConfig` plus where it came from. Every CLI command after config loading works with
2
+ * this, not the raw `DsViewerConfig`, so paths are always resolved against the right project. */
3
+ /** The generated Expo workspace's directory name, relative to `projectRoot`. The default is the
4
+ * only one a user ever sees; a throwaway server (a render check's own `dev`) uses its own so the
5
+ * two never write the same files. */
6
+ export const DEFAULT_WORKSPACE_DIR = '.ds-viewer';
7
+ /** Used by `doctor --render`'s own `dev`. Gitignored next to the default. */
8
+ export const RENDER_WORKSPACE_DIR = '.ds-viewer-render';
@@ -2,6 +2,7 @@
2
2
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { compareVersions, isBreakingUpgrade, parseVersion } from "./semver.js";
5
+ import { DEFAULT_WORKSPACE_DIR } from "./types.js";
5
6
  const CACHE_FILE_NAME = 'update-check.json';
6
7
  const PACKAGE_NAME = '@krapwoo/ds-viewer';
7
8
  const REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME).replace('%40', '@')}/latest`;
@@ -229,9 +230,10 @@ export function statusFromCheck(currentVersion, autoCheck, outcome) {
229
230
  update: toNotice(outcome.result),
230
231
  };
231
232
  }
232
- /** `dev` writes this next to `update.json`; the generated entry file imports it. */
233
- export function writeVersionFile(projectRoot, status) {
234
- const file = path.join(projectRoot, '.ds-viewer', 'version.json');
233
+ /** `dev` writes this next to `update.json`; the generated entry file imports it. `workspaceDir`
234
+ * is the run's own generated directory — see `workspacePath`. */
235
+ export function writeVersionFile(projectRoot, status, workspaceDir = DEFAULT_WORKSPACE_DIR) {
236
+ const file = path.join(projectRoot, workspaceDir, 'version.json');
235
237
  mkdirSync(path.dirname(file), { recursive: true });
236
238
  writeFileSync(file, JSON.stringify(status));
237
239
  return file;
@@ -239,8 +241,8 @@ export function writeVersionFile(projectRoot, status) {
239
241
  /** Design §5: "`dev` writes the result to `.ds-viewer/update.json`; the viewer reads it." Always
240
242
  * writes something — `null` when there is no result — so the generated entry file (Task 17) can
241
243
  * unconditionally `import updateNotice from './update.json'` without ever hitting a missing file. */
242
- export function writeUpdateFile(projectRoot, result) {
243
- const file = path.join(projectRoot, '.ds-viewer', 'update.json');
244
+ export function writeUpdateFile(projectRoot, result, workspaceDir = DEFAULT_WORKSPACE_DIR) {
245
+ const file = path.join(projectRoot, workspaceDir, 'update.json');
244
246
  mkdirSync(path.dirname(file), { recursive: true });
245
247
  writeFileSync(file, JSON.stringify(result ?? null));
246
248
  return file;
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { globBaseFolder } from "./glob.js";
4
+ import { DEFAULT_WORKSPACE_DIR } from "./types.js";
4
5
  function metroConfigJs(extraWatchFolders) {
5
6
  return `const path = require('path');
6
7
  const fs = require('fs');
@@ -99,11 +100,23 @@ function configGlobFolders(config) {
99
100
  function projectIncludeGlobs(config) {
100
101
  return configGlobFolders(config).map((folder) => `../${folder}/**/*`);
101
102
  }
103
+ /** This run's generated workspace directory. Every writer goes through here, so a run that owns a
104
+ * different directory (a render check's throwaway server) cannot touch another server's files. */
105
+ export function workspacePath(config, ...segments) {
106
+ return path.join(config.projectRoot, config.workspaceDir ?? DEFAULT_WORKSPACE_DIR, ...segments);
107
+ }
102
108
  /** Writes the self-contained Expo web workspace at `.ds-viewer/` (design §2 "The preview
103
109
  * workspace and `npx ds-viewer dev`"). Rewritten freely on every `dev` run — nothing here is
104
- * user-owned. */
110
+ * user-owned.
111
+ *
112
+ * `updateEndpoint` is the caller's own, inlined into `entry.tsx`. Without one (a render check's
113
+ * throwaway server, `--no-update-endpoint`) the catalog is served with no endpoint at all, so it
114
+ * makes no calls: the shell polls `/version` from every page, so inlining a *different* server's
115
+ * endpoint here would have every page call it from the wrong origin. Two `dev` servers share this
116
+ * one directory, so the last writer decides — see the README on running a render check beside an
117
+ * open catalog. */
105
118
  export function writeWorkspace(config, updateEndpoint) {
106
- const workspace = path.join(config.projectRoot, '.ds-viewer');
119
+ const workspace = workspacePath(config);
107
120
  mkdirSync(workspace, { recursive: true });
108
121
  writeFileSync(path.join(workspace, 'package.json'), `${JSON.stringify({ name: 'ds-viewer-workspace', private: true, main: 'entry.tsx' }, null, 2)}\n`);
109
122
  writeFileSync(path.join(workspace, 'app.json'), `${JSON.stringify({ expo: { name: config.name, slug: 'ds-viewer-workspace', platforms: ['web'] } }, null, 2)}\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krapwoo/ds-viewer",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A browsable, always-current catalog for an Expo / React Native design system.",
@@ -6,6 +6,7 @@ import { DS_SEMANTIC } from '../../tokens';
6
6
  export default defineCatalogPage({
7
7
  component: 'AnimatedChevron',
8
8
  group: 'Components',
9
+ whenToUse: 'An expand/collapse affordance that should animate its own state change — it flips vertically in place, so it never points sideways mid-transition. Where a chevron never changes state, a plain icon is enough.',
9
10
  description: 'A chevron that morphs between down (collapsed) and up (expanded) — flipping vertically in place instead of rotating through a sideways-pointing angle, and instead of swapping icons. The morph Banner\'s own collapsible header uses for its disclosure indicator. Not commonly used on its own; exported for building a custom disclosure/accordion toggle.',
10
11
  a11y: 'Purely decorative — no accessibility role of its own. Wrap it in an accessible parent (the way Banner\'s own header Pressable does) if the toggle needs to be announced.',
11
12
  variants: {
@@ -32,6 +32,7 @@ const AVATAR_COMPARISON = grid(
32
32
  export default defineCatalogPage({
33
33
  component: 'Avatar',
34
34
  group: 'Components',
35
+ whenToUse: 'A person or place\'s identity in a circle. Pass whatever you have: image, icon, or initials are tried in that order, so a missing image degrades to initials without leaving a gap.',
35
36
  specimenSize: 'compact',
36
37
  comparison: AVATAR_COMPARISON,
37
38
  description: 'A circular image, or an initials fallback on a solid fill when there\'s no image (or it fails to load).',
@@ -8,48 +8,41 @@ import { BottomSheet } from './BottomSheet';
8
8
  import { DS_SEMANTIC, DS_SPACING, DS_TYPOGRAPHY } from '../../tokens';
9
9
 
10
10
  const styles = StyleSheet.create({
11
- // BottomSheet/Dropdown demos render inside the shared `PhoneFrame` (./PhoneFrame.tsx) so their
12
- // absolute overlays stay contained instead of covering the whole catalog page. No padding here —
13
- // BottomSheet's own content area already supplies the standard 16px.
14
- sheetContent: {},
11
+ // No padding here — BottomSheet's own content area already supplies the standard 16px.
15
12
  cardTitle: { ...DS_TYPOGRAPHY.labelMd, color: DS_SEMANTIC.text.regular },
16
13
  cardBody: { ...DS_TYPOGRAPHY.bodySm, color: DS_SEMANTIC.text.muted, marginTop: DS_SPACING[200] },
17
14
  });
18
15
 
19
- function BottomSheetDemo() {
16
+ const EXTRA_PARAGRAPHS = [
17
+ 'Board at the front car for a faster transfer at Union Sq.',
18
+ 'This line runs express between 96 St and 168 St during rush hours, skipping local stops in between. Weekend service runs local along the full route, with some stations closed for planned maintenance.',
19
+ 'Elevators are available at 168 St, 137 St, and 125 St — check the map for accessible entrances before you travel.',
20
+ ];
21
+
22
+ /** Every example is a real sheet inside a phone: the trigger opens it, the backdrop dismisses it.
23
+ * `OverlayDemo` supplies the phone, so the sheet's absolute overlay stays in its own cell. */
24
+ function Sheet({
25
+ header,
26
+ footer,
27
+ /** Extra paragraphs; enough of them and the content area passes 90% height and starts scrolling. */
28
+ paragraphs = 0,
29
+ }: {
30
+ header?: (close: () => void) => React.ReactNode;
31
+ footer?: (close: () => void) => React.ReactNode;
32
+ paragraphs?: number;
33
+ }) {
20
34
  return (
21
- <OverlayDemo triggerLabel="Open sheet">
35
+ // Open from the start: closed, every example is the same empty phone behind the same trigger,
36
+ // and what each one is named for — the header, the footer, the scrolling — is invisible.
37
+ <OverlayDemo triggerLabel="Open sheet" initiallyOpen>
22
38
  {({ open, close }) => (
23
- <BottomSheet
24
- visible={open}
25
- onDismiss={close}
26
- header={
27
- <TopNav
28
- title="Trip details"
29
- trailing={<Button variant="secondary" size="small" showIcon showLabel={false} iconName="clear" accessibilityLabel="Close" onPress={close} />}
30
- />
31
- }
32
- footer={
33
- <Dock>
34
- <Button label="Confirm" onPress={close} />
35
- </Dock>
36
- }
37
- >
38
- <View style={styles.sheetContent}>
39
- <Text style={styles.cardTitle}>Uptown & The Bronx</Text>
39
+ <BottomSheet visible={open} onDismiss={close} header={header?.(close)} footer={footer?.(close)}>
40
+ <View>
41
+ <Text style={styles.cardTitle}>Uptown &amp; The Bronx</Text>
40
42
  <Text style={styles.cardBody}>Next train in 4 min · every 6–8 min.</Text>
41
- {/* Long enough to overflow the phone frame's fixed height — demonstrates the Dock footer's
42
- `elevated` shadow turning on automatically once this content area actually scrolls. */}
43
- <Text style={styles.cardBody}>Board at the front car for a faster transfer at Union Sq.</Text>
44
- <Text style={styles.cardBody}>
45
- This line runs express between 96 St and 168 St during rush hours, skipping local stops in
46
- between. Weekend service runs local along the full route, with some stations closed for
47
- planned maintenance.
48
- </Text>
49
- <Text style={styles.cardBody}>
50
- Elevators are available at 168 St, 137 St, and 125 St — check the map for accessible
51
- entrances before you travel.
52
- </Text>
43
+ {EXTRA_PARAGRAPHS.slice(0, paragraphs).map((line) => (
44
+ <Text key={line} style={styles.cardBody}>{line}</Text>
45
+ ))}
53
46
  </View>
54
47
  </BottomSheet>
55
48
  )}
@@ -57,6 +50,18 @@ function BottomSheetDemo() {
57
50
  );
58
51
  }
59
52
 
53
+ const topNav = (close: () => void) => (
54
+ <TopNav
55
+ title="Trip details"
56
+ trailing={<Button variant="secondary" size="small" showIcon showLabel={false} iconName="clear" accessibilityLabel="Close" onPress={close} />}
57
+ />
58
+ );
59
+ const dock = (close: () => void) => (
60
+ <Dock>
61
+ <Button label="Confirm" onPress={close} />
62
+ </Dock>
63
+ );
64
+
60
65
  export default defineCatalogPage({
61
66
  component: 'BottomSheet',
62
67
  group: 'Components',
@@ -67,5 +72,19 @@ export default defineCatalogPage({
67
72
  description: 'A sheet that slides up over a dismissible backdrop. Height is driven by its content (not fixed snap points) up to 90% of the available height, then the content area scrolls. Compose header with TopNav and footer with Dock.',
68
73
  whenToUse: 'For longer, browsable content, or anything that benefits from a TopNav/Dock header-footer structure. For a short, focused decision that interrupts the flow, use Dialog.',
69
74
  a11y: 'The backdrop is a Pressable with accessibilityRole="button" and accessibilityLabel="Dismiss"; header/content/footer carry their own accessibility (e.g. TopNav\'s title as accessibilityRole="header").',
70
- render: () => <BottomSheetDemo />,
75
+ variants: {
76
+ items: [
77
+ // Both slots empty: the sheet is only as tall as its content, above its own drag handle.
78
+ { key: 'content-only', name: 'Content only', node: <Sheet /> },
79
+ { key: 'header', name: 'With a TopNav header', node: <Sheet header={topNav} /> },
80
+ { key: 'header-footer', name: 'Header and a Dock footer', node: <Sheet header={topNav} footer={dock} /> },
81
+ ],
82
+ },
83
+ states: {
84
+ items: [
85
+ // Past 90% of the phone's height the content area scrolls, which is what turns on the Dock
86
+ // footer's shadow — overflowing it is the only way to see that happen.
87
+ { key: 'scrolling', name: 'Content long enough to scroll', node: <Sheet header={topNav} footer={dock} paragraphs={3} /> },
88
+ ],
89
+ },
71
90
  });
@@ -6,6 +6,7 @@ import { Button } from '../Button';
6
6
  export default defineCatalogPage({
7
7
  component: 'ButtonGroup',
8
8
  group: 'Components',
9
+ whenToUse: 'Two or three Buttons that should read as one family — horizontal for a trailing-aligned pair (a dialog\'s Cancel and Confirm), vertical for up to three stretched full width. Keep one variant tier and one size across the group. For a screen\'s bottom action area, use Dock.',
9
10
  composedOf: [
10
11
  { component: 'Button', role: "Its children: up to two Buttons side by side, or three stacked.", relationship: 'slot' },
11
12
  ],
@@ -12,19 +12,18 @@ const styles = StyleSheet.create({
12
12
  dialogActions: { marginTop: DS_SPACING[800] },
13
13
  });
14
14
 
15
- function DialogDemo() {
15
+ /** Every example is a real dialog inside a phone, so the backdrop covers the screen it belongs to
16
+ * rather than the catalog page. `OverlayDemo` supplies the phone and the trigger. */
17
+ function Demo({ title, body, actions }: { title: string; body?: string; actions: (close: () => void) => React.ReactNode }) {
16
18
  return (
17
- <OverlayDemo triggerLabel="Open dialog">
19
+ // Open from the start: a closed dialog is just a trigger, so the card each example is named
20
+ // for would never be on screen.
21
+ <OverlayDemo triggerLabel="Open dialog" initiallyOpen>
18
22
  {({ open, close }) => (
19
23
  <Dialog visible={open} onDismiss={close}>
20
- <Text style={styles.cardTitle}>Delete this trip?</Text>
21
- <Text style={styles.cardBody}>This can't be undone.</Text>
22
- <View style={styles.dialogActions}>
23
- <ButtonGroup>
24
- <Button label="Cancel" variant="tertiary" onPress={close} />
25
- <Button label="Delete" onPress={close} />
26
- </ButtonGroup>
27
- </View>
24
+ <Text style={styles.cardTitle}>{title}</Text>
25
+ {body ? <Text style={styles.cardBody}>{body}</Text> : null}
26
+ <View style={styles.dialogActions}>{actions(close)}</View>
28
27
  </Dialog>
29
28
  )}
30
29
  </OverlayDemo>
@@ -34,8 +33,58 @@ function DialogDemo() {
34
33
  export default defineCatalogPage({
35
34
  component: 'Dialog',
36
35
  group: 'Components',
36
+ composedOf: [
37
+ { component: 'ButtonGroup', role: "Lays out the dialog's actions; a dialog's own children decide this, not Dialog.", relationship: 'related' },
38
+ ],
37
39
  description: 'A card centred on screen over a dismissible backdrop — fades and scales in, distinct from BottomSheet\'s bottom-anchored slide. Named Dialog (not Modal) to avoid shadowing React Native\'s own built-in Modal.',
38
40
  whenToUse: 'A short, focused decision that interrupts the flow (confirm/cancel, a single form). For anything longer, browsable, or that needs its own internal scrolling, use BottomSheet.',
39
41
  a11y: 'The backdrop is a Pressable with accessibilityRole="button" and accessibilityLabel="Dismiss"; content you pass as children carries its own accessibility.',
40
- render: () => <DialogDemo />,
42
+ variants: {
43
+ items: [
44
+ {
45
+ key: 'confirm',
46
+ name: 'A confirmation, two actions',
47
+ node: (
48
+ <Demo
49
+ title="Delete this trip?"
50
+ body="This can't be undone."
51
+ actions={(close) => (
52
+ <ButtonGroup>
53
+ <Button label="Cancel" variant="tertiary" onPress={close} />
54
+ <Button label="Delete" onPress={close} />
55
+ </ButtonGroup>
56
+ )}
57
+ />
58
+ ),
59
+ },
60
+ {
61
+ key: 'acknowledge',
62
+ name: 'One action to acknowledge',
63
+ node: (
64
+ <Demo
65
+ title="Trip saved"
66
+ body="You'll find it under Saved trips."
67
+ actions={(close) => <Button label="Got it" onPress={close} />}
68
+ />
69
+ ),
70
+ },
71
+ {
72
+ // Title alone: Dialog adds no body of its own, so a question that needs no explanation
73
+ // keeps the card to two lines.
74
+ key: 'title-only',
75
+ name: 'Title and actions only',
76
+ node: (
77
+ <Demo
78
+ title="Discard changes?"
79
+ actions={(close) => (
80
+ <ButtonGroup>
81
+ <Button label="Keep editing" variant="tertiary" onPress={close} />
82
+ <Button label="Discard" onPress={close} />
83
+ </ButtonGroup>
84
+ )}
85
+ />
86
+ ),
87
+ },
88
+ ],
89
+ },
41
90
  });
@@ -12,6 +12,7 @@ const styles = StyleSheet.create({
12
12
  export default defineCatalogPage({
13
13
  component: 'Divider',
14
14
  group: 'Components',
15
+ whenToUse: 'A single hairline between two pieces of content you are laying out yourself. List already puts one between each pair of its rows, so adding your own there would double them.',
15
16
  specimenSize: 'regular',
16
17
  description: 'A 1px hairline separator at the divider token colour.',
17
18
  a11y: 'A plain, non-interactive View — purely decorative.',
@@ -6,6 +6,7 @@ import { Dock } from './Dock';
6
6
  export default defineCatalogPage({
7
7
  component: 'Dock',
8
8
  group: 'Components',
9
+ whenToUse: 'A screen\'s bottom action area: up to three full-width buttons above the home indicator, with an optional caption. It is not positioned absolutely itself — the composing screen decides whether to pin it or lay it out inline at the end of a column. For actions inside a card or dialog, use ButtonGroup.',
9
10
  composedOf: [
10
11
  { component: 'Button', role: "Its children: up to three full-width Buttons, stacked.", relationship: 'slot' },
11
12
  ],
@@ -13,6 +13,7 @@ const styles = StyleSheet.create({
13
13
  export default defineCatalogPage({
14
14
  component: 'FieldContainer',
15
15
  group: 'Components',
16
+ whenToUse: 'Building a new kind of field. It carries the shared field chrome and reads the ambient Surface tone, so it stays visible on white and on muted backgrounds alike. For the fields that already exist, use InputField or TextArea instead of wrapping this yourself.',
16
17
  composedOf: [
17
18
  { component: 'Surface', role: "Reads the surrounding Surface tone: a recessed fill on white, opaque white inside a muted Surface.", relationship: 'related' },
18
19
  ],
@@ -5,6 +5,7 @@ import { InputClearButton } from './InputClearButton';
5
5
  export default defineCatalogPage({
6
6
  component: 'InputClearButton',
7
7
  group: 'Components',
8
+ whenToUse: 'The clear control inside a text field. InputField and SearchField already include one, so reach for it directly only when building another field type that needs the same affordance.',
8
9
  description: 'The clear (×) button InputField and SearchField both show once a field is active and holds a value — a filled circle-x icon sized to reach the 44pt touch target via hitSlop, not visual size.',
9
10
  a11y: 'A Pressable with accessibilityRole="button" and the given (or default "Clear field") accessibilityLabel; hitSlop of 10 on every side pads its 24×24 visual size out to the 44pt minimum.',
10
11
  variants: {
@@ -9,6 +9,7 @@ import { DS_SEMANTIC } from '../../tokens';
9
9
  export default defineCatalogPage({
10
10
  component: 'List',
11
11
  group: 'Components',
12
+ whenToUse: 'A set of ListItem rows as one rounded surface, with dividers between rows and never after the last. For a single row standing on its own, use ListItem directly.',
12
13
  specimenSize: 'regular',
13
14
  composedOf: [
14
15
  { component: 'Divider', role: "The hairline between consecutive items (none after the last).", relationship: 'built-in' },
@@ -6,6 +6,7 @@ import { DS_SEMANTIC } from '../../tokens';
6
6
  export default defineCatalogPage({
7
7
  component: 'Loading',
8
8
  group: 'Components',
9
+ whenToUse: 'An indeterminate wait of unknown length — `circle` inline, `linear` where a bar suits the layout better. Button and Pill already show this in their own loading states, so don\'t nest one inside them. For a known number of steps, use ProgressDots.',
9
10
  specimenSize: 'regular',
10
11
  description: 'An indeterminate loader — the shape fills up, empties out, then fills again, seamlessly. Two variants: circle (used internally by Button and Pill for their own loading states) and linear.',
11
12
  a11y: 'Exposed to assistive tech as accessibilityRole="progressbar" with accessibilityLabel="Loading" — no extra wiring needed at the call site.',
@@ -5,6 +5,7 @@ import { ProgressDots } from './ProgressDots';
5
5
  export default defineCatalogPage({
6
6
  component: 'ProgressDots',
7
7
  group: 'Components',
8
+ whenToUse: 'A short sequence of steps whose length is known, with the current step widened into a pill. For a wait of unknown length, use Loading.',
8
9
  description: 'A row of dots for a stepped flow; the active dot widens into a pill.',
9
10
  a11y: 'Decorative progress indicator; convey the "step X of Y" position in text for screen readers.',
10
11
  // No `variant` prop exists on ProgressDots — its "Variants" column just shows the one default
@@ -1,29 +1,50 @@
1
1
  import React, { useState } from 'react';
2
2
  import { defineCatalogPage } from '@krapwoo/ds-viewer';
3
- import { SegmentedToggle } from './SegmentedToggle';
3
+ import { SegmentedToggle, type SegmentedToggleOption } from './SegmentedToggle';
4
4
 
5
- function SegmentedToggleDemo() {
6
- const [value, setValue] = useState('map');
7
- return (
8
- <SegmentedToggle
9
- value={value}
10
- onChange={setValue}
11
- options={[
12
- { value: 'map', label: 'Map', iconName: 'map' },
13
- { value: 'list', label: 'List', iconName: 'menu' },
14
- { value: 'saved', label: 'Saved', iconName: 'bell', badge: 3 },
15
- // Label-only segment (no iconName) — the layout every other option's icon would otherwise hide.
16
- { value: 'settings', label: 'Settings' },
17
- ]}
18
- />
19
- );
5
+ /** Each example owns its own selection, so the thumb really slides when a segment is pressed. */
6
+ function Demo({ options, initial }: { options: SegmentedToggleOption[]; initial?: string }) {
7
+ const [value, setValue] = useState(initial ?? options[0].value);
8
+ return <SegmentedToggle value={value} onChange={setValue} options={options} />;
20
9
  }
21
10
 
11
+ const TWO: SegmentedToggleOption[] = [
12
+ { value: 'map', label: 'Map' },
13
+ { value: 'list', label: 'List' },
14
+ ];
15
+ const ICONS: SegmentedToggleOption[] = [
16
+ { value: 'map', label: 'Map', iconName: 'map' },
17
+ { value: 'list', label: 'List', iconName: 'menu' },
18
+ { value: 'saved', label: 'Saved', iconName: 'bell' },
19
+ ];
20
+ const MIXED: SegmentedToggleOption[] = [
21
+ { value: 'map', label: 'Map', iconName: 'map' },
22
+ { value: 'list', label: 'List', iconName: 'menu' },
23
+ { value: 'saved', label: 'Saved', iconName: 'bell', badge: 3 },
24
+ // Label-only, beside segments that have icons: the row keeps one height either way.
25
+ { value: 'settings', label: 'Settings' },
26
+ ];
27
+
22
28
  export default defineCatalogPage({
23
29
  component: 'SegmentedToggle',
24
30
  group: 'Components',
25
31
  description: 'A row of mutually-exclusive options on a recessed track, with a white thumb that slides to the selected segment. Two or more options.',
26
32
  whenToUse: 'A filled, heavier-weight control for a primary, prominent choice on the screen. For quieter secondary navigation, use UnderlineTabs.',
27
33
  a11y: 'The row is accessibilityRole="tablist"; each segment is a "tab" with accessibilityState.selected reflecting the current value.',
28
- render: () => <SegmentedToggleDemo />,
34
+ variants: {
35
+ itemsFill: true,
36
+ items: [
37
+ { key: 'two', name: 'Two options', node: <Demo options={TWO} /> },
38
+ { key: 'icons', name: 'An icon per segment', node: <Demo options={ICONS} /> },
39
+ { key: 'badge', name: 'A badge, and a label-only segment', node: <Demo options={MIXED} /> },
40
+ ],
41
+ },
42
+ states: {
43
+ itemsFill: true,
44
+ items: [
45
+ // Where the thumb sits is the component's whole job, so show both ends of the track.
46
+ { key: 'first', name: 'First segment selected', node: <Demo options={ICONS} initial="map" /> },
47
+ { key: 'last', name: 'Last segment selected', node: <Demo options={ICONS} initial="saved" /> },
48
+ ],
49
+ },
29
50
  });
@@ -11,6 +11,7 @@ const styles = StyleSheet.create({
11
11
  export default defineCatalogPage({
12
12
  component: 'Breathing',
13
13
  group: 'Components',
14
+ whenToUse: 'The raw pulsing block behind Shimmer, exported so a composite skeleton can pulse in lockstep with plain Shimmers off the one shared clock. For an ordinary loading placeholder, use Shimmer.',
14
15
  specimenSize: 'regular',
15
16
  description:
16
17
  "The shared animated primitive behind Shimmer — a solid block that breathes between a low and high colour on one shared, module-level clock, so every Breathing/Shimmer instance on screen stays phase-locked instead of drifting out of sync. Exported so a custom composite skeleton can reuse the same pulse directly instead of going through Shimmer's fixed text/circle/container shapes.",
@@ -12,6 +12,7 @@ const styles = StyleSheet.create({
12
12
  export default defineCatalogPage({
13
13
  component: 'Surface',
14
14
  group: 'Components',
15
+ a11y: 'No semantics of its own: it renders a plain filled View and passes its tone down through context, so it adds nothing to the accessibility tree and neither groups nor labels what sits inside it.',
15
16
  specimenSize: 'regular',
16
17
  description:
17
18
  "Declares \"this subtree's background is tone\" — renders a plain View filled with the matching token (surface.white or surface.main) and provides that tone to descendants via context, so a tone-aware component inside (e.g. FieldContainer/InputField) can automatically pick a fill that contrasts with it instead of assuming white.",
@@ -10,6 +10,7 @@ function TextAreaDemo() {
10
10
  export default defineCatalogPage({
11
11
  component: 'TextArea',
12
12
  group: 'Components',
13
+ whenToUse: 'Multiline free text that grows from a minimum height. For a single-line labelled row use InputField — both share FieldContainer\'s chrome.',
13
14
  specimenSize: 'regular',
14
15
  composedOf: [
15
16
  { component: 'FieldContainer', role: "The field frame around the multi-line input.", relationship: 'built-in' },
@@ -32,6 +32,7 @@ function TooltipDemo() {
32
32
  export default defineCatalogPage({
33
33
  component: 'Tooltip',
34
34
  group: 'Components',
35
+ whenToUse: 'A brief floating label for a control whose purpose an icon alone doesn\'t carry. It measures the trigger\'s real screen position, so the bubble stays inside the screen\'s edges and the arrow still points at the trigger\'s true centre. For something the reader must acknowledge, use Dialog.',
35
36
  specimenSize: 'regular',
36
37
  description: 'A small floating label anchored above or below its wrapped trigger. Fully controlled — drive `visible` from the trigger\'s own onLongPress/onPressIn, since mobile has no hover.',
37
38
  a11y: 'The bubble is a plain, non-interactive View; the trigger you wrap it around carries its own accessibility.',
@@ -12,6 +12,7 @@ const styles = StyleSheet.create({
12
12
  export default defineCatalogPage({
13
13
  component: 'TopNav',
14
14
  group: 'Components',
15
+ whenToUse: 'A screen\'s top bar, and BottomSheet\'s header slot. Its leading and trailing slots reserve their width even when empty, so the title stays centred whether one, both, or neither side has content. For actions at the bottom of a screen, use Dock.',
15
16
  composedOf: [
16
17
  { component: 'Button', role: "The leading and trailing slots, typically small icon Buttons (back, close, an action).", relationship: 'slot' },
17
18
  ],
@@ -1,28 +1,50 @@
1
1
  import React, { useState } from 'react';
2
2
  import { defineCatalogPage } from '@krapwoo/ds-viewer';
3
- import { UnderlineTabs } from './UnderlineTabs';
3
+ import { UnderlineTabs, type UnderlineTabOption } from './UnderlineTabs';
4
4
 
5
- function UnderlineTabsDemo() {
6
- const [value, setValue] = useState('all');
7
- return (
8
- <UnderlineTabs
9
- value={value}
10
- onChange={setValue}
11
- options={[
12
- { value: 'all', label: 'All' },
13
- // Leading-icon tab — the layout every other option (icon-less) would otherwise hide.
14
- { value: 'nearby', label: 'Nearby', iconName: 'pin' },
15
- { value: 'favorites', label: 'Favorites', badge: 2 },
16
- ]}
17
- />
18
- );
5
+ /** Each example owns its own selection, so the underline really slides when a tab is pressed. */
6
+ function Demo({ options, initial }: { options: UnderlineTabOption[]; initial?: string }) {
7
+ const [value, setValue] = useState(initial ?? options[0].value);
8
+ return <UnderlineTabs value={value} onChange={setValue} options={options} />;
19
9
  }
20
10
 
11
+ const LABELS: UnderlineTabOption[] = [
12
+ { value: 'all', label: 'All' },
13
+ { value: 'nearby', label: 'Nearby' },
14
+ { value: 'favorites', label: 'Favorites' },
15
+ ];
16
+ const WITH_ICON: UnderlineTabOption[] = [
17
+ { value: 'all', label: 'All' },
18
+ // A leading icon beside tabs that have none: the row still aligns on one baseline.
19
+ { value: 'nearby', label: 'Nearby', iconName: 'pin' },
20
+ { value: 'favorites', label: 'Favorites' },
21
+ ];
22
+ const WITH_BADGE: UnderlineTabOption[] = [
23
+ { value: 'all', label: 'All' },
24
+ { value: 'nearby', label: 'Nearby', iconName: 'pin' },
25
+ { value: 'favorites', label: 'Favorites', badge: 2 },
26
+ ];
27
+
21
28
  export default defineCatalogPage({
22
29
  component: 'UnderlineTabs',
23
30
  group: 'Components',
24
31
  description: 'A quieter tab switcher — left-aligned labels over a hairline rule, with a sliding underline indicator. Same options/value/onChange API as SegmentedToggle.',
25
32
  whenToUse: "Quiet, secondary navigation within a screen that already has a clear primary focus. For a prominent, primary choice, use SegmentedToggle.",
26
33
  a11y: 'Each tab is a Pressable label; the underline is a visual indicator only, so selection is also conveyed by the active label weight.',
27
- render: () => <UnderlineTabsDemo />,
34
+ variants: {
35
+ itemsFill: true,
36
+ items: [
37
+ { key: 'labels', name: 'Labels only', node: <Demo options={LABELS} /> },
38
+ { key: 'icon', name: 'A leading icon on one tab', node: <Demo options={WITH_ICON} /> },
39
+ { key: 'badge', name: 'A badge on a tab', node: <Demo options={WITH_BADGE} /> },
40
+ ],
41
+ },
42
+ states: {
43
+ itemsFill: true,
44
+ items: [
45
+ // The underline's position is the selection, so show it at both ends of the row.
46
+ { key: 'first', name: 'First tab selected', node: <Demo options={LABELS} initial="all" /> },
47
+ { key: 'last', name: 'Last tab selected', node: <Demo options={LABELS} initial="favorites" /> },
48
+ ],
49
+ },
28
50
  });