@krapwoo/ds-viewer 0.6.10 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -0
- package/dist/cli/dev.js +148 -13
- package/dist/cli/main.js +19 -1
- package/dist/cli/render.js +87 -3
- package/dist/cli/workspace.js +15 -3
- package/dist/native/catalog/comparison.js +37 -4
- package/native/catalog/SpacingScaleGallery.tsx +4 -4
- package/native/catalog/TokenRow.tsx +64 -14
- package/native/catalog/TypeScaleGallery.tsx +4 -4
- package/native/catalog/comparison.ts +38 -4
- package/native/catalog/index.ts +1 -1
- package/package.json +1 -1
- package/starter-kit/components/Tooltip/Tooltip.catalog.tsx +4 -1
- package/starter-kit/pages/Motion.catalog.tsx +151 -83
- package/starter-kit/pages/Radius.catalog.tsx +22 -18
- package/starter-kit/pages/Shadow.catalog.tsx +27 -15
package/README.md
CHANGED
|
@@ -72,6 +72,29 @@ loser then serves a workspace that no longer describes it, which Expo reports as
|
|
|
72
72
|
with `--workspace-dir .ds-viewer-second`. A lock left behind by a crashed `dev` never blocks the
|
|
73
73
|
next one.
|
|
74
74
|
|
|
75
|
+
`.ds-viewer/` belongs to the running server, and it repairs it. If something outside the server
|
|
76
|
+
deletes the generated files while it is serving, it says so and writes them back, rather than
|
|
77
|
+
leaving Expo to report an unresolvable `main` field — which names your project's `package.json`, a
|
|
78
|
+
file that is fine and that you never touched:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
Something outside this server deleted package.json, app.json, tsconfig.json, metro.config.js,
|
|
82
|
+
entry.tsx from .ds-viewer/. Rewriting them — your project is fine, this is the catalog's own
|
|
83
|
+
generated workspace. If the catalog looks broken, reload the page.
|
|
84
|
+
Restarting Metro so the catalog picks the files back up — the page reloads itself in a few seconds.
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Metro is restarted as part of the repair, because rewriting the files is not enough on its own: it
|
|
88
|
+
keeps serving the module map it cached before they went missing, and answers every request with
|
|
89
|
+
`Unable to resolve "expo" from "entry.tsx"` until it starts again. The server stays up and the page
|
|
90
|
+
reloads itself.
|
|
91
|
+
|
|
92
|
+
The repair is capped at five per run: if something is deleting continuously, repairing forever
|
|
93
|
+
would only hide it, so past the cap the server stops and keeps reporting. If you ever see that
|
|
94
|
+
message, what else was running at the time would be useful on
|
|
95
|
+
[issue #41](https://github.com/krapwoo/design-system-viewer/issues/41) — the cause is not yet
|
|
96
|
+
identified.
|
|
97
|
+
|
|
75
98
|
`ds-viewer.config.ts` fields: `name`, `logo` (optional — a path to an image asset, shown in the
|
|
76
99
|
sidebar beside the name, or in place of it for a wide wordmark; validated at `sync`/`dev` time
|
|
77
100
|
and bundled into the preview workspace), `components`, `exclude`, `tokens`, `pages`, `groupOrder`, `starterKit` (new-project only —
|
|
@@ -544,12 +567,28 @@ and adds what only a real render shows:
|
|
|
544
567
|
prop that was never wired up. The fingerprint reads each specimen's whole subtree (including a
|
|
545
568
|
PhoneFrame's child document and a field's value) but not the caption carrying the variant's own
|
|
546
569
|
name, so two examples differing only in colour are not reported.
|
|
570
|
+
- `specimen-clipped` (warning): an example in a list card painting past the left or right of its
|
|
571
|
+
cell, where it is cut off rather than contained. Content the specimen holds itself is not
|
|
572
|
+
reported — anything inside something that scrolls (`auto`/`scroll`, so the reader can reach it)
|
|
573
|
+
or clips (`hidden`/`clip`) is treated as the specimen's own containment. That last one is a
|
|
574
|
+
trade-off worth knowing: a full-width component clipping its own corners hides a genuinely
|
|
575
|
+
too-narrow cell from this rule, so `hidden` buys quiet for carousels and shimmer sweeps at the
|
|
576
|
+
cost of that case. Only horizontal crossings in `role=list` cells are measured — a cell's width
|
|
577
|
+
is what the page allocates, while its height grows with content, and matrix (`ComparisonGrid`)
|
|
578
|
+
cells are not visited at all. Usually fixed with a wider `specimenSize`, by stacking the blocks so
|
|
579
|
+
the example gets the full row, or by dropping `itemsFill` from a small instance that should sit
|
|
580
|
+
centered.
|
|
547
581
|
- `render-unavailable` / `render-failed` (errors): no browser, or the catalog didn't start.
|
|
548
582
|
|
|
549
583
|
It needs `puppeteer` in your project (`npm install --save-dev puppeteer`); ds-viewer doesn't
|
|
550
584
|
install a browser. It takes about two minutes for 50 pages and works with `--json` (progress
|
|
551
585
|
goes to stderr), `--ci` and `doctor.strict`, so CI can run `npx ds-viewer doctor --render --ci`.
|
|
552
586
|
|
|
587
|
+
It starts its own catalog on the first free port from 5181. `--port <n>` pins it instead, which
|
|
588
|
+
you want when something else already holds that range — another project's catalog, or a second
|
|
589
|
+
`dev`. The check cannot tell a stranger's server on its port from its own, so a collision reports
|
|
590
|
+
that server's pages as your project's failures.
|
|
591
|
+
|
|
553
592
|
It starts its own `dev` server, with `--no-update-endpoint` and its own generated directory
|
|
554
593
|
(`.ds-viewer-render/`, gitignored by `init` alongside `.ds-viewer/`). Nothing it generates touches
|
|
555
594
|
the workspace a running `dev` owns, so you can run it beside a catalog you already have open and
|
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 { clearDevLock, liveDevLock, workspacePath, writeDevLock, writeWorkspace } from "./workspace.js";
|
|
9
|
+
import { clearDevLock, liveDevLock, missingWorkspaceFiles, 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";
|
|
@@ -326,6 +326,42 @@ export async function performRestart(handoff, deps = {}) {
|
|
|
326
326
|
* component/token/page changes to re-run `sync`. Metro's own HMR updates the rendered component
|
|
327
327
|
* when its source changes, but not the generated props table or page list (Task 17 Step 4 relies
|
|
328
328
|
* on this: editing `Button.tsx` must update its props table, not just hot-reload the component). */
|
|
329
|
+
/** How often a running `dev` checks that its own workspace still exists. Cheap (five `existsSync`
|
|
330
|
+
* calls), and slow enough to be invisible: the failure it covers is rare, and noticing within a
|
|
331
|
+
* few seconds is soon enough to repair before the next page load. */
|
|
332
|
+
const WORKSPACE_WATCH_INTERVAL_MS = 4000;
|
|
333
|
+
/** How many times one `dev` run will rewrite a workspace that keeps losing files. Past this,
|
|
334
|
+
* something is deleting continuously and repairing again would only hide it. */
|
|
335
|
+
const WORKSPACE_REPAIR_LIMIT = 5;
|
|
336
|
+
/** One pass of the workspace integrity check. Returns what it did, so the caller owns the repair
|
|
337
|
+
* budget and the tests can drive it without a timer.
|
|
338
|
+
*
|
|
339
|
+
* `updateEndpoint` is the caller's own, passed through unchanged: rebuilding it here from the
|
|
340
|
+
* Metro port would point the repaired catalog at the wrong server, silently breaking the update
|
|
341
|
+
* panel while telling the developer everything is fine. */
|
|
342
|
+
export function repairWorkspaceIfMissing(deps) {
|
|
343
|
+
const { config, updateEndpoint, versionStatus, sync, reclaimLock, log = console.warn } = deps;
|
|
344
|
+
const missing = missingWorkspaceFiles(config);
|
|
345
|
+
if (missing.length === 0)
|
|
346
|
+
return { missing, repaired: false };
|
|
347
|
+
log(`Something outside this server deleted ${missing.join(', ')} from ${config.workspaceDir ?? DEFAULT_WORKSPACE_DIR}/. ` +
|
|
348
|
+
"Rewriting them — your project is fine, this is the catalog's own generated workspace. " +
|
|
349
|
+
'If the catalog looks broken, reload the page.');
|
|
350
|
+
writeWorkspace(config, updateEndpoint);
|
|
351
|
+
// `entry.tsx` imports both of these, so a rewrite that skips them resolves nothing. Both come
|
|
352
|
+
// from one status, the way `refreshUpdateFile` writes them, so a repair cannot leave
|
|
353
|
+
// `version.json` announcing an update that `update.json` denies.
|
|
354
|
+
const status = versionStatus();
|
|
355
|
+
if (!existsSync(workspacePath(config, 'update.json'))) {
|
|
356
|
+
const update = status.update ? { ...status.update, checkedAt: status.lastCheckedAt ?? new Date().toISOString() } : undefined;
|
|
357
|
+
writeUpdateFile(config.projectRoot, update, config.workspaceDir);
|
|
358
|
+
}
|
|
359
|
+
if (!existsSync(workspacePath(config, 'version.json')))
|
|
360
|
+
writeVersionFile(config.projectRoot, status, config.workspaceDir);
|
|
361
|
+
reclaimLock();
|
|
362
|
+
sync(config);
|
|
363
|
+
return { missing, repaired: true };
|
|
364
|
+
}
|
|
329
365
|
export async function dev(initialConfig, devOptions = {}) {
|
|
330
366
|
assertLocalInstall(initialConfig.projectRoot);
|
|
331
367
|
const ownVersion = readOwnVersion(import.meta.dirname);
|
|
@@ -499,23 +535,122 @@ export async function dev(initialConfig, devOptions = {}) {
|
|
|
499
535
|
for (const watcher of watchers) {
|
|
500
536
|
watcher.on('error', (error) => console.warn(`File watcher error: ${error.message}`));
|
|
501
537
|
}
|
|
502
|
-
|
|
538
|
+
const spawnMetro = () => spawn('npx', ['expo', 'start', '--web', '--host', 'localhost', '--port', String(port)], {
|
|
503
539
|
cwd: workspace,
|
|
504
540
|
stdio: 'inherit',
|
|
505
541
|
shell: process.platform === 'win32',
|
|
506
542
|
detached: true,
|
|
507
543
|
});
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
544
|
+
// Set while this process is deliberately replacing Metro, so the exit handler below respawns it
|
|
545
|
+
// instead of running the shutdown path. Restoring the files is not enough on its own: Metro has
|
|
546
|
+
// already cached a module map that no longer matches the directory, and it answers every request
|
|
547
|
+
// with `Unable to resolve "expo" from "entry.tsx"` until it is restarted. Observed four times —
|
|
548
|
+
// the files came back and the catalog stayed blank.
|
|
549
|
+
let restartingMetro = false;
|
|
550
|
+
child = spawnMetro();
|
|
551
|
+
// This process owns its workspace: nothing else should remove the files that define it. When
|
|
552
|
+
// they vanish anyway, Metro reports it as `Cannot resolve entry file: the 'main' field defined
|
|
553
|
+
// in your package.json points to an unresolvable or non-existent path` — which names the
|
|
554
|
+
// *project's* package.json, a file that is fine and that the developer did not touch. The cause
|
|
555
|
+
// is still unidentified (issue #41), so this does not pretend to prevent it; it notices, says
|
|
556
|
+
// plainly what happened, and writes the files back, because `writeWorkspace` is idempotent and
|
|
557
|
+
// already knows how.
|
|
558
|
+
//
|
|
559
|
+
// Capped rather than endless: if something is deleting continuously, rewriting forever would
|
|
560
|
+
// hide it behind a repair loop. After the cap this stops repairing and keeps saying so, which
|
|
561
|
+
// leaves the evidence visible.
|
|
562
|
+
let repairs = 0;
|
|
563
|
+
const workspaceWatch = setInterval(() => {
|
|
564
|
+
if (watchersPaused)
|
|
565
|
+
return; // an update is rewriting the workspace on purpose.
|
|
566
|
+
if (repairs >= WORKSPACE_REPAIR_LIMIT) {
|
|
567
|
+
const missing = missingWorkspaceFiles(config);
|
|
568
|
+
if (missing.length > 0) {
|
|
569
|
+
console.warn(`The catalog workspace is still losing files (missing ${missing.join(', ')}). Not rewriting them again — ` +
|
|
570
|
+
'something outside this server is deleting them. Please report what else was running at https://github.com/krapwoo/design-system-viewer/issues/41');
|
|
571
|
+
}
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
// Counted as an attempt, not a success: a workspace that cannot be rewritten (a permission
|
|
575
|
+
// error, a sync failure) would otherwise retry every four seconds forever.
|
|
576
|
+
if (missingWorkspaceFiles(config).length > 0)
|
|
577
|
+
repairs += 1;
|
|
578
|
+
try {
|
|
579
|
+
// `updateEndpoint` is passed through, never rebuilt: the endpoint listens on its own
|
|
580
|
+
// ephemeral port, so deriving one from Metro's port here would repair the files and break
|
|
581
|
+
// the update panel in the same breath.
|
|
582
|
+
const { repaired } = repairWorkspaceIfMissing({
|
|
583
|
+
config, updateEndpoint, versionStatus: version.getVersionStatus, sync,
|
|
584
|
+
// Only if no one else took it meanwhile: between losing the lock and this tick, a second
|
|
585
|
+
// `dev` can legitimately have started and be serving the directory. Overwriting its lock
|
|
586
|
+
// would leave two servers each believing they own it.
|
|
587
|
+
reclaimLock: () => {
|
|
588
|
+
const other = liveDevLock(config);
|
|
589
|
+
if (other && other.pid !== process.pid) {
|
|
590
|
+
console.warn(`Not reclaiming ${config.workspaceDir ?? DEFAULT_WORKSPACE_DIR}/: pid ${other.pid} owns it now.`);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
writeDevLock(config, { pid: process.pid, port });
|
|
594
|
+
},
|
|
595
|
+
});
|
|
596
|
+
// Rewritten files are not enough: Metro keeps serving the module map it cached before they
|
|
597
|
+
// went missing. Restarting it is the only thing that gets the catalog back, and the repair
|
|
598
|
+
// cap bounds how often this can happen.
|
|
599
|
+
if (repaired && !restartingMetro) {
|
|
600
|
+
console.warn('Restarting Metro so the catalog picks the files back up — the page reloads itself in a few seconds.');
|
|
601
|
+
restartingMetro = true;
|
|
602
|
+
killGroup(child, 'SIGTERM');
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
catch (error) {
|
|
606
|
+
console.warn(`Could not rewrite the workspace: ${error.message}`);
|
|
607
|
+
}
|
|
608
|
+
}, WORKSPACE_WATCH_INTERVAL_MS);
|
|
609
|
+
workspaceWatch.unref?.();
|
|
610
|
+
const bindChild = () => {
|
|
611
|
+
child.on('error', (error) => console.warn(`Could not start Expo: ${error.message}`));
|
|
612
|
+
child.on('exit', (code) => {
|
|
613
|
+
// A Metro we killed on purpose: respawn it on the same port rather than tearing the server
|
|
614
|
+
// down, once the port it held is actually free again.
|
|
615
|
+
if (restartingMetro) {
|
|
616
|
+
// The flag stays set for the whole restart, not just until this handler returns. Clearing
|
|
617
|
+
// it before the wait left a window where the repair timer could ask for another restart,
|
|
618
|
+
// find no live child to kill, and leave the flag set with nothing pending — after which
|
|
619
|
+
// the next genuine Metro crash would be mistaken for a deliberate restart and silently
|
|
620
|
+
// respawn, skipping the shutdown path that releases the watchers and the dev lock.
|
|
621
|
+
// `repairWorkspaceIfMissing`'s caller already checks this flag, so holding it also stops a
|
|
622
|
+
// second repair stacking a restart on top of this one.
|
|
623
|
+
//
|
|
624
|
+
// The port is not free the instant this child exits: `performRestart` waits for the same
|
|
625
|
+
// reason, and Errata 7c records that Metro listens on `[::1]`, so a naive probe reports
|
|
626
|
+
// free while it is still bound. Respawning early lets Expo find its own port taken and
|
|
627
|
+
// either prompt — giving up with "Skipping dev server" in a non-interactive shell — or move
|
|
628
|
+
// to another port, leaving the catalog where the dev lock does not name.
|
|
629
|
+
void waitForPortFree(port, defaultProbePortFree)
|
|
630
|
+
.catch(() => {
|
|
631
|
+
// Still spawn: a catalog that tried and failed to come back is recoverable, one that
|
|
632
|
+
// silently never tries is not. Expo reports the port conflict itself if there is one.
|
|
633
|
+
console.warn(`Port ${port} was still held after the wait; starting Metro anyway.`);
|
|
634
|
+
})
|
|
635
|
+
.then(() => {
|
|
636
|
+
child = spawnMetro();
|
|
637
|
+
bindChild();
|
|
638
|
+
restartingMetro = false;
|
|
639
|
+
});
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
clearInterval(workspaceWatch);
|
|
643
|
+
for (const watcher of watchers)
|
|
644
|
+
watcher.close();
|
|
645
|
+
endpointServer?.close();
|
|
646
|
+
// Released so the next `dev` is not refused by a lock naming a process that has gone.
|
|
647
|
+
// `liveDevLock` already ignores a dead pid, so this is tidiness, not correctness — it keeps a
|
|
648
|
+
// stale file out of a directory a developer may well read.
|
|
649
|
+
clearDevLock(config);
|
|
650
|
+
process.exitCode = code ?? 0;
|
|
651
|
+
});
|
|
652
|
+
};
|
|
653
|
+
bindChild();
|
|
519
654
|
// Node's default SIGTERM/SIGINT/SIGHUP behavior exits this process without touching its
|
|
520
655
|
// children — left alone, a signal sent only to `dev`'s own pid (not its process group) would
|
|
521
656
|
// leave the spawned `expo start` running, still bound to its port (Important finding, Fable
|
package/dist/cli/main.js
CHANGED
|
@@ -34,6 +34,8 @@ Commands:
|
|
|
34
34
|
--json print the machine-readable report instead
|
|
35
35
|
--ci exit 1 when any error was found (never on a warning alone)
|
|
36
36
|
--render also open every page in a headless browser (needs puppeteer)
|
|
37
|
+
--port <n> pin the render server's port (with --render), for when something
|
|
38
|
+
else already holds the range it would pick from
|
|
37
39
|
explain <Page>
|
|
38
40
|
Print why a page's specimens are laid out the way they are
|
|
39
41
|
--heights <first>,<second> check side-by-side placement with these measured heights
|
|
@@ -128,7 +130,23 @@ export async function runDoctorCommand(projectRoot, rest, options = {}) {
|
|
|
128
130
|
// Progress goes to stderr, so `--json` output stays pure JSON.
|
|
129
131
|
if (rest.includes('--render')) {
|
|
130
132
|
const files = result.pageFiles;
|
|
131
|
-
|
|
133
|
+
// `--port <n>`: pin the render server instead of taking the first free port from 5181. Needed
|
|
134
|
+
// when something else already holds that range (another project's catalog, a second `dev`),
|
|
135
|
+
// because the check cannot tell a stranger's server on its port from its own and would report
|
|
136
|
+
// that server's pages as this project's failures.
|
|
137
|
+
const portFlag = rest.indexOf('--port');
|
|
138
|
+
let renderPort;
|
|
139
|
+
if (portFlag !== -1) {
|
|
140
|
+
const raw = rest[portFlag + 1];
|
|
141
|
+
renderPort = Number(raw);
|
|
142
|
+
if (!raw || !Number.isInteger(renderPort) || renderPort < 1 || renderPort > 65535) {
|
|
143
|
+
// Refused rather than quietly falling back to a port it picked itself: a check that
|
|
144
|
+
// ignores the port you gave it would render against whatever else is there and report a
|
|
145
|
+
// stranger's pages as this project's failures.
|
|
146
|
+
return { output: `doctor --render --port needs a port between 1 and 65535, not ${raw ?? '(nothing)'}.`, exitCode: 1 };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const rendered = await (options.renderCheck ?? renderCheck)(config.projectRoot, { files, log: (line) => process.stderr.write(`${line}\n`), ...(renderPort ? { port: renderPort } : {}) });
|
|
132
150
|
result = withExtraIssues(result, rendered, Boolean(config.doctor?.strict));
|
|
133
151
|
}
|
|
134
152
|
// Design §4 "`--ci`: ... no update check." — `--ci` skips this whole block even when
|
package/dist/cli/render.js
CHANGED
|
@@ -121,6 +121,79 @@ export function identicalVariantIssues(page, pairs) {
|
|
|
121
121
|
fix: "Make the difference visible — a wider specimenSize when the cell is too narrow to show it, or the prop the name claims — or drop one of the two examples.",
|
|
122
122
|
}));
|
|
123
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Runs in the browser: finds specimen content that paints outside its cell and cannot be reached.
|
|
126
|
+
*
|
|
127
|
+
* A cell is the width the page gives an example. Content wider than it is either clipped — the
|
|
128
|
+
* reader sees a cut-off component and no indication anything is missing — or drawn over its
|
|
129
|
+
* neighbour. Either way the page is showing something other than the component.
|
|
130
|
+
*
|
|
131
|
+
* Scrollable content is deliberately not reported, and so is content a component clips itself:
|
|
132
|
+
* both are decisions the specimen made, not width the page failed to give it. `PillRow`'s "Many
|
|
133
|
+
* pills (scrollable)" paints 220px outside its cell at 1280 and is correct; a shimmer sweep or a
|
|
134
|
+
* carousel that hides its own overflow is equally deliberate.
|
|
135
|
+
*
|
|
136
|
+
* The containment walk stops at the cell. Looking past it was wrong: `ComparisonGroups` wraps every
|
|
137
|
+
* grouped card in a horizontal `ScrollView`, which react-native-web renders as `overflow-x: auto`,
|
|
138
|
+
* so a walk to the document root found that scroller above every grouped cell and exempted all of
|
|
139
|
+
* them — the rule covered plain list blocks only, while its calibration claimed the whole corpus.
|
|
140
|
+
* What matters is whether the *specimen* contains its own overflow, which is a question about the
|
|
141
|
+
* ancestors inside the cell.
|
|
142
|
+
*
|
|
143
|
+
* Only horizontal crossings are measured. A cell's width is what the page allocates and what
|
|
144
|
+
* `specimenSize` controls; its height grows with content.
|
|
145
|
+
*/
|
|
146
|
+
export const CLIPPED_SPECIMEN_SCRIPT = `(() => {
|
|
147
|
+
const doc = document;
|
|
148
|
+
const CONTAINS = /^(auto|scroll|hidden|clip)$/;
|
|
149
|
+
const out = [];
|
|
150
|
+
for (const card of doc.querySelectorAll('[role=list][aria-label]')) {
|
|
151
|
+
if (!/^[^:]+: .+/.test(card.getAttribute('aria-label') || '')) continue;
|
|
152
|
+
for (const cell of card.querySelectorAll('[role=listitem]')) {
|
|
153
|
+
if (cell.closest('[role=list]') !== card) continue;
|
|
154
|
+
const box = cell.getBoundingClientRect();
|
|
155
|
+
if (box.width < 2) continue;
|
|
156
|
+
let worst = 0;
|
|
157
|
+
let side = 'right';
|
|
158
|
+
for (const el of cell.querySelectorAll('*')) {
|
|
159
|
+
const r = el.getBoundingClientRect();
|
|
160
|
+
if (r.width < 2 || r.height < 2) continue;
|
|
161
|
+
const over = Math.max(box.left - r.left, r.right - box.right);
|
|
162
|
+
if (over <= worst) continue;
|
|
163
|
+
// Invisible content, including anything inside an invisible or offscreen wrapper: an exit
|
|
164
|
+
// animation mid-flight, or a visually-hidden node parked at a large negative offset.
|
|
165
|
+
let hidden = false;
|
|
166
|
+
let contained = false;
|
|
167
|
+
for (let p = el; p && p !== cell; p = p.parentElement) {
|
|
168
|
+
const style = getComputedStyle(p);
|
|
169
|
+
if (style.visibility === 'hidden' || style.opacity === '0' || style.display === 'none') { hidden = true; break; }
|
|
170
|
+
// Anything between the content and the cell that handles its own overflow — scrolling it
|
|
171
|
+
// or clipping it — is the specimen's own decision, not the page denying it width.
|
|
172
|
+
if (p !== el && CONTAINS.test(style.overflowX)) { contained = true; break; }
|
|
173
|
+
}
|
|
174
|
+
if (hidden || contained) continue;
|
|
175
|
+
worst = over;
|
|
176
|
+
side = box.left - r.left >= r.right - box.right ? 'left' : 'right';
|
|
177
|
+
}
|
|
178
|
+
if (worst > 1) {
|
|
179
|
+
const caption = cell.children[0] ? (cell.children[0].textContent || '') : '';
|
|
180
|
+
out.push({ name: caption.trim(), overflow: Math.round(worst), side });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
})()`;
|
|
186
|
+
/** Turns the browser's findings into doctor issues, one per clipped example. */
|
|
187
|
+
export function clippedSpecimenIssues(page, clipped) {
|
|
188
|
+
const ref = { page: page.id, ...(page.file ? { file: page.file } : {}) };
|
|
189
|
+
return clipped.map((c) => ({
|
|
190
|
+
id: 'specimen-clipped',
|
|
191
|
+
severity: 'warning',
|
|
192
|
+
...ref,
|
|
193
|
+
message: `${c.name ? `"${c.name}"` : 'An example'} paints ${c.overflow}px past the ${c.side} of its cell, where it is cut off rather than scrollable.`,
|
|
194
|
+
fix: 'Give the page a wider specimenSize, stack the blocks so the example gets the full row, or drop `itemsFill` if the example is a small instance that should sit centered.',
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
124
197
|
/** The project's own puppeteer, or `undefined` when it isn't installed. */
|
|
125
198
|
export function loadProjectPuppeteer(projectRoot) {
|
|
126
199
|
try {
|
|
@@ -195,14 +268,14 @@ async function waitForServer(port, timeoutMs = DEV_START_TIMEOUT_MS) {
|
|
|
195
268
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
196
269
|
}
|
|
197
270
|
}
|
|
198
|
-
function startDev(projectRoot, onSpawn) {
|
|
271
|
+
function startDev(projectRoot, onSpawn, pinnedPort) {
|
|
199
272
|
const main = path.join(path.dirname(fileURLToPath(import.meta.url)), 'main.js');
|
|
200
273
|
return new Promise((resolve, reject) => {
|
|
201
274
|
// Its own workspace directory and no update endpoint: this server exists to render pages, and
|
|
202
275
|
// every file it generates would otherwise overwrite the ones an already-running `dev` is
|
|
203
276
|
// serving — including that developer's endpoint, pointing their open catalog at a port that
|
|
204
277
|
// dies with this check.
|
|
205
|
-
const child = spawn(process.execPath, [main, 'dev', '--no-update-endpoint', '--workspace-dir', RENDER_WORKSPACE_DIR], {
|
|
278
|
+
const child = spawn(process.execPath, [main, 'dev', '--no-update-endpoint', '--workspace-dir', RENDER_WORKSPACE_DIR, ...(pinnedPort ? ['--port', String(pinnedPort)] : [])], {
|
|
206
279
|
cwd: projectRoot,
|
|
207
280
|
env: { ...process.env, CI: '1', DS_VIEWER_NO_UPDATE_CHECK: '1' },
|
|
208
281
|
detached: process.platform !== 'win32',
|
|
@@ -289,7 +362,7 @@ export async function renderCheck(projectRoot, options = {}) {
|
|
|
289
362
|
}
|
|
290
363
|
({ port } = await (deps.startDev ?? startDev)(projectRoot, (child) => {
|
|
291
364
|
devChild = child;
|
|
292
|
-
}));
|
|
365
|
+
}, options.port));
|
|
293
366
|
const home = await browser.newPage();
|
|
294
367
|
const deadline = Date.now() + FIRST_LOAD_TIMEOUT_MS;
|
|
295
368
|
for (;;) {
|
|
@@ -352,6 +425,17 @@ export async function renderCheck(projectRoot, options = {}) {
|
|
|
352
425
|
}
|
|
353
426
|
if (Array.isArray(identical))
|
|
354
427
|
issues.push(...identicalVariantIssues({ id, file: options.files?.get(id) }, identical));
|
|
428
|
+
// Advisory for the same reason as the pass above, and kept separate so one failing script
|
|
429
|
+
// does not cost the other its findings.
|
|
430
|
+
let clipped;
|
|
431
|
+
try {
|
|
432
|
+
clipped = (await page.evaluate(CLIPPED_SPECIMEN_SCRIPT));
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
clipped = undefined;
|
|
436
|
+
}
|
|
437
|
+
if (Array.isArray(clipped))
|
|
438
|
+
issues.push(...clippedSpecimenIssues({ id, file: options.files?.get(id) }, clipped));
|
|
355
439
|
await page.close();
|
|
356
440
|
}
|
|
357
441
|
catch (error) {
|
package/dist/cli/workspace.js
CHANGED
|
@@ -151,6 +151,18 @@ export function liveDevLock(config, ownPid = process.pid) {
|
|
|
151
151
|
return undefined;
|
|
152
152
|
return lock;
|
|
153
153
|
}
|
|
154
|
+
/** The files `writeWorkspace` always writes: the ones that define the workspace to Expo and Metro.
|
|
155
|
+
* `babel.config.js` is conditional and the update files are written separately, so neither counts
|
|
156
|
+
* as missing here. */
|
|
157
|
+
export const WORKSPACE_DEFINING_FILES = ['package.json', 'app.json', 'tsconfig.json', 'metro.config.js', 'entry.tsx'];
|
|
158
|
+
/** Which of those a workspace has lost. A running `dev` owns these files and nothing else should
|
|
159
|
+
* remove them, so a non-empty answer means something outside this process deleted them — see
|
|
160
|
+
* issue #41, where the cause is still unidentified. Reported rather than diagnosed: the point is
|
|
161
|
+
* that the server can say what happened instead of leaving Expo to blame the project's own
|
|
162
|
+
* `package.json` with `Cannot resolve entry file`. */
|
|
163
|
+
export function missingWorkspaceFiles(config) {
|
|
164
|
+
return WORKSPACE_DEFINING_FILES.filter((name) => !existsSync(workspacePath(config, name)));
|
|
165
|
+
}
|
|
154
166
|
/** Writes the self-contained Expo web workspace at `.ds-viewer/` (design §2 "The preview
|
|
155
167
|
* workspace and `npx ds-viewer dev`"). Rewritten freely on every `dev` run — nothing here is
|
|
156
168
|
* user-owned.
|
|
@@ -158,9 +170,9 @@ export function liveDevLock(config, ownPid = process.pid) {
|
|
|
158
170
|
* `updateEndpoint` is the caller's own, inlined into `entry.tsx`. Without one (a render check's
|
|
159
171
|
* throwaway server, `--no-update-endpoint`) the catalog is served with no endpoint at all, so it
|
|
160
172
|
* makes no calls: the shell polls `/version` from every page, so inlining a *different* server's
|
|
161
|
-
* endpoint here would have every page call it from the wrong origin.
|
|
162
|
-
*
|
|
163
|
-
*
|
|
173
|
+
* endpoint here would have every page call it from the wrong origin. Each `dev` owns its own
|
|
174
|
+
* workspace directory (`dev-lock.json`), so a render check beside an open catalog writes to
|
|
175
|
+
* `.ds-viewer-render/` rather than this one — see the README. */
|
|
164
176
|
export function writeWorkspace(config, updateEndpoint) {
|
|
165
177
|
const workspace = workspacePath(config);
|
|
166
178
|
mkdirSync(workspace, { recursive: true });
|
|
@@ -337,16 +337,49 @@ function heightAtWidth(block, width, available) {
|
|
|
337
337
|
const rows = listGeometry(block.itemCount, width, block.size, block.maxColumns).rows;
|
|
338
338
|
return (block.height / Math.max(1, stackedRows)) * rows;
|
|
339
339
|
}
|
|
340
|
-
/**
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
340
|
+
/** Whether a block still fits on a single row in half the width. Deliberately stricter than "the
|
|
341
|
+
* same number of rows as a full row would give": a column cap can already force a block to wrap
|
|
342
|
+
* at full width, and two wrapped rows staying two wrapped rows is not a reason to halve every
|
|
343
|
+
* specimen on the page. One row is the case where sharing genuinely costs nothing. */
|
|
344
|
+
function fitsOneRow(block, half) {
|
|
345
|
+
if (block.kind !== 'list' || !block.itemCount || !block.size)
|
|
346
|
+
return true;
|
|
347
|
+
return listGeometry(block.itemCount, half, block.size, block.maxColumns).rows <= 1;
|
|
348
|
+
}
|
|
349
|
+
/** Side by side or stacked, for a page with exactly two blocks.
|
|
350
|
+
*
|
|
351
|
+
* **Stacked is the default.** A page reads top to bottom, each block gets the whole row, and every
|
|
352
|
+
* specimen keeps the width its component is actually used at. Side by side halves that width, so
|
|
353
|
+
* it has to earn the space rather than be taken whenever it happens to save height — which is how
|
|
354
|
+
* `Banner` ended up showing five full-width banners in a 462px column.
|
|
355
|
+
*
|
|
356
|
+
* Two things earn it:
|
|
357
|
+
*
|
|
358
|
+
* - **A single tall specimen.** One specimen taller than the column is wide leaves a long empty
|
|
359
|
+
* strip beside it (an open `Dropdown` is 738px tall in a 462px column), and the other block
|
|
360
|
+
* fits there for free.
|
|
361
|
+
* - **Both still fit one row.** Specimens small enough that each block is a single row in half
|
|
362
|
+
* the width lose nothing by sharing it.
|
|
363
|
+
*
|
|
364
|
+
* Both still require each block to keep its minimum width in half a row, and the result to save
|
|
365
|
+
* at least `PLACEMENT_MIN_SAVING` of height — side by side that saves nothing is just a narrower
|
|
366
|
+
* page. */
|
|
344
367
|
export function choosePlacement({ available, gap, first, second }) {
|
|
345
368
|
const half = Math.floor((available - gap) / 2);
|
|
346
369
|
if (half <= 0)
|
|
347
370
|
return 'stacked';
|
|
348
371
|
if ((first.minWidth ?? Infinity) > half || (second.minWidth ?? Infinity) > half)
|
|
349
372
|
return 'stacked';
|
|
373
|
+
// Measured at the width it would have: a specimen is "tall" when it runs longer than the column
|
|
374
|
+
// it would sit in, which is what leaves room beside it.
|
|
375
|
+
// Either block can be the tall one. Checking `first` alone made placement depend on the order the
|
|
376
|
+
// page happens to declare its blocks in, so the same two blocks laid out variants-then-states and
|
|
377
|
+
// states-then-variants got different answers — and the rule says "a single specimen taller than
|
|
378
|
+
// its column", which says nothing about order.
|
|
379
|
+
const singleTallSpecimen = (first.itemCount === 1 && first.height > half) || (second.itemCount === 1 && second.height > half);
|
|
380
|
+
const bothFitOneRow = fitsOneRow(first, half) && fitsOneRow(second, half);
|
|
381
|
+
if (!singleTallSpecimen && !bothFitOneRow)
|
|
382
|
+
return 'stacked';
|
|
350
383
|
const sideHeight = Math.max(heightAtWidth(first, half, available), heightAtWidth(second, half, available));
|
|
351
384
|
const stackedHeight = first.height + gap + second.height;
|
|
352
385
|
return sideHeight <= stackedHeight - PLACEMENT_MIN_SAVING ? 'side' : 'stacked';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { View, Text, StyleSheet } from 'react-native';
|
|
2
|
-
import { TokenRow } from './TokenRow';
|
|
2
|
+
import { TokenRow, TokenRowGroup } from './TokenRow';
|
|
3
3
|
import { CATALOG_TYPE, CATALOG_COLOR, CATALOG_SPACE, CATALOG_RADIUS } from './tokens';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -18,11 +18,11 @@ export function SpacingScaleGallery<TStep extends string | number>({
|
|
|
18
18
|
useNotes: Record<TStep, string>;
|
|
19
19
|
}) {
|
|
20
20
|
return (
|
|
21
|
-
<View style={styles.tokenStack}>
|
|
21
|
+
<TokenRowGroup><View style={styles.tokenStack}>
|
|
22
22
|
{steps.map((step, i) => {
|
|
23
23
|
const px = values[step];
|
|
24
24
|
return (
|
|
25
|
-
<TokenRow key={String(step)} use={useNotes[step]} last={i === steps.length - 1}>
|
|
25
|
+
<TokenRow key={String(step)} use={useNotes[step]} last={i === steps.length - 1} notePlacement="right">
|
|
26
26
|
<View style={styles.row}>
|
|
27
27
|
<Text style={styles.label}>{String(step)}</Text>
|
|
28
28
|
<View style={[styles.bar, { width: Math.max(px, 1) }]} />
|
|
@@ -31,7 +31,7 @@ export function SpacingScaleGallery<TStep extends string | number>({
|
|
|
31
31
|
</TokenRow>
|
|
32
32
|
);
|
|
33
33
|
})}
|
|
34
|
-
</View>
|
|
34
|
+
</View></TokenRowGroup>
|
|
35
35
|
);
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -1,18 +1,45 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { View, Text, StyleSheet } from 'react-native';
|
|
1
|
+
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
|
2
|
+
import { View, Text, StyleSheet, type LayoutChangeEvent } from 'react-native';
|
|
3
3
|
import { CATALOG_TYPE, CATALOG_COLOR, CATALOG_SPACE } from './tokens';
|
|
4
4
|
|
|
5
|
+
type Lane = { width: number; report: (width: number) => void };
|
|
6
|
+
|
|
7
|
+
const LaneContext = createContext<Lane | undefined>(undefined);
|
|
8
|
+
|
|
5
9
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
10
|
+
* Gives a stack of `notePlacement="right"` rows one shared sample column, measured from the widest
|
|
11
|
+
* sample actually in the stack.
|
|
12
|
+
*
|
|
13
|
+
* A fixed column has to be wide enough for the biggest sample a page might hold, which strands
|
|
14
|
+
* every smaller one: a `normal 400` weight sample is ~110px, and in a column sized for an 88px
|
|
15
|
+
* display sample its note sat 700px away from it. Measuring instead means the Font page's column
|
|
16
|
+
* is as wide as a font weight, and the Type scale's is as wide as `display` — each page tight to
|
|
17
|
+
* its own content, with the notes still aligned in a single column to read down.
|
|
11
18
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
|
|
19
|
+
* The width only ever grows, so it settles after the first layout: once the column is the widest
|
|
20
|
+
* natural sample, no sample can report wider than it and nothing re-measures. Rows sit at their
|
|
21
|
+
* natural width for that first frame.
|
|
22
|
+
*/
|
|
23
|
+
export function TokenRowGroup({ children }: { children: React.ReactNode }) {
|
|
24
|
+
const [width, setWidth] = useState(0);
|
|
25
|
+
const report = useCallback((next: number) => {
|
|
26
|
+
setWidth((current) => (next > current ? next : current));
|
|
27
|
+
}, []);
|
|
28
|
+
const value = useMemo(() => ({ width, report }), [width, report]);
|
|
29
|
+
return <LaneContext.Provider value={value}>{children}</LaneContext.Provider>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Wraps one token's rendered example with a grounded "when to use this" note — the shared shape
|
|
34
|
+
* behind every token-gallery row (Spacing, Type Scale, …) in both the starter kit's own token pages
|
|
35
|
+
* and the catalog framework's own `viewer-pages/` pages. The row's own content (a spacing bar, a
|
|
36
|
+
* type sample, …) is freeform `children`; only the value-plus-use-note arrangement is standardized
|
|
37
|
+
* here.
|
|
38
|
+
*
|
|
39
|
+
* Draws a 1px `CATALOG_COLOR.border` bottom divider by default (the same divider PropsTable's rows
|
|
40
|
+
* use) so a stack of TokenRows reads as a list, not a loose pile of paragraphs. Pass `last` on the
|
|
41
|
+
* final row in a stack to drop the divider, matching PropsTable's own `rowLast` convention.
|
|
42
|
+
* Balanced `CATALOG_SPACE.md` padding sits above and below, so a stack of rows breathes evenly
|
|
16
43
|
* instead of only the divider-adjacent gap existing.
|
|
17
44
|
*/
|
|
18
45
|
export function TokenRow({
|
|
@@ -25,15 +52,36 @@ export function TokenRow({
|
|
|
25
52
|
children: React.ReactNode;
|
|
26
53
|
use: string;
|
|
27
54
|
last?: boolean;
|
|
28
|
-
/** `'right'` puts the note beside
|
|
29
|
-
*
|
|
55
|
+
/** `'right'` puts the note beside the sample (font weights, spacing, type sizes) so each token is
|
|
56
|
+
* one line. Inside a `TokenRowGroup` the sample column is measured from the widest sample in the
|
|
57
|
+
* stack; a `TokenRowGroup`'s measured column wins when there is one, and `sampleWidth`
|
|
58
|
+
* (default 280) is the fallback for a row standing on its own. Keeping the default matters for
|
|
59
|
+
* rows outside a group: without it every row sizes to its own sample and the notes stop lining
|
|
60
|
+
* up in a column, which is the thing `notePlacement="right"` exists to do. */
|
|
30
61
|
notePlacement?: 'below' | 'right';
|
|
31
62
|
sampleWidth?: number;
|
|
32
63
|
}) {
|
|
64
|
+
const lane = useContext(LaneContext);
|
|
65
|
+
|
|
33
66
|
if (notePlacement === 'right') {
|
|
67
|
+
const measured = lane && lane.width > 0 ? lane.width : undefined;
|
|
68
|
+
// Inside a group the lane governs, and only the lane: falling back to `sampleWidth` on the
|
|
69
|
+
// first frame would pin the sample to 280 before anything is measured, and the measurement
|
|
70
|
+
// taken through that constraint can never report wider than 280 — capping a lane that exists
|
|
71
|
+
// precisely to grow to the widest sample (a type scale's `display` needs ~470).
|
|
72
|
+
// `sampleWidth` is for a row standing on its own, where there is nothing to measure against.
|
|
73
|
+
const column = lane ? measured : sampleWidth;
|
|
74
|
+
const onLayout = lane
|
|
75
|
+
? (event: LayoutChangeEvent) => lane.report(Math.ceil(event.nativeEvent.layout.width))
|
|
76
|
+
: undefined;
|
|
34
77
|
return (
|
|
35
78
|
<View style={[styles.itemRight, !last && styles.itemDivider]}>
|
|
36
|
-
|
|
79
|
+
{/* While measuring, the inner view sits at its natural width so it reports what the sample
|
|
80
|
+
actually takes. Standing alone there is nothing to report, so it stretches to the column
|
|
81
|
+
instead — otherwise a sample sized as a percentage would collapse to nothing. */}
|
|
82
|
+
<View style={[styles.sample, column !== undefined && { width: column }]}>
|
|
83
|
+
<View style={onLayout ? styles.natural : undefined} onLayout={onLayout}>{children}</View>
|
|
84
|
+
</View>
|
|
37
85
|
{use ? <Text style={[styles.use, styles.useRight]}>{use}</Text> : null}
|
|
38
86
|
</View>
|
|
39
87
|
);
|
|
@@ -49,6 +97,8 @@ export function TokenRow({
|
|
|
49
97
|
const styles = StyleSheet.create({
|
|
50
98
|
item: { gap: CATALOG_SPACE.xs, paddingVertical: CATALOG_SPACE.md },
|
|
51
99
|
itemRight: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.xl, paddingVertical: CATALOG_SPACE.md },
|
|
100
|
+
sample: { flexShrink: 0 },
|
|
101
|
+
natural: { alignSelf: 'flex-start' },
|
|
52
102
|
useRight: { flex: 1 },
|
|
53
103
|
itemDivider: {
|
|
54
104
|
borderBottomWidth: 1,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { View, Text, StyleSheet, type TextStyle } from 'react-native';
|
|
2
|
-
import { TokenRow } from './TokenRow';
|
|
2
|
+
import { TokenRow, TokenRowGroup } from './TokenRow';
|
|
3
3
|
import { CATALOG_TYPE, CATALOG_COLOR, CATALOG_SPACE } from './tokens';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -22,9 +22,9 @@ export function TypeScaleGallery<TStep extends string>({
|
|
|
22
22
|
useNotes: Record<TStep, string>;
|
|
23
23
|
}) {
|
|
24
24
|
return (
|
|
25
|
-
<View style={styles.tokenStack}>
|
|
25
|
+
<TokenRowGroup><View style={styles.tokenStack}>
|
|
26
26
|
{steps.map((step, i) => (
|
|
27
|
-
<TokenRow key={String(step)} use={useNotes[step]} last={i === steps.length - 1}>
|
|
27
|
+
<TokenRow key={String(step)} use={useNotes[step]} last={i === steps.length - 1} notePlacement="right">
|
|
28
28
|
<View style={styles.row}>
|
|
29
29
|
<Text style={[sampleStyle(step), styles.sample]} numberOfLines={1}>
|
|
30
30
|
{String(step)}
|
|
@@ -33,7 +33,7 @@ export function TypeScaleGallery<TStep extends string>({
|
|
|
33
33
|
</View>
|
|
34
34
|
</TokenRow>
|
|
35
35
|
))}
|
|
36
|
-
</View>
|
|
36
|
+
</View></TokenRowGroup>
|
|
37
37
|
);
|
|
38
38
|
}
|
|
39
39
|
|
|
@@ -437,14 +437,48 @@ function heightAtWidth(block: PlacementBlock, width: number, available: number):
|
|
|
437
437
|
return (block.height / Math.max(1, stackedRows)) * rows;
|
|
438
438
|
}
|
|
439
439
|
|
|
440
|
-
/**
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
440
|
+
/** Whether a block still fits on a single row in half the width. Deliberately stricter than "the
|
|
441
|
+
* same number of rows as a full row would give": a column cap can already force a block to wrap
|
|
442
|
+
* at full width, and two wrapped rows staying two wrapped rows is not a reason to halve every
|
|
443
|
+
* specimen on the page. One row is the case where sharing genuinely costs nothing. */
|
|
444
|
+
function fitsOneRow(block: PlacementBlock, half: number): boolean {
|
|
445
|
+
if (block.kind !== 'list' || !block.itemCount || !block.size) return true;
|
|
446
|
+
return listGeometry(block.itemCount, half, block.size, block.maxColumns).rows <= 1;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Side by side or stacked, for a page with exactly two blocks.
|
|
450
|
+
*
|
|
451
|
+
* **Stacked is the default.** A page reads top to bottom, each block gets the whole row, and every
|
|
452
|
+
* specimen keeps the width its component is actually used at. Side by side halves that width, so
|
|
453
|
+
* it has to earn the space rather than be taken whenever it happens to save height — which is how
|
|
454
|
+
* `Banner` ended up showing five full-width banners in a 462px column.
|
|
455
|
+
*
|
|
456
|
+
* Two things earn it:
|
|
457
|
+
*
|
|
458
|
+
* - **A single tall specimen.** One specimen taller than the column is wide leaves a long empty
|
|
459
|
+
* strip beside it (an open `Dropdown` is 738px tall in a 462px column), and the other block
|
|
460
|
+
* fits there for free.
|
|
461
|
+
* - **Both still fit one row.** Specimens small enough that each block is a single row in half
|
|
462
|
+
* the width lose nothing by sharing it.
|
|
463
|
+
*
|
|
464
|
+
* Both still require each block to keep its minimum width in half a row, and the result to save
|
|
465
|
+
* at least `PLACEMENT_MIN_SAVING` of height — side by side that saves nothing is just a narrower
|
|
466
|
+
* page. */
|
|
444
467
|
export function choosePlacement({ available, gap, first, second }: { available: number; gap: number; first: PlacementBlock; second: PlacementBlock }): 'side' | 'stacked' {
|
|
445
468
|
const half = Math.floor((available - gap) / 2);
|
|
446
469
|
if (half <= 0) return 'stacked';
|
|
447
470
|
if ((first.minWidth ?? Infinity) > half || (second.minWidth ?? Infinity) > half) return 'stacked';
|
|
471
|
+
|
|
472
|
+
// Measured at the width it would have: a specimen is "tall" when it runs longer than the column
|
|
473
|
+
// it would sit in, which is what leaves room beside it.
|
|
474
|
+
// Either block can be the tall one. Checking `first` alone made placement depend on the order the
|
|
475
|
+
// page happens to declare its blocks in, so the same two blocks laid out variants-then-states and
|
|
476
|
+
// states-then-variants got different answers — and the rule says "a single specimen taller than
|
|
477
|
+
// its column", which says nothing about order.
|
|
478
|
+
const singleTallSpecimen = (first.itemCount === 1 && first.height > half) || (second.itemCount === 1 && second.height > half);
|
|
479
|
+
const bothFitOneRow = fitsOneRow(first, half) && fitsOneRow(second, half);
|
|
480
|
+
if (!singleTallSpecimen && !bothFitOneRow) return 'stacked';
|
|
481
|
+
|
|
448
482
|
const sideHeight = Math.max(heightAtWidth(first, half, available), heightAtWidth(second, half, available));
|
|
449
483
|
const stackedHeight = first.height + gap + second.height;
|
|
450
484
|
return sideHeight <= stackedHeight - PLACEMENT_MIN_SAVING ? 'side' : 'stacked';
|
package/native/catalog/index.ts
CHANGED
|
@@ -23,7 +23,7 @@ export { ComparisonList } from './ComparisonList';
|
|
|
23
23
|
export { ReferenceDetails } from './ReferenceDetails';
|
|
24
24
|
export { PropsTable } from './PropsTable';
|
|
25
25
|
export { VariantGroup } from './VariantGroup';
|
|
26
|
-
export { TokenRow } from './TokenRow';
|
|
26
|
+
export { TokenRow, TokenRowGroup } from './TokenRow';
|
|
27
27
|
export { MotionSpecimen } from './MotionSpecimen';
|
|
28
28
|
export type { MotionSpecimenProps, MotionSpecimenSpringConfig } from './MotionSpecimen';
|
|
29
29
|
export { SpecimenSurface } from './SpecimenSurface';
|
package/package.json
CHANGED
|
@@ -51,7 +51,10 @@ export default defineCatalogPage({
|
|
|
51
51
|
],
|
|
52
52
|
},
|
|
53
53
|
states: {
|
|
54
|
-
itemsFill
|
|
54
|
+
// No `itemsFill`: these specimens are a small icon trigger with a floating bubble, not a
|
|
55
|
+
// block-level component that wants the row's width. Filling pins the trigger to the cell's
|
|
56
|
+
// left edge (a filled specimen aligns to start, which is right for a Banner and wrong here),
|
|
57
|
+
// and the bubble — which centres on its trigger — then overflows the cell and is clipped.
|
|
55
58
|
items: [
|
|
56
59
|
{
|
|
57
60
|
key: 'bottom',
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { View, Text, StyleSheet, Easing } from 'react-native';
|
|
3
|
-
import { defineCatalogPage,
|
|
3
|
+
import { defineCatalogPage, TokenRow, TokenRowGroup, MotionSpecimen } from '@krapwoo/ds-viewer';
|
|
4
4
|
import {
|
|
5
5
|
DS_MOTION_DURATION,
|
|
6
6
|
DS_MOTION_DURATION_STEPS,
|
|
@@ -26,98 +26,127 @@ const styles = StyleSheet.create({
|
|
|
26
26
|
previewLabel: { minWidth: 64, ...DS_TYPOGRAPHY.labelSm, color: DS_SEMANTIC.text.regular },
|
|
27
27
|
previewValue: { ...DS_TYPOGRAPHY.bodyXs, color: DS_SEMANTIC.text.muted },
|
|
28
28
|
durationBar: { height: 12, borderRadius: DS_RADIUS.small, backgroundColor: DS_SEMANTIC.emphasis.info },
|
|
29
|
-
// Spring config — a wrapping grid of key/value fields, since DS_MOTION_SPRING has 6 fields, too
|
|
30
|
-
// many to lay out as a single previewRow.
|
|
31
|
-
springGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: DS_SPACING[600] },
|
|
32
|
-
springField: { width: 168, gap: DS_SPACING[100] },
|
|
33
29
|
easingValue: { ...DS_TYPOGRAPHY.bodyXs, color: DS_SEMANTIC.text.muted, fontFamily: 'Menlo' },
|
|
30
|
+
|
|
31
|
+
// A spring is a config object, not a step on a scale: six named fields that only mean anything
|
|
32
|
+
// together. Laid out with the fields down the side and each spring token as its own column, so a
|
|
33
|
+
// second spring adds a column instead of repeating all six names, and `damping` reads across
|
|
34
|
+
// tokens on one line. The field vocabulary is fixed; the number of tokens is what grows.
|
|
35
|
+
springRow: {
|
|
36
|
+
flexDirection: 'row',
|
|
37
|
+
alignItems: 'flex-start',
|
|
38
|
+
gap: DS_SPACING[600],
|
|
39
|
+
paddingVertical: DS_SPACING[400],
|
|
40
|
+
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
41
|
+
borderColor: DS_SEMANTIC.border.light,
|
|
42
|
+
},
|
|
43
|
+
springRowLast: { borderBottomWidth: 0 },
|
|
44
|
+
// Wide enough that `restDisplacementThreshold` sits on one line instead of breaking mid-word,
|
|
45
|
+
// which is what the previous fixed 168px field did to it.
|
|
46
|
+
springField: { width: 210, ...DS_TYPOGRAPHY.labelSm, color: DS_SEMANTIC.text.regular },
|
|
47
|
+
springCell: { flex: 1, minWidth: 130 },
|
|
48
|
+
springValue: { ...DS_TYPOGRAPHY.bodyXs, color: DS_SEMANTIC.text.regular },
|
|
49
|
+
springNote: { ...DS_TYPOGRAPHY.bodyXs, color: DS_SEMANTIC.text.muted },
|
|
34
50
|
});
|
|
35
51
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
52
|
+
/** Every spring token on the page. One today — the shape is what lets a second arrive without
|
|
53
|
+
* redesigning the section. */
|
|
54
|
+
const SPRINGS: Record<string, typeof DS_MOTION_SPRING> = { snappy: DS_MOTION_SPRING };
|
|
55
|
+
const SPRING_NOTES: Record<string, string> = { snappy: DS_MOTION_SPRING_USE };
|
|
56
|
+
const SPRING_FIELDS = Object.keys(DS_MOTION_SPRING) as (keyof typeof DS_MOTION_SPRING)[];
|
|
57
|
+
|
|
58
|
+
function SpringTable() {
|
|
59
|
+
const names = Object.keys(SPRINGS);
|
|
60
|
+
const cells = (render: (name: string) => React.ReactNode) =>
|
|
61
|
+
names.map((name) => (
|
|
62
|
+
<View key={name} style={styles.springCell}>
|
|
63
|
+
{render(name)}
|
|
64
|
+
</View>
|
|
65
|
+
));
|
|
39
66
|
return (
|
|
40
|
-
<
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
<View style={styles.previewRow}>
|
|
47
|
-
<Text style={styles.previewLabel}>{step}</Text>
|
|
48
|
-
<View style={[styles.durationBar, { width: (DS_MOTION_DURATION[step] / maxDuration) * 120 }]} />
|
|
49
|
-
<Text style={styles.previewValue}>{DS_MOTION_DURATION[step]}ms</Text>
|
|
50
|
-
</View>
|
|
51
|
-
<MotionSpecimen
|
|
52
|
-
kind="timing"
|
|
53
|
-
duration={DS_MOTION_DURATION[step]}
|
|
54
|
-
easing={Easing.bezier(...DS_MOTION_EASING.standard)}
|
|
55
|
-
label={`${DS_MOTION_DURATION[step]}ms · standard easing`}
|
|
56
|
-
/>
|
|
57
|
-
</View>
|
|
58
|
-
</TokenRow>
|
|
59
|
-
))}
|
|
67
|
+
<View>
|
|
68
|
+
{/* A single spring needs no column header — the section title is already its name. */}
|
|
69
|
+
{names.length > 1 ? (
|
|
70
|
+
<View style={styles.springRow}>
|
|
71
|
+
<Text style={styles.springField}>field</Text>
|
|
72
|
+
{cells((name) => <Text style={styles.previewLabel}>{name}</Text>)}
|
|
60
73
|
</View>
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
<View style={styles.
|
|
64
|
-
{
|
|
65
|
-
|
|
66
|
-
<View style={styles.rowContent}>
|
|
67
|
-
<View style={styles.previewRow}>
|
|
68
|
-
<Text style={styles.previewLabel}>{step}</Text>
|
|
69
|
-
<View style={[styles.durationBar, { width: (DS_MOTION_LOOP_DURATION[step] / maxLoopDuration) * 120 }]} />
|
|
70
|
-
<Text style={styles.previewValue}>{DS_MOTION_LOOP_DURATION[step]}ms</Text>
|
|
71
|
-
</View>
|
|
72
|
-
<MotionSpecimen
|
|
73
|
-
kind="timing"
|
|
74
|
-
duration={DS_MOTION_LOOP_DURATION[step]}
|
|
75
|
-
easing={Easing.bezier(...DS_MOTION_EASING.standard)}
|
|
76
|
-
label={`${DS_MOTION_LOOP_DURATION[step]}ms · one cycle`}
|
|
77
|
-
/>
|
|
78
|
-
</View>
|
|
79
|
-
</TokenRow>
|
|
80
|
-
))}
|
|
74
|
+
) : null}
|
|
75
|
+
{SPRING_FIELDS.map((field) => (
|
|
76
|
+
<View key={String(field)} style={styles.springRow}>
|
|
77
|
+
<Text style={styles.springField}>{String(field)}</Text>
|
|
78
|
+
{cells((name) => <Text style={styles.springValue}>{String(SPRINGS[name][field])}</Text>)}
|
|
81
79
|
</View>
|
|
82
|
-
|
|
83
|
-
<
|
|
84
|
-
<
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
80
|
+
))}
|
|
81
|
+
<View style={styles.springRow}>
|
|
82
|
+
<Text style={styles.springField}>preview</Text>
|
|
83
|
+
{cells((name) => (
|
|
84
|
+
<MotionSpecimen kind="spring" spring={SPRINGS[name]} label="Animated.spring(value, DS_MOTION_SPRING)" />
|
|
85
|
+
))}
|
|
86
|
+
</View>
|
|
87
|
+
<View style={[styles.springRow, styles.springRowLast]}>
|
|
88
|
+
<Text style={styles.springField}>when to use</Text>
|
|
89
|
+
{cells((name) => <Text style={styles.springNote}>{SPRING_NOTES[name]}</Text>)}
|
|
90
|
+
</View>
|
|
91
|
+
</View>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A millisecond scale: a bar for the relative length, then the real animation at that duration. */
|
|
96
|
+
function DurationRows({ steps, values, notes, label }: {
|
|
97
|
+
steps: readonly string[];
|
|
98
|
+
values: Record<string, number>;
|
|
99
|
+
notes: Record<string, string>;
|
|
100
|
+
label: (ms: number) => string;
|
|
101
|
+
}) {
|
|
102
|
+
const max = Math.max(...steps.map((step) => values[step]));
|
|
103
|
+
return (
|
|
104
|
+
<TokenRowGroup>
|
|
105
|
+
<View style={styles.tokenStack}>
|
|
106
|
+
{steps.map((step, i) => (
|
|
107
|
+
<TokenRow key={step} use={notes[step]} last={i === steps.length - 1} notePlacement="right">
|
|
108
|
+
<View style={styles.rowContent}>
|
|
109
|
+
<View style={styles.previewRow}>
|
|
110
|
+
<Text style={styles.previewLabel}>{step}</Text>
|
|
111
|
+
<View style={[styles.durationBar, { width: (values[step] / max) * 120 }]} />
|
|
112
|
+
<Text style={styles.previewValue}>{values[step]}ms</Text>
|
|
98
113
|
</View>
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
114
|
+
<MotionSpecimen
|
|
115
|
+
kind="timing"
|
|
116
|
+
duration={values[step]}
|
|
117
|
+
easing={Easing.bezier(...DS_MOTION_EASING.standard)}
|
|
118
|
+
label={label(values[step])}
|
|
119
|
+
/>
|
|
120
|
+
</View>
|
|
121
|
+
</TokenRow>
|
|
122
|
+
))}
|
|
123
|
+
</View>
|
|
124
|
+
</TokenRowGroup>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function EasingRows() {
|
|
129
|
+
return (
|
|
130
|
+
<TokenRowGroup>
|
|
131
|
+
<View style={styles.tokenStack}>
|
|
132
|
+
{DS_MOTION_EASING_STEPS.map((step, i) => (
|
|
133
|
+
<TokenRow key={step} use={DS_MOTION_EASING_USE[step]} last={i === DS_MOTION_EASING_STEPS.length - 1} notePlacement="right">
|
|
106
134
|
<View style={styles.rowContent}>
|
|
107
|
-
<View style={styles.
|
|
108
|
-
{
|
|
109
|
-
|
|
110
|
-
<Text style={styles.previewLabel}>{key}</Text>
|
|
111
|
-
<Text style={styles.previewValue}>{String(value)}</Text>
|
|
112
|
-
</View>
|
|
113
|
-
))}
|
|
135
|
+
<View style={styles.previewRow}>
|
|
136
|
+
<Text style={styles.previewLabel}>{step}</Text>
|
|
137
|
+
<Text style={styles.easingValue}>cubic-bezier({DS_MOTION_EASING[step].join(', ')})</Text>
|
|
114
138
|
</View>
|
|
115
|
-
<MotionSpecimen
|
|
139
|
+
<MotionSpecimen
|
|
140
|
+
kind="timing"
|
|
141
|
+
duration={DS_MOTION_DURATION.base}
|
|
142
|
+
easing={Easing.bezier(...DS_MOTION_EASING[step])}
|
|
143
|
+
label={`${DS_MOTION_DURATION.base}ms · ${step}`}
|
|
144
|
+
/>
|
|
116
145
|
</View>
|
|
117
146
|
</TokenRow>
|
|
118
|
-
|
|
119
|
-
</
|
|
120
|
-
</
|
|
147
|
+
))}
|
|
148
|
+
</View>
|
|
149
|
+
</TokenRowGroup>
|
|
121
150
|
);
|
|
122
151
|
}
|
|
123
152
|
|
|
@@ -125,5 +154,44 @@ export default defineCatalogPage({
|
|
|
125
154
|
group: 'Tokens',
|
|
126
155
|
description: 'Duration (one-shot transitions), loop duration (continuous, indeterminate loops like Loading/Shimmer), easing, and spring tokens for transitions/animations. Easings are cubic-bezier tuples, not Easing objects, so the token file stays free of any react-native import. The spring config is grounded in the metro-native app this template was extracted from, where the same values drive its BottomSheet\'s snap-point transitions.',
|
|
127
156
|
tokenGallery: true,
|
|
128
|
-
|
|
157
|
+
// A card per kind of token. Durations are a scale in milliseconds, easings are bezier curves and
|
|
158
|
+
// a spring is a config object: four different things that read as one long box when they share a
|
|
159
|
+
// single card. One column, because every section's rows want the full width.
|
|
160
|
+
tokenColumns: 1,
|
|
161
|
+
tokenSections: [
|
|
162
|
+
{
|
|
163
|
+
title: 'Duration',
|
|
164
|
+
desc: 'one-shot transitions — reach for base (240ms) first',
|
|
165
|
+
render: () => (
|
|
166
|
+
<DurationRows
|
|
167
|
+
steps={DS_MOTION_DURATION_STEPS}
|
|
168
|
+
values={DS_MOTION_DURATION}
|
|
169
|
+
notes={DS_MOTION_DURATION_USE}
|
|
170
|
+
label={(ms) => `${ms}ms · standard easing`}
|
|
171
|
+
/>
|
|
172
|
+
),
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
title: 'Loop duration',
|
|
176
|
+
desc: 'continuous, indeterminate loops — a spinner or a shimmer pulse, not a one-shot transition',
|
|
177
|
+
render: () => (
|
|
178
|
+
<DurationRows
|
|
179
|
+
steps={DS_MOTION_LOOP_DURATION_STEPS}
|
|
180
|
+
values={DS_MOTION_LOOP_DURATION}
|
|
181
|
+
notes={DS_MOTION_LOOP_DURATION_USE}
|
|
182
|
+
label={(ms) => `${ms}ms · one cycle`}
|
|
183
|
+
/>
|
|
184
|
+
),
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
title: 'Easing',
|
|
188
|
+
desc: 'cubic-bezier curves — convert with Easing.bezier(...DS_MOTION_EASING.x)',
|
|
189
|
+
render: () => <EasingRows />,
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
title: 'Spring',
|
|
193
|
+
desc: 'for Animated.spring(value, DS_MOTION_SPRING) — snap-point transitions, not fixed-duration timing',
|
|
194
|
+
render: () => <SpringTable />,
|
|
195
|
+
},
|
|
196
|
+
],
|
|
129
197
|
});
|
|
@@ -1,29 +1,33 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { View,
|
|
3
|
-
import { defineCatalogPage,
|
|
4
|
-
import { DS_RADIUS, DS_RADIUS_STEPS, DS_RADIUS_USE, DS_SEMANTIC
|
|
2
|
+
import { View, StyleSheet } from 'react-native';
|
|
3
|
+
import { defineCatalogPage, TokenGrid, TokenTile } from '@krapwoo/ds-viewer';
|
|
4
|
+
import { DS_RADIUS, DS_RADIUS_STEPS, DS_RADIUS_USE, DS_SEMANTIC } from '../tokens';
|
|
5
5
|
|
|
6
6
|
const styles = StyleSheet.create({
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
// Square, so the corner is read as a corner rather than as the end of a bar. Large enough that
|
|
8
|
+
// 4px and 8px are still telling apart at a glance.
|
|
9
|
+
radiusBox: {
|
|
10
|
+
width: 64,
|
|
11
|
+
height: 64,
|
|
12
|
+
backgroundColor: DS_SEMANTIC.emphasis.info,
|
|
13
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
14
|
+
borderColor: DS_SEMANTIC.border.light,
|
|
15
|
+
},
|
|
12
16
|
});
|
|
13
17
|
|
|
14
18
|
function RadiusGallery() {
|
|
15
19
|
return (
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
</
|
|
20
|
+
// Tiles, not rows: a radius preview is small and self-contained, so the steps belong side by
|
|
21
|
+
// side where `xs` can be compared against `large` directly — which is the only way to read a
|
|
22
|
+
// scale. A row each would put them 100px apart vertically and leave the right half of every
|
|
23
|
+
// row empty.
|
|
24
|
+
<TokenGrid minTileWidth={150}>
|
|
25
|
+
{DS_RADIUS_STEPS.map((step) => (
|
|
26
|
+
<TokenTile key={step} name={step} value={`${DS_RADIUS[step]}px`} note={DS_RADIUS_USE[step]}>
|
|
27
|
+
<View style={[styles.radiusBox, { borderRadius: DS_RADIUS[step] }]} />
|
|
28
|
+
</TokenTile>
|
|
25
29
|
))}
|
|
26
|
-
</
|
|
30
|
+
</TokenGrid>
|
|
27
31
|
);
|
|
28
32
|
}
|
|
29
33
|
|
|
@@ -1,28 +1,40 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { View,
|
|
3
|
-
import { defineCatalogPage,
|
|
4
|
-
import { DS_RADIUS, DS_SEMANTIC, DS_SHADOW, DS_SHADOW_USE, DS_SPACING,
|
|
2
|
+
import { View, StyleSheet } from 'react-native';
|
|
3
|
+
import { defineCatalogPage, TokenGrid, TokenTile } from '@krapwoo/ds-viewer';
|
|
4
|
+
import { DS_RADIUS, DS_SEMANTIC, DS_SHADOW, DS_SHADOW_USE, DS_SPACING, type ShadowToken } from '../tokens';
|
|
5
5
|
|
|
6
6
|
const styles = StyleSheet.create({
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
// A shadow is cast *onto* something. On the catalog's own white card the elevated surface is
|
|
8
|
+
// white on white and the token reads as an empty space, so each tile gives it a tinted backdrop
|
|
9
|
+
// to fall on — the same reason a product screen sits a card on a page colour, not on paper.
|
|
10
|
+
stage: {
|
|
11
|
+
backgroundColor: DS_SEMANTIC.surface.main,
|
|
12
|
+
borderRadius: DS_RADIUS.small,
|
|
13
|
+
paddingVertical: DS_SPACING[800],
|
|
14
|
+
alignItems: 'center',
|
|
15
|
+
},
|
|
16
|
+
surface: {
|
|
17
|
+
width: 76,
|
|
18
|
+
height: 48,
|
|
19
|
+
borderRadius: DS_RADIUS.medium,
|
|
20
|
+
backgroundColor: DS_SEMANTIC.surface.white,
|
|
21
|
+
},
|
|
11
22
|
});
|
|
12
23
|
|
|
13
24
|
function ShadowGallery() {
|
|
14
25
|
const steps = Object.keys(DS_SHADOW) as ShadowToken[];
|
|
15
26
|
return (
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
27
|
+
// Tiles: an elevation preview is small and self-contained, and seeing `resting` beside
|
|
28
|
+
// `bottomSheet` is the only way to judge how much stronger one lift is than another.
|
|
29
|
+
<TokenGrid minTileWidth={170}>
|
|
30
|
+
{steps.map((step) => (
|
|
31
|
+
<TokenTile key={step} name={step} note={DS_SHADOW_USE[step]}>
|
|
32
|
+
<View style={styles.stage}>
|
|
33
|
+
<View style={[styles.surface, DS_SHADOW[step]]} />
|
|
22
34
|
</View>
|
|
23
|
-
</
|
|
35
|
+
</TokenTile>
|
|
24
36
|
))}
|
|
25
|
-
</
|
|
37
|
+
</TokenGrid>
|
|
26
38
|
);
|
|
27
39
|
}
|
|
28
40
|
|