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.
- package/LICENSE +21 -0
- package/NOTICE +26 -0
- package/README.md +113 -0
- package/licenses/geist-OFL.txt +93 -0
- package/licenses/lucide-ISC.txt +17 -0
- package/package.json +90 -0
- package/src/cli.js +101 -0
- package/src/client/App.jsx +216 -0
- package/src/client/canvas/Canvas.jsx +95 -0
- package/src/client/canvas/ErrorBoundary.jsx +56 -0
- package/src/client/canvas/Preview.jsx +70 -0
- package/src/client/canvas/Viewport.jsx +211 -0
- package/src/client/chords.js +38 -0
- package/src/client/css.d.ts +5 -0
- package/src/client/fonts.css +48 -0
- package/src/client/frame.css +107 -0
- package/src/client/frame.html +11 -0
- package/src/client/frame.jsx +73 -0
- package/src/client/index.html +12 -0
- package/src/client/main.jsx +10 -0
- package/src/client/open-folder.js +84 -0
- package/src/client/selection.js +22 -0
- package/src/client/styles.css +1264 -0
- package/src/client/sweep/SweepPanel.jsx +206 -0
- package/src/client/sweep/measure.js +230 -0
- package/src/client/sweep/report.js +54 -0
- package/src/client/sweep/run-sweep.js +185 -0
- package/src/client/toolbar/Picker.jsx +343 -0
- package/src/client/toolbar/Toolbar.jsx +231 -0
- package/src/client/toolbar/ViewportMenu.jsx +83 -0
- package/src/client/toolbar/bar-shape.js +402 -0
- package/src/client/toolbar/icons.jsx +129 -0
- package/src/client/toolbar/use-draggable-bar.js +204 -0
- package/src/client/viewports.js +43 -0
- package/src/client/virtual.d.ts +28 -0
- package/src/index.js +4 -0
- package/src/server/create-server.js +147 -0
- package/src/server/plugin.js +207 -0
- package/src/server/safe-path.js +42 -0
- package/src/server/scan.js +350 -0
- package/types/index.d.ts +3 -0
- package/types/server/create-server.d.ts +22 -0
- package/types/server/plugin.d.ts +20 -0
- package/types/server/safe-path.d.ts +25 -0
- 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
|
+
}
|