@krapwoo/ds-viewer 0.6.7 → 0.6.9
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 +17 -0
- package/dist/cli/dev.js +49 -4
- package/dist/cli/packageManager.js +41 -6
- package/dist/cli/update.js +28 -2
- package/dist/cli/workspace.js +47 -1
- package/package.json +1 -1
- package/starter-kit/components/Banner/Banner.catalog.tsx +3 -1
- 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 —
|
|
@@ -622,6 +629,16 @@ Downloads the target version, runs its own `migrate --from <your version>`, upgr
|
|
|
622
629
|
whichever package manager your lockfile names, re-runs `migrate` for real, then `doctor`. Changes
|
|
623
630
|
are left uncommitted — review them like any other change.
|
|
624
631
|
|
|
632
|
+
The upgrade keeps the package in whichever section your `package.json` already declares it in,
|
|
633
|
+
rather than always writing a devDependency.
|
|
634
|
+
|
|
635
|
+
If that spec is a local one — `file:`, `link:`, `workspace:`, `portal:`, or a bare path — `update`
|
|
636
|
+
refuses and tells you how to proceed, because installing a published version would replace the
|
|
637
|
+
checkout your project deliberately points at. The catalog would keep running while no longer
|
|
638
|
+
reflecting the source you are editing, which is hard to notice. `--force` does not override this:
|
|
639
|
+
it exists for uncommitted files, not for changing where the package comes from. The update page's
|
|
640
|
+
**Update now** refuses the same way, reporting it as a failed install step.
|
|
641
|
+
|
|
625
642
|
### `npx ds-viewer migrate --from <version>`
|
|
626
643
|
|
|
627
644
|
Runs every migration introduced after `<version>` — `update` calls this for you; run it directly
|
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";
|
|
@@ -14,10 +14,16 @@ import { readOwnVersion } from "./packageVersion.js";
|
|
|
14
14
|
import { createUpdateEndpoint } from "./endpoint.js";
|
|
15
15
|
import { buildUpdatePlan } from "./updatePlan.js";
|
|
16
16
|
import { lastSummaryLine } from "./doctor.js";
|
|
17
|
-
import { detectPackageManager, installUpgradeCommand, platformCommand } from "./packageManager.js";
|
|
17
|
+
import { declaredDependency, detectPackageManager, installUpgradeCommand, isLocalSpec, platformCommand } from "./packageManager.js";
|
|
18
|
+
import { localInstallRefusal } from "./update.js";
|
|
18
19
|
import { DEFAULT_WORKSPACE_DIR, RENDER_WORKSPACE_DIR } from "./types.js";
|
|
19
20
|
export class LocalInstallMissingError extends Error {
|
|
20
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
|
+
}
|
|
21
27
|
const IGNORED_WATCH_SEGMENTS = new Set([DEFAULT_WORKSPACE_DIR, RENDER_WORKSPACE_DIR, 'node_modules', '.git']);
|
|
22
28
|
/** False for a changed path under `.ds-viewer/` (package-owned output — reacting to it would make
|
|
23
29
|
* `dev`'s own `sync` retrigger itself), `node_modules/`, or `.git/`; true for everything else, so
|
|
@@ -142,7 +148,22 @@ export async function performUpdate(config, currentVersion, plan, deps) {
|
|
|
142
148
|
});
|
|
143
149
|
report(startAt);
|
|
144
150
|
if (startAt <= 1) {
|
|
145
|
-
|
|
151
|
+
// The same refusal the CLI gives: installing a registry version over a `file:`/`link:`/
|
|
152
|
+
// `workspace:` spec would replace the local checkout the project points at, and the catalog
|
|
153
|
+
// would keep serving while no longer reflecting the source being edited. Reported as a
|
|
154
|
+
// failure of the install step, so the page offers its normal recovery rather than appearing
|
|
155
|
+
// to succeed.
|
|
156
|
+
const declared = declaredDependency(config.projectRoot, '@krapwoo/ds-viewer');
|
|
157
|
+
if (declared && isLocalSpec(declared.spec)) {
|
|
158
|
+
deps.onStatus({
|
|
159
|
+
phase: 'failure',
|
|
160
|
+
log: localInstallRefusal(declared.spec, plan.latest),
|
|
161
|
+
failedStep: stepDefs[1].failLabel ?? stepDefs[1].label,
|
|
162
|
+
step: 'install',
|
|
163
|
+
});
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const { command, args } = installUpgradeCommand(pm, '@krapwoo/ds-viewer', plan.latest, declared?.section);
|
|
146
167
|
try {
|
|
147
168
|
await deps.execImpl(command, args, { cwd: config.projectRoot, encoding: 'utf8' });
|
|
148
169
|
}
|
|
@@ -269,6 +290,9 @@ export async function performRestart(handoff, deps = {}) {
|
|
|
269
290
|
if (!exited)
|
|
270
291
|
killGroup(handoff.child, 'SIGKILL');
|
|
271
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 });
|
|
272
296
|
// The old child is gone and the port is free — if a signal arrived in the window above (before
|
|
273
297
|
// there was anything to forward it to), this restart is itself cancelled: the user asked to
|
|
274
298
|
// stop, not to come back up on the new version.
|
|
@@ -336,6 +360,22 @@ export async function dev(initialConfig, devOptions = {}) {
|
|
|
336
360
|
rmSync(statusFile, { force: true });
|
|
337
361
|
}
|
|
338
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 });
|
|
339
379
|
console.log(`Starting the catalog at http://localhost:${port}`);
|
|
340
380
|
let watchersPaused = false;
|
|
341
381
|
// Metro's own child process doesn't exist yet — assigned below, well before this is ever read
|
|
@@ -359,7 +399,7 @@ export async function dev(initialConfig, devOptions = {}) {
|
|
|
359
399
|
},
|
|
360
400
|
restart: async (result) => {
|
|
361
401
|
writeFileSync(statusFile, JSON.stringify({ phase: 'success', ...result }));
|
|
362
|
-
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 });
|
|
363
403
|
},
|
|
364
404
|
}).catch((error) => {
|
|
365
405
|
// `performUpdate` only reports 'failure' from its own guarded steps; anything thrown outside
|
|
@@ -470,6 +510,10 @@ export async function dev(initialConfig, devOptions = {}) {
|
|
|
470
510
|
for (const watcher of watchers)
|
|
471
511
|
watcher.close();
|
|
472
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);
|
|
473
517
|
process.exitCode = code ?? 0;
|
|
474
518
|
});
|
|
475
519
|
// Node's default SIGTERM/SIGINT/SIGHUP behavior exits this process without touching its
|
|
@@ -483,6 +527,7 @@ export async function dev(initialConfig, devOptions = {}) {
|
|
|
483
527
|
for (const watcher of watchers)
|
|
484
528
|
watcher.close();
|
|
485
529
|
endpointServer?.close();
|
|
530
|
+
clearDevLock(config);
|
|
486
531
|
killGroup(child, signal);
|
|
487
532
|
process.exit(0);
|
|
488
533
|
});
|
|
@@ -15,17 +15,52 @@ export function detectPackageManager(projectRoot) {
|
|
|
15
15
|
}
|
|
16
16
|
return 'npm'; // package-lock.json, or no lockfile at all (npm's own documented default either way).
|
|
17
17
|
}
|
|
18
|
+
/** How `pkg` is declared in this project, or undefined when it is not declared at all.
|
|
19
|
+
* `dependencies` wins when both sections name it, matching npm's own resolution. */
|
|
20
|
+
export function declaredDependency(projectRoot, pkg) {
|
|
21
|
+
const packageJsonPath = path.join(projectRoot, 'package.json');
|
|
22
|
+
if (!existsSync(packageJsonPath))
|
|
23
|
+
return undefined;
|
|
24
|
+
let parsed;
|
|
25
|
+
try {
|
|
26
|
+
parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined; // An unreadable package.json is not this function's error to report.
|
|
30
|
+
}
|
|
31
|
+
const fromDeps = parsed.dependencies?.[pkg];
|
|
32
|
+
if (typeof fromDeps === 'string')
|
|
33
|
+
return { section: 'dependencies', spec: fromDeps };
|
|
34
|
+
const fromDevDeps = parsed.devDependencies?.[pkg];
|
|
35
|
+
if (typeof fromDevDeps === 'string')
|
|
36
|
+
return { section: 'devDependencies', spec: fromDevDeps };
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
/** A spec that resolves to something other than a published version: the local source tree, a
|
|
40
|
+
* linked folder, or another package in the same workspace. Installing a registry version over one
|
|
41
|
+
* of these replaces the thing the project is deliberately pointing at, so `update` refuses rather
|
|
42
|
+
* than doing it silently. `npm:` aliases and plain ranges are registry-backed and not local. */
|
|
43
|
+
export function isLocalSpec(spec) {
|
|
44
|
+
const value = spec.trim();
|
|
45
|
+
if (/^(file|link|workspace|portal):/i.test(value))
|
|
46
|
+
return true;
|
|
47
|
+
// A bare relative or absolute path is a file dependency too — npm accepts `../pkg` unprefixed.
|
|
48
|
+
return value.startsWith('./') || value.startsWith('../') || value.startsWith('/') || value.startsWith('~/');
|
|
49
|
+
}
|
|
18
50
|
/** The one command `cli/update.ts`'s "Upgrades with the project's package manager" step (design
|
|
19
51
|
* §5 "`npx ds-viewer update`" step 3) runs, for whichever manager `detectPackageManager` found.
|
|
20
|
-
* Yarn (classic and Berry both) use the same `yarn add
|
|
21
|
-
*
|
|
22
|
-
|
|
52
|
+
* Yarn (classic and Berry both) use the same `yarn add` syntax for upgrading a single package to
|
|
53
|
+
* an exact version. `section` keeps the package in the section it is already declared in; it
|
|
54
|
+
* defaults to `devDependencies`, which is where `init` puts it. */
|
|
55
|
+
export function installUpgradeCommand(pm, pkg, version, section = 'devDependencies') {
|
|
23
56
|
const spec = `${pkg}@${version}`;
|
|
57
|
+
const dev = section === 'devDependencies';
|
|
24
58
|
if (pm === 'npm')
|
|
25
|
-
return { command: 'npm', args: ['install', '--save-dev', spec] };
|
|
59
|
+
return { command: 'npm', args: ['install', dev ? '--save-dev' : '--save-prod', spec] };
|
|
26
60
|
if (pm === 'pnpm')
|
|
27
|
-
return { command: 'pnpm', args: ['add', '--save-dev', spec] };
|
|
28
|
-
|
|
61
|
+
return { command: 'pnpm', args: ['add', dev ? '--save-dev' : '--save-prod', spec] };
|
|
62
|
+
// Yarn has no --save-prod: a plain `yarn add` is already a production dependency.
|
|
63
|
+
return { command: 'yarn', args: dev ? ['add', '--dev', spec] : ['add', spec] };
|
|
29
64
|
}
|
|
30
65
|
const WINDOWS_SHIMS = new Set(['npm', 'npx', 'pnpm', 'yarn']);
|
|
31
66
|
/** On Windows, `npm`, `npx`, `pnpm` and `yarn` are `.cmd` shims, which Node refuses to run
|
package/dist/cli/update.js
CHANGED
|
@@ -4,7 +4,7 @@ import { createInterface } from 'node:readline/promises';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { buildUpdatePlan, isAlreadyUpToDate } from "./updatePlan.js";
|
|
7
|
-
import { detectPackageManager, installUpgradeCommand, platformCommand } from "./packageManager.js";
|
|
7
|
+
import { declaredDependency, detectPackageManager, installUpgradeCommand, isLocalSpec, platformCommand } from "./packageManager.js";
|
|
8
8
|
import { readOwnVersion } from "./packageVersion.js";
|
|
9
9
|
import { lastSummaryLine } from "./doctor.js";
|
|
10
10
|
function formatPlanHuman(plan) {
|
|
@@ -32,6 +32,22 @@ async function defaultConfirm(message) {
|
|
|
32
32
|
function installedMainJs(projectRoot) {
|
|
33
33
|
return path.join(projectRoot, 'node_modules', '@krapwoo', 'ds-viewer', 'dist', 'cli', 'main.js');
|
|
34
34
|
}
|
|
35
|
+
/** Why an update cannot run over a local checkout, and what to do instead. Shared by the CLI and
|
|
36
|
+
* the update page so both refuse in the same words. */
|
|
37
|
+
export function localInstallRefusal(spec, latest) {
|
|
38
|
+
return [
|
|
39
|
+
`Refusing: @krapwoo/ds-viewer is installed from "${spec}", not from the registry.`,
|
|
40
|
+
'',
|
|
41
|
+
'Updating would install the published package over it, replacing the local checkout this',
|
|
42
|
+
'project deliberately points at — the catalog would keep running while no longer reflecting',
|
|
43
|
+
'the source being edited.',
|
|
44
|
+
'',
|
|
45
|
+
`To move this project onto the published ${latest}, change the dependency yourself:`,
|
|
46
|
+
` npm install --save-dev @krapwoo/ds-viewer@${latest}`,
|
|
47
|
+
'',
|
|
48
|
+
'To stay on local source, pull and rebuild it instead.',
|
|
49
|
+
].join('\n');
|
|
50
|
+
}
|
|
35
51
|
/** Design §5 "`npx ds-viewer update`", steps 1-6. */
|
|
36
52
|
export async function runUpdate(config, options = {}) {
|
|
37
53
|
const currentVersion = options.currentVersion ?? readOwnVersion(path.dirname(fileURLToPath(import.meta.url)));
|
|
@@ -41,6 +57,14 @@ export async function runUpdate(config, options = {}) {
|
|
|
41
57
|
const planText = formatPlanHuman(plan);
|
|
42
58
|
if (options.dryRun)
|
|
43
59
|
return { output: planText, exitCode: 0 };
|
|
60
|
+
// Refused before the dirty-file check and before any prompt: a `--yes --force` script must not
|
|
61
|
+
// be able to swap a local checkout for a registry copy either. `--force` deliberately does not
|
|
62
|
+
// override this — it exists for uncommitted files, not for changing where the package comes
|
|
63
|
+
// from, which is a decision the project's own package.json already recorded.
|
|
64
|
+
const declared = declaredDependency(config.projectRoot, '@krapwoo/ds-viewer');
|
|
65
|
+
if (declared && isLocalSpec(declared.spec)) {
|
|
66
|
+
return { output: `${planText}\n\n${localInstallRefusal(declared.spec, plan.latest)}`, exitCode: 1 };
|
|
67
|
+
}
|
|
44
68
|
// Step 2: refuses on any dirty planned file, unless --force — checked before the confirmation
|
|
45
69
|
// prompt, so a script passing --yes without --force still gets refused instead of silently
|
|
46
70
|
// overwriting uncommitted work.
|
|
@@ -59,7 +83,9 @@ export async function runUpdate(config, options = {}) {
|
|
|
59
83
|
const run = platformCommand(cmd, args);
|
|
60
84
|
return execFileSync(run.command, run.args, { ...opts, encoding: 'utf8', shell: run.shell });
|
|
61
85
|
});
|
|
62
|
-
const { command, args } = installUpgradeCommand(detectPackageManager(config.projectRoot), '@krapwoo/ds-viewer', plan.latest
|
|
86
|
+
const { command, args } = installUpgradeCommand(detectPackageManager(config.projectRoot), '@krapwoo/ds-viewer', plan.latest,
|
|
87
|
+
// Keep it in the section the project already declared, instead of forcing devDependencies.
|
|
88
|
+
declared?.section);
|
|
63
89
|
try {
|
|
64
90
|
execImpl(command, args, { cwd: config.projectRoot, encoding: 'utf8' });
|
|
65
91
|
}
|
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.
|
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
|
],
|
|
@@ -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
|
-
});
|