@krapwoo/ds-viewer 0.4.9 → 0.5.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
@@ -272,6 +272,28 @@ actually apply:
272
272
  in an iPhone SE viewport (375 × 667 points), scaled down only when its cell is narrower. In the
273
273
  browser viewer it opens that example in its own device-sized document, so overlays that portal
274
274
  to the page (React Native's `Modal`) open inside the phone, not over the catalog.
275
+ - **`OverlayDemo`** — a phone with a catalog button that opens an overlay. Children get
276
+ `{ open, close }`; keep the overlay mounted and drive its own `visible` from `open`, so its
277
+ animations play. `keepTrigger` makes the button a toggle; `triggerLabel` can follow the state;
278
+ `initiallyOpen` starts it open.
279
+ ```tsx
280
+ <OverlayDemo triggerLabel="Open sheet">
281
+ {({ open, close }) => (
282
+ <BottomSheet visible={open} onDismiss={close} footer={<Dock><Button label="Confirm" onPress={close} /></Dock>}>
283
+ …
284
+ </BottomSheet>
285
+ )}
286
+ </OverlayDemo>
287
+ ```
288
+ - **`PhoneScreen`** — a whole app screen inside a `PhoneFrame`: `header` stays at the top,
289
+ `footer` at the phone's bottom, and the children scroll between them instead of pushing the
290
+ footer off the screen. `floating` is a layer over the top for toasts (position its content
291
+ yourself); `bodyWrapper` puts your own surface around the scrolling body.
292
+ ```tsx
293
+ <PhoneFrame>
294
+ <PhoneScreen header={<TopNav title="Saved trips" />} footer={<Dock>…</Dock>}>…</PhoneScreen>
295
+ </PhoneFrame>
296
+ ```
275
297
  - Full-width (`itemsFill`) variants and states wrap at most **3** across; set `maxColumns` to
276
298
  change that. A filled example that keeps its own fixed width is centred in its cell.
277
299
  Default to one preview; add a second width only when a narrower (or wider) viewport actually
@@ -463,6 +485,27 @@ Button
463
485
  dynamically-constructed references remain uncheckable rather than being guessed at. A
464
486
  declared `intentionalStaticPreview` is taken as the author's own call, not independently
465
487
  verified; only a real browser check confirms actual behavior.
488
+ - `overlay-without-device-frame` (warning) — the component imports React Native's `Modal`, which
489
+ opens over the whole catalog page on the web, but its page imports no `PhoneFrame`,
490
+ `OverlayDemo`, `PhoneScreen` or `BoundedOverlayViewport`. Conservative: it can't see
491
+ `import * as RN`, a re-exported `Modal`, or a Modal opened by a sub-component.
492
+
493
+ ### `doctor --render`
494
+
495
+ `npx ds-viewer doctor --render` also starts the viewer, opens every page in a headless browser,
496
+ and adds what only a real render shows:
497
+
498
+ - `render-error` / `render-no-heading` (errors): a console error, or a page that didn't render.
499
+ - `render-layout` (warning): an example wider than its cell (the viewer's own check), with the
500
+ page and file. `render-catalog-warning` (warning): any other `[Catalog]` warning, reported once.
501
+ - `render-unavailable` / `render-failed` (errors): no browser, or the catalog didn't start.
502
+
503
+ It needs `puppeteer` in your project (`npm install --save-dev puppeteer`); ds-viewer doesn't
504
+ install a browser. It takes about two minutes for 50 pages and works with `--json` (progress
505
+ goes to stderr), `--ci` and `doctor.strict`, so CI can run `npx ds-viewer doctor --render --ci`.
506
+ It stops the viewer it started, including on Ctrl-C (exit codes 130, 143 or 129 for SIGINT,
507
+ SIGTERM or SIGHUP). On Windows it stops the viewer with `taskkill`; that path is unit-tested, not
508
+ run on a real Windows machine.
466
509
 
467
510
  ## Known gaps
468
511
 
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { discoverAllPageFiles, resolvePageId } from "./pageIndex.js";
5
5
  import { readHostTsconfigOptions } from "./props.js";
6
- import { readStaticPages } from "./staticPage.js";
6
+ import { importedNames, readStaticPages } from "./staticPage.js";
7
7
  import { resolveGlob } from "./glob.js";
8
8
  import { sync } from "./sync.js";
9
9
  import { gridColumnLimit } from "../native/catalog/comparison.js";
@@ -208,6 +208,43 @@ function checkMissingComposition(pages, components) {
208
208
  }
209
209
  return issues;
210
210
  }
211
+ /** The viewer exports that frame an example as a phone (or another bounded document), so an
212
+ * overlay that portals to the document body opens inside it instead of over the catalog. */
213
+ const DEVICE_FRAME_EXPORTS = ['PhoneFrame', 'OverlayDemo', 'PhoneScreen', 'BoundedOverlayViewport'];
214
+ /** Whether a component's own source imports React Native's `Modal` (by its imported name, so an
215
+ * alias still counts and an unrelated local `Modal` doesn't). Conservative by design: it can't see
216
+ * `import * as RN`, a re-exported `Modal`, or a Modal opened by a sub-component. */
217
+ function importsModal(file) {
218
+ try {
219
+ const source = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.ES2022, true, ts.ScriptKind.TSX);
220
+ return [...importedNames(source, 'react-native').values()].includes('Modal');
221
+ }
222
+ catch {
223
+ return false;
224
+ }
225
+ }
226
+ /** `overlay-without-device-frame`: a page whose component opens a React Native `Modal` (which
227
+ * portals to the document body on the web, covering the whole catalog) but whose page imports no
228
+ * device frame. A page whose imports couldn't be read is skipped, never assumed unframed. */
229
+ export function checkOverlaysFramed(pages, components, projectRoot) {
230
+ const byName = new Map(components.map((c) => [c.name, c]));
231
+ const issues = [];
232
+ for (const page of pages) {
233
+ if (!page.component || page.viewerImports === undefined)
234
+ continue;
235
+ if (page.viewerImports.some((name) => DEVICE_FRAME_EXPORTS.includes(name)))
236
+ continue;
237
+ const component = byName.get(page.component);
238
+ if (!component || !importsModal(path.resolve(projectRoot, component.file)))
239
+ continue;
240
+ issues.push(issue({
241
+ id: 'overlay-without-device-frame', severity: 'warning', ...pageRef(page),
242
+ message: `"${component.name}" opens a React Native Modal, which covers the whole catalog page unless its example is inside a phone.`,
243
+ fix: 'Wrap the example in <PhoneFrame> (or use <OverlayDemo>), so it opens inside the phone.',
244
+ }));
245
+ }
246
+ return issues;
247
+ }
211
248
  /** A function-typed prop's printed type always contains `=>` (`cli/props.ts`'s own displayed type
212
249
  * strings — "(value: string) => void", "() => void", …); a plain data prop's never does. */
213
250
  function isFunctionPropType(type) {
@@ -509,6 +546,15 @@ export function checkPageTypeErrors(pages, options = {}) {
509
546
  }
510
547
  return issues;
511
548
  }
549
+ /** Adds findings to a finished result: applies `doctor.strict`, recounts errors and warnings, and
550
+ * resets the exit code. */
551
+ export function withExtraIssues(result, extra, strict) {
552
+ const added = strict ? extra.map((i) => (i.severity === 'warning' ? { ...i, severity: 'error' } : i)) : extra;
553
+ const issues = [...result.issues, ...added];
554
+ const errors = issues.filter((i) => i.severity === 'error').length;
555
+ const summary = { ...result.summary, errors, warnings: issues.length - errors };
556
+ return { ...result, issues, summary, exitCode: errors > 0 ? 1 : 0 };
557
+ }
512
558
  /** "With examples" means what `checkComponentNoExamples` (Task 4) checks the *absence* of: at
513
559
  * least one checkable page with a real example, not merely "has a page" (an `init`-drafted
514
560
  * "Needs examples" page has one, with none). */
@@ -572,6 +618,7 @@ export function runDoctor(config) {
572
618
  let issues = [
573
619
  ...collectIssues(pages, components),
574
620
  ...checkPageTypeErrors(pages, { fallbackPaths, hostRoot: config.projectRoot }),
621
+ ...checkOverlaysFramed(pages, components, config.projectRoot),
575
622
  ...collectGlobalIssues({ syncWarnings: syncResult.warnings, tokenFileCount }),
576
623
  ];
577
624
  // Design's own `--json` example gives a project-relative `file`; `StaticPage.file` is absolute
@@ -588,7 +635,8 @@ export function runDoctor(config) {
588
635
  withExamples: countWithExamples(pages, components),
589
636
  unboundExamples: countUnboundExamples(pages),
590
637
  };
591
- return { version: 1, summary, update: null, issues, exitCode: summary.errors > 0 ? 1 : 0 };
638
+ const pageFileById = new Map(pages.map((p) => [resolvePageId(p, p.file), path.relative(config.projectRoot, p.file)]));
639
+ return { version: 1, summary, update: null, issues, exitCode: summary.errors > 0 ? 1 : 0, pageFiles: pageFileById };
592
640
  }
593
641
  export function toDoctorJson(result) {
594
642
  const { version, summary, update, issues } = result;
package/dist/cli/main.js CHANGED
@@ -5,7 +5,8 @@ import { resolveConfig } from "./config.js";
5
5
  import { initExistingProject, initNewProject, promptInitMode } from "./init.js";
6
6
  import { dev } from "./dev.js";
7
7
  import { sync } from "./sync.js";
8
- import { formatHuman, runDoctor, toDoctorJson } from "./doctor.js";
8
+ import { formatHuman, runDoctor, toDoctorJson, withExtraIssues } from "./doctor.js";
9
+ import { renderCheck } from "./render.js";
9
10
  import { explainPage, formatExplain, parseHeights } from "./explain.js";
10
11
  import { readOwnVersion } from "./packageVersion.js";
11
12
  import { checkForUpdate, defaultPreferencesDir, readPersonalAutoCheck, resolveAutoCheck } from "./updateCheck.js";
@@ -27,6 +28,7 @@ Commands:
27
28
  doctor Check every page for drift and coverage gaps
28
29
  --json print the machine-readable report instead
29
30
  --ci exit 1 when any error was found (never on a warning alone)
31
+ --render also open every page in a headless browser (needs puppeteer)
30
32
  explain <Page>
31
33
  Print why a page's specimens are laid out the way they are
32
34
  --heights <first>,<second> check side-by-side placement with these measured heights
@@ -116,7 +118,14 @@ export async function runInit(projectRoot, rest, options = {}) {
116
118
  * plain `doctor` (no `--ci`) always returns 0 here regardless of errors found. */
117
119
  export async function runDoctorCommand(projectRoot, rest, options = {}) {
118
120
  const config = resolveConfig(projectRoot);
119
- const result = runDoctor(config);
121
+ let result = runDoctor(config);
122
+ // `--render`: open every page in a headless browser and add what only a real render shows.
123
+ // Progress goes to stderr, so `--json` output stays pure JSON.
124
+ if (rest.includes('--render')) {
125
+ const files = result.pageFiles;
126
+ const rendered = await (options.renderCheck ?? renderCheck)(config.projectRoot, { files, log: (line) => process.stderr.write(`${line}\n`) });
127
+ result = withExtraIssues(result, rendered, Boolean(config.doctor?.strict));
128
+ }
120
129
  // Design §4 "`--ci`: ... no update check." — `--ci` skips this whole block even when
121
130
  // `updateCheck` is otherwise enabled; `result.update` stays `null`.
122
131
  // Same precedence as the viewer: CI's environment variable, your own switch, the project config.
@@ -0,0 +1,247 @@
1
+ // cli/render.ts — `doctor --render`: open every catalog page in a headless browser and report what
2
+ // only a real render shows (console errors, pages that fail to render, the viewer's own [Catalog]
3
+ // layout warnings). The browser is resolved from the project; ds-viewer never installs one.
4
+ import { spawn, spawnSync } from 'node:child_process';
5
+ import { createRequire } from 'node:module';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ const OVERFLOW = /is \d+px wide in a \d+px cell/;
9
+ /**
10
+ * Turns one page's console output into doctor findings. Console errors and a missing heading are
11
+ * errors. The viewer's own `[Catalog]` warnings are warnings: `render-layout` for an example that
12
+ * overflows its cell, `render-catalog-warning` for anything else. Other console noise (React Native
13
+ * web deprecations, logs) is ignored. `seen` is shared across the whole run, so a warning the
14
+ * catalog logs on every page (a duplicate id, an unknown `composedOf` name) is reported once.
15
+ */
16
+ export function issuesFromPage(page, messages, hasHeading, seen = new Set()) {
17
+ const ref = { page: page.id, ...(page.file ? { file: page.file } : {}) };
18
+ const issues = [];
19
+ if (!hasHeading) {
20
+ issues.push({ id: 'render-no-heading', severity: 'error', ...ref, message: 'The page rendered without its heading, so it probably failed to render.', fix: 'Open the page in `ds-viewer dev` and check the browser console.' });
21
+ }
22
+ const pageErrors = new Set();
23
+ for (const m of messages) {
24
+ const first = m.text.split('\n')[0];
25
+ if (m.type === 'error' || m.type === 'pageerror') {
26
+ if (pageErrors.has(first))
27
+ continue;
28
+ pageErrors.add(first);
29
+ issues.push({ id: 'render-error', severity: 'error', ...ref, message: `Console error while rendering: ${first}`, fix: 'Open the page in `ds-viewer dev` and fix the error in the browser console.' });
30
+ }
31
+ else if ((m.type === 'warn' || m.type === 'warning') && first.startsWith('[Catalog] ')) {
32
+ const text = first.slice('[Catalog] '.length);
33
+ if (seen.has(text))
34
+ continue;
35
+ seen.add(text);
36
+ issues.push(OVERFLOW.test(text)
37
+ ? { id: 'render-layout', severity: 'warning', ...ref, message: text, fix: 'Give the page a wider specimenSize; see the README page guide.' }
38
+ : { id: 'render-catalog-warning', severity: 'warning', ...ref, message: text, fix: 'Follow the message; the static doctor rule of the same name has the details.' });
39
+ }
40
+ }
41
+ return issues;
42
+ }
43
+ /** The project's own puppeteer, or `undefined` when it isn't installed. */
44
+ export function loadProjectPuppeteer(projectRoot) {
45
+ try {
46
+ return createRequire(path.join(path.resolve(projectRoot), 'package.json'))('puppeteer');
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ }
52
+ export const RENDER_UNAVAILABLE = {
53
+ id: 'render-unavailable',
54
+ severity: 'error',
55
+ message: "`doctor --render` needs a headless browser, and puppeteer isn't installed in this project.",
56
+ fix: 'npm install --save-dev puppeteer (then run doctor --render again).',
57
+ };
58
+ const DEV_START_TIMEOUT_MS = 120_000;
59
+ const FIRST_LOAD_TIMEOUT_MS = 300_000;
60
+ const PAGE_TIMEOUT_MS = 120_000;
61
+ const SETTLE_MS = 900;
62
+ /** Stops `dev` and everything it started (Expo/Metro): the whole process tree on Windows, the
63
+ * whole process group elsewhere (Expo stops through dev's own signal handler). Releases the pipes
64
+ * so this process can exit. `os` is injectable for tests. */
65
+ export function stopDev(child, os = {}) {
66
+ const platform = os.platform ?? process.platform;
67
+ if (child.pid) {
68
+ try {
69
+ if (platform === 'win32')
70
+ (os.spawnSync ?? spawnSync)('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
71
+ else
72
+ (os.kill ?? ((pid, signal) => process.kill(pid, signal)))(-child.pid, 'SIGTERM');
73
+ }
74
+ catch {
75
+ // Already exited.
76
+ }
77
+ }
78
+ child.stdout?.destroy();
79
+ child.stderr?.destroy();
80
+ child.unref();
81
+ }
82
+ /** The last few KB of dev's output, so a failed start can say why. */
83
+ function outputTail() {
84
+ let tail = '';
85
+ return {
86
+ push: (data) => {
87
+ tail = (tail + String(data)).slice(-4000);
88
+ },
89
+ text: () => {
90
+ const lines = tail.trim().split('\n').slice(-8).join('\n');
91
+ return lines ? `\n${lines}` : '';
92
+ },
93
+ };
94
+ }
95
+ /** Starts the project's own `dev` in its own process group. `onSpawn` receives the child at once,
96
+ * so a signal during startup can still stop it. */
97
+ function startDev(projectRoot, onSpawn) {
98
+ const main = path.join(path.dirname(fileURLToPath(import.meta.url)), 'main.js');
99
+ return new Promise((resolve, reject) => {
100
+ const child = spawn(process.execPath, [main, 'dev'], {
101
+ cwd: projectRoot,
102
+ env: { ...process.env, CI: '1', DS_VIEWER_NO_UPDATE_CHECK: '1' },
103
+ detached: process.platform !== 'win32',
104
+ stdio: ['ignore', 'pipe', 'pipe'],
105
+ });
106
+ onSpawn(child);
107
+ const output = outputTail();
108
+ let done = false;
109
+ const fail = (message) => {
110
+ if (done)
111
+ return;
112
+ done = true;
113
+ clearTimeout(timer);
114
+ stopDev(child);
115
+ reject(new Error(`${message}${output.text()}`));
116
+ };
117
+ const timer = setTimeout(() => fail('Timed out waiting for ds-viewer dev to start.'), DEV_START_TIMEOUT_MS);
118
+ // Read continuously (so Metro's output never fills the pipe) and keep the tail for errors.
119
+ child.stderr?.on('data', output.push);
120
+ child.stdout?.on('data', (data) => {
121
+ output.push(data);
122
+ const match = String(data).match(/Starting the catalog at http:\/\/localhost:(\d+)/);
123
+ if (match && !done) {
124
+ done = true;
125
+ clearTimeout(timer);
126
+ resolve({ child, port: Number(match[1]) });
127
+ }
128
+ });
129
+ child.on('exit', (code) => fail(`ds-viewer dev exited early (code ${code}).`));
130
+ });
131
+ }
132
+ const SIGNAL_EXIT_CODES = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 };
133
+ /** A sidebar href's page id; a malformed escape falls back to the raw text (like `idFromHash`). */
134
+ function idFromHref(href) {
135
+ const raw = href.slice(1);
136
+ try {
137
+ return decodeURIComponent(raw);
138
+ }
139
+ catch {
140
+ return raw;
141
+ }
142
+ }
143
+ /** Opens every sidebar page and returns its findings. `files` maps page ids to project-relative
144
+ * page files, for the findings' `file`. `log` receives progress lines (stderr in the CLI). `deps`
145
+ * replaces the browser, dev process control and exit for tests. */
146
+ export async function renderCheck(projectRoot, options = {}) {
147
+ projectRoot = path.resolve(projectRoot);
148
+ const deps = options.deps ?? {};
149
+ const puppeteer = (deps.puppeteer ?? loadProjectPuppeteer(projectRoot));
150
+ if (!puppeteer)
151
+ return [RENDER_UNAVAILABLE];
152
+ const stop = deps.stopDev ?? ((child) => stopDev(child));
153
+ const exit = deps.exit ?? ((code) => process.exit(code));
154
+ const log = options.log ?? (() => { });
155
+ // Set as soon as dev is spawned, before it's ready, so a signal during startup still stops it.
156
+ let devChild;
157
+ let port = 0;
158
+ let browser;
159
+ // Ctrl-C or a CI cancel must still stop Metro: `dev` runs in its own process group, so the
160
+ // terminal's signal never reaches it. Registered before the browser launches, so it runs ahead
161
+ // of puppeteer's own handler.
162
+ const handlers = Object.keys(SIGNAL_EXIT_CODES).map((signal) => {
163
+ const handler = () => {
164
+ if (devChild)
165
+ stop(devChild);
166
+ exit(SIGNAL_EXIT_CODES[signal]);
167
+ };
168
+ process.on(signal, handler);
169
+ return [signal, handler];
170
+ });
171
+ try {
172
+ // The browser first: a missing browser download shouldn't start Metro for nothing.
173
+ try {
174
+ browser = await puppeteer.launch({ headless: true });
175
+ }
176
+ catch (error) {
177
+ return [{ ...RENDER_UNAVAILABLE, message: `Couldn't start the headless browser: ${error.message.split('\n')[0]}`, fix: 'npx puppeteer browsers install chrome' }];
178
+ }
179
+ ({ port } = await (deps.startDev ?? startDev)(projectRoot, (child) => {
180
+ devChild = child;
181
+ }));
182
+ const home = await browser.newPage();
183
+ const deadline = Date.now() + FIRST_LOAD_TIMEOUT_MS;
184
+ for (;;) {
185
+ try {
186
+ await home.goto(`http://localhost:${port}`, { waitUntil: 'networkidle0', timeout: FIRST_LOAD_TIMEOUT_MS });
187
+ break;
188
+ }
189
+ catch (error) {
190
+ if (!String(error).includes('ERR_CONNECTION_REFUSED') || Date.now() > deadline)
191
+ throw error;
192
+ await new Promise((r) => setTimeout(r, 1000));
193
+ }
194
+ }
195
+ // Runs in the browser; `document` is typed loosely because the CLI compiles without DOM types.
196
+ const hrefs = await home.evaluate(() => {
197
+ const doc = globalThis.document;
198
+ return [...new Set(Array.from(doc.querySelectorAll('a[href^="#"]')).map((a) => a.getAttribute('href') ?? ''))];
199
+ });
200
+ await home.close();
201
+ const ids = hrefs.map(idFromHref).filter(Boolean);
202
+ if (ids.length === 0) {
203
+ return [{ id: 'render-failed', severity: 'error', message: 'The catalog rendered no sidebar links, so it probably failed to render at all.', fix: 'Run `ds-viewer dev` and check the browser console.' }];
204
+ }
205
+ log(`Rendering ${ids.length} pages…`);
206
+ const seen = new Set();
207
+ const issues = [];
208
+ for (const id of ids) {
209
+ // One page failing (a navigation timeout) is that page's finding; the others still run.
210
+ let page;
211
+ try {
212
+ page = await browser.newPage();
213
+ await page.setViewport({ width: 1280, height: 800 });
214
+ const messages = [];
215
+ page.on('console', (m) => messages.push({ type: m.type(), text: m.text() }));
216
+ page.on('pageerror', (e) => messages.push({ type: 'pageerror', text: String(e) }));
217
+ await page.goto(`http://localhost:${port}/#${encodeURIComponent(id)}`, { waitUntil: 'networkidle0', timeout: PAGE_TIMEOUT_MS });
218
+ await new Promise((r) => setTimeout(r, options.settleMs ?? SETTLE_MS));
219
+ const hasHeading = await page.evaluate(() => {
220
+ const doc = globalThis.document;
221
+ return Boolean(doc.querySelector('[role="heading"]')?.textContent?.trim());
222
+ });
223
+ issues.push(...issuesFromPage({ id, file: options.files?.get(id) }, messages, hasHeading, seen));
224
+ await page.close();
225
+ }
226
+ catch (error) {
227
+ // A stuck page's tab is closed, so it doesn't keep loading next to the remaining pages.
228
+ await page?.close().catch(() => { });
229
+ const file = options.files?.get(id);
230
+ issues.push({ id: 'render-error', severity: 'error', page: id, ...(file ? { file } : {}), message: `Couldn't open the page: ${error.message.split('\n')[0]}`, fix: 'Open the page in `ds-viewer dev`; it may be very slow to load or stuck.' });
231
+ }
232
+ }
233
+ log(`Checked ${ids.length} pages.`);
234
+ return issues;
235
+ }
236
+ catch (error) {
237
+ return [{ id: 'render-failed', severity: 'error', message: `Couldn't render the catalog: ${error.message}`, fix: "Run `ds-viewer dev` to see why the catalog doesn't start." }];
238
+ }
239
+ finally {
240
+ for (const [signal, handler] of handlers)
241
+ process.off(signal, handler);
242
+ if (browser)
243
+ await browser.close().catch(() => { });
244
+ if (devChild)
245
+ stop(devChild);
246
+ }
247
+ }
@@ -42,6 +42,7 @@ function readStaticPage(file) {
42
42
  }
43
43
  const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TSX);
44
44
  const imports = importedNames(sourceFile, '@krapwoo/ds-viewer');
45
+ const viewerImports = [...new Set(imports.values())];
45
46
  const defineCatalogPageNames = localNamesFor(imports, 'defineCatalogPage');
46
47
  const gridNames = localNamesFor(imports, 'grid');
47
48
  const call = defaultExportCall(sourceFile);
@@ -49,13 +50,13 @@ function readStaticPage(file) {
49
50
  // Not a recognized `defineCatalogPage(...)` default export — e.g. a hand-written object, or a
50
51
  // shadowed/renamed import this reader can't trace. Not a parse error: the file is valid
51
52
  // TypeScript, just not literal-checkable.
52
- return { file, checkable: false };
53
+ return { file, checkable: false, viewerImports };
53
54
  }
54
55
  const consts = topLevelConsts(sourceFile);
55
56
  const pageObject = resolve(call.arguments[0], consts, new Set());
56
57
  if (!ts.isObjectLiteralExpression(pageObject))
57
- return { file, checkable: false };
58
- const page = { file, checkable: true };
58
+ return { file, checkable: false, viewerImports };
59
+ const page = { file, checkable: true, viewerImports };
59
60
  const idProp = findProp(pageObject, 'id');
60
61
  const componentProp = findProp(pageObject, 'component');
61
62
  const groupProp = findProp(pageObject, 'group');
@@ -193,16 +194,21 @@ function readComposedOf(node, consts) {
193
194
  return entries;
194
195
  }
195
196
  /** Local name -> imported name, for every named import from `moduleSpecifier`. */
196
- function importedNames(sourceFile, moduleSpecifier) {
197
+ export function importedNames(sourceFile, moduleSpecifier) {
197
198
  const names = new Map();
198
199
  for (const statement of sourceFile.statements) {
199
200
  if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== moduleSpecifier) {
200
201
  continue;
201
202
  }
203
+ // Type-only imports bring no runtime value (`import type { X }`, `import { type X }`).
204
+ if (statement.importClause?.isTypeOnly)
205
+ continue;
202
206
  const bindings = statement.importClause?.namedBindings;
203
207
  if (!bindings || !ts.isNamedImports(bindings))
204
208
  continue;
205
209
  for (const element of bindings.elements) {
210
+ if (element.isTypeOnly)
211
+ continue;
206
212
  names.set(element.name.text, element.propertyName ? element.propertyName.text : element.name.text);
207
213
  }
208
214
  }
@@ -0,0 +1,46 @@
1
+ import React, { forwardRef, useState } from 'react';
2
+ import { Pressable, StyleSheet, Text, type View } from 'react-native';
3
+ import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_TYPE } from './tokens';
4
+
5
+ /** The viewer's own button, for catalog chrome and demo triggers (not an app component). Same look
6
+ * as the update page's buttons. */
7
+ export const CatalogButton = forwardRef<View, {
8
+ label: string;
9
+ onPress: () => void;
10
+ variant?: 'primary' | 'secondary';
11
+ accessibilityLabel?: string;
12
+ }>(function CatalogButton({ label, onPress, variant = 'primary', accessibilityLabel }, ref) {
13
+ const [focused, setFocused] = useState(false);
14
+ const primary = variant === 'primary';
15
+ return (
16
+ <Pressable
17
+ ref={ref}
18
+ onPress={onPress}
19
+ onFocus={() => setFocused(true)}
20
+ onBlur={() => setFocused(false)}
21
+ accessibilityRole="button"
22
+ accessibilityLabel={accessibilityLabel ?? label}
23
+ style={({ pressed }) => [
24
+ styles.base,
25
+ primary ? styles.primary : styles.secondary,
26
+ pressed && (primary ? styles.primaryPressed : styles.secondaryPressed),
27
+ focused && styles.focus,
28
+ ]}
29
+ >
30
+ <Text style={[styles.label, primary ? styles.labelPrimary : styles.labelSecondary]}>{label}</Text>
31
+ </Pressable>
32
+ );
33
+ });
34
+
35
+ const styles = StyleSheet.create({
36
+ base: { height: CATALOG_LAYOUT.controlSize, paddingHorizontal: 18, borderRadius: 22, alignItems: 'center', justifyContent: 'center', alignSelf: 'center' },
37
+ primary: { backgroundColor: CATALOG_COLOR.text },
38
+ primaryPressed: { opacity: 0.85 },
39
+ secondary: { borderWidth: 1, borderColor: CATALOG_COLOR.borderStrong, backgroundColor: CATALOG_COLOR.surface },
40
+ secondaryPressed: { backgroundColor: CATALOG_COLOR.surfacePressed },
41
+ // Same ring as the sidebar's: an outline, so focusing never shifts the layout.
42
+ focus: { outlineWidth: CATALOG_LAYOUT.focusRingWidth, outlineStyle: 'solid', outlineColor: CATALOG_COLOR.focusRing } as object,
43
+ label: { fontSize: CATALOG_TYPE.md, fontWeight: '700' },
44
+ labelPrimary: { color: '#ffffff' },
45
+ labelSecondary: { color: CATALOG_COLOR.text },
46
+ });
@@ -0,0 +1,52 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { StyleSheet, View } from 'react-native';
3
+ import { PhoneFrame } from './PhoneFrame';
4
+ import { CatalogButton } from './CatalogButton';
5
+ import { overlayTrigger } from './overlayDemoState';
6
+
7
+ // Not `StyleSheet.absoluteFill`: React Native 0.81's types reject spreading it.
8
+ const FILL = { position: 'absolute' as const, top: 0, right: 0, bottom: 0, left: 0 };
9
+
10
+ /**
11
+ * A phone with a catalog button that opens an overlay: a sheet, a dialog, a toast. The overlay
12
+ * (`children`) fills the device above the centred trigger and gets `{ open, close }`; keep it
13
+ * mounted and drive its own `visible` from `open`, so its open and close animations play.
14
+ */
15
+ export function OverlayDemo({
16
+ triggerLabel,
17
+ initiallyOpen = false,
18
+ keepTrigger = false,
19
+ children,
20
+ }: {
21
+ triggerLabel: string | ((open: boolean) => string);
22
+ initiallyOpen?: boolean;
23
+ /** Keep the trigger visible while open, as a toggle. @default false */
24
+ keepTrigger?: boolean;
25
+ children: (state: { open: boolean; close: () => void }) => React.ReactNode;
26
+ }) {
27
+ const [open, setOpen] = useState(initiallyOpen);
28
+ const trigger = overlayTrigger(open, { triggerLabel, keepTrigger });
29
+ // When the overlay closes, keyboard focus returns to the trigger instead of the page body.
30
+ const triggerRef = useRef<View>(null);
31
+ const wasOpen = useRef(open);
32
+ useEffect(() => {
33
+ if (wasOpen.current && !open) (triggerRef.current as unknown as { focus?: () => void } | null)?.focus?.();
34
+ wasOpen.current = open;
35
+ }, [open]);
36
+ return (
37
+ <PhoneFrame>
38
+ <View style={styles.screen}>
39
+ {trigger.visible && (
40
+ <CatalogButton ref={triggerRef} label={trigger.label} onPress={() => setOpen((value) => (keepTrigger ? !value : true))} />
41
+ )}
42
+ <View style={FILL} pointerEvents="box-none">
43
+ {children({ open, close: () => setOpen(false) })}
44
+ </View>
45
+ </View>
46
+ </PhoneFrame>
47
+ );
48
+ }
49
+
50
+ const styles = StyleSheet.create({
51
+ screen: { flex: 1, alignSelf: 'stretch', alignItems: 'center', justifyContent: 'center' },
52
+ });
@@ -0,0 +1,51 @@
1
+ import React from 'react';
2
+ import { ScrollView, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
3
+
4
+ /**
5
+ * A whole app screen inside a `PhoneFrame`: `header` pinned to the top, `footer` to the bottom,
6
+ * and the children in a body between them that scrolls instead of pushing the footer off the
7
+ * device. `floating` is a layer over the top of the screen for toasts; it adds no inset of its
8
+ * own, so the app positions its content. `bodyWrapper` lets the app put its own surface or
9
+ * context provider around the scrolling body; give its root `flex: 1, minHeight: 0`.
10
+ */
11
+ export function PhoneScreen({
12
+ header,
13
+ footer,
14
+ floating,
15
+ bodyWrapper,
16
+ scroll = true,
17
+ contentContainerStyle,
18
+ children,
19
+ }: {
20
+ header?: React.ReactNode;
21
+ footer?: React.ReactNode;
22
+ floating?: React.ReactNode;
23
+ bodyWrapper?: (body: React.ReactNode) => React.ReactNode;
24
+ /** Scroll the body (default), or lay it out to fit. */
25
+ scroll?: boolean;
26
+ contentContainerStyle?: StyleProp<ViewStyle>;
27
+ children?: React.ReactNode;
28
+ }) {
29
+ const body = scroll ? (
30
+ <ScrollView style={styles.flex} contentContainerStyle={contentContainerStyle}>{children}</ScrollView>
31
+ ) : (
32
+ <View style={[styles.flex, contentContainerStyle]}>{children}</View>
33
+ );
34
+ return (
35
+ <View style={styles.screen}>
36
+ {header}
37
+ <View style={styles.flex}>{bodyWrapper ? bodyWrapper(body) : body}</View>
38
+ {footer}
39
+ {floating ? (
40
+ <View style={styles.floating} pointerEvents="box-none">{floating}</View>
41
+ ) : null}
42
+ </View>
43
+ );
44
+ }
45
+
46
+ const styles = StyleSheet.create({
47
+ // Fills the device; `minHeight: 0` lets a flex child shrink below its content on the web.
48
+ screen: { flex: 1, alignSelf: 'stretch', minHeight: 0 },
49
+ flex: { flex: 1, minHeight: 0 },
50
+ floating: { position: 'absolute', top: 0, left: 0, right: 0, zIndex: 20 },
51
+ });
@@ -31,6 +31,9 @@ export { TokenSections, TokenGrid, TokenTile } from './TokenLayouts';
31
31
  export { DividedStack } from './DividedStack';
32
32
  export { Swatch } from './Swatch';
33
33
  export { PhoneFrame } from './PhoneFrame';
34
+ export { PhoneScreen } from './PhoneScreen';
35
+ export { OverlayDemo } from './OverlayDemo';
36
+ export { CatalogButton } from './CatalogButton';
34
37
  export { BoundedOverlayViewport } from './BoundedOverlayViewport';
35
38
  export type { BoundedOverlayViewportProps } from './BoundedOverlayViewport';
36
39
  export { comparisonCellItemKey } from './overlayViewport';
@@ -0,0 +1,8 @@
1
+ /** What `OverlayDemo` shows for a given open state. Kept free of runtime imports for `node --test`. */
2
+ export function overlayTrigger(
3
+ open: boolean,
4
+ options: { triggerLabel: string | ((open: boolean) => string); keepTrigger?: boolean },
5
+ ): { visible: boolean; label: string } {
6
+ const label = typeof options.triggerLabel === 'function' ? options.triggerLabel(open) : options.triggerLabel;
7
+ return { visible: !open || Boolean(options.keepTrigger), label };
8
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krapwoo/ds-viewer",
3
- "version": "0.4.9",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A browsable, always-current catalog for an Expo / React Native design system.",
@@ -1,6 +1,6 @@
1
- import React, { useState } from 'react';
1
+ import React from 'react';
2
2
  import { View, Text, StyleSheet } from 'react-native';
3
- import { defineCatalogPage, PhoneFrame } from '@krapwoo/ds-viewer';
3
+ import { defineCatalogPage, OverlayDemo } from '@krapwoo/ds-viewer';
4
4
  import { Button } from '../Button';
5
5
  import { Dock } from '../Dock';
6
6
  import { TopNav } from '../TopNav';
@@ -17,53 +17,43 @@ const styles = StyleSheet.create({
17
17
  });
18
18
 
19
19
  function BottomSheetDemo() {
20
- const [visible, setVisible] = useState(false);
21
20
  return (
22
- <PhoneFrame>
23
- {!visible && <Button label="Open sheet" onPress={() => setVisible(true)} />}
24
- <BottomSheet
25
- visible={visible}
26
- onDismiss={() => setVisible(false)}
27
- header={
28
- <TopNav
29
- title="Trip details"
30
- trailing={
31
- <Button
32
- variant="secondary"
33
- size="small"
34
- showIcon
35
- showLabel={false}
36
- iconName="clear"
37
- accessibilityLabel="Close"
38
- onPress={() => setVisible(false)}
39
- />
40
- }
41
- />
42
- }
43
- footer={
44
- <Dock>
45
- <Button label="Confirm" onPress={() => setVisible(false)} />
46
- </Dock>
47
- }
48
- >
49
- <View style={styles.sheetContent}>
50
- <Text style={styles.cardTitle}>Uptown & The Bronx</Text>
51
- <Text style={styles.cardBody}>Next train in 4 min · every 6–8 min.</Text>
52
- {/* Long enough to overflow the phone frame's fixed height — demonstrates the Dock footer's
53
- `elevated` shadow turning on automatically once this content area actually scrolls. */}
54
- <Text style={styles.cardBody}>Board at the front car for a faster transfer at Union Sq.</Text>
55
- <Text style={styles.cardBody}>
56
- This line runs express between 96 St and 168 St during rush hours, skipping local stops in
57
- between. Weekend service runs local along the full route, with some stations closed for
58
- planned maintenance.
59
- </Text>
60
- <Text style={styles.cardBody}>
61
- Elevators are available at 168 St, 137 St, and 125 St — check the map for accessible
62
- entrances before you travel.
63
- </Text>
64
- </View>
65
- </BottomSheet>
66
- </PhoneFrame>
21
+ <OverlayDemo triggerLabel="Open sheet">
22
+ {({ 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>
40
+ <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>
53
+ </View>
54
+ </BottomSheet>
55
+ )}
56
+ </OverlayDemo>
67
57
  );
68
58
  }
69
59
 
@@ -1,6 +1,6 @@
1
- import React, { useState } from 'react';
1
+ import React from 'react';
2
2
  import { View, Text, StyleSheet } from 'react-native';
3
- import { defineCatalogPage, PhoneFrame } from '@krapwoo/ds-viewer';
3
+ import { defineCatalogPage, OverlayDemo } from '@krapwoo/ds-viewer';
4
4
  import { Button } from '../Button';
5
5
  import { ButtonGroup } from '../ButtonGroup';
6
6
  import { Dialog } from './Dialog';
@@ -13,21 +13,21 @@ const styles = StyleSheet.create({
13
13
  });
14
14
 
15
15
  function DialogDemo() {
16
- const [visible, setVisible] = useState(false);
17
16
  return (
18
- <PhoneFrame>
19
- {!visible && <Button label="Open dialog" onPress={() => setVisible(true)} />}
20
- <Dialog visible={visible} onDismiss={() => setVisible(false)}>
21
- <Text style={styles.cardTitle}>Delete this trip?</Text>
22
- <Text style={styles.cardBody}>This can't be undone.</Text>
23
- <View style={styles.dialogActions}>
24
- <ButtonGroup>
25
- <Button label="Cancel" variant="tertiary" onPress={() => setVisible(false)} />
26
- <Button label="Delete" onPress={() => setVisible(false)} />
27
- </ButtonGroup>
28
- </View>
29
- </Dialog>
30
- </PhoneFrame>
17
+ <OverlayDemo triggerLabel="Open dialog">
18
+ {({ open, close }) => (
19
+ <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>
28
+ </Dialog>
29
+ )}
30
+ </OverlayDemo>
31
31
  );
32
32
  }
33
33
 
@@ -1,7 +1,6 @@
1
- import React, { useState } from 'react';
1
+ import React from 'react';
2
2
  import { View, StyleSheet } from 'react-native';
3
- import { defineCatalogPage, PhoneFrame } from '@krapwoo/ds-viewer';
4
- import { Button } from '../Button';
3
+ import { defineCatalogPage, OverlayDemo } from '@krapwoo/ds-viewer';
5
4
  import { Toast } from './Toast';
6
5
  import { DS_SPACING } from '../../tokens';
7
6
 
@@ -15,14 +14,14 @@ const styles = StyleSheet.create({
15
14
  // pattern) so this demo actually exercises the slide-in-from-top/fade animation, not just the two
16
15
  // static end states.
17
16
  function ToastDemo() {
18
- const [visible, setVisible] = useState(true);
19
17
  return (
20
- <PhoneFrame>
21
- <Button label={visible ? 'Hide toast' : 'Show toast'} onPress={() => setVisible((v) => !v)} />
22
- <View style={styles.toastDemoOverlay} pointerEvents="box-none">
23
- <Toast message="Trip saved" variant="success" visible={visible} />
24
- </View>
25
- </PhoneFrame>
18
+ <OverlayDemo triggerLabel={(open) => (open ? 'Hide toast' : 'Show toast')} keepTrigger initiallyOpen>
19
+ {({ open }) => (
20
+ <View style={styles.toastDemoOverlay} pointerEvents="box-none">
21
+ <Toast message="Trip saved" variant="success" visible={open} />
22
+ </View>
23
+ )}
24
+ </OverlayDemo>
26
25
  );
27
26
  }
28
27
 
@@ -1,6 +1,6 @@
1
1
  import React, { useState } from 'react';
2
2
  import { View, Text, StyleSheet } from 'react-native';
3
- import { defineCatalogPage, PhoneFrame } from '@krapwoo/ds-viewer';
3
+ import { defineCatalogPage, OverlayDemo } from '@krapwoo/ds-viewer';
4
4
  import { Banner } from '../components/Banner';
5
5
  import { BottomSheet } from '../components/BottomSheet';
6
6
  import { Button } from '../components/Button';
@@ -51,7 +51,6 @@ const REPORT_ISSUE_TYPE_PILLS: PillRowItem[] = [
51
51
  ];
52
52
 
53
53
  function ReportIssueDemo() {
54
- const [visible, setVisible] = useState(false);
55
54
  const [confirmDiscard, setConfirmDiscard] = useState(false);
56
55
  const [issueType, setIssueType] = useState('delay');
57
56
  const [line, setLine] = useState('4');
@@ -60,17 +59,19 @@ function ReportIssueDemo() {
60
59
  const [visibility, setVisibility] = useState('public');
61
60
  const [notify, setNotify] = useState(true);
62
61
 
63
- const requestClose = () => setConfirmDiscard(true);
64
- const discard = () => {
65
- setConfirmDiscard(false);
66
- setVisible(false);
67
- };
68
-
69
62
  return (
70
- <PhoneFrame>
71
- {!visible && <Button label="Report an issue" onPress={() => setVisible(true)} />}
63
+ <OverlayDemo triggerLabel="Report an issue">
64
+ {({ open, close }) => {
65
+ // Closing asks first; discarding confirms and closes the sheet.
66
+ const requestClose = () => setConfirmDiscard(true);
67
+ const discard = () => {
68
+ setConfirmDiscard(false);
69
+ close();
70
+ };
71
+ return (
72
+ <>
72
73
  <BottomSheet
73
- visible={visible}
74
+ visible={open}
74
75
  onDismiss={requestClose}
75
76
  header={
76
77
  <TopNav
@@ -82,7 +83,7 @@ function ReportIssueDemo() {
82
83
  }
83
84
  footer={
84
85
  <Dock>
85
- <Button label="Submit report" onPress={() => setVisible(false)} />
86
+ <Button label="Submit report" onPress={close} />
86
87
  </Dock>
87
88
  }
88
89
  >
@@ -135,7 +136,10 @@ function ReportIssueDemo() {
135
136
  </ButtonGroup>
136
137
  </View>
137
138
  </Dialog>
138
- </PhoneFrame>
139
+ </>
140
+ );
141
+ }}
142
+ </OverlayDemo>
139
143
  );
140
144
  }
141
145
 
@@ -1,6 +1,6 @@
1
1
  import React, { useEffect, useRef, useState } from 'react';
2
2
  import { View, Text, ScrollView, StyleSheet } from 'react-native';
3
- import { defineCatalogPage, PhoneFrame } from '@krapwoo/ds-viewer';
3
+ import { defineCatalogPage, PhoneFrame, PhoneScreen } from '@krapwoo/ds-viewer';
4
4
  import { Avatar } from '../components/Avatar';
5
5
  import { Badge } from '../components/Badge';
6
6
  import { Button } from '../components/Button';
@@ -23,13 +23,6 @@ import { Icon } from '../icons/Icon.native';
23
23
  import { DS_ICON_SIZE, DS_MOTION_DURATION, DS_SEMANTIC, DS_SPACING, DS_TYPOGRAPHY } from '../tokens';
24
24
 
25
25
  const styles = StyleSheet.create({
26
- // Saved trips recipe — fills PhoneFrame edge-to-edge (like dropdownFrameContent), with a
27
- // fixed TopNav header and Dock footer around a scrollable middle, the same fixed-header/
28
- // scrollable-body/fixed-footer shape a real screen would use.
29
- savedTripsScreen: { flex: 1, alignSelf: 'stretch' },
30
- // Takes the space between the TopNav and the Dock and scrolls (`minHeight: 0` lets a flex child
31
- // shrink below its content on the web).
32
- savedTripsScroll: { flex: 1, minHeight: 0 },
33
26
  // The muted area between TopNav and Dock takes the remaining height, so the list scrolls there.
34
27
  savedTripsBody: { flex: 1, minHeight: 0 },
35
28
  savedTripsScrollContent: { padding: DS_SPACING[800], gap: DS_SPACING[600] },
@@ -40,7 +33,7 @@ const styles = StyleSheet.create({
40
33
  // Floats over the top of the screen — same top/left/right inset Toast's own catalog demo uses —
41
34
  // instead of sitting inline in the flex flow and pushing the Dock down. A column with a small
42
35
  // gap, since back-to-back removals stack one toast per removal (newest on top).
43
- savedTripsToastOverlay: { position: 'absolute', top: DS_SPACING[800], left: DS_SPACING[600], right: DS_SPACING[600], zIndex: 20, gap: DS_SPACING[300] },
36
+ savedTripsToastOverlay: { marginTop: DS_SPACING[800], marginHorizontal: DS_SPACING[600], gap: DS_SPACING[300] },
44
37
  // Map mode's stand-in — a real map isn't in scope for this recipe, just enough to show the
45
38
  // SegmentedToggle actually switches the screen's content, not just its own thumb.
46
39
  savedTripsMapPlaceholder: { alignItems: 'center', justifyContent: 'center', gap: DS_SPACING[400], paddingVertical: DS_SPACING[2400] },
@@ -111,130 +104,132 @@ function SavedTripsDemo() {
111
104
 
112
105
  return (
113
106
  <PhoneFrame>
114
- <View style={styles.savedTripsScreen}>
115
- <TopNav
116
- title="Saved trips"
117
- trailing={
118
- <Tooltip visible={showFilterTip} label="Filter by mode" placement="bottom" align="right">
119
- <Button
120
- variant="secondary"
121
- size="small"
122
- showIcon
123
- showLabel={false}
124
- iconName="menu"
125
- accessibilityLabel="Filters"
126
- onPress={() => setShowFilterTip((v) => !v)}
107
+ <PhoneScreen
108
+ header={
109
+ <TopNav
110
+ title="Saved trips"
111
+ trailing={
112
+ <Tooltip visible={showFilterTip} label="Filter by mode" placement="bottom" align="right">
113
+ <Button
114
+ variant="secondary"
115
+ size="small"
116
+ showIcon
117
+ showLabel={false}
118
+ iconName="menu"
119
+ accessibilityLabel="Filters"
120
+ onPress={() => setShowFilterTip((v) => !v)}
121
+ />
122
+ </Tooltip>
123
+ }
124
+ />
125
+ }
126
+ bodyWrapper={(body) => <Surface tone="muted" style={styles.savedTripsBody}>{body}</Surface>}
127
+ contentContainerStyle={styles.savedTripsScrollContent}
128
+ // Toasts float over the top of the screen instead of pushing the Dock down. One per
129
+ // pending removal, newest on top; each stays mounted (visible: false) through its exit.
130
+ floating={
131
+ <View style={styles.savedTripsToastOverlay} pointerEvents="box-none">
132
+ {toasts.map((t) => (
133
+ <Toast
134
+ key={t.key}
135
+ visible={t.visible}
136
+ message={`${t.trip.title} removed`}
137
+ action={{ label: 'Undo', onPress: () => undoRemove(t.key, t.trip) }}
127
138
  />
128
- </Tooltip>
129
- }
139
+ ))}
140
+ </View>
141
+ }
142
+ footer={
143
+ <Dock>
144
+ <Button label="Plan a new trip" onPress={() => {}} />
145
+ </Dock>
146
+ }
147
+ >
148
+ <SearchField value={query} onChangeText={setQuery} placeholder="Search stations" />
149
+ <SegmentedToggle
150
+ value={viewMode}
151
+ onChange={setViewMode}
152
+ options={[
153
+ { value: 'list', label: 'List', iconName: 'menu' },
154
+ { value: 'map', label: 'Map', iconName: 'map' },
155
+ ]}
130
156
  />
131
- <Surface tone="muted" style={styles.savedTripsBody}>
132
- <ScrollView style={styles.savedTripsScroll} contentContainerStyle={styles.savedTripsScrollContent}>
133
- <SearchField value={query} onChangeText={setQuery} placeholder="Search stations" />
134
- <SegmentedToggle
135
- value={viewMode}
136
- onChange={setViewMode}
157
+ {viewMode === 'map' ? (
158
+ <View style={styles.savedTripsMapPlaceholder}>
159
+ <Icon name="map" size={DS_ICON_SIZE.xl} color={DS_SEMANTIC.text.muted} />
160
+ <Text style={styles.savedTripsMapPlaceholderText}>Map view</Text>
161
+ </View>
162
+ ) : (
163
+ <>
164
+ <UnderlineTabs
165
+ value={tab}
166
+ onChange={setTab}
137
167
  options={[
138
- { value: 'list', label: 'List', iconName: 'menu' },
139
- { value: 'map', label: 'Map', iconName: 'map' },
168
+ { value: 'all', label: 'All' },
169
+ { value: 'nearby', label: 'Nearby' },
170
+ { value: 'favorites', label: 'Favorites' },
140
171
  ]}
141
172
  />
142
- {viewMode === 'map' ? (
143
- <View style={styles.savedTripsMapPlaceholder}>
144
- <Icon name="map" size={DS_ICON_SIZE.xl} color={DS_SEMANTIC.text.muted} />
145
- <Text style={styles.savedTripsMapPlaceholderText}>Map view</Text>
146
- </View>
173
+ {tab === 'favorites' ? (
174
+ <EmptyState
175
+ iconName="waypoints"
176
+ title="No favorites yet"
177
+ description="Star a trip to see it here."
178
+ action={{ label: 'Browse trips', onPress: () => setTab('all') }}
179
+ secondaryAction={{ label: 'Not now', onPress: () => {} }}
180
+ />
147
181
  ) : (
148
182
  <>
149
- <UnderlineTabs
150
- value={tab}
151
- onChange={setTab}
152
- options={[
153
- { value: 'all', label: 'All' },
154
- { value: 'nearby', label: 'Nearby' },
155
- { value: 'favorites', label: 'Favorites' },
156
- ]}
183
+ <PillRow
184
+ pills={pills.map((p) => ({ ...p, onPress: () => setPills((prev) => prev.map((x) => ({ ...x, variant: x.id === p.id ? 'selected' : 'not_selected' }))) }))}
185
+ showAddPill={false}
157
186
  />
158
- {tab === 'favorites' ? (
187
+ <View style={styles.savedTripsInlineRow}>
188
+ <Loading size={14} />
189
+ <Text style={styles.savedTripsLoadingText}>Updating arrival times…</Text>
190
+ </View>
191
+ {/* Dropdown's own `label` prop, not a hand-rolled Text above it — the component
192
+ already owns the labeled-field pattern (PillRow, which has no label prop, is
193
+ the case where an external label is legitimately the only option). */}
194
+ <Dropdown label="Sort by" value={sortBy} onChange={setSortBy} options={SAVED_TRIPS_SORT_OPTIONS} />
195
+ <SectionHeader title="Nearby stations" />
196
+ {trips.length > 0 ? (
197
+ <List>
198
+ {trips.map((trip) => (
199
+ <ListItem
200
+ key={trip.id}
201
+ title={trip.title}
202
+ subtitle={trip.subtitle}
203
+ leading={<Avatar iconName={trip.iconName} size={40} />}
204
+ trailing={
205
+ <View style={styles.savedTripsInlineRow}>
206
+ <Badge variant={trip.badgeVariant} label={trip.badgeLabel} />
207
+ <Button
208
+ variant="ghost"
209
+ size="small"
210
+ showIcon
211
+ showLabel={false}
212
+ iconName="clear"
213
+ accessibilityLabel={`Remove ${trip.title}`}
214
+ onPress={() => removeTrip(trip.id)}
215
+ />
216
+ </View>
217
+ }
218
+ />
219
+ ))}
220
+ </List>
221
+ ) : (
159
222
  <EmptyState
160
223
  iconName="waypoints"
161
- title="No favorites yet"
162
- description="Star a trip to see it here."
163
- action={{ label: 'Browse trips', onPress: () => setTab('all') }}
164
- secondaryAction={{ label: 'Not now', onPress: () => {} }}
224
+ title="No nearby trips"
225
+ description="Removed trips you save will show up here again."
165
226
  />
166
- ) : (
167
- <>
168
- <PillRow
169
- pills={pills.map((p) => ({ ...p, onPress: () => setPills((prev) => prev.map((x) => ({ ...x, variant: x.id === p.id ? 'selected' : 'not_selected' }))) }))}
170
- showAddPill={false}
171
- />
172
- <View style={styles.savedTripsInlineRow}>
173
- <Loading size={14} />
174
- <Text style={styles.savedTripsLoadingText}>Updating arrival times…</Text>
175
- </View>
176
- {/* Dropdown's own `label` prop, not a hand-rolled Text above it — the component
177
- already owns the labeled-field pattern (PillRow, which has no label prop, is
178
- the case where an external label is legitimately the only option). */}
179
- <Dropdown label="Sort by" value={sortBy} onChange={setSortBy} options={SAVED_TRIPS_SORT_OPTIONS} />
180
- <SectionHeader title="Nearby stations" />
181
- {trips.length > 0 ? (
182
- <List>
183
- {trips.map((trip) => (
184
- <ListItem
185
- key={trip.id}
186
- title={trip.title}
187
- subtitle={trip.subtitle}
188
- leading={<Avatar iconName={trip.iconName} size={40} />}
189
- trailing={
190
- <View style={styles.savedTripsInlineRow}>
191
- <Badge variant={trip.badgeVariant} label={trip.badgeLabel} />
192
- <Button
193
- variant="ghost"
194
- size="small"
195
- showIcon
196
- showLabel={false}
197
- iconName="clear"
198
- accessibilityLabel={`Remove ${trip.title}`}
199
- onPress={() => removeTrip(trip.id)}
200
- />
201
- </View>
202
- }
203
- />
204
- ))}
205
- </List>
206
- ) : (
207
- <EmptyState
208
- iconName="waypoints"
209
- title="No nearby trips"
210
- description="Removed trips you save will show up here again."
211
- />
212
- )}
213
- </>
214
227
  )}
215
228
  </>
216
229
  )}
217
- </ScrollView>
218
- </Surface>
219
- {/* Absolutely positioned over the top of the screen (not inline in the flex flow — a real
220
- toast floats over content, it doesn't push the Dock down) — same placement Toast's own
221
- catalog demo uses. One Toast per pending removal, stacked newest-on-top; each entry
222
- stays in the array (with `visible: false`) through its own exit animation instead of
223
- being yanked out of the tree mid-motion. */}
224
- <View style={styles.savedTripsToastOverlay} pointerEvents="box-none">
225
- {toasts.map((t) => (
226
- <Toast
227
- key={t.key}
228
- visible={t.visible}
229
- message={`${t.trip.title} removed`}
230
- action={{ label: 'Undo', onPress: () => undoRemove(t.key, t.trip) }}
231
- />
232
- ))}
233
- </View>
234
- <Dock>
235
- <Button label="Plan a new trip" onPress={() => {}} />
236
- </Dock>
237
- </View>
230
+ </>
231
+ )}
232
+ </PhoneScreen>
238
233
  </PhoneFrame>
239
234
  );
240
235
  }