@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/README.md
CHANGED
|
@@ -63,8 +63,43 @@ npm run ds-viewer dev # opens the catalog in a browser, re-syncing on every f
|
|
|
63
63
|
npm run ds-viewer sync # regenerates component/token/page data without starting a server
|
|
64
64
|
npm run ds-viewer doctor # checks every page for drift and coverage gaps (--json, --ci)
|
|
65
65
|
npm run ds-viewer explain <Page> # prints why a page's specimens are laid out the way they are
|
|
66
|
+
npm run ds-viewer explain --all # every page's geometry as JSON, to diff across versions
|
|
67
|
+
npm run ds-viewer layout-diff before.json after.json # what an upgrade changed about your layout
|
|
66
68
|
```
|
|
67
69
|
|
|
70
|
+
### Seeing what an upgrade changes
|
|
71
|
+
|
|
72
|
+
Layout rules live in this package and reach your catalog on `update`, while your page files never
|
|
73
|
+
change. So a new version can reshape a page with nothing in your repository to explain it. Snapshot
|
|
74
|
+
before and after, and the change becomes something you can read:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
npx ds-viewer explain --all > before.json # on the version you have
|
|
78
|
+
# ...upgrade...
|
|
79
|
+
npx ds-viewer explain --all > after.json
|
|
80
|
+
npx ds-viewer layout-diff before.json after.json
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
0.6.11 → 0.7.0, measured at 952px
|
|
85
|
+
|
|
86
|
+
Layout changes on 3 pages: Banner, EmptyState, Shimmer.
|
|
87
|
+
|
|
88
|
+
Banner
|
|
89
|
+
its two blocks can no longer share a row
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The snapshot holds what can be decided without a browser: each block's kind and minimum width;
|
|
93
|
+
list blocks add specimen size, item count, and column×row geometry at full and half width, while
|
|
94
|
+
token-section blocks add section count and their responsive column×row geometry. For a two-block
|
|
95
|
+
page, `sideEligible` records every gate known before measurement: both blocks must keep their minimum
|
|
96
|
+
width and both must still fit one row at half width. Eligibility is not the outcome — placement also
|
|
97
|
+
weighs a single tall specimen and the height actually saved, and neither is knowable without measuring
|
|
98
|
+
a real render.
|
|
99
|
+
|
|
100
|
+
Snapshots taken at different widths refuse to compare, since geometry at 952px and 1112px
|
|
101
|
+
legitimately differs and diffing them would report changes that never happened.
|
|
102
|
+
|
|
68
103
|
Only one `dev` serves a project at a time. A second one refuses, naming the port the first is on,
|
|
69
104
|
because both would generate into the same `.ds-viewer/` and overwrite each other's files — the
|
|
70
105
|
loser then serves a workspace that no longer describes it, which Expo reports as an unresolvable
|
|
@@ -81,13 +116,19 @@ file that is fine and that you never touched:
|
|
|
81
116
|
Something outside this server deleted package.json, app.json, tsconfig.json, metro.config.js,
|
|
82
117
|
entry.tsx from .ds-viewer/. Rewriting them — your project is fine, this is the catalog's own
|
|
83
118
|
generated workspace. If the catalog looks broken, reload the page.
|
|
119
|
+
Restarting Metro so the catalog picks the files back up — the page reloads itself in a few seconds.
|
|
84
120
|
```
|
|
85
121
|
|
|
86
|
-
Metro
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
122
|
+
Metro is restarted as part of the repair, because rewriting the files is not enough on its own: it
|
|
123
|
+
keeps serving the module map it cached before they went missing, and answers every request with
|
|
124
|
+
`Unable to resolve "expo" from "entry.tsx"` until it starts again. The server stays up and the page
|
|
125
|
+
reloads itself.
|
|
126
|
+
|
|
127
|
+
The repair is capped at five per run: if something is deleting continuously, repairing forever
|
|
128
|
+
would only hide it, so past the cap the server stops and keeps reporting. If you ever see that
|
|
129
|
+
message, what else was running at the time would be useful on
|
|
130
|
+
[issue #41](https://github.com/krapwoo/design-system-viewer/issues/41) — the cause is not yet
|
|
131
|
+
identified.
|
|
91
132
|
|
|
92
133
|
`ds-viewer.config.ts` fields: `name`, `logo` (optional — a path to an image asset, shown in the
|
|
93
134
|
sidebar beside the name, or in place of it for a wide wordmark; validated at `sync`/`dev` time
|
|
@@ -561,12 +602,28 @@ and adds what only a real render shows:
|
|
|
561
602
|
prop that was never wired up. The fingerprint reads each specimen's whole subtree (including a
|
|
562
603
|
PhoneFrame's child document and a field's value) but not the caption carrying the variant's own
|
|
563
604
|
name, so two examples differing only in colour are not reported.
|
|
605
|
+
- `specimen-clipped` (warning): an example in a list card painting past the left or right of its
|
|
606
|
+
cell, where it is cut off rather than contained. Content the specimen holds itself is not
|
|
607
|
+
reported — anything inside something that scrolls (`auto`/`scroll`, so the reader can reach it)
|
|
608
|
+
or clips (`hidden`/`clip`) is treated as the specimen's own containment. That last one is a
|
|
609
|
+
trade-off worth knowing: a full-width component clipping its own corners hides a genuinely
|
|
610
|
+
too-narrow cell from this rule, so `hidden` buys quiet for carousels and shimmer sweeps at the
|
|
611
|
+
cost of that case. Only horizontal crossings in `role=list` cells are measured — a cell's width
|
|
612
|
+
is what the page allocates, while its height grows with content, and matrix (`ComparisonGrid`)
|
|
613
|
+
cells are not visited at all. Usually fixed with a wider `specimenSize`, by stacking the blocks so
|
|
614
|
+
the example gets the full row, or by dropping `itemsFill` from a small instance that should sit
|
|
615
|
+
centered.
|
|
564
616
|
- `render-unavailable` / `render-failed` (errors): no browser, or the catalog didn't start.
|
|
565
617
|
|
|
566
618
|
It needs `puppeteer` in your project (`npm install --save-dev puppeteer`); ds-viewer doesn't
|
|
567
619
|
install a browser. It takes about two minutes for 50 pages and works with `--json` (progress
|
|
568
620
|
goes to stderr), `--ci` and `doctor.strict`, so CI can run `npx ds-viewer doctor --render --ci`.
|
|
569
621
|
|
|
622
|
+
It starts its own catalog on the first free port from 5181. `--port <n>` pins it instead, which
|
|
623
|
+
you want when something else already holds that range — another project's catalog, or a second
|
|
624
|
+
`dev`. The check cannot tell a stranger's server on its port from its own, so a collision reports
|
|
625
|
+
that server's pages as your project's failures.
|
|
626
|
+
|
|
570
627
|
It starts its own `dev` server, with `--no-update-endpoint` and its own generated directory
|
|
571
628
|
(`.ds-viewer-render/`, gitignored by `init` alongside `.ds-viewer/`). Nothing it generates touches
|
|
572
629
|
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
|
@@ -535,12 +535,19 @@ export async function dev(initialConfig, devOptions = {}) {
|
|
|
535
535
|
for (const watcher of watchers) {
|
|
536
536
|
watcher.on('error', (error) => console.warn(`File watcher error: ${error.message}`));
|
|
537
537
|
}
|
|
538
|
-
|
|
538
|
+
const spawnMetro = () => spawn('npx', ['expo', 'start', '--web', '--host', 'localhost', '--port', String(port)], {
|
|
539
539
|
cwd: workspace,
|
|
540
540
|
stdio: 'inherit',
|
|
541
541
|
shell: process.platform === 'win32',
|
|
542
542
|
detached: true,
|
|
543
543
|
});
|
|
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();
|
|
544
551
|
// This process owns its workspace: nothing else should remove the files that define it. When
|
|
545
552
|
// they vanish anyway, Metro reports it as `Cannot resolve entry file: the 'main' field defined
|
|
546
553
|
// in your package.json points to an unresolvable or non-existent path` — which names the
|
|
@@ -572,7 +579,7 @@ export async function dev(initialConfig, devOptions = {}) {
|
|
|
572
579
|
// `updateEndpoint` is passed through, never rebuilt: the endpoint listens on its own
|
|
573
580
|
// ephemeral port, so deriving one from Metro's port here would repair the files and break
|
|
574
581
|
// the update panel in the same breath.
|
|
575
|
-
repairWorkspaceIfMissing({
|
|
582
|
+
const { repaired } = repairWorkspaceIfMissing({
|
|
576
583
|
config, updateEndpoint, versionStatus: version.getVersionStatus, sync,
|
|
577
584
|
// Only if no one else took it meanwhile: between losing the lock and this tick, a second
|
|
578
585
|
// `dev` can legitimately have started and be serving the directory. Overwriting its lock
|
|
@@ -586,24 +593,64 @@ export async function dev(initialConfig, devOptions = {}) {
|
|
|
586
593
|
writeDevLock(config, { pid: process.pid, port });
|
|
587
594
|
},
|
|
588
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
|
+
}
|
|
589
604
|
}
|
|
590
605
|
catch (error) {
|
|
591
606
|
console.warn(`Could not rewrite the workspace: ${error.message}`);
|
|
592
607
|
}
|
|
593
608
|
}, WORKSPACE_WATCH_INTERVAL_MS);
|
|
594
609
|
workspaceWatch.unref?.();
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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();
|
|
607
654
|
// Node's default SIGTERM/SIGINT/SIGHUP behavior exits this process without touching its
|
|
608
655
|
// children — left alone, a signal sent only to `dev`'s own pid (not its process group) would
|
|
609
656
|
// leave the spawned `expo start` running, still bound to its port (Important finding, Fable
|
package/dist/cli/explain.js
CHANGED
|
@@ -35,7 +35,7 @@ function cellOverrideNote(cells) {
|
|
|
35
35
|
* look at it; only the counts/keys/sizes they do look at are carried over. Every field Task 3
|
|
36
36
|
* reads is carried through here, including `previewWidths`/`fullWidthLabel`/`hide`/`itemsFill`/
|
|
37
37
|
* `maxColumns` — dropping any of them makes `explain` report different geometry than the viewer. */
|
|
38
|
-
function toSectionDef(page) {
|
|
38
|
+
export function toSectionDef(page) {
|
|
39
39
|
const comparison = page.comparison && {
|
|
40
40
|
rowLabel: page.comparison.rowLabel ?? '',
|
|
41
41
|
columnLabel: page.comparison.columnLabel ?? '',
|
|
@@ -125,12 +125,11 @@ function toPlacementBlock(block, height) {
|
|
|
125
125
|
? { ...base, itemCount: block.items.length, size: block.size, maxColumns: block.maxColumns }
|
|
126
126
|
: base;
|
|
127
127
|
}
|
|
128
|
-
/**
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
// page should never resolve here either.
|
|
128
|
+
/** Every page `cli/doctor.ts`'s own `discoverAllPageFiles` would find, with the same exclusions
|
|
129
|
+
* `runDoctor` applies (mirroring `sync`), so an excluded folder's page never resolves. Shared with
|
|
130
|
+
* `cli/layout.ts` rather than copied: a snapshot built from a different page set than `explain`
|
|
131
|
+
* reads would diff pages the viewer does not have. */
|
|
132
|
+
export function discoverStaticPages(config) {
|
|
134
133
|
const excluded = new Set((config.exclude ?? []).flatMap((pattern) => resolveGlob(config.projectRoot, pattern)));
|
|
135
134
|
const componentFolders = [
|
|
136
135
|
...new Set(config.components
|
|
@@ -139,7 +138,13 @@ export function explainPage(config, pageId, options = {}) {
|
|
|
139
138
|
.map((file) => path.dirname(file))),
|
|
140
139
|
];
|
|
141
140
|
const pageFiles = discoverAllPageFiles({ componentFolders, standalonePageGlobs: config.pages ?? [], projectRoot: config.projectRoot });
|
|
142
|
-
|
|
141
|
+
return readStaticPages(pageFiles);
|
|
142
|
+
}
|
|
143
|
+
/** Finds `pageId` among every page `cli/doctor.ts`'s own `discoverAllPageFiles` would find, builds
|
|
144
|
+
* its throwaway `SectionDef`, and asks the viewer's own pure layout functions for each block's
|
|
145
|
+
* decision — undefined when no page resolves to that id. */
|
|
146
|
+
export function explainPage(config, pageId, options = {}) {
|
|
147
|
+
const page = discoverStaticPages(config).find((p) => resolvePageId(p, p.file) === pageId);
|
|
143
148
|
if (!page)
|
|
144
149
|
return undefined;
|
|
145
150
|
// Important finding 4 (Fable's review): a page whose layout data isn't a literal (or that has a
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// cli/layout.ts — `explain --all` and `layout-diff`: capture every page's geometry as data,
|
|
2
|
+
// and report what a version changed.
|
|
3
|
+
//
|
|
4
|
+
// Layout rules live in this package and reach every consuming project on `update`, while their page
|
|
5
|
+
// files never change. So a release can reshape a catalog with nothing in the consumer's repository
|
|
6
|
+
// to explain it — 0.7.0 moved three pages from side-by-side to stacked, and the only trace was a
|
|
7
|
+
// changelog paragraph. This turns that into a measured artifact: snapshot before, snapshot after,
|
|
8
|
+
// diff the two.
|
|
9
|
+
import { resolvePageId } from "./pageIndex.js";
|
|
10
|
+
import { discoverStaticPages } from "./explain.js";
|
|
11
|
+
import { blockMinWidth, fitsOneRow, listGeometry, presentationBlocks, MATRIX_LAYOUT, } from "../native/catalog/comparison.js";
|
|
12
|
+
import { CATALOG_LAYOUT } from "../native/catalog/tokens.js";
|
|
13
|
+
import { sectionColumns } from "../native/catalog/tokenLayout.js";
|
|
14
|
+
import { toSectionDef } from "./explain.js";
|
|
15
|
+
function tokenSectionGeometry(block, width) {
|
|
16
|
+
const columns = sectionColumns(width, block.columns);
|
|
17
|
+
let rows = 0;
|
|
18
|
+
let used = 0;
|
|
19
|
+
let fillers = 0;
|
|
20
|
+
const closePartialRow = () => {
|
|
21
|
+
if (used === 0)
|
|
22
|
+
return;
|
|
23
|
+
rows += 1;
|
|
24
|
+
fillers += columns - used;
|
|
25
|
+
used = 0;
|
|
26
|
+
};
|
|
27
|
+
for (const section of block.sections) {
|
|
28
|
+
if (section.wide) {
|
|
29
|
+
closePartialRow();
|
|
30
|
+
rows += 1;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
used += 1;
|
|
34
|
+
if (used === columns)
|
|
35
|
+
closePartialRow();
|
|
36
|
+
}
|
|
37
|
+
closePartialRow();
|
|
38
|
+
return { columns, rows, fillers };
|
|
39
|
+
}
|
|
40
|
+
function geometryFor(block, width) {
|
|
41
|
+
if (block.kind === 'list') {
|
|
42
|
+
const { columns, rows, fillers } = listGeometry(block.items.length, width, block.size, block.maxColumns);
|
|
43
|
+
return { columns, rows, fillers };
|
|
44
|
+
}
|
|
45
|
+
if (block.kind === 'tokenSections')
|
|
46
|
+
return tokenSectionGeometry(block, width);
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
function snapshotMinWidth(block) {
|
|
50
|
+
const width = blockMinWidth(block);
|
|
51
|
+
return Number.isFinite(width) ? width : null;
|
|
52
|
+
}
|
|
53
|
+
/** One page's geometry, from the same static read `explain` uses. */
|
|
54
|
+
export function pageSnapshot(page, pageId) {
|
|
55
|
+
if (!page.checkable)
|
|
56
|
+
return { page: pageId, blocks: [], notCheckable: true };
|
|
57
|
+
const blocks = presentationBlocks(toSectionDef(page), { defaultPreviewWidths: [402] });
|
|
58
|
+
const full = MATRIX_LAYOUT.laptopContentWidth;
|
|
59
|
+
const half = Math.floor((full - CATALOG_LAYOUT.blockGap) / 2);
|
|
60
|
+
const snapshot = {
|
|
61
|
+
page: pageId,
|
|
62
|
+
blocks: blocks.map((block) => {
|
|
63
|
+
const fullGeometry = geometryFor(block, full);
|
|
64
|
+
const halfGeometry = geometryFor(block, half);
|
|
65
|
+
return {
|
|
66
|
+
title: block.kind === 'tokenSections' ? 'Token sections' : block.title,
|
|
67
|
+
kind: block.kind,
|
|
68
|
+
...(block.kind === 'list' ? { size: block.size, items: block.items.length } : {}),
|
|
69
|
+
...(block.kind === 'tokenSections' ? { items: block.sections.length } : {}),
|
|
70
|
+
minWidth: snapshotMinWidth(block),
|
|
71
|
+
...(fullGeometry ? { full: fullGeometry } : {}),
|
|
72
|
+
...(halfGeometry ? { half: halfGeometry } : {}),
|
|
73
|
+
};
|
|
74
|
+
}),
|
|
75
|
+
};
|
|
76
|
+
if (blocks.length === 2) {
|
|
77
|
+
const [first, second] = blocks;
|
|
78
|
+
const firstMin = blockMinWidth(first);
|
|
79
|
+
const secondMin = blockMinWidth(second);
|
|
80
|
+
const asPlacement = (b) => ({
|
|
81
|
+
kind: b.kind, height: 0, minWidth: blockMinWidth(b),
|
|
82
|
+
...(b.kind === 'list' ? { itemCount: b.items.length, size: b.size, maxColumns: b.maxColumns } : {}),
|
|
83
|
+
});
|
|
84
|
+
// This is every static gate that runs before measured height enters choosePlacement. Omitting
|
|
85
|
+
// the min-width check made Avatar claim eligibility even though its 600px grid can never fit the
|
|
86
|
+
// 462px half-row; a later fitsOneRow change would then fabricate a placement change for it.
|
|
87
|
+
snapshot.sideEligible = half > 0 && firstMin <= half && secondMin <= half
|
|
88
|
+
&& fitsOneRow(asPlacement(first), half) && fitsOneRow(asPlacement(second), half);
|
|
89
|
+
}
|
|
90
|
+
return snapshot;
|
|
91
|
+
}
|
|
92
|
+
/** Every page's geometry, ordered by page id so two snapshots diff line for line. */
|
|
93
|
+
export function layoutSnapshot(config, version) {
|
|
94
|
+
const staticPages = discoverStaticPages(config);
|
|
95
|
+
const ids = staticPages.map((page) => resolvePageId(page, page.file));
|
|
96
|
+
const duplicates = [...new Set(ids.filter((id, index) => ids.indexOf(id) !== index))];
|
|
97
|
+
if (duplicates.length > 0) {
|
|
98
|
+
throw new Error(`Cannot snapshot duplicate page id${duplicates.length === 1 ? '' : 's'}: ${duplicates.join(', ')}. Run ds-viewer doctor and give each page a unique id.`);
|
|
99
|
+
}
|
|
100
|
+
const pages = staticPages
|
|
101
|
+
.map((page, index) => pageSnapshot(page, ids[index]))
|
|
102
|
+
// Code-point order is stable across machines; localeCompare can produce different snapshot text
|
|
103
|
+
// under different host locales, defeating a committed artifact's textual review.
|
|
104
|
+
.sort((a, b) => (a.page < b.page ? -1 : a.page > b.page ? 1 : 0));
|
|
105
|
+
return { version, width: MATRIX_LAYOUT.laptopContentWidth, pages };
|
|
106
|
+
}
|
|
107
|
+
function describeGeometry(g) {
|
|
108
|
+
return g ? `${g.columns}×${g.rows}` : '—';
|
|
109
|
+
}
|
|
110
|
+
function describeMinWidth(width) {
|
|
111
|
+
return width === null ? 'a full row' : `${width}px`;
|
|
112
|
+
}
|
|
113
|
+
function blockKeys(blocks) {
|
|
114
|
+
const seen = new Map();
|
|
115
|
+
const totals = new Map();
|
|
116
|
+
for (const block of blocks)
|
|
117
|
+
totals.set(block.title, (totals.get(block.title) ?? 0) + 1);
|
|
118
|
+
return blocks.map((block) => {
|
|
119
|
+
const occurrence = (seen.get(block.title) ?? 0) + 1;
|
|
120
|
+
seen.set(block.title, occurrence);
|
|
121
|
+
return {
|
|
122
|
+
key: `${block.title}\u0000${occurrence}`,
|
|
123
|
+
label: (totals.get(block.title) ?? 0) > 1 ? `${block.title} (${occurrence})` : block.title,
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/** What changed between two snapshots. Pure, so it is the part worth testing.
|
|
128
|
+
*
|
|
129
|
+
* Blocks are matched by title, not by position. Comparing by index means a block inserted at the
|
|
130
|
+
* front diffs every later block against its neighbour: a page that gained an intro block reported
|
|
131
|
+
* four changes, including a brand-new block "changing" from a geometry it never had and an
|
|
132
|
+
* untouched block changing to its neighbour's. A tool whose output is meant to be believed cannot
|
|
133
|
+
* invent changes that did not happen. */
|
|
134
|
+
export function diffLayouts(before, after) {
|
|
135
|
+
const changes = [];
|
|
136
|
+
const beforePages = new Map(before.pages.map((p) => [p.page, p]));
|
|
137
|
+
const afterPages = new Map(after.pages.map((p) => [p.page, p]));
|
|
138
|
+
for (const page of before.pages) {
|
|
139
|
+
if (!afterPages.has(page.page))
|
|
140
|
+
changes.push({ page: page.page, kind: 'removed', detail: 'the page is gone' });
|
|
141
|
+
}
|
|
142
|
+
for (const page of after.pages) {
|
|
143
|
+
const was = beforePages.get(page.page);
|
|
144
|
+
if (!was) {
|
|
145
|
+
changes.push({ page: page.page, kind: 'added', detail: 'a new page' });
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (Boolean(was.notCheckable) !== Boolean(page.notCheckable)) {
|
|
149
|
+
changes.push({
|
|
150
|
+
page: page.page, kind: 'checkable',
|
|
151
|
+
detail: page.notCheckable ? 'its layout is no longer statically readable' : 'its layout became statically readable',
|
|
152
|
+
});
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
// Title is a block's identity here. Two blocks on one page sharing a title would be
|
|
156
|
+
// indistinguishable, so those fall back to their position *within that title* — which keeps a
|
|
157
|
+
// page with two "Variants" blocks comparing like for like instead of silently dropping one.
|
|
158
|
+
const index = (blocks) => {
|
|
159
|
+
const map = new Map();
|
|
160
|
+
for (const block of blocks) {
|
|
161
|
+
const list = map.get(block.title) ?? [];
|
|
162
|
+
list.push(block);
|
|
163
|
+
map.set(block.title, list);
|
|
164
|
+
}
|
|
165
|
+
return map;
|
|
166
|
+
};
|
|
167
|
+
const oldBlocks = index(was.blocks);
|
|
168
|
+
const newBlocks = index(page.blocks);
|
|
169
|
+
for (const [title, list] of oldBlocks) {
|
|
170
|
+
const now = newBlocks.get(title) ?? [];
|
|
171
|
+
for (let i = now.length; i < list.length; i += 1) {
|
|
172
|
+
changes.push({ page: page.page, kind: 'blocks', detail: `"${title}" is gone` });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
for (const [title, list] of newBlocks) {
|
|
176
|
+
const before_ = oldBlocks.get(title) ?? [];
|
|
177
|
+
for (const [i, block] of list.entries()) {
|
|
178
|
+
const old = before_[i];
|
|
179
|
+
if (!old) {
|
|
180
|
+
changes.push({ page: page.page, kind: 'blocks', detail: `"${title}" is new` });
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (old.kind !== block.kind) {
|
|
184
|
+
changes.push({ page: page.page, kind: 'blocks', detail: `"${block.title}" is a ${block.kind} block, was ${old.kind}` });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (old.minWidth !== block.minWidth) {
|
|
188
|
+
changes.push({
|
|
189
|
+
page: page.page,
|
|
190
|
+
kind: 'geometry',
|
|
191
|
+
detail: `"${block.title}" needs ${describeMinWidth(block.minWidth)}, was ${describeMinWidth(old.minWidth)}`,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
for (const width of ['full', 'half']) {
|
|
195
|
+
const a = old[width];
|
|
196
|
+
const b = block[width];
|
|
197
|
+
if (describeGeometry(a) !== describeGeometry(b)) {
|
|
198
|
+
changes.push({
|
|
199
|
+
page: page.page, kind: 'geometry',
|
|
200
|
+
detail: `"${block.title}" at ${width} width: ${describeGeometry(a)} → ${describeGeometry(b)}`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// Compare relative order among blocks that exist on both sides. Inserting a new block at the
|
|
207
|
+
// front does not falsely say every existing block moved, but swapping two existing blocks does
|
|
208
|
+
// remain visible rather than disappearing behind title-based identity.
|
|
209
|
+
const oldKeyed = blockKeys(was.blocks);
|
|
210
|
+
const newKeyed = blockKeys(page.blocks);
|
|
211
|
+
const oldSet = new Set(oldKeyed.map(({ key }) => key));
|
|
212
|
+
const newSet = new Set(newKeyed.map(({ key }) => key));
|
|
213
|
+
const oldCommon = oldKeyed.filter(({ key }) => newSet.has(key));
|
|
214
|
+
const newCommon = newKeyed.filter(({ key }) => oldSet.has(key));
|
|
215
|
+
const oldPositions = new Map(oldCommon.map(({ key }, index) => [key, index]));
|
|
216
|
+
for (const [index, { key, label }] of newCommon.entries()) {
|
|
217
|
+
const oldIndex = oldPositions.get(key);
|
|
218
|
+
if (oldIndex !== undefined && oldIndex !== index) {
|
|
219
|
+
changes.push({
|
|
220
|
+
page: page.page,
|
|
221
|
+
kind: 'blocks',
|
|
222
|
+
detail: `"${label}" moved from block ${oldIndex + 1} to ${index + 1}`,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (typeof was.sideEligible === 'boolean' && typeof page.sideEligible === 'boolean' && was.sideEligible !== page.sideEligible) {
|
|
227
|
+
changes.push({
|
|
228
|
+
page: page.page, kind: 'placement',
|
|
229
|
+
detail: page.sideEligible
|
|
230
|
+
? 'its two blocks can now share a row'
|
|
231
|
+
: 'its two blocks can no longer share a row',
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return changes;
|
|
236
|
+
}
|
|
237
|
+
/** True when two snapshots were measured at the same width, and so can be compared at all.
|
|
238
|
+
* Geometry at 952px and at 1112px legitimately differs; diffing across them reports rule changes
|
|
239
|
+
* that never happened. Exported because every caller needs the guard, not just the one that
|
|
240
|
+
* prints prose. */
|
|
241
|
+
export function comparableWidths(before, after) {
|
|
242
|
+
return before.width === after.width;
|
|
243
|
+
}
|
|
244
|
+
/** Human output. Silence is the useful answer, so it says so explicitly rather than printing
|
|
245
|
+
* nothing and leaving the reader to wonder whether it ran. */
|
|
246
|
+
export function formatLayoutDiff(before, after, changes) {
|
|
247
|
+
const header = `${before.version} → ${after.version}, measured at ${after.width}px`;
|
|
248
|
+
if (before.width !== after.width) {
|
|
249
|
+
return `${header}\n\nThese snapshots were taken at different widths (${before.width}px and ${after.width}px), so their geometry is not comparable.`;
|
|
250
|
+
}
|
|
251
|
+
if (changes.length === 0)
|
|
252
|
+
return `${header}\n\nNo page changes layout.`;
|
|
253
|
+
const byPage = new Map();
|
|
254
|
+
for (const change of changes) {
|
|
255
|
+
const list = byPage.get(change.page) ?? [];
|
|
256
|
+
list.push(change);
|
|
257
|
+
byPage.set(change.page, list);
|
|
258
|
+
}
|
|
259
|
+
const pages = [...byPage.keys()];
|
|
260
|
+
const lines = [header, '', `Layout changes on ${pages.length} page${pages.length === 1 ? '' : 's'}: ${pages.join(', ')}.`, ''];
|
|
261
|
+
for (const [page, list] of byPage) {
|
|
262
|
+
lines.push(`${page}`);
|
|
263
|
+
for (const change of list)
|
|
264
|
+
lines.push(` ${change.detail}`);
|
|
265
|
+
}
|
|
266
|
+
return lines.join('\n');
|
|
267
|
+
}
|