@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 +23 -0
- package/dist/cli/dev.js +31 -2
- package/dist/cli/explain.js +18 -7
- package/dist/cli/workspace.js +47 -1
- package/native/catalog/PhoneFrame.tsx +29 -3
- package/native/catalog/deviceFrame.ts +9 -0
- package/package.json +1 -1
- package/starter-kit/components/Banner/Banner.catalog.tsx +3 -1
- package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +4 -0
- package/starter-kit/components/Dialog/Dialog.catalog.tsx +3 -0
- package/starter-kit/components/FieldContainer/FieldContainer.catalog.tsx +2 -2
- package/starter-kit/components/FieldContainer/FieldContainer.tsx +3 -3
- package/starter-kit/components/InputField/InputField.catalog.tsx +1 -1
- package/starter-kit/components/InputField/InputField.tsx +1 -1
- package/starter-kit/components/SurfaceScope/SurfaceScope.catalog.tsx +78 -0
- package/starter-kit/components/SurfaceScope/SurfaceScope.tsx +51 -0
- package/starter-kit/components/SurfaceScope/index.ts +2 -0
- package/starter-kit/components/Toast/Toast.catalog.tsx +3 -1
- package/starter-kit/components/index.ts +1 -1
- package/starter-kit/pages/SavedTrips.catalog.tsx +3 -3
- package/starter-kit/components/Surface/Surface.catalog.tsx +0 -46
- package/starter-kit/components/Surface/Surface.tsx +0 -45
- package/starter-kit/components/Surface/index.ts +0 -2
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
|
});
|
package/dist/cli/explain.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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:
|
|
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 (${
|
|
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.
|
|
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) {
|
package/dist/cli/workspace.js
CHANGED
|
@@ -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({
|
|
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
|
@@ -5,7 +5,9 @@ import { Banner } from './Banner';
|
|
|
5
5
|
export default defineCatalogPage({
|
|
6
6
|
component: 'Banner',
|
|
7
7
|
group: 'Components',
|
|
8
|
-
|
|
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
|
|
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: '
|
|
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 '../
|
|
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 `
|
|
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
|
-
* `<
|
|
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: '
|
|
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 '../
|
|
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
|
+
});
|
|
@@ -28,7 +28,9 @@ function ToastDemo() {
|
|
|
28
28
|
export default defineCatalogPage({
|
|
29
29
|
component: 'Toast',
|
|
30
30
|
group: 'Components',
|
|
31
|
-
|
|
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 './
|
|
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 {
|
|
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) => <
|
|
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: '
|
|
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
|
-
});
|