@krapwoo/ds-viewer 0.6.8 → 0.6.10

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
@@ -65,6 +65,13 @@ npm run ds-viewer doctor # checks every page for drift and coverage gaps (--js
65
65
  npm run ds-viewer explain <Page> # prints why a page's specimens are laid out the way they are
66
66
  ```
67
67
 
68
+ Only one `dev` serves a project at a time. A second one refuses, naming the port the first is on,
69
+ because both would generate into the same `.ds-viewer/` and overwrite each other's files — the
70
+ loser then serves a workspace that no longer describes it, which Expo reports as an unresolvable
71
+ `main` field rather than as a second server. To run two deliberately, give one its own directory
72
+ with `--workspace-dir .ds-viewer-second`. A lock left behind by a crashed `dev` never blocks the
73
+ next one.
74
+
68
75
  `ds-viewer.config.ts` fields: `name`, `logo` (optional — a path to an image asset, shown in the
69
76
  sidebar beside the name, or in place of it for a wide wordmark; validated at `sync`/`dev` time
70
77
  and bundled into the preview workspace), `components`, `exclude`, `tokens`, `pages`, `groupOrder`, `starterKit` (new-project only —
@@ -495,6 +502,22 @@ Button
495
502
  gallery or a whole-page layout.
496
503
  - `internal-prop-unknown` — `internalProps` names something that is not a prop of the component,
497
504
  so the exemption it was written for is not in effect. Correct the spelling or delete the entry.
505
+
506
+ The catalog also warns at render time when a page under-declares the width its examples need.
507
+ A `PhoneFrame` given a cell narrower than the 375px device scales the whole phone down, and its
508
+ own body text shrinks with it, so it reports the page, the example and the cell:
509
+
510
+ [Catalog] BottomSheet (variants/content-only) is shown at 75% of device size: its cell is
511
+ 283px, narrower than the 375px device. Declare a larger specimenSize so the example keeps
512
+ the width it is used at.
513
+
514
+ `check:catalog` fails on any `[Catalog]` warning, so this is caught in CI. The threshold is 95%,
515
+ not 100%: a regular cell leaves 370px for a 375px device, and warning about a reduction nobody
516
+ can see would only train you to ignore the warning. Where the scaling *is* the subject — a page
517
+ documenting that a narrow cell shrinks the device rather than reflowing it — write
518
+ `<PhoneFrame intentionalScale>` in that example, so the exemption is visible where the decision
519
+ was made.
520
+
498
521
  A page's `internalProps: { size: 'why' }` exempts a named prop from `option-not-covered` — for
499
522
  plumbing a parent passes (a layout size, an internal surface kind) rather than a value the
500
523
  reader picks. It needs a reason per prop, so the exemption is a visible declaration in the page;
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 { workspacePath, writeWorkspace } from "./workspace.js";
9
+ import { clearDevLock, liveDevLock, workspacePath, writeDevLock, writeWorkspace } from "./workspace.js";
10
10
  import { findFreePort } from "./port.js";
11
11
  import { globBaseFolder } from "./glob.js";
12
12
  import { buildVersionStatus, createVersionController, defaultPreferencesDir, readPersonalAutoCheck, resolveAutoCheck, runUpdateCheck, statusFromCheck, writeUpdateFile, writeVersionFile, } from "./updateCheck.js";
@@ -19,6 +19,11 @@ import { localInstallRefusal } from "./update.js";
19
19
  import { DEFAULT_WORKSPACE_DIR, RENDER_WORKSPACE_DIR } from "./types.js";
20
20
  export class LocalInstallMissingError extends Error {
21
21
  }
22
+ /** Another `dev` already owns this project's generated workspace. Its own class so `main` can
23
+ * print the message plainly, the way it already does for a missing local install, instead of a
24
+ * stack trace. */
25
+ export class DevWorkspaceBusyError extends Error {
26
+ }
22
27
  const IGNORED_WATCH_SEGMENTS = new Set([DEFAULT_WORKSPACE_DIR, RENDER_WORKSPACE_DIR, 'node_modules', '.git']);
23
28
  /** False for a changed path under `.ds-viewer/` (package-owned output — reacting to it would make
24
29
  * `dev`'s own `sync` retrigger itself), `node_modules/`, or `.git/`; true for everything else, so
@@ -285,6 +290,9 @@ export async function performRestart(handoff, deps = {}) {
285
290
  if (!exited)
286
291
  killGroup(handoff.child, 'SIGKILL');
287
292
  await waitForPortFree(handoff.port, deps.probePortFree ?? defaultProbePortFree);
293
+ // Released before the replacement starts. This process stays alive as the new `dev`'s
294
+ // supervisor, so a lock still naming it would make its own replacement refuse the workspace.
295
+ clearDevLock({ projectRoot: handoff.projectRoot, workspaceDir: handoff.workspaceDir });
288
296
  // The old child is gone and the port is free — if a signal arrived in the window above (before
289
297
  // there was anything to forward it to), this restart is itself cancelled: the user asked to
290
298
  // stop, not to come back up on the new version.
@@ -352,6 +360,22 @@ export async function dev(initialConfig, devOptions = {}) {
352
360
  rmSync(statusFile, { force: true });
353
361
  }
354
362
  const port = await findFreePort(devOptions.pinnedPort ?? 5181, devOptions.pinnedPort ?? 5199);
363
+ // Refused before anything is written: a second `dev` on the same workspace rewrites entry.tsx,
364
+ // package.json, app.json, metro.config.js and tsconfig.json underneath the first, and the loser
365
+ // ends up serving a workspace that no longer describes it — Expo reports that as an unresolvable
366
+ // `main` field, which names neither the other server nor the shared directory.
367
+ const existing = liveDevLock(config);
368
+ if (existing) {
369
+ throw new DevWorkspaceBusyError([
370
+ `Another ds-viewer dev (pid ${existing.pid}) is already serving this project at http://localhost:${existing.port}.`,
371
+ '',
372
+ `Both would generate into ${config.workspaceDir ?? DEFAULT_WORKSPACE_DIR}/ and overwrite each other's files.`,
373
+ '',
374
+ 'Stop that one first, or give this run its own directory:',
375
+ ` npx ds-viewer dev --workspace-dir .ds-viewer-second`,
376
+ ].join('\n'));
377
+ }
378
+ writeDevLock(config, { pid: process.pid, port });
355
379
  console.log(`Starting the catalog at http://localhost:${port}`);
356
380
  let watchersPaused = false;
357
381
  // Metro's own child process doesn't exist yet — assigned below, well before this is ever read
@@ -375,7 +399,7 @@ export async function dev(initialConfig, devOptions = {}) {
375
399
  },
376
400
  restart: async (result) => {
377
401
  writeFileSync(statusFile, JSON.stringify({ phase: 'success', ...result }));
378
- await performRestart({ projectRoot: config.projectRoot, port, child, watchers, endpointServer, doctorSummary: result.doctorSummary, files: result.files });
402
+ await performRestart({ projectRoot: config.projectRoot, workspaceDir: config.workspaceDir, port, child, watchers, endpointServer, doctorSummary: result.doctorSummary, files: result.files });
379
403
  },
380
404
  }).catch((error) => {
381
405
  // `performUpdate` only reports 'failure' from its own guarded steps; anything thrown outside
@@ -486,6 +510,10 @@ export async function dev(initialConfig, devOptions = {}) {
486
510
  for (const watcher of watchers)
487
511
  watcher.close();
488
512
  endpointServer?.close();
513
+ // Released so the next `dev` is not refused by a lock naming a process that has gone.
514
+ // `liveDevLock` already ignores a dead pid, so this is tidiness, not correctness — it keeps a
515
+ // stale file out of a directory a developer may well read.
516
+ clearDevLock(config);
489
517
  process.exitCode = code ?? 0;
490
518
  });
491
519
  // Node's default SIGTERM/SIGINT/SIGHUP behavior exits this process without touching its
@@ -499,6 +527,7 @@ export async function dev(initialConfig, devOptions = {}) {
499
527
  for (const watcher of watchers)
500
528
  watcher.close();
501
529
  endpointServer?.close();
530
+ clearDevLock(config);
502
531
  killGroup(child, signal);
503
532
  process.exit(0);
504
533
  });
@@ -70,7 +70,7 @@ function toSectionDef(page) {
70
70
  hide: (page.hideVariants || page.hideStates) ? { variants: page.hideVariants, states: page.hideStates } : undefined,
71
71
  };
72
72
  }
73
- function explainBlock(block) {
73
+ function explainBlock(block, availableWidth) {
74
74
  if (block.kind === 'grid') {
75
75
  const rows = axisItems(block.comparison.rows).length;
76
76
  const columns = axisItems(block.comparison.columns).length;
@@ -82,10 +82,10 @@ function explainBlock(block) {
82
82
  };
83
83
  }
84
84
  if (block.kind === 'list') {
85
- const geometry = listGeometry(block.items.length, MATRIX_LAYOUT.laptopContentWidth, block.size, block.maxColumns);
85
+ const geometry = listGeometry(block.items.length, availableWidth, block.size, block.maxColumns);
86
86
  return {
87
87
  kind: 'list', title: block.title,
88
- reason: `${block.items.length} items (${block.size}) → ${geometry.columns} columns × ${geometry.rows} row${geometry.rows === 1 ? '' : 's'}` +
88
+ reason: `${block.items.length} items (${block.size}) → ${geometry.columns} column${geometry.columns === 1 ? '' : 's'} × ${geometry.rows} row${geometry.rows === 1 ? '' : 's'}` +
89
89
  (geometry.fillers > 0 ? ` (${geometry.fillers} filler${geometry.fillers === 1 ? '' : 's'})` : '') + '.' +
90
90
  surfaceOverrideNote(block.items) + alignOverrideNote(block.items),
91
91
  };
@@ -149,29 +149,40 @@ export function explainPage(config, pageId, options = {}) {
149
149
  return { pageId, notCheckable: true, blocks: [] };
150
150
  const def = toSectionDef(page);
151
151
  const blocks = presentationBlocks(def, { defaultPreviewWidths: [402] });
152
- const explained = blocks.map(explainBlock);
152
+ // Two blocks placed side by side get half the row each, which changes how many columns fit.
153
+ // Reporting the full-width counts for a page the viewer will halve states the wrong geometry as
154
+ // fact, so the width every block is measured at follows the placement decision.
155
+ const full = MATRIX_LAYOUT.laptopContentWidth;
156
+ const half = Math.floor((full - CATALOG_LAYOUT.blockGap) / 2);
153
157
  let placement;
158
+ let blockWidth = full;
154
159
  if (blocks.length === 2) {
155
160
  if (options.heights) {
156
161
  const [firstHeight, secondHeight] = options.heights;
157
162
  const decision = choosePlacement({
158
- available: MATRIX_LAYOUT.laptopContentWidth,
163
+ available: full,
159
164
  gap: CATALOG_LAYOUT.blockGap,
160
165
  first: toPlacementBlock(blocks[0], firstHeight),
161
166
  second: toPlacementBlock(blocks[1], secondHeight),
162
167
  });
168
+ if (decision === 'side')
169
+ blockWidth = half;
163
170
  placement = {
164
171
  decision,
165
- reason: `With heights ${firstHeight}px/${secondHeight}px on a 1280px laptop (${MATRIX_LAYOUT.laptopContentWidth}px content width): ${decision}.`,
172
+ reason: `With heights ${firstHeight}px/${secondHeight}px on a 1280px laptop (${full}px content width): ${decision}` +
173
+ (decision === 'side' ? `, so each block gets ${half}px.` : `, so each block gets the full ${full}px.`),
166
174
  };
167
175
  }
168
176
  else {
169
177
  placement = {
170
178
  decision: 'decided-in-viewer',
171
- reason: "Side-by-side placement depends on each block's measured height — decided in the viewer at runtime. Pass --heights <first>,<second> to check it here.",
179
+ reason: "Side-by-side placement depends on each block's measured height — decided in the viewer at runtime. " +
180
+ `The counts above are for the full ${full}px row; side by side each block gets ${half}px instead, which can fit fewer columns. ` +
181
+ 'Pass --heights <first>,<second> to resolve it here.',
172
182
  };
173
183
  }
174
184
  }
185
+ const explained = blocks.map((block) => explainBlock(block, blockWidth));
175
186
  return { pageId, blocks: explained, placement, intentionalStaticPreviewReason: page.intentionalStaticPreviewReason };
176
187
  }
177
188
  export function formatExplain(result) {
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { globBaseFolder } from "./glob.js";
4
4
  import { DEFAULT_WORKSPACE_DIR } from "./types.js";
@@ -105,6 +105,52 @@ function projectIncludeGlobs(config) {
105
105
  export function workspacePath(config, ...segments) {
106
106
  return path.join(config.projectRoot, config.workspaceDir ?? DEFAULT_WORKSPACE_DIR, ...segments);
107
107
  }
108
+ const LOCK_FILE = 'dev-lock.json';
109
+ /** Records which `dev` owns this workspace, so a second one can refuse instead of writing over it.
110
+ * Two servers sharing one directory both rewrite `entry.tsx`, `package.json`, `app.json`,
111
+ * `metro.config.js` and `tsconfig.json`, and the loser is left serving a workspace that no longer
112
+ * describes it — reported by Expo as an unresolvable `main` field, which names neither cause. */
113
+ export function writeDevLock(config, lock) {
114
+ mkdirSync(workspacePath(config), { recursive: true });
115
+ writeFileSync(workspacePath(config, LOCK_FILE), `${JSON.stringify(lock, null, 2)}\n`);
116
+ }
117
+ export function clearDevLock(config) {
118
+ rmSync(workspacePath(config, LOCK_FILE), { force: true });
119
+ }
120
+ /** True when `pid` is a process this user can signal. Signal 0 performs the permission and
121
+ * existence checks without delivering anything. EPERM means it exists but belongs to someone
122
+ * else, which still counts as alive. */
123
+ function processAlive(pid) {
124
+ try {
125
+ process.kill(pid, 0);
126
+ return true;
127
+ }
128
+ catch (error) {
129
+ return error.code === 'EPERM';
130
+ }
131
+ }
132
+ /** The live `dev` already serving this workspace, or undefined when there isn't one.
133
+ *
134
+ * A lock left behind by a crashed process is not live, so it never blocks a restart: the pid is
135
+ * gone and the file is ignored. `ownPid` is excluded so a process cannot find itself — `dev`'s own
136
+ * restart path re-enters this with the lock it just wrote. */
137
+ export function liveDevLock(config, ownPid = process.pid) {
138
+ const lockPath = workspacePath(config, LOCK_FILE);
139
+ if (!existsSync(lockPath))
140
+ return undefined;
141
+ let lock;
142
+ try {
143
+ lock = JSON.parse(readFileSync(lockPath, 'utf8'));
144
+ }
145
+ catch {
146
+ return undefined; // A partial write from a process that died mid-write owns nothing.
147
+ }
148
+ if (typeof lock.pid !== 'number' || typeof lock.port !== 'number')
149
+ return undefined;
150
+ if (lock.pid === ownPid || !processAlive(lock.pid))
151
+ return undefined;
152
+ return lock;
153
+ }
108
154
  /** Writes the self-contained Expo web workspace at `.ds-viewer/` (design §2 "The preview
109
155
  * workspace and `npx ds-viewer dev`"). Rewritten freely on every `dev` run — nothing here is
110
156
  * user-owned.
@@ -1,7 +1,7 @@
1
- import React, { useState } from 'react';
1
+ import React, { useState, useRef } from 'react';
2
2
  import { Platform, StyleSheet, Text, View, type LayoutChangeEvent } from 'react-native';
3
3
  import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
4
- import { DEVICE_FRAME, deviceFrameScale } from './deviceFrame';
4
+ import { DEVICE_FRAME, DEVICE_SCALE_WARN_BELOW, deviceFrameScale } from './deviceFrame';
5
5
  import { resolveOverlayViewport } from './overlayViewport';
6
6
  import { useSpecimenAddress } from './SpecimenAddress';
7
7
 
@@ -27,7 +27,16 @@ function isWeb(): boolean {
27
27
  * Purely a documentation device: a real app screen never frames itself, so this lives in the
28
28
  * catalog framework rather than in an app's component library.
29
29
  */
30
- export function PhoneFrame({ children }: { children: React.ReactNode }) {
30
+ export function PhoneFrame({
31
+ children,
32
+ intentionalScale = false,
33
+ }: {
34
+ children: React.ReactNode;
35
+ /** This example is *about* the scaling — a page demonstrating that a narrow cell shrinks the
36
+ * device rather than reflowing it. Declared here, in the example itself, so the exemption is
37
+ * visible where the decision was made instead of hidden in config. */
38
+ intentionalScale?: boolean;
39
+ }) {
31
40
  const address = useSpecimenAddress();
32
41
  const [available, setAvailable] = useState(0);
33
42
  const [status, setStatus] = useState<'loading' | 'ready' | 'unavailable'>('loading');
@@ -36,6 +45,23 @@ export function PhoneFrame({ children }: { children: React.ReactNode }) {
36
45
  if (width > 0 && width !== available) setAvailable(width);
37
46
  };
38
47
  const scale = deviceFrameScale(available);
48
+ // A scaled device is a page that under-declared its width: the cell it was given is narrower
49
+ // than the phone, so the whole viewport shrinks and the example's own text shrinks with it,
50
+ // which is not the size anyone reads the component at. The scale is already computed, so this
51
+ // needs no measurement of its own. Guarded to once per transition, like TokenSections' own
52
+ // degradation warning; `check:catalog` fails on any [Catalog] warning, so this blocks CI.
53
+ const warnedScale = useRef<string | undefined>(undefined);
54
+ if (!intentionalScale && available > 0 && scale < DEVICE_SCALE_WARN_BELOW && (globalThis as { __DEV__?: boolean }).__DEV__) {
55
+ const key = `${available}`;
56
+ if (warnedScale.current !== key) {
57
+ warnedScale.current = key;
58
+ const where = address ? `${address.pageId} (${address.slot}/${address.itemKey})` : 'a PhoneFrame';
59
+ console.warn(
60
+ `[Catalog] ${where} is shown at ${Math.round(scale * 100)}% of device size: its cell is ${available}px, ` +
61
+ `narrower than the ${DEVICE_FRAME.width}px device. Declare a larger specimenSize so the example keeps the width it is used at.`,
62
+ );
63
+ }
64
+ }
39
65
  // Until the cell is measured, fit it at the device's proportions rather than drawing at full size
40
66
  // for a frame (which would briefly overflow a cell narrower than the device).
41
67
  const box = available > 0
@@ -12,3 +12,12 @@ export function deviceFrameScale(availableWidth: number): number {
12
12
  if (!(availableWidth > 0)) return 1;
13
13
  return Math.min(1, availableWidth / DEVICE_FRAME.width);
14
14
  }
15
+
16
+ /** Below this, a device is scaled enough that its own text is no longer the size a reader sees it
17
+ * at, and the page should declare a wider specimen instead.
18
+ *
19
+ * Not exactly 1. A regular cell leaves 370px for a 375px device — a 1.3% reduction nobody can
20
+ * see, and warning about it would train authors to ignore the warning. The cases worth a page's
21
+ * attention are the ones that shrink a phone by a tenth or more: a 283px cell renders the device
22
+ * at 75%, which is the difference between readable body text and not. */
23
+ export const DEVICE_SCALE_WARN_BELOW = 0.95;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krapwoo/ds-viewer",
3
- "version": "0.6.8",
3
+ "version": "0.6.10",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A browsable, always-current catalog for an Expo / React Native design system.",
@@ -5,7 +5,9 @@ import { Banner } from './Banner';
5
5
  export default defineCatalogPage({
6
6
  component: 'Banner',
7
7
  group: 'Components',
8
- specimenSize: 'regular',
8
+ // Spans the screen: 'wide' keeps a 402px minimum, so two blocks never share a row and each
9
+ // specimen keeps a width the component is actually used at.
10
+ specimenSize: 'wide',
9
11
  composedOf: [
10
12
  { component: 'AnimatedChevron', role: "The expand/collapse chevron in the header of a collapsible banner.", relationship: 'built-in' },
11
13
  ],
@@ -65,6 +65,10 @@ const dock = (close: () => void) => (
65
65
  export default defineCatalogPage({
66
66
  component: 'BottomSheet',
67
67
  group: 'Components',
68
+ // Every example is a phone. At the default size two blocks share the row and each cell falls to
69
+ // 270px, which squeezes the frame until the sheet's own text is barely readable — 'wide' keeps a
70
+ // 402px minimum, the width the sheet is actually used at.
71
+ specimenSize: 'wide',
68
72
  composedOf: [
69
73
  { component: 'TopNav', role: "The header slot, typically a TopNav with a close or back action.", relationship: 'slot' },
70
74
  { component: 'Dock', role: "The footer slot, typically a Dock with the sheet's primary actions; a Dock footer gets its shadow once the content scrolls.", relationship: 'slot' },
@@ -33,6 +33,9 @@ function Demo({ title, body, actions }: { title: string; body?: string; actions:
33
33
  export default defineCatalogPage({
34
34
  component: 'Dialog',
35
35
  group: 'Components',
36
+ // Every example is a phone. The default size left 283px for a 375px device, rendering it at 75%
37
+ // — small enough that the dialog's own body text stopped being the size a reader sees.
38
+ specimenSize: 'wide',
36
39
  composedOf: [
37
40
  { component: 'ButtonGroup', role: "Lays out the dialog's actions; a dialog's own children decide this, not Dialog.", relationship: 'related' },
38
41
  ],
@@ -13,9 +13,9 @@ 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
+ whenToUse: 'Building a new kind of field. It carries the shared field chrome and reads the ambient SurfaceScope 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.',
17
17
  composedOf: [
18
- { component: 'Surface', role: "Reads the surrounding Surface tone: a recessed fill on white, opaque white inside a muted Surface.", relationship: 'related' },
18
+ { component: 'SurfaceScope', role: "Publishes the tone this reads: a recessed fill on white, opaque white inside a muted SurfaceScope.", relationship: 'related' },
19
19
  ],
20
20
  specimenSize: 'regular',
21
21
  description: 'The shared field chrome — white surface, medium radius, a 1px border that darkens on focus and mutes when disabled — behind InputField, TextArea, Dropdown, and SearchField, so all four share one visually-consistent field look instead of each re-implementing it.',
@@ -1,16 +1,16 @@
1
1
  import React from 'react';
2
2
  import { View, Pressable, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
3
3
  import { DS_SEMANTIC, DS_RADIUS } from '../../tokens';
4
- import { useSurfaceTone } from '../Surface';
4
+ import { useSurfaceTone } from '../SurfaceScope';
5
5
  import type { FieldContainerProps } from './FieldContainer.types';
6
6
 
7
7
  /**
8
8
  * The shared "field" chrome: medium radius, a 1px subtle border that darkens to border.dark on
9
9
  * focus, and a fill that contrasts with whatever it's actually placed on — reads the ambient
10
- * `useSurfaceTone()` (see `Surface`) rather than assuming one fixed background. On the default
10
+ * `useSurfaceTone()` (see `SurfaceScope`) rather than assuming one fixed background. On the default
11
11
  * white tone it's a translucent recessed fill (the same adaptive-overlay mechanism SegmentedToggle's
12
12
  * track and Button's own `secondary` variant use); on a `muted` surface (wrapped in
13
- * `<Surface tone="muted">`) it flips to opaque white instead, so it never goes invisible against
13
+ * `<SurfaceScope tone="muted">`) it flips to opaque white instead, so it never goes invisible against
14
14
  * whatever's behind it either way. It owns *only* the container look — consumers (InputField,
15
15
  * TextArea, SearchField) supply their own layout (height, padding, content) via `style` and
16
16
  * children, so a single-line row and a multiline area stay visually consistent.
@@ -21,7 +21,7 @@ export default defineCatalogPage({
21
21
  specimenSize: 'regular',
22
22
  composedOf: [
23
23
  { component: 'InputClearButton', role: "The clear (\u00d7) button, shown while the field has a value and is focused (or active, when not editable).", relationship: 'built-in' },
24
- { component: 'Surface', role: 'Reads the surrounding Surface tone so the field stays visible on a muted Surface.', relationship: 'related' },
24
+ { component: 'SurfaceScope', role: 'Publishes the tone this reads, so the field stays visible on a muted background.', relationship: 'related' },
25
25
  ],
26
26
  description: 'A floating-label field. Resting: a centred, body-sized label with no border. Active (focused, or picker with active set) or filled: the label floats to a small top caption, row 2 shows the value/input, and a border fades in while active.',
27
27
  whenToUse: 'A named field with a fixed identity ("To", "Arrive by") across the interaction. For free-text search with no floating label, use SearchField; for one of a small known set of choices, use Dropdown.',
@@ -12,7 +12,7 @@ import {
12
12
  type TextInputProps,
13
13
  } from 'react-native';
14
14
  import { InputClearButton } from '../InputClearButton';
15
- import { useSurfaceTone } from '../Surface';
15
+ import { useSurfaceTone } from '../SurfaceScope';
16
16
  import { DS_SEMANTIC, DS_SPACING, DS_RADIUS, DS_TYPOGRAPHY, DS_FONT_WEIGHT } from '../../tokens';
17
17
  import { Icon } from '../../icons/Icon.native';
18
18
  import type { IconName } from '../../icons';
@@ -0,0 +1,78 @@
1
+ import React from 'react';
2
+ import { Text, StyleSheet } from 'react-native';
3
+ import { defineCatalogPage } from '@krapwoo/ds-viewer';
4
+ import { SurfaceScope } from './SurfaceScope';
5
+ import { InputField } from '../InputField';
6
+ import { DS_SEMANTIC, DS_SPACING, DS_TYPOGRAPHY } from '../../tokens';
7
+
8
+ const styles = StyleSheet.create({
9
+ surfaceDemo: { height: 96, justifyContent: 'center', alignItems: 'center', padding: DS_SPACING[800] },
10
+ surfaceText: { ...DS_TYPOGRAPHY.bodyMd, color: DS_SEMANTIC.text.regular },
11
+ fieldDemo: { padding: DS_SPACING[800], gap: DS_SPACING[400] },
12
+ });
13
+
14
+ export default defineCatalogPage({
15
+ component: 'SurfaceScope',
16
+ group: 'Components',
17
+ 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.',
18
+ specimenSize: 'regular',
19
+ description:
20
+ "Declares \"this subtree's background is tone\": renders a plain View filled with the matching token (surface.white or surface.main) and publishes that tone to everything inside it. Publishing is the half a token cannot do — DS_SEMANTIC.surface.* already holds the values, but a value cannot tell a descendant what it is sitting on.",
21
+ whenToUse:
22
+ "Wrap a screen (or any region) wherever its background isn't the default white. Reach for it whenever something inside needs to contrast with the background rather than assume white — that is what makes this a component rather than just the surface.* tokens it paints with.",
23
+ composedOf: [
24
+ { component: 'InputField', role: 'One of the components that reads the published tone, via useSurfaceTone(), to pick a fill that stays visible on it.', relationship: 'related' },
25
+ { component: 'FieldContainer', role: 'The shared field chrome that actually does the reading, on behalf of every field built from it.', relationship: 'related' },
26
+ ],
27
+ variants: {
28
+ itemsFill: true,
29
+ items: [
30
+ {
31
+ key: 'white',
32
+ name: 'White',
33
+ props: { tone: 'white' },
34
+ node: (
35
+ <SurfaceScope tone="white" style={styles.surfaceDemo}>
36
+ <Text style={styles.surfaceText}>surface.white</Text>
37
+ </SurfaceScope>
38
+ ),
39
+ },
40
+ {
41
+ key: 'muted',
42
+ name: 'Muted',
43
+ props: { tone: 'muted' },
44
+ node: (
45
+ <SurfaceScope tone="muted" style={styles.surfaceDemo}>
46
+ <Text style={styles.surfaceText}>surface.main</Text>
47
+ </SurfaceScope>
48
+ ),
49
+ },
50
+ ],
51
+ },
52
+ states: {
53
+ itemsFill: true,
54
+ // The two cells above only show which token gets painted, which a swatch could do. These show
55
+ // the part that needs a component: the same field, told nothing, picking a different fill
56
+ // because of the scope it happens to be inside.
57
+ items: [
58
+ {
59
+ key: 'field-on-white',
60
+ name: 'A field inside tone="white"',
61
+ node: (
62
+ <SurfaceScope tone="white" style={styles.fieldDemo}>
63
+ <InputField label="Name" value="Recessed fill" />
64
+ </SurfaceScope>
65
+ ),
66
+ },
67
+ {
68
+ key: 'field-on-muted',
69
+ name: 'The same field inside tone="muted"',
70
+ node: (
71
+ <SurfaceScope tone="muted" style={styles.fieldDemo}>
72
+ <InputField label="Name" value="Opaque white" />
73
+ </SurfaceScope>
74
+ ),
75
+ },
76
+ ],
77
+ },
78
+ });
@@ -0,0 +1,51 @@
1
+ import React, { createContext, useContext, type ReactNode } from 'react';
2
+ import { View, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { DS_SEMANTIC } from '../../tokens';
4
+
5
+ export type SurfaceTone = 'white' | 'muted';
6
+
7
+ // Defaults to 'white' — a screen with no explicit <SurfaceScope> ancestor is assumed to sit on a
8
+ // white background, the common case (matches e.g. Card's own default fill).
9
+ const SurfaceToneContext = createContext<SurfaceTone>('white');
10
+
11
+ /** What background tone the nearest ancestor `<SurfaceScope>` declared (or 'white' if there isn't
12
+ * one). Components that need to contrast with whatever's actually behind them — FieldContainer/
13
+ * InputField today — read this instead of assuming one fixed background. */
14
+ export function useSurfaceTone(): SurfaceTone {
15
+ return useContext(SurfaceToneContext);
16
+ }
17
+
18
+ export interface SurfaceScopeProps {
19
+ /** Which background this scope renders. Descendants read this via `useSurfaceTone()` to
20
+ * automatically pick a fill that contrasts with it (e.g. a field goes white on a muted page,
21
+ * recessed on a white one) instead of needing to be told individually. */
22
+ tone: SurfaceTone;
23
+ style?: StyleProp<ViewStyle>;
24
+ children?: ReactNode;
25
+ }
26
+
27
+ /**
28
+ * Declares "this subtree's background is `tone`": renders a plain View filled with the matching
29
+ * token (`surface.white` or `surface.main`) **and** publishes that tone to everything inside it.
30
+ *
31
+ * The second half is the reason this is a component and not just a token. `DS_SEMANTIC.surface.*`
32
+ * already holds the values; what a value cannot do is tell a descendant what it is sitting on.
33
+ * `FieldContainer` and `InputField` call `useSurfaceTone()` to choose between a translucent
34
+ * recessed fill and an opaque white one, so a field stays visible either way — that only works
35
+ * because something above them declared the tone. This is a scoped background declaration, which
36
+ * is why it is named for the scope rather than for the surface itself.
37
+ *
38
+ * Wrap a screen (or any region) wherever its background isn't the default white.
39
+ */
40
+ export function SurfaceScope({ tone, style, children }: SurfaceScopeProps) {
41
+ return (
42
+ <SurfaceToneContext.Provider value={tone}>
43
+ <View style={[tone === 'white' ? styles.white : styles.muted, style]}>{children}</View>
44
+ </SurfaceToneContext.Provider>
45
+ );
46
+ }
47
+
48
+ const styles = StyleSheet.create({
49
+ white: { backgroundColor: DS_SEMANTIC.surface.white },
50
+ muted: { backgroundColor: DS_SEMANTIC.surface.main },
51
+ });
@@ -0,0 +1,2 @@
1
+ export { SurfaceScope, useSurfaceTone } from './SurfaceScope';
2
+ export type { SurfaceScopeProps, SurfaceTone } from './SurfaceScope';
@@ -28,7 +28,9 @@ function ToastDemo() {
28
28
  export default defineCatalogPage({
29
29
  component: 'Toast',
30
30
  group: 'Components',
31
- specimenSize: 'regular',
31
+ // Spans the screen: 'wide' keeps a 402px minimum, so two blocks never share a row and each
32
+ // specimen keeps a width the component is actually used at.
33
+ specimenSize: 'wide',
32
34
  description: 'A transient confirmation bar. Without a variant it renders the dark surface with inverse text; a variant shifts to a pastel status color.',
33
35
  whenToUse: "Transient and self-contained, floating over the screen, expected to go away on its own or via its own action. For a persistent, in-flow message about a standing condition, use Banner.",
34
36
  a11y: 'Rendered with accessibilityRole="alert" so screen readers announce it when it appears.',
@@ -27,7 +27,7 @@ export * from './SearchField';
27
27
  export * from './SectionHeader';
28
28
  export * from './SegmentedToggle';
29
29
  export * from './Shimmer';
30
- export * from './Surface';
30
+ export * from './SurfaceScope';
31
31
  export * from './Switch';
32
32
  export * from './TextArea';
33
33
  export * from './Toast';
@@ -14,7 +14,7 @@ import { PillRow, type PillRowItem } from '../components/PillRow';
14
14
  import { SearchField } from '../components/SearchField';
15
15
  import { SectionHeader } from '../components/SectionHeader';
16
16
  import { SegmentedToggle } from '../components/SegmentedToggle';
17
- import { Surface } from '../components/Surface';
17
+ import { SurfaceScope } from '../components/SurfaceScope';
18
18
  import { Toast } from '../components/Toast';
19
19
  import { Tooltip } from '../components/Tooltip';
20
20
  import { TopNav } from '../components/TopNav';
@@ -123,7 +123,7 @@ function SavedTripsDemo() {
123
123
  }
124
124
  />
125
125
  }
126
- bodyWrapper={(body) => <Surface tone="muted" style={styles.savedTripsBody}>{body}</Surface>}
126
+ bodyWrapper={(body) => <SurfaceScope tone="muted" style={styles.savedTripsBody}>{body}</SurfaceScope>}
127
127
  contentContainerStyle={styles.savedTripsScrollContent}
128
128
  // Toasts float over the top of the screen instead of pushing the Dock down. One per
129
129
  // pending removal, newest on top; each stays mounted (visible: false) through its exit.
@@ -239,7 +239,7 @@ export default defineCatalogPage({
239
239
  composedOf: [
240
240
  { component: 'TopNav', role: "The screen header, titled Saved trips, with a Filters button.", relationship: 'built-in' },
241
241
  { component: 'Tooltip', role: "The Filter by mode hint on the Filters button.", relationship: 'built-in' },
242
- { component: 'Surface', role: "The muted background behind the search, toggles and list.", relationship: 'built-in' },
242
+ { component: 'SurfaceScope', role: "Declares the muted background behind the search, toggles and list, so the fields inside it pick a fill that stays visible on it.", relationship: 'built-in' },
243
243
  { component: 'SearchField', role: "Searches stations.", relationship: 'built-in' },
244
244
  { component: 'SegmentedToggle', role: "Switches between List and Map views.", relationship: 'built-in' },
245
245
  { component: 'UnderlineTabs', role: "The All, Nearby and Favorites tabs.", relationship: 'built-in' },
@@ -1,46 +0,0 @@
1
- import React from 'react';
2
- import { Text, StyleSheet } from 'react-native';
3
- import { defineCatalogPage } from '@krapwoo/ds-viewer';
4
- import { Surface } from './Surface';
5
- import { DS_SEMANTIC, DS_SPACING, DS_TYPOGRAPHY } from '../../tokens';
6
-
7
- const styles = StyleSheet.create({
8
- surfaceDemo: { height: 96, justifyContent: 'center', alignItems: 'center', padding: DS_SPACING[800] },
9
- surfaceText: { ...DS_TYPOGRAPHY.bodyMd, color: DS_SEMANTIC.text.regular },
10
- });
11
-
12
- export default defineCatalogPage({
13
- component: 'Surface',
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.',
16
- specimenSize: 'regular',
17
- description:
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.",
19
- whenToUse:
20
- "Wrap a screen (or any region) wherever its background isn't the default white — a page with a muted/grey background, for instance — so anything inside that needs to contrast with its background picks the right fill automatically.",
21
- variants: {
22
- itemsFill: true,
23
- items: [
24
- {
25
- key: 'white',
26
- name: 'White',
27
- props: { tone: 'white' },
28
- node: (
29
- <Surface tone="white" style={styles.surfaceDemo}>
30
- <Text style={styles.surfaceText}>surface.white</Text>
31
- </Surface>
32
- ),
33
- },
34
- {
35
- key: 'muted',
36
- name: 'Muted',
37
- props: { tone: 'muted' },
38
- node: (
39
- <Surface tone="muted" style={styles.surfaceDemo}>
40
- <Text style={styles.surfaceText}>surface.main</Text>
41
- </Surface>
42
- ),
43
- },
44
- ],
45
- },
46
- });
@@ -1,45 +0,0 @@
1
- import React, { createContext, useContext, type ReactNode } from 'react';
2
- import { View, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
3
- import { DS_SEMANTIC } from '../../tokens';
4
-
5
- export type SurfaceTone = 'white' | 'muted';
6
-
7
- // Defaults to 'white' — a screen with no explicit <Surface> ancestor is assumed to sit on a white
8
- // background, the common case (matches e.g. Card's own default fill).
9
- const SurfaceToneContext = createContext<SurfaceTone>('white');
10
-
11
- /** What background tone the nearest ancestor `<Surface>` declared (or 'white' if there isn't one).
12
- * Components that need to contrast with whatever's actually behind them — FieldContainer/
13
- * InputField today — read this instead of assuming one fixed background. */
14
- export function useSurfaceTone(): SurfaceTone {
15
- return useContext(SurfaceToneContext);
16
- }
17
-
18
- export interface SurfaceProps {
19
- /** Which background this surface renders. Descendants read this via `useSurfaceTone()` to
20
- * automatically pick a fill that contrasts with it (e.g. a field goes white on a muted page,
21
- * recessed on a white one) instead of needing to be told individually. */
22
- tone: SurfaceTone;
23
- style?: StyleProp<ViewStyle>;
24
- children?: ReactNode;
25
- }
26
-
27
- /**
28
- * Declares "this subtree's background is `tone`" — renders a plain View filled with the matching
29
- * token (`surface.white` or `surface.main`) and provides that tone to descendants via context.
30
- * Wrap a screen (or any region) in this wherever its background isn't the default white — a page
31
- * with a muted/grey background, for instance — so anything inside that needs to contrast with its
32
- * background picks the right fill automatically rather than assuming white.
33
- */
34
- export function Surface({ tone, style, children }: SurfaceProps) {
35
- return (
36
- <SurfaceToneContext.Provider value={tone}>
37
- <View style={[tone === 'white' ? styles.white : styles.muted, style]}>{children}</View>
38
- </SurfaceToneContext.Provider>
39
- );
40
- }
41
-
42
- const styles = StyleSheet.create({
43
- white: { backgroundColor: DS_SEMANTIC.surface.white },
44
- muted: { backgroundColor: DS_SEMANTIC.surface.main },
45
- });
@@ -1,2 +0,0 @@
1
- export { Surface, useSurfaceTone } from './Surface';
2
- export type { SurfaceProps, SurfaceTone } from './Surface';