vite-plugin-nora 0.1.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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +26 -0
  3. package/README.md +113 -0
  4. package/licenses/geist-OFL.txt +93 -0
  5. package/licenses/lucide-ISC.txt +17 -0
  6. package/package.json +90 -0
  7. package/src/cli.js +101 -0
  8. package/src/client/App.jsx +216 -0
  9. package/src/client/canvas/Canvas.jsx +95 -0
  10. package/src/client/canvas/ErrorBoundary.jsx +56 -0
  11. package/src/client/canvas/Preview.jsx +70 -0
  12. package/src/client/canvas/Viewport.jsx +211 -0
  13. package/src/client/chords.js +38 -0
  14. package/src/client/css.d.ts +5 -0
  15. package/src/client/fonts.css +48 -0
  16. package/src/client/frame.css +107 -0
  17. package/src/client/frame.html +11 -0
  18. package/src/client/frame.jsx +73 -0
  19. package/src/client/index.html +12 -0
  20. package/src/client/main.jsx +10 -0
  21. package/src/client/open-folder.js +84 -0
  22. package/src/client/selection.js +22 -0
  23. package/src/client/styles.css +1264 -0
  24. package/src/client/sweep/SweepPanel.jsx +206 -0
  25. package/src/client/sweep/measure.js +230 -0
  26. package/src/client/sweep/report.js +54 -0
  27. package/src/client/sweep/run-sweep.js +185 -0
  28. package/src/client/toolbar/Picker.jsx +343 -0
  29. package/src/client/toolbar/Toolbar.jsx +231 -0
  30. package/src/client/toolbar/ViewportMenu.jsx +83 -0
  31. package/src/client/toolbar/bar-shape.js +402 -0
  32. package/src/client/toolbar/icons.jsx +129 -0
  33. package/src/client/toolbar/use-draggable-bar.js +204 -0
  34. package/src/client/viewports.js +43 -0
  35. package/src/client/virtual.d.ts +28 -0
  36. package/src/index.js +4 -0
  37. package/src/server/create-server.js +147 -0
  38. package/src/server/plugin.js +207 -0
  39. package/src/server/safe-path.js +42 -0
  40. package/src/server/scan.js +350 -0
  41. package/types/index.d.ts +3 -0
  42. package/types/server/create-server.d.ts +22 -0
  43. package/types/server/plugin.d.ts +20 -0
  44. package/types/server/safe-path.d.ts +25 -0
  45. package/types/server/scan.d.ts +106 -0
@@ -0,0 +1,206 @@
1
+ import { useEffect, useState } from "react";
2
+ import { CheckIcon } from "../toolbar/icons.jsx";
3
+ import { buildReport } from "./report.js";
4
+ import { SWEEP_MIN, SWEEP_MAX } from "./run-sweep.js";
5
+
6
+ const pct = (w) => ((w - SWEEP_MIN) / (SWEEP_MAX - SWEEP_MIN)) * 100;
7
+
8
+ /**
9
+ * Sweep results, as a popover on the bar.
10
+ *
11
+ * It lives here rather than in a panel below the canvas for two reasons. The
12
+ * bar already owns this idiom — the folder browser, the palette and the
13
+ * viewport menu are all popovers, and a fourth surface type would be one too
14
+ * many. And a panel docked under the canvas shrinks the canvas, which changes
15
+ * the Fit width and reflows the very component the results describe.
16
+ *
17
+ * The axis is the point: findings are located *at widths*, so they belong on a
18
+ * ruler. Click anywhere on it to send the frame to that width and look for
19
+ * yourself — a flagged range you can't inspect is just an accusation.
20
+ */
21
+ /**
22
+ * @param {object} props
23
+ * @param {import("./run-sweep.js").SweepResult} props.result
24
+ * @param {{ name?: string, file?: string } | null} [props.subject]
25
+ * @param {number | null} props.currentWidth
26
+ * @param {(width: number) => void} props.onPickWidth
27
+ * @param {() => void} props.onRerun
28
+ * @param {() => void} props.onClose
29
+ * @param {boolean} props.sweeping
30
+ */
31
+ export function SweepPanel({
32
+ result,
33
+ subject,
34
+ currentWidth,
35
+ onPickWidth,
36
+ onRerun,
37
+ onClose,
38
+ sweeping,
39
+ }) {
40
+ const { breakpoints, overflowRanges } = result;
41
+ const clean = !breakpoints.length && !overflowRanges.length;
42
+
43
+ const [copied, setCopied] = useState(false);
44
+
45
+ // The confirmation clears itself. Keyed on `copied` rather than set inside the
46
+ // click handler so the timer is cancelled if the panel closes first.
47
+ useEffect(() => {
48
+ if (!copied) return;
49
+ const t = setTimeout(() => setCopied(false), 1600);
50
+ return () => clearTimeout(t);
51
+ }, [copied]);
52
+
53
+ const copy = async () => {
54
+ try {
55
+ await navigator.clipboard.writeText(buildReport(result, subject));
56
+ setCopied(true);
57
+ } catch {
58
+ /* clipboard refused — the findings are still on screen to read */
59
+ }
60
+ };
61
+
62
+ const pick = (event) => {
63
+ const box = event.currentTarget.getBoundingClientRect();
64
+ const ratio = (event.clientX - box.left) / box.width;
65
+ const w = Math.round(SWEEP_MIN + ratio * (SWEEP_MAX - SWEEP_MIN));
66
+ onPickWidth(Math.max(SWEEP_MIN, Math.min(SWEEP_MAX, w)));
67
+ };
68
+
69
+ return (
70
+ <div className="nora-panel nora-sweep-panel" role="dialog" aria-label="Sweep Results">
71
+ <div className="nora-panel-head">
72
+ <span className="nora-panel-title">
73
+ Swept {SWEEP_MIN}–{SWEEP_MAX}
74
+ </span>
75
+ <span className="nora-sweep-summary">
76
+ {overflowRanges.length ? (
77
+ <span className="nora-sweep-bad">
78
+ {overflowRanges.length} Overflow{overflowRanges.length === 1 ? "" : "s"}
79
+ </span>
80
+ ) : null}
81
+ {breakpoints.length ? (
82
+ <span className="nora-sweep-neutral">
83
+ {breakpoints.length} Breakpoint{breakpoints.length === 1 ? "" : "s"}
84
+ </span>
85
+ ) : null}
86
+ {clean ? (
87
+ <span className="nora-sweep-ok">
88
+ <CheckIcon size={12} strokeWidth={2.75} />
89
+ Passed
90
+ </span>
91
+ ) : null}
92
+ </span>
93
+ </div>
94
+
95
+ <div
96
+ className="nora-sweep-axis"
97
+ data-clean={clean ? "true" : "false"}
98
+ data-ticks={breakpoints.length ? "true" : "false"}
99
+ onClick={pick}
100
+ role="slider"
101
+ tabIndex={0}
102
+ aria-label="Jump to Width"
103
+ aria-valuemin={SWEEP_MIN}
104
+ aria-valuemax={SWEEP_MAX}
105
+ aria-valuenow={currentWidth ?? SWEEP_MIN}
106
+ // A slider must carry aria-valuenow, but "Fit" is not a width, and
107
+ // reporting SWEEP_MIN for it would be a confident lie. valuetext is
108
+ // what a screen reader actually announces, so the truth goes there.
109
+ aria-valuetext={currentWidth ? `${currentWidth}px` : "No width chosen"}
110
+ onKeyDown={(e) => {
111
+ if (e.key === "ArrowLeft") onPickWidth(Math.max(SWEEP_MIN, (currentWidth ?? 800) - 8));
112
+ if (e.key === "ArrowRight") onPickWidth(Math.min(SWEEP_MAX, (currentWidth ?? 800) + 8));
113
+ }}
114
+ >
115
+ <div className="nora-sweep-track" />
116
+
117
+ {overflowRanges.map((r) => (
118
+ <div
119
+ key={`o-${r.from}`}
120
+ className="nora-sweep-band"
121
+ style={{ left: `${pct(r.from)}%`, width: `${Math.max(0.8, pct(r.to) - pct(r.from))}%` }}
122
+ title={`Overflows by up to ${r.worst}px between ${r.from} and ${r.to}`}
123
+ />
124
+ ))}
125
+
126
+ {breakpoints.map((b) => (
127
+ <div
128
+ key={`b-${b.width}`}
129
+ className="nora-sweep-tick"
130
+ style={{ left: `${pct(b.width)}%` }}
131
+ >
132
+ <span className="nora-sweep-tick-label">{b.width}</span>
133
+ </div>
134
+ ))}
135
+
136
+ {currentWidth ? (
137
+ <div className="nora-sweep-cursor" style={{ left: `${pct(currentWidth)}%` }} />
138
+ ) : null}
139
+ </div>
140
+
141
+ <div className="nora-sweep-scale">
142
+ <span>{SWEEP_MIN}</span>
143
+ <span>{SWEEP_MAX}</span>
144
+ </div>
145
+
146
+ {clean ? (
147
+ <div className="nora-sweep-clear">No overflow or layout transitions.</div>
148
+ ) : (
149
+ <div className="nora-panel-body">
150
+ {overflowRanges.map((r) => (
151
+ <button
152
+ key={`of-${r.from}`}
153
+ className="nora-finding is-bad"
154
+ onClick={() => onPickWidth(r.from)}
155
+ title={r.offenders?.[0]?.label ?? ""}
156
+ >
157
+ <span className="nora-finding-range">
158
+ {r.from}–{r.to}
159
+ </span>
160
+ <span className="nora-finding-text">
161
+ overflows {r.worst}px
162
+ {r.offenders?.[0] ? ` · ${r.offenders[0].label}` : ""}
163
+ </span>
164
+ </button>
165
+ ))}
166
+
167
+ {breakpoints.map((b) => (
168
+ <button
169
+ key={`bp-${b.width}`}
170
+ className="nora-finding"
171
+ onClick={() => onPickWidth(b.width)}
172
+ >
173
+ <span className="nora-finding-range">{b.width}</span>
174
+ <span className="nora-finding-text">
175
+ {b.changes.length
176
+ ? b.changes
177
+ .slice(0, 2)
178
+ .map((c) => `${c.prop} ${c.from} → ${c.to}`)
179
+ .join(", ")
180
+ : `reflows ${b.rowsBefore} → ${b.rowsAfter} rows`}
181
+ </span>
182
+ </button>
183
+ ))}
184
+ </div>
185
+ )}
186
+
187
+ <div className="nora-sweep-actions">
188
+ <button className="nora-sweep-rerun" onClick={onRerun} disabled={sweeping}>
189
+ {sweeping ? "Sweeping…" : "Sweep"}
190
+ </button>
191
+ {clean ? null : (
192
+ <button
193
+ className="nora-sweep-copy"
194
+ onClick={copy}
195
+ title="Copy the findings as text, ready to paste"
196
+ >
197
+ {copied ? "Copied" : "Copy"}
198
+ </button>
199
+ )}
200
+ <button className="nora-panel-close nora-sweep-dismiss" onClick={onClose}>
201
+ Close
202
+ </button>
203
+ </div>
204
+ </div>
205
+ );
206
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * The probe. Runs against the frame document at one width and returns
3
+ * everything the sweep needs to reason about that width.
4
+ *
5
+ * Two jobs, and they are different in kind:
6
+ *
7
+ * 1. Detect breakage — overflow, content pushed off-screen. Judgemental, so
8
+ * it has to be nearly false-positive free or nobody will trust the panel.
9
+ *
10
+ * 2. Fingerprint the layout — so the sweep can find the widths where the
11
+ * component genuinely changes shape. Purely descriptive, so it can't be
12
+ * "wrong", only interesting.
13
+ *
14
+ * The fingerprint is the subtle part. Under a fluid layout, geometry changes at
15
+ * every single width, so "did the boxes move" is useless — it is always true.
16
+ * What we track instead are the *discrete* computed values: display,
17
+ * flex-direction, wrap, grid column count, visibility. Those don't drift as you
18
+ * resize; they change only when a rule starts or stops applying. A change in
19
+ * that fingerprint is a real layout transition, which is what a breakpoint is.
20
+ */
21
+
22
+ /** Discrete properties: they change by rule, not by pixel. */
23
+ const DISCRETE_PROPS = [
24
+ "display",
25
+ "flexDirection",
26
+ "flexWrap",
27
+ "position",
28
+ "visibility",
29
+ "float",
30
+ "whiteSpace",
31
+ "textAlign",
32
+ "gridAutoFlow",
33
+ ];
34
+
35
+ /** Elements to inspect before giving up — keeps a huge tree from stalling the sweep. */
36
+ const MAX_ELEMENTS = 400;
37
+
38
+ /** Grid track lists are pixel values under a fluid layout; only the count is discrete. */
39
+ function trackCount(value) {
40
+ if (!value || value === "none") return 0;
41
+ return value.trim().split(/\s+/).length;
42
+ }
43
+
44
+ /** A short, human-readable handle for an element, for use in findings. */
45
+ export function describe(el) {
46
+ const tag = el.tagName.toLowerCase();
47
+ const cls =
48
+ typeof el.className === "string" && el.className.trim()
49
+ ? "." + el.className.trim().split(/\s+/).slice(0, 2).join(".")
50
+ : "";
51
+ const id = el.id ? `#${el.id}` : "";
52
+ const text = (el.textContent ?? "").trim().replace(/\s+/g, " ").slice(0, 28);
53
+ const label = `${tag}${id}${cls}`;
54
+ return text && text.length > 2 ? `${label} — “${text}”` : label;
55
+ }
56
+
57
+ /** Is this element inside something that scrolls? Then sticking out is intentional. */
58
+ function insideScroller(el, root) {
59
+ let node = el.parentElement;
60
+ while (node && node !== root) {
61
+ const style = getComputedStyle(node);
62
+ if (/(auto|scroll)/.test(style.overflowX + style.overflow)) return true;
63
+ node = node.parentElement;
64
+ }
65
+ return false;
66
+ }
67
+
68
+ function fnv1a(str) {
69
+ let h = 0x811c9dc5;
70
+ for (let i = 0; i < str.length; i++) {
71
+ h ^= str.charCodeAt(i);
72
+ h = Math.imul(h, 0x01000193);
73
+ }
74
+ return (h >>> 0).toString(36);
75
+ }
76
+
77
+ /**
78
+ * @param {Document} doc the frame document
79
+ * @param {number} width the viewport width it is currently at
80
+ * @param {boolean} [detailed] keep per-element data so two samples can be diffed
81
+ */
82
+ export function measure(doc, width, detailed = false) {
83
+ const root = doc.documentElement;
84
+ const stage = doc.querySelector(".noraf-stage") ?? doc.body;
85
+
86
+ // --- breakage -----------------------------------------------------------
87
+ // Start from the document: if nothing overflows the page, there is no
88
+ // overflow bug worth reporting, however far individual boxes stick out of
89
+ // their own scrollable parents.
90
+ const docOverflow = root.scrollWidth - root.clientWidth;
91
+ const overflows = docOverflow > 1;
92
+
93
+ const offenders = [];
94
+ if (overflows) {
95
+ const all = stage.querySelectorAll("*");
96
+ const limit = Math.min(all.length, MAX_ELEMENTS);
97
+ for (let i = 0; i < limit; i++) {
98
+ const el = all[i];
99
+ const rect = el.getBoundingClientRect();
100
+ if (rect.width === 0 || rect.height === 0) continue;
101
+ const past = rect.right - root.clientWidth;
102
+ if (past <= 1 && rect.left >= -1) continue;
103
+ if (insideScroller(el, stage)) continue;
104
+ offenders.push({
105
+ label: describe(el),
106
+ by: Math.round(Math.max(past, -rect.left)),
107
+ });
108
+ if (offenders.length >= 3) break;
109
+ }
110
+ }
111
+
112
+ // --- fingerprint --------------------------------------------------------
113
+ const all = stage.querySelectorAll("*");
114
+ const limit = Math.min(all.length, MAX_ELEMENTS);
115
+ const parts = [];
116
+ const details = detailed ? [] : null;
117
+ const wrapRows = [];
118
+ let visible = 0;
119
+
120
+ for (let i = 0; i < limit; i++) {
121
+ const el = all[i];
122
+ const style = getComputedStyle(el);
123
+ const values = DISCRETE_PROPS.map((p) => style[p]);
124
+ values.push(String(trackCount(style.gridTemplateColumns)));
125
+
126
+ if (style.display !== "none" && style.visibility !== "hidden") visible++;
127
+
128
+ // Row counting is restricted to containers where "rows" is a layout
129
+ // concept — a wrapping flex container or a grid. Counting distinct tops
130
+ // across every element instead would make ordinary text rewrapping look
131
+ // like a breakpoint, which is exactly the kind of false positive that gets
132
+ // this panel closed and never reopened.
133
+ if (style.flexWrap === "wrap" || style.display === "grid" || style.display === "inline-grid") {
134
+ const tops = new Set();
135
+ for (const child of el.children) {
136
+ const rect = child.getBoundingClientRect();
137
+ if (rect.height > 0) tops.add(Math.round(rect.top));
138
+ }
139
+ wrapRows.push(tops.size);
140
+ }
141
+
142
+ parts.push(values.join("|"));
143
+ if (details) details.push({ label: describe(el), values });
144
+ }
145
+
146
+ const rows = wrapRows.reduce((a, b) => a + b, 0);
147
+ const signature = `${visible}~${wrapRows.join(",")}~${parts.join(";")}`;
148
+
149
+ return {
150
+ width,
151
+ overflows,
152
+ overflowBy: Math.max(0, docOverflow),
153
+ offenders,
154
+ rowCount: rows,
155
+ visibleCount: visible,
156
+ height: Math.round(stage.getBoundingClientRect().height),
157
+ hash: fnv1a(signature),
158
+ details,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Given two detailed samples, say what actually changed. Turns a bare
164
+ * "something happened at 771px" into a line a person can act on.
165
+ */
166
+ /**
167
+ * Index a sample's details so the same element can be found in both samples.
168
+ *
169
+ * The key is the element's label plus how many elements with that label came
170
+ * before it, because labels repeat: five `li.item` siblings all describe the
171
+ * same. Insertion order is preserved, so iterating the result walks the
172
+ * document in order exactly as the old index-based comparison did.
173
+ */
174
+ function keyed(details) {
175
+ const seen = new Map();
176
+ const out = new Map();
177
+ for (const d of details) {
178
+ const n = seen.get(d.label) ?? 0;
179
+ seen.set(d.label, n + 1);
180
+ out.set(`${d.label}\u0000${n}`, d);
181
+ }
182
+ return out;
183
+ }
184
+
185
+ export function diffSamples(before, after) {
186
+ if (!before?.details || !after?.details) return [];
187
+ const changes = [];
188
+
189
+ // Matched by identity rather than by position. The two samples are separate
190
+ // walks of `querySelectorAll("*")`, so a component that renders a different
191
+ // tree at a different width — `{wide ? <Nav/> : <Drawer/>}`, which is exactly
192
+ // what a breakpoint often is — shifts every element after the change by one.
193
+ // Compared by index, that reported a fabricated property change for each of
194
+ // them and buried the real finding, because the count fallback below only
195
+ // runs when nothing else was found.
196
+ const was = keyed(before.details);
197
+ const now = keyed(after.details);
198
+
199
+ for (const [key, a] of was) {
200
+ const b = now.get(key);
201
+ if (!b) continue; // absent at this width: a structure change, not a property one
202
+ const len = Math.min(a.values.length, b.values.length);
203
+ for (let p = 0; p < len; p++) {
204
+ if (a.values[p] === b.values[p]) continue;
205
+ const prop = p < DISCRETE_PROPS.length ? DISCRETE_PROPS[p] : "gridColumns";
206
+ changes.push({ label: a.label, prop, from: a.values[p], to: b.values[p] });
207
+ if (changes.length >= 4) return changes;
208
+ }
209
+ }
210
+
211
+ if (!changes.length) {
212
+ if (before.visibleCount !== after.visibleCount) {
213
+ changes.push({
214
+ label: "element count",
215
+ prop: "visible elements",
216
+ from: String(before.visibleCount),
217
+ to: String(after.visibleCount),
218
+ });
219
+ } else if (before.rowCount !== after.rowCount) {
220
+ changes.push({
221
+ label: "layout",
222
+ prop: "rows",
223
+ from: String(before.rowCount),
224
+ to: String(after.rowCount),
225
+ });
226
+ }
227
+ }
228
+
229
+ return changes;
230
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Turn a sweep result into text worth pasting somewhere else.
3
+ *
4
+ * The panel is built for looking: findings sit on a ruler, and clicking one
5
+ * sends the frame to that width. None of that survives a copy. What survives is
6
+ * this: the component, the range it was rendered across, and every finding with
7
+ * the width it happens at.
8
+ *
9
+ * It is written to be read cold, by a person or a model that has none of the
10
+ * panel's context, so each section says what its numbers mean rather than
11
+ * assuming the reader knows what "overflow" measures here. Ranges use a plain
12
+ * hyphen so nothing depends on the reader handling an en dash.
13
+ *
14
+ * Pure, and separate from the panel, so the wording can be tested without a DOM.
15
+ *
16
+ * @param {Partial<import("./run-sweep.js").SweepResult>} [result] what `runSweep` returned
17
+ * @param {{ name?: string, file?: string } | null} [subject] the component swept
18
+ * @returns {string}
19
+ */
20
+ export function buildReport(result = {}, subject) {
21
+ const { min, max, breakpoints = [], overflowRanges = [] } = result;
22
+
23
+ const who = subject?.name
24
+ ? subject.file
25
+ ? `${subject.name} (${subject.file})`
26
+ : subject.name
27
+ : "the previewed component";
28
+
29
+ const lines = [`nora sweep of ${who}`, `Rendered at every width from ${min}px to ${max}px.`];
30
+
31
+ if (overflowRanges.length) {
32
+ lines.push("", "OVERFLOW (content reaching past the viewport)");
33
+ for (const r of overflowRanges) {
34
+ const culprit = r.offenders?.[0]?.label;
35
+ lines.push(` ${r.from}-${r.to}px by up to ${r.worst}px` + (culprit ? ` ${culprit}` : ""));
36
+ }
37
+ }
38
+
39
+ if (breakpoints.length) {
40
+ lines.push("", "LAYOUT TRANSITIONS (a width where a CSS rule starts or stops applying)");
41
+ for (const b of breakpoints) {
42
+ const what = b.changes?.length
43
+ ? b.changes.map((c) => `${c.prop}: ${c.from} → ${c.to}`).join(", ")
44
+ : `reflows ${b.rowsBefore} → ${b.rowsAfter} rows`;
45
+ lines.push(` ${b.width}px ${what}`);
46
+ }
47
+ }
48
+
49
+ if (!overflowRanges.length && !breakpoints.length) {
50
+ lines.push("", "No overflow and no layout transitions.");
51
+ }
52
+
53
+ return lines.join("\n");
54
+ }
@@ -0,0 +1,185 @@
1
+ import { measure, diffSamples } from "./measure.js";
2
+
3
+ export const SWEEP_MIN = 320;
4
+ export const SWEEP_MAX = 1600;
5
+ const COARSE_STEP = 16;
6
+
7
+ /** Two frames is enough for CSS layout to settle after a width change. */
8
+ function settle() {
9
+ return new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
10
+ }
11
+
12
+ /**
13
+ * Measure until two consecutive reads agree.
14
+ *
15
+ * CSS settles in a frame, but a component that subscribes to `resize` and calls
16
+ * setState needs React to re-render first — so a single read can catch the
17
+ * previous width's DOM and invent a breakpoint that isn't there. Requiring two
18
+ * matching reads costs one extra frame and removes that whole class of ghost.
19
+ */
20
+ async function stableMeasure(readDoc, w, detailed, attempts = 3) {
21
+ let previous = measure(readDoc(), w, detailed);
22
+ for (let i = 0; i < attempts; i++) {
23
+ await settle();
24
+ const next = measure(readDoc(), w, detailed);
25
+ if (next.hash === previous.hash) return next;
26
+ previous = next;
27
+ }
28
+ return previous;
29
+ }
30
+
31
+ /**
32
+ * Sweep the frame across a width range and report what happened.
33
+ *
34
+ * Coarse pass first, then bisection. Sampling every pixel from 320 to 1600
35
+ * would be 1280 layouts for no benefit — layout only changes at a handful of
36
+ * widths, so the cheap pass finds the 16px bracket a change falls in and the
37
+ * bisection narrows it to the exact pixel in four more steps.
38
+ *
39
+ * @param {object} io
40
+ * @param {(w:number)=>void} io.setWidth drive the frame to a width
41
+ * @param {()=>Document} io.readDoc read the frame document
42
+ * @param {(p:number)=>void} [io.onProgress]
43
+ */
44
+ export async function runSweep({ setWidth, readDoc, onProgress }) {
45
+ const sample = async (w, detailed = false) => {
46
+ setWidth(w);
47
+ await settle();
48
+ return stableMeasure(readDoc, w, detailed);
49
+ };
50
+
51
+ // Warm up at the starting width before recording anything, so the first
52
+ // sample isn't a stale read of whatever width the frame was already at.
53
+ setWidth(SWEEP_MIN);
54
+ await settle();
55
+ await stableMeasure(readDoc, SWEEP_MIN, false);
56
+
57
+ // --- coarse pass --------------------------------------------------------
58
+ const coarse = [];
59
+ const total = Math.floor((SWEEP_MAX - SWEEP_MIN) / COARSE_STEP) + 1;
60
+
61
+ for (let i = 0; i < total; i++) {
62
+ const w = SWEEP_MIN + i * COARSE_STEP;
63
+ coarse.push(await sample(w));
64
+ onProgress?.(((i + 1) / total) * 0.8);
65
+ }
66
+
67
+ // --- refine layout transitions -----------------------------------------
68
+ const breakpoints = [];
69
+ for (let i = 1; i < coarse.length; i++) {
70
+ if (coarse[i].hash === coarse[i - 1].hash) continue;
71
+
72
+ const exact = await bisect(coarse[i - 1].width, coarse[i].width, coarse[i - 1].hash, sample);
73
+
74
+ // Re-measure either side in detail so we can say what changed.
75
+ const before = await sample(exact - 1, true);
76
+ const after = await sample(exact, true);
77
+
78
+ breakpoints.push({
79
+ width: exact,
80
+ changes: diffSamples(before, after),
81
+ rowsBefore: before.rowCount,
82
+ rowsAfter: after.rowCount,
83
+ });
84
+
85
+ if (breakpoints.length >= 12) break;
86
+ }
87
+
88
+ onProgress?.(0.9);
89
+
90
+ // --- overflow ranges ----------------------------------------------------
91
+ const ranges = [];
92
+ let open = null;
93
+ for (const s of coarse) {
94
+ if (s.overflows && !open) {
95
+ open = { from: s.width, to: s.width, worst: s.overflowBy, offenders: s.offenders };
96
+ } else if (s.overflows && open) {
97
+ open.to = s.width;
98
+ if (s.overflowBy > open.worst) {
99
+ open.worst = s.overflowBy;
100
+ open.offenders = s.offenders;
101
+ }
102
+ } else if (!s.overflows && open) {
103
+ ranges.push(open);
104
+ open = null;
105
+ }
106
+ }
107
+ if (open) ranges.push(open);
108
+
109
+ // Narrow each range's edges to the exact pixel where overflow starts or ends.
110
+ for (const range of ranges) {
111
+ if (range.from > SWEEP_MIN) {
112
+ range.from = await bisectFlag(
113
+ range.from - COARSE_STEP,
114
+ range.from,
115
+ (s) => s.overflows,
116
+ sample,
117
+ );
118
+ }
119
+ if (range.to < SWEEP_MAX) {
120
+ const end = await bisectFlag(range.to, range.to + COARSE_STEP, (s) => !s.overflows, sample);
121
+ range.to = end - 1;
122
+ }
123
+ }
124
+
125
+ onProgress?.(1);
126
+
127
+ return {
128
+ min: SWEEP_MIN,
129
+ max: SWEEP_MAX,
130
+ breakpoints,
131
+ overflowRanges: ranges,
132
+ samples: coarse.map((s) => ({ width: s.width, overflows: s.overflows, height: s.height })),
133
+ };
134
+ }
135
+
136
+ /**
137
+ * A width range where content reached past the viewport.
138
+ *
139
+ * @typedef {object} SweepOverflow
140
+ * @property {number} from
141
+ * @property {number} to
142
+ * @property {number} worst the worst overhang across the range, in px
143
+ * @property {{ label: string }[]} [offenders] the elements responsible, worst first
144
+ */
145
+
146
+ /**
147
+ * A width at which the layout genuinely changed shape.
148
+ *
149
+ * @typedef {object} SweepBreakpoint
150
+ * @property {number} width
151
+ * @property {{ prop: string, from: string, to: string }[]} [changes] discrete properties that flipped
152
+ * @property {number} [rowsBefore] when nothing discrete changed, the row count either side
153
+ * @property {number} [rowsAfter]
154
+ */
155
+
156
+ /**
157
+ * @typedef {object} SweepResult
158
+ * @property {number} min
159
+ * @property {number} max
160
+ * @property {SweepBreakpoint[]} breakpoints
161
+ * @property {SweepOverflow[]} overflowRanges
162
+ * @property {{ width: number, overflows: boolean, height: number }[]} samples
163
+ */
164
+
165
+ /** Smallest width in (lo, hi] whose hash differs from loHash. */
166
+ async function bisect(lo, hi, loHash, sample) {
167
+ while (hi - lo > 1) {
168
+ const mid = Math.floor((lo + hi) / 2);
169
+ const s = await sample(mid);
170
+ if (s.hash === loHash) lo = mid;
171
+ else hi = mid;
172
+ }
173
+ return hi;
174
+ }
175
+
176
+ /** Smallest width in (lo, hi] where predicate(sample) first holds. */
177
+ async function bisectFlag(lo, hi, predicate, sample) {
178
+ while (hi - lo > 1) {
179
+ const mid = Math.floor((lo + hi) / 2);
180
+ const s = await sample(mid);
181
+ if (predicate(s)) hi = mid;
182
+ else lo = mid;
183
+ }
184
+ return hi;
185
+ }