@krapwoo/ds-viewer 0.6.11 → 0.8.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 +62 -5
- package/dist/cli/dev.js +61 -14
- package/dist/cli/explain.js +13 -8
- package/dist/cli/layout.js +267 -0
- package/dist/cli/main.js +145 -2
- package/dist/cli/render.js +87 -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/dist/cli/main.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
4
5
|
import { resolveConfig } from "./config.js";
|
|
5
6
|
import { initExistingProject, initNewProject, promptInitMode } from "./init.js";
|
|
@@ -9,6 +10,7 @@ import { formatHuman, runDoctor, toDoctorJson, withExtraIssues } from "./doctor.
|
|
|
9
10
|
import { renderCheck } from "./render.js";
|
|
10
11
|
import { RENDER_WORKSPACE_DIR } from "./types.js";
|
|
11
12
|
import { explainPage, formatExplain, parseHeights } from "./explain.js";
|
|
13
|
+
import { comparableWidths, diffLayouts, formatLayoutDiff, layoutSnapshot } from "./layout.js";
|
|
12
14
|
import { readOwnVersion } from "./packageVersion.js";
|
|
13
15
|
import { checkForUpdate, defaultPreferencesDir, readPersonalAutoCheck, resolveAutoCheck } from "./updateCheck.js";
|
|
14
16
|
import { runKitDiffCommand } from "./kitDiff.js";
|
|
@@ -34,10 +36,17 @@ Commands:
|
|
|
34
36
|
--json print the machine-readable report instead
|
|
35
37
|
--ci exit 1 when any error was found (never on a warning alone)
|
|
36
38
|
--render also open every page in a headless browser (needs puppeteer)
|
|
39
|
+
--port <n> pin the render server's port (with --render), for when something
|
|
40
|
+
else already holds the range it would pick from
|
|
37
41
|
explain <Page>
|
|
38
42
|
Print why a page's specimens are laid out the way they are
|
|
39
43
|
--heights <first>,<second> check side-by-side placement with these measured heights
|
|
40
44
|
--json print the machine-readable report instead
|
|
45
|
+
--all every page's geometry as JSON, for layout-diff
|
|
46
|
+
layout-diff <before.json> <after.json>
|
|
47
|
+
What changed about every page's layout between two explain --all snapshots, so a
|
|
48
|
+
viewer upgrade that reshapes your catalog is visible rather than silent
|
|
49
|
+
--json print the changes as JSON
|
|
41
50
|
kit diff <Component>
|
|
42
51
|
Show the user's kit file(s) for <Component> against the installed kit's copy
|
|
43
52
|
migrate --from <version>
|
|
@@ -128,7 +137,23 @@ export async function runDoctorCommand(projectRoot, rest, options = {}) {
|
|
|
128
137
|
// Progress goes to stderr, so `--json` output stays pure JSON.
|
|
129
138
|
if (rest.includes('--render')) {
|
|
130
139
|
const files = result.pageFiles;
|
|
131
|
-
|
|
140
|
+
// `--port <n>`: pin the render server instead of taking the first free port from 5181. Needed
|
|
141
|
+
// when something else already holds that range (another project's catalog, a second `dev`),
|
|
142
|
+
// because the check cannot tell a stranger's server on its port from its own and would report
|
|
143
|
+
// that server's pages as this project's failures.
|
|
144
|
+
const portFlag = rest.indexOf('--port');
|
|
145
|
+
let renderPort;
|
|
146
|
+
if (portFlag !== -1) {
|
|
147
|
+
const raw = rest[portFlag + 1];
|
|
148
|
+
renderPort = Number(raw);
|
|
149
|
+
if (!raw || !Number.isInteger(renderPort) || renderPort < 1 || renderPort > 65535) {
|
|
150
|
+
// Refused rather than quietly falling back to a port it picked itself: a check that
|
|
151
|
+
// ignores the port you gave it would render against whatever else is there and report a
|
|
152
|
+
// stranger's pages as this project's failures.
|
|
153
|
+
return { output: `doctor --render --port needs a port between 1 and 65535, not ${raw ?? '(nothing)'}.`, exitCode: 1 };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const rendered = await (options.renderCheck ?? renderCheck)(config.projectRoot, { files, log: (line) => process.stderr.write(`${line}\n`), ...(renderPort ? { port: renderPort } : {}) });
|
|
132
157
|
result = withExtraIssues(result, rendered, Boolean(config.doctor?.strict));
|
|
133
158
|
}
|
|
134
159
|
// Design §4 "`--ci`: ... no update check." — `--ci` skips this whole block even when
|
|
@@ -153,8 +178,21 @@ export async function runDoctorCommand(projectRoot, rest, options = {}) {
|
|
|
153
178
|
}
|
|
154
179
|
/** Same "testable without spawning" shape as `runDoctorCommand` above. */
|
|
155
180
|
export function runExplainCommand(projectRoot, pageId, rest) {
|
|
181
|
+
// `--all`: every page's geometry as one JSON document, for `layout-diff` to compare across
|
|
182
|
+
// versions. It takes no page id, and it is JSON only — the whole point is a machine-readable
|
|
183
|
+
// artifact, and a prose dump of 50 pages would be read by nobody.
|
|
184
|
+
if (pageId === '--all' || rest.includes('--all')) {
|
|
185
|
+
const config = resolveConfig(projectRoot);
|
|
186
|
+
const version = readOwnVersion(path.dirname(fileURLToPath(import.meta.url)));
|
|
187
|
+
try {
|
|
188
|
+
return { output: JSON.stringify(layoutSnapshot(config, version), null, 2), exitCode: 0 };
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
return { output: error.message, exitCode: 1 };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
156
194
|
if (!pageId)
|
|
157
|
-
return { output: 'Usage: ds-viewer explain <Page> [--heights <first>,<second>] [--json]', exitCode: 1 };
|
|
195
|
+
return { output: 'Usage: ds-viewer explain <Page> [--heights <first>,<second>] [--json], or explain --all for every page as JSON', exitCode: 1 };
|
|
158
196
|
const heightsIndex = rest.indexOf('--heights');
|
|
159
197
|
const heightsRaw = heightsIndex !== -1 ? rest[heightsIndex + 1] : undefined;
|
|
160
198
|
const heights = heightsRaw ? parseHeights(heightsRaw) : undefined;
|
|
@@ -167,6 +205,104 @@ export function runExplainCommand(projectRoot, pageId, rest) {
|
|
|
167
205
|
return { output: `No page named "${pageId}" was found.`, exitCode: 1 };
|
|
168
206
|
return { output: rest.includes('--json') ? JSON.stringify(result, null, 2) : formatExplain(result), exitCode: 0 };
|
|
169
207
|
}
|
|
208
|
+
function isFiniteNumber(value) {
|
|
209
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
210
|
+
}
|
|
211
|
+
function isGeometry(value) {
|
|
212
|
+
if (!value || typeof value !== 'object')
|
|
213
|
+
return false;
|
|
214
|
+
const geometry = value;
|
|
215
|
+
return Number.isInteger(geometry.columns) && geometry.columns >= 0
|
|
216
|
+
&& Number.isInteger(geometry.rows) && geometry.rows >= 0
|
|
217
|
+
&& Number.isInteger(geometry.fillers) && geometry.fillers >= 0;
|
|
218
|
+
}
|
|
219
|
+
function isLayoutSnapshot(value) {
|
|
220
|
+
if (!value || typeof value !== 'object')
|
|
221
|
+
return false;
|
|
222
|
+
const snapshot = value;
|
|
223
|
+
if (typeof snapshot.version !== 'string' || !isFiniteNumber(snapshot.width) || snapshot.width <= 0 || !Array.isArray(snapshot.pages))
|
|
224
|
+
return false;
|
|
225
|
+
const ids = new Set();
|
|
226
|
+
for (const entry of snapshot.pages) {
|
|
227
|
+
if (!entry || typeof entry !== 'object')
|
|
228
|
+
return false;
|
|
229
|
+
const page = entry;
|
|
230
|
+
if (typeof page.page !== 'string' || page.page.length === 0 || ids.has(page.page) || !Array.isArray(page.blocks))
|
|
231
|
+
return false;
|
|
232
|
+
ids.add(page.page);
|
|
233
|
+
if (page.sideEligible !== undefined && typeof page.sideEligible !== 'boolean')
|
|
234
|
+
return false;
|
|
235
|
+
if (page.notCheckable !== undefined && page.notCheckable !== true)
|
|
236
|
+
return false;
|
|
237
|
+
for (const entryBlock of page.blocks) {
|
|
238
|
+
if (!entryBlock || typeof entryBlock !== 'object')
|
|
239
|
+
return false;
|
|
240
|
+
const block = entryBlock;
|
|
241
|
+
if (typeof block.title !== 'string' || !['grid', 'grouped', 'list', 'preview', 'tokenSections', 'empty'].includes(String(block.kind)))
|
|
242
|
+
return false;
|
|
243
|
+
if (!(block.minWidth === null || isFiniteNumber(block.minWidth)))
|
|
244
|
+
return false;
|
|
245
|
+
if (block.items !== undefined && (!Number.isInteger(block.items) || block.items < 0))
|
|
246
|
+
return false;
|
|
247
|
+
if (block.size !== undefined && typeof block.size !== 'string')
|
|
248
|
+
return false;
|
|
249
|
+
if (block.full !== undefined && !isGeometry(block.full))
|
|
250
|
+
return false;
|
|
251
|
+
if (block.half !== undefined && !isGeometry(block.half))
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
/** `layout-diff <before.json> <after.json>`: what a version changed about every page's geometry. */
|
|
258
|
+
export function runLayoutDiffCommand(args, deps = {}) {
|
|
259
|
+
const [beforePath, afterPath] = args.filter((a) => !a.startsWith('--'));
|
|
260
|
+
if (!beforePath || !afterPath) {
|
|
261
|
+
return { output: 'Usage: ds-viewer layout-diff <before.json> <after.json>\n\nBoth are written by `ds-viewer explain --all`.', exitCode: 1 };
|
|
262
|
+
}
|
|
263
|
+
const read = deps.readFile ?? ((file) => readFileSync(file, 'utf8'));
|
|
264
|
+
const load = (file) => {
|
|
265
|
+
let raw;
|
|
266
|
+
try {
|
|
267
|
+
raw = read(file);
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return `Could not read ${file}.`;
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
const parsed = JSON.parse(raw);
|
|
274
|
+
// Refuse plausible-but-incomplete JSON before the diff touches it. Treating an absent block
|
|
275
|
+
// array as empty reports invented removals; dereferencing it reports a raw TypeError instead
|
|
276
|
+
// of telling the user they selected the wrong file.
|
|
277
|
+
if (!isLayoutSnapshot(parsed)) {
|
|
278
|
+
return `${file} is not a layout snapshot. Write one with \`ds-viewer explain --all\`.`;
|
|
279
|
+
}
|
|
280
|
+
return parsed;
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return `${file} is not valid JSON.`;
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
const before = load(beforePath);
|
|
287
|
+
if (typeof before === 'string')
|
|
288
|
+
return { output: before, exitCode: 1 };
|
|
289
|
+
const after = load(afterPath);
|
|
290
|
+
if (typeof after === 'string')
|
|
291
|
+
return { output: after, exitCode: 1 };
|
|
292
|
+
// Before diffing, not only before printing: the guard used to live in `formatLayoutDiff`, so
|
|
293
|
+
// `--json` — the path most likely to be automated in CI — returned a confident list of changes
|
|
294
|
+
// computed across incomparable widths.
|
|
295
|
+
if (!comparableWidths(before, after)) {
|
|
296
|
+
return {
|
|
297
|
+
output: `These snapshots were taken at different widths (${before.width}px and ${after.width}px), so their geometry is not comparable. The two viewer versions use different catalog content widths; compare releases whose snapshot widths match.`,
|
|
298
|
+
exitCode: 1,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
const changes = diffLayouts(before, after);
|
|
302
|
+
if (args.includes('--json'))
|
|
303
|
+
return { output: JSON.stringify(changes, null, 2), exitCode: 0 };
|
|
304
|
+
return { output: formatLayoutDiff(before, after, changes), exitCode: 0 };
|
|
305
|
+
}
|
|
170
306
|
async function main() {
|
|
171
307
|
const [command, ...rest] = process.argv.slice(2);
|
|
172
308
|
const projectRoot = process.cwd();
|
|
@@ -201,6 +337,13 @@ async function main() {
|
|
|
201
337
|
process.exitCode = exitCode;
|
|
202
338
|
return;
|
|
203
339
|
}
|
|
340
|
+
if (command === 'layout-diff') {
|
|
341
|
+
const { output, exitCode } = runLayoutDiffCommand(rest);
|
|
342
|
+
console.log(output);
|
|
343
|
+
if (exitCode)
|
|
344
|
+
process.exitCode = exitCode;
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
204
347
|
if (command === 'explain') {
|
|
205
348
|
const [pageId, ...explainRest] = rest;
|
|
206
349
|
const { output, exitCode } = runExplainCommand(projectRoot, pageId, explainRest);
|
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) {
|
|
@@ -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
|
+
export 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
|
+
export 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',
|