gap-inspector 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/dist/index.js ADDED
@@ -0,0 +1,878 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import { inferAxis, inspectPoint, measureGap, resolveSelector } from "./measurement";
5
+ import { gapInspectorStyles } from "./styles";
6
+ export function GapInspector({ initiallyOpen = false, onMeasure }) {
7
+ const rootRef = useRef(null);
8
+ const [open, setOpen] = useState(initiallyOpen);
9
+ const [drag, setDrag] = useState(null);
10
+ const dragRef = useRef(null);
11
+ const [measurement, setMeasurement] = useState(null);
12
+ const [pointInspection, setPointInspection] = useState(null);
13
+ const [copyState, setCopyState] = useState("idle");
14
+ const [preview, setPreview] = useState(null);
15
+ const [passThrough, setPassThrough] = useState(false);
16
+ const panelRef = useRef(null);
17
+ const launcherRef = useRef(null);
18
+ const gripRef = useRef(null);
19
+ const suppressLauncherClickRef = useRef(false);
20
+ const [panelPosition, setPanelPosition] = useState(null);
21
+ const [hover, setHover] = useState(null);
22
+ useEffect(() => {
23
+ setHover(null);
24
+ }, [measurement]);
25
+ const activeAxis = drag ? inferAxis(drag.start, drag.end) : "horizontal";
26
+ // Computed in pointer handlers (not during render): measureGap reads layout,
27
+ // and the boundary-scan fallback is skipped so dragging stays cheap on big pages.
28
+ function buildPreview(nextDrag) {
29
+ if (dragDistance(nextDrag) < 8) {
30
+ return null;
31
+ }
32
+ const previewReport = measureGap({
33
+ axis: inferAxis(nextDrag.start, nextDrag.end),
34
+ start: nextDrag.start,
35
+ end: nextDrag.end,
36
+ ignoreElements: [rootRef.current],
37
+ boundaryScan: false
38
+ });
39
+ if (!previewReport) {
40
+ return null;
41
+ }
42
+ const snapshot = buildOverlaySnapshot(previewReport);
43
+ return snapshot ? { measurement: previewReport, snapshot } : null;
44
+ }
45
+ useEffect(() => {
46
+ if (!open) {
47
+ return;
48
+ }
49
+ function handleKeyDown(event) {
50
+ if (event.key === "Alt") {
51
+ setPassThrough(true);
52
+ }
53
+ }
54
+ function handleKeyUp(event) {
55
+ if (event.key === "Alt") {
56
+ setPassThrough(false);
57
+ }
58
+ }
59
+ function handleBlur() {
60
+ setPassThrough(false);
61
+ }
62
+ window.addEventListener("keydown", handleKeyDown);
63
+ window.addEventListener("keyup", handleKeyUp);
64
+ window.addEventListener("blur", handleBlur);
65
+ return () => {
66
+ window.removeEventListener("keydown", handleKeyDown);
67
+ window.removeEventListener("keyup", handleKeyUp);
68
+ window.removeEventListener("blur", handleBlur);
69
+ setPassThrough(false);
70
+ };
71
+ }, [open]);
72
+ function beginMeasure(event) {
73
+ if (event.button !== 0) {
74
+ return;
75
+ }
76
+ event.currentTarget.setPointerCapture(event.pointerId);
77
+ const point = pointFromEvent(event);
78
+ const nextDrag = { start: point, end: point };
79
+ dragRef.current = nextDrag;
80
+ setDrag(nextDrag);
81
+ setPreview(null);
82
+ setCopyState("idle");
83
+ }
84
+ function updateMeasure(event) {
85
+ if (!dragRef.current) {
86
+ return;
87
+ }
88
+ const nextDrag = { ...dragRef.current, end: pointFromEvent(event) };
89
+ dragRef.current = nextDrag;
90
+ setDrag(nextDrag);
91
+ setPreview(buildPreview(nextDrag));
92
+ }
93
+ function finishMeasure(event) {
94
+ if (!dragRef.current) {
95
+ return;
96
+ }
97
+ const end = pointFromEvent(event);
98
+ const nextDrag = { ...dragRef.current, end };
99
+ dragRef.current = null;
100
+ const distance = dragDistance(nextDrag);
101
+ if (distance < 6) {
102
+ if (measurement || pointInspection) {
103
+ setMeasurement(null);
104
+ setPointInspection(null);
105
+ }
106
+ else {
107
+ const inspection = inspectPoint({
108
+ point: end,
109
+ ignoreElements: [rootRef.current]
110
+ });
111
+ setPointInspection(inspection ? toDocumentInspection(inspection) : inspection);
112
+ }
113
+ setDrag(null);
114
+ setPreview(null);
115
+ return;
116
+ }
117
+ const axis = inferAxis(nextDrag.start, nextDrag.end);
118
+ const report = measureGap({
119
+ axis,
120
+ start: nextDrag.start,
121
+ end,
122
+ ignoreElements: [rootRef.current]
123
+ });
124
+ if (report) {
125
+ setMeasurement(report);
126
+ setPointInspection(null);
127
+ onMeasure?.(report);
128
+ }
129
+ setDrag(null);
130
+ setPreview(null);
131
+ }
132
+ function cancelMeasure() {
133
+ dragRef.current = null;
134
+ setDrag(null);
135
+ setPreview(null);
136
+ }
137
+ // The pill and the panel share one position (their top-left corner), so the
138
+ // inspector stays where the user put it when collapsing or expanding.
139
+ function beginInspectorDrag(event, target) {
140
+ if (event.button !== 0 || !target) {
141
+ return;
142
+ }
143
+ if (event.target instanceof Element && event.target.closest(".gi-close")) {
144
+ return;
145
+ }
146
+ const rect = target.getBoundingClientRect();
147
+ gripRef.current = {
148
+ dx: event.clientX - rect.left,
149
+ dy: event.clientY - rect.top,
150
+ startX: event.clientX,
151
+ startY: event.clientY,
152
+ target,
153
+ moved: false
154
+ };
155
+ event.currentTarget.setPointerCapture(event.pointerId);
156
+ }
157
+ function updateInspectorDrag(event) {
158
+ const grip = gripRef.current;
159
+ if (!grip) {
160
+ return;
161
+ }
162
+ if (!grip.moved && Math.hypot(event.clientX - grip.startX, event.clientY - grip.startY) < 4) {
163
+ return;
164
+ }
165
+ grip.moved = true;
166
+ setPanelPosition(clampToViewport(event.clientX - grip.dx, event.clientY - grip.dy, grip.target));
167
+ }
168
+ function endInspectorDrag() {
169
+ suppressLauncherClickRef.current = Boolean(gripRef.current?.moved);
170
+ gripRef.current = null;
171
+ }
172
+ function handleLauncherClick() {
173
+ if (suppressLauncherClickRef.current) {
174
+ suppressLauncherClickRef.current = false;
175
+ return;
176
+ }
177
+ setOpen(true);
178
+ }
179
+ // Re-clamp whenever the visible element changes (pill <-> panel swap, window
180
+ // resize): the panel is much larger than the pill, so a position that suits
181
+ // one can put the other off screen.
182
+ useEffect(() => {
183
+ if (!panelPosition) {
184
+ return;
185
+ }
186
+ const target = open ? panelRef.current : launcherRef.current;
187
+ if (!target) {
188
+ return;
189
+ }
190
+ const clampNow = () => {
191
+ setPanelPosition((current) => current ? clampToViewport(current.x, current.y, target) : current);
192
+ };
193
+ clampNow();
194
+ window.addEventListener("resize", clampNow);
195
+ return () => {
196
+ window.removeEventListener("resize", clampNow);
197
+ };
198
+ }, [panelPosition !== null, open]);
199
+ async function copyMeasurement() {
200
+ const markdown = measurement?.markdown ?? pointInspection?.markdown;
201
+ if (!markdown) {
202
+ return;
203
+ }
204
+ try {
205
+ await navigator.clipboard.writeText(markdown);
206
+ setCopyState("copied");
207
+ }
208
+ catch {
209
+ setCopyState("failed");
210
+ }
211
+ }
212
+ if (!open) {
213
+ return (_jsxs("div", { className: "gi-root", ref: rootRef, children: [_jsx("style", { children: gapInspectorStyles }), _jsxs("button", { className: "gi-button", type: "button", ref: launcherRef, style: panelPosition
214
+ ? { left: panelPosition.x, top: panelPosition.y, right: "auto", bottom: "auto" }
215
+ : undefined, onPointerDown: (event) => beginInspectorDrag(event, launcherRef.current), onPointerMove: updateInspectorDrag, onPointerUp: endInspectorDrag, onPointerCancel: endInspectorDrag, onClick: handleLauncherClick, children: [_jsx("span", { className: "gi-button-mark", "aria-hidden": "true" }), "Gap Inspector"] })] }));
216
+ }
217
+ return (_jsxs("div", { className: "gi-root", ref: rootRef, children: [_jsx("style", { children: gapInspectorStyles }), _jsx("div", { className: "gi-canvas", "data-passthrough": passThrough ? "true" : undefined, onPointerDown: beginMeasure, onPointerMove: updateMeasure, onPointerUp: finishMeasure, onPointerCancel: cancelMeasure }), typeof document === "undefined"
218
+ ? null
219
+ : createPortal(_jsx(MeasurementOverlay, { drag: drag, axis: activeAxis, measurement: measurement, pointInspection: pointInspection, preview: preview, hover: hover }), document.body), _jsxs("section", { className: "gi-panel", "aria-label": "Gap Inspector", ref: panelRef, style: panelPosition
220
+ ? { left: panelPosition.x, top: panelPosition.y, right: "auto", bottom: "auto" }
221
+ : undefined, children: [_jsxs("div", { className: "gi-body", children: [_jsxs("div", { className: "gi-header", onPointerDown: (event) => beginInspectorDrag(event, panelRef.current), onPointerMove: updateInspectorDrag, onPointerUp: endInspectorDrag, onPointerCancel: endInspectorDrag, children: [_jsx("div", { className: "gi-title", children: "Gap Inspector" }), _jsx("button", { className: "gi-close", type: "button", "aria-label": "Close Gap Inspector", onClick: () => setOpen(false), children: _jsx("svg", { width: "12", height: "12", viewBox: "0 0 12 12", "aria-hidden": "true", children: _jsx("path", { d: "M3 3l6 6M9 3l-6 6", stroke: "currentColor", strokeWidth: "1.4", strokeLinecap: "round" }) }) })] }), _jsx("div", { className: "gi-content", children: measurement ? (_jsx(Report, { measurement: measurement, hover: hover, onHover: setHover })) : pointInspection ? (_jsx(PointReport, { inspection: pointInspection })) : (_jsxs("div", { className: "gi-empty", children: [_jsx("p", { children: "Draw a line between two rendered edges to measure the gap and see which CSS declarations produce it. Click once to inspect a point." }), _jsxs("div", { className: "gi-hint", children: [_jsx("span", { className: "gi-kbd", children: "\u2325" }), _jsx("span", { children: "Hold Alt to interact with the page underneath." })] })] })) })] }), measurement || pointInspection ? (_jsxs("div", { className: "gi-footer", children: [_jsx("button", { className: "gi-ghost", type: "button", onClick: () => {
222
+ setMeasurement(null);
223
+ setPointInspection(null);
224
+ }, children: "Clear" }), _jsx("button", { className: "gi-primary", "data-state": copyState, type: "button", onClick: copyMeasurement, children: copyState === "copied" ? "Copied" : copyState === "failed" ? "Failed" : "Copy report" })] })) : null] })] }));
225
+ }
226
+ // A miniature of the measured gap: contributions are in geometric order along
227
+ // the axis, so each segment sits where that spacing actually renders.
228
+ function SpacingBar({ measurement, onHover }) {
229
+ if (measurement.totalPx < 0.5) {
230
+ return null;
231
+ }
232
+ return (_jsxs("div", { className: "gi-bar", role: "img", "aria-label": measurement.equation, children: [measurement.contributions.map((contribution, index) => (_jsx("span", { className: `gi-bar-segment gi-kind-${contribution.kind}`, style: { flexGrow: contribution.valuePx }, onPointerEnter: (event) => onHover(contributionHover(contribution, index, event.currentTarget)), onPointerLeave: () => onHover(null) }, `${contribution.selector}-${contribution.property}-${index}`))), measurement.unattributedPx > 0.49 ? (_jsx("span", { className: "gi-bar-segment gi-bar-unattributed", style: { flexGrow: measurement.unattributedPx }, onPointerEnter: (event) => onHover(unattributedHover(measurement, event.currentTarget)), onPointerLeave: () => onHover(null) })) : null] }));
233
+ }
234
+ function contributionHover(contribution, index, target) {
235
+ const element = liveElement(contribution.element);
236
+ let meta;
237
+ if (element) {
238
+ const rect = element.getBoundingClientRect();
239
+ meta = `${element.tagName.toLowerCase()} \u00b7 ${getComputedStyle(element).display} \u00b7 ${roundPx(rect.width)} \u00d7 ${roundPx(rect.height)}px`;
240
+ }
241
+ const anchor = target.getBoundingClientRect();
242
+ return {
243
+ kind: contribution.kind,
244
+ index,
245
+ property: contribution.property,
246
+ valuePx: contribution.valuePx,
247
+ cssValue: contribution.cssValue,
248
+ selector: contribution.selector,
249
+ meta,
250
+ note: contribution.note,
251
+ element: contribution.element,
252
+ anchor: { left: anchor.left, top: anchor.top, bottom: anchor.bottom }
253
+ };
254
+ }
255
+ function unattributedHover(measurement, target) {
256
+ const anchor = target.getBoundingClientRect();
257
+ return {
258
+ kind: "unknown",
259
+ index: measurement.contributions.length,
260
+ property: "unattributed space",
261
+ valuePx: measurement.unattributedPx,
262
+ note: "Rendered space not tied to a direct margin, padding, border, or gap declaration.",
263
+ anchor: { left: anchor.left, top: anchor.top, bottom: anchor.bottom }
264
+ };
265
+ }
266
+ function HoverCard({ info }) {
267
+ const width = 280;
268
+ const left = Math.min(Math.max(info.anchor.left, 8), Math.max(8, window.innerWidth - width - 8));
269
+ const placeBelow = info.anchor.top < 240;
270
+ const style = placeBelow
271
+ ? { left, top: info.anchor.bottom + 8, width }
272
+ : { left, bottom: window.innerHeight - info.anchor.top + 8, width };
273
+ return (_jsxs("div", { className: `gi-hovercard gi-kind-${info.kind}`, style: style, children: [_jsxs("div", { className: "gi-hovercard-row", children: [_jsx("span", { className: "gi-kind-label", children: info.kind }), _jsxs("span", { className: "gi-hovercard-prop", children: [info.property, info.cssValue ? _jsxs("span", { className: "gi-contrib-css", children: [" \u00B7 ", info.cssValue] }) : null] }), _jsx("span", { className: "gi-hovercard-value", children: formatPx(info.valuePx) })] }), info.selector ? _jsx("div", { className: "gi-hovercard-selector", children: info.selector }) : null, info.meta ? _jsx("div", { className: "gi-hovercard-meta", children: info.meta }) : null, info.note ? _jsx("div", { className: "gi-hovercard-note", children: info.note }) : null] }));
274
+ }
275
+ function Report({ measurement, hover, onHover }) {
276
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "gi-report-title", children: [_jsx("span", { className: "gi-metric", children: formatPx(measurement.totalPx) }), " ", _jsx("span", { className: "gi-metric-axis", children: measurement.axis })] }), _jsx(SpacingBar, { measurement: measurement, onHover: onHover }), measurement.contributions.length ? (_jsxs("div", { className: "gi-contribs", children: [_jsx("div", { className: "gi-caps", children: "Contributors" }), measurement.contributions.map((contribution, index) => (_jsxs("div", { className: `gi-contrib gi-kind-${contribution.kind}`, "data-kind": contribution.kind, onPointerEnter: (event) => onHover(contributionHover(contribution, index, event.currentTarget)), onPointerLeave: () => onHover(null), children: [_jsxs("div", { className: "gi-contrib-row", children: [_jsx("span", { className: "gi-kind-label", children: contribution.kind }), _jsxs("span", { className: "gi-contrib-prop", children: [contribution.property, contribution.cssValue ? (_jsxs("span", { className: "gi-contrib-css", children: [" \u00B7 ", contribution.cssValue] })) : null] }), _jsx("span", { className: "gi-contrib-value", children: formatPx(contribution.valuePx) })] }), contribution.note ? _jsx("div", { className: "gi-contrib-note", children: contribution.note }) : null] }, `${contribution.selector}-${contribution.property}-${index}`)))] })) : null, measurement.warnings.map((warning) => (_jsx("div", { className: "gi-warning", children: warning }, warning))), hover ? _jsx(HoverCard, { info: hover }) : null] }));
277
+ }
278
+ function PointReport({ inspection }) {
279
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: `gi-report-title gi-kind-${inspection.region}`, children: inspection.totalPx !== undefined ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "gi-metric", children: formatPx(inspection.totalPx) }), " ", _jsx("span", { className: "gi-metric-axis", children: inspection.region })] })) : (_jsx("span", { className: "gi-metric", children: inspection.region })) }), _jsxs("div", { className: "gi-equation", children: [inspection.property ?? inspection.region, inspection.cssValue ? ` (${inspection.cssValue})` : ""] }), inspection.note ? _jsx("div", { className: "gi-contrib-note", children: inspection.note }) : null] }));
280
+ }
281
+ function MeasurementOverlay({ drag, axis, measurement, pointInspection, preview, hover }) {
282
+ const snapshot = useLiveOverlaySnapshot(measurement);
283
+ const pointSnapshot = useLivePointSnapshot(pointInspection);
284
+ return (_jsxs("svg", { className: "gi-svg", width: "100%", height: "100%", "aria-hidden": "true", children: [measurement && snapshot && !drag ? (_jsx(MeasurementRects, { measurement: measurement, snapshot: snapshot, variant: "committed", hover: hover })) : null, pointInspection && pointSnapshot && !drag ? (_jsx(PointInspectionRects, { inspection: pointInspection, snapshot: pointSnapshot })) : null, drag && preview ? (_jsx(MeasurementRects, { measurement: preview.measurement, snapshot: preview.snapshot, variant: "preview", line: lineFromDrag(preview.measurement.axis, drag) })) : null, drag ? _jsx(DragLine, { drag: drag, axis: axis }) : null] }));
285
+ }
286
+ function DragLine({ drag, axis }) {
287
+ const start = toDocumentPoint(drag.start);
288
+ const end = toDocumentPoint(drag.end);
289
+ const x1 = axis === "horizontal" ? start.x : (start.x + end.x) / 2;
290
+ const x2 = axis === "horizontal" ? end.x : (start.x + end.x) / 2;
291
+ const y1 = axis === "horizontal" ? (start.y + end.y) / 2 : start.y;
292
+ const y2 = axis === "horizontal" ? (start.y + end.y) / 2 : end.y;
293
+ return _jsx("line", { className: "gi-line", x1: x1, y1: y1, x2: x2, y2: y2 });
294
+ }
295
+ function MeasurementRects({ measurement, snapshot, variant, line, hover }) {
296
+ const from = snapshot.from;
297
+ const to = snapshot.to;
298
+ const geometry = bandGeometry(measurement, from, to, line);
299
+ const band = geometry
300
+ ? rectFromBand(measurement.axis, geometry.start, geometry.end, geometry.perp, GAP_BAND_THICKNESS)
301
+ : null;
302
+ const { strips, remainder } = geometry
303
+ ? contributionStrips(measurement, geometry)
304
+ : { strips: [], remainder: null };
305
+ const hoverStrip = hover
306
+ ? hover.index === measurement.contributions.length
307
+ ? remainder
308
+ : strips[hover.index] ?? null
309
+ : null;
310
+ const hoverRect = hover && hoverStrip
311
+ ? hoverHighlightRect(measurement.axis, hoverStrip, hover)
312
+ : null;
313
+ const fromEdge = measurement.internalSide
314
+ ? internalEdgeMarkerRect(measurement.axis, from, measurement.internalSide, "container")
315
+ : edgeMarkerRect(measurement.axis, from, "from");
316
+ const toEdge = measurement.internalSide
317
+ ? internalEdgeMarkerRect(measurement.axis, to, measurement.internalSide, "child")
318
+ : edgeMarkerRect(measurement.axis, to, "to");
319
+ const prefix = variant === "preview" ? "gi-preview" : "gi-committed";
320
+ return (_jsxs(_Fragment, { children: [snapshot.series.map((series, index) => (_jsx("rect", { className: `gi-series-box ${prefix}-series gi-kind-${series.kind}`, ...svgRect(series.rect) }, `${series.kind}-${index}`))), _jsx("rect", { className: `gi-element-box ${prefix}-box gi-from-box`, ...svgRect(from) }), _jsx("rect", { className: `gi-element-box ${prefix}-box gi-to-box`, ...svgRect(to) }), band ? _jsx("rect", { className: `gi-gap-band ${prefix}-gap`, ...svgRect(band) }) : null, strips.map((strip, index) => strip ? (_jsx("rect", { className: `gi-contributor-box ${prefix}-contributor gi-kind-${measurement.contributions[index].kind}`, ...svgRect(strip) }, `${measurement.contributions[index].kind}-${index}`)) : null), hover && hoverRect ? (_jsx("rect", { className: `gi-hover-box gi-kind-${hover.kind}`, ...svgRect(hoverRect) })) : null, _jsx("rect", { className: `gi-edge-marker ${prefix}-edge gi-from-edge`, ...svgRect(fromEdge) }), _jsx("rect", { className: `gi-edge-marker ${prefix}-edge gi-to-edge`, ...svgRect(toEdge) })] }));
321
+ }
322
+ const GAP_BAND_THICKNESS = 12;
323
+ // The span of the measured gap along the axis plus the perpendicular center of
324
+ // the drawn line (or the from/to overlap when re-rendered without a line).
325
+ function bandGeometry(measurement, from, to, line) {
326
+ const horizontal = measurement.axis === "horizontal";
327
+ const props = horizontal
328
+ ? { start: "left", end: "right", perpStart: "top", perpEnd: "bottom" }
329
+ : { start: "top", end: "bottom", perpStart: "left", perpEnd: "right" };
330
+ let start;
331
+ let end;
332
+ let fallbackPerp;
333
+ if (measurement.internalSide) {
334
+ start = measurement.internalSide === "before" ? from[props.start] : to[props.end];
335
+ end = measurement.internalSide === "before" ? to[props.start] : from[props.end];
336
+ fallbackPerp = (to[props.perpStart] + to[props.perpEnd]) / 2;
337
+ }
338
+ else {
339
+ start = from[props.end];
340
+ end = to[props.start];
341
+ fallbackPerp =
342
+ (Math.max(Math.min(from[props.perpStart], to[props.perpStart]), 0)
343
+ + Math.max(from[props.perpEnd], to[props.perpEnd])) / 2;
344
+ }
345
+ const overlapStart = Math.max(from[props.perpStart], to[props.perpStart]);
346
+ const overlapEnd = Math.min(from[props.perpEnd], to[props.perpEnd]);
347
+ const perp = line?.perp ?? (overlapEnd > overlapStart ? (overlapStart + overlapEnd) / 2 : fallbackPerp);
348
+ return end > start ? { start, end, perp } : null;
349
+ }
350
+ function rectFromBand(axis, start, end, perp, thickness) {
351
+ if (axis === "horizontal") {
352
+ return {
353
+ left: start,
354
+ right: end,
355
+ top: perp - thickness / 2,
356
+ bottom: perp + thickness / 2,
357
+ width: end - start,
358
+ height: thickness
359
+ };
360
+ }
361
+ return {
362
+ left: perp - thickness / 2,
363
+ right: perp + thickness / 2,
364
+ top: start,
365
+ bottom: end,
366
+ width: thickness,
367
+ height: end - start
368
+ };
369
+ }
370
+ // Contributions are in geometric order, so stacking their values from the gap's
371
+ // start edge places each strip where that spacing actually renders. The array
372
+ // is index-aligned with measurement.contributions (null for sub-pixel strips).
373
+ function contributionStrips(measurement, geometry) {
374
+ const strips = [];
375
+ let cursor = geometry.start;
376
+ for (const contribution of measurement.contributions) {
377
+ const next = Math.min(cursor + contribution.valuePx, geometry.end);
378
+ strips.push(next - cursor > 0.1
379
+ ? rectFromBand(measurement.axis, cursor, next, geometry.perp, GAP_BAND_THICKNESS)
380
+ : null);
381
+ cursor = next;
382
+ }
383
+ const remainder = geometry.end - cursor > 0.1
384
+ ? rectFromBand(measurement.axis, cursor, geometry.end, geometry.perp, GAP_BAND_THICKNESS)
385
+ : null;
386
+ return { strips, remainder };
387
+ }
388
+ // The hovered contribution's slice along the axis, extended across its
389
+ // element's full perpendicular extent — the actual spacing region (a padding
390
+ // slice spans the container's full height, a margin slice its element's, etc.).
391
+ // Falls back to a thickened band slice when there is no live element (e.g. the
392
+ // unattributed remainder).
393
+ function hoverHighlightRect(axis, strip, hover) {
394
+ const blockRect = hover.element ? liveRect(hover.element) : null;
395
+ if (!blockRect) {
396
+ return expandAcross(strip, axis, 4);
397
+ }
398
+ return axis === "horizontal"
399
+ ? {
400
+ left: strip.left,
401
+ right: strip.right,
402
+ top: blockRect.top,
403
+ bottom: blockRect.bottom,
404
+ width: strip.width,
405
+ height: blockRect.bottom - blockRect.top
406
+ }
407
+ : {
408
+ left: blockRect.left,
409
+ right: blockRect.right,
410
+ top: strip.top,
411
+ bottom: strip.bottom,
412
+ width: blockRect.right - blockRect.left,
413
+ height: strip.height
414
+ };
415
+ }
416
+ function expandAcross(rect, axis, amount) {
417
+ return axis === "horizontal"
418
+ ? { ...rect, top: rect.top - amount, bottom: rect.bottom + amount, height: rect.height + amount * 2 }
419
+ : { ...rect, left: rect.left - amount, right: rect.right + amount, width: rect.width + amount * 2 };
420
+ }
421
+ function PointInspectionRects({ inspection, snapshot }) {
422
+ const kindClass = pointKindClass(inspection.region);
423
+ return (_jsxs(_Fragment, { children: [snapshot.from ? _jsx("rect", { className: "gi-element-box gi-committed-box", ...svgRect(snapshot.from) }) : null, snapshot.to ? _jsx("rect", { className: "gi-element-box gi-committed-box", ...svgRect(snapshot.to) }) : null, snapshot.element ? _jsx("rect", { className: "gi-element-box gi-committed-box", ...svgRect(snapshot.element) }) : null, snapshot.rect ? _jsx("rect", { className: `gi-point-region ${kindClass}`, ...svgRect(snapshot.rect) }) : null, _jsx("circle", { className: `gi-point-dot ${kindClass}`, cx: inspection.point.x, cy: inspection.point.y, r: 4 })] }));
424
+ }
425
+ function useLivePointSnapshot(inspection) {
426
+ return useLiveSnapshot(inspection, buildPointSnapshot);
427
+ }
428
+ function buildPointSnapshot(inspection) {
429
+ const rect = inspection.rect;
430
+ const element = inspection.element ? liveRect(inspection.element) ?? undefined : undefined;
431
+ const from = inspection.from ? liveRect(inspection.from) ?? undefined : undefined;
432
+ const to = inspection.to ? liveRect(inspection.to) ?? undefined : undefined;
433
+ const key = [
434
+ rect ? rectKey(rect) : "",
435
+ element ? rectKey(element) : "",
436
+ from ? rectKey(from) : "",
437
+ to ? rectKey(to) : ""
438
+ ].join("::");
439
+ if (!rect && !element && !from && !to) {
440
+ return null;
441
+ }
442
+ return { key, rect, element, from, to };
443
+ }
444
+ function useLiveOverlaySnapshot(measurement) {
445
+ return useLiveSnapshot(measurement, buildOverlaySnapshot);
446
+ }
447
+ // Snapshots are in document coordinates, so page scroll moves the overlay natively
448
+ // (no recompute needed for correctness there). Scroll events still trigger a recompute
449
+ // because nested scrollers change clipping and sticky/fixed elements move in document
450
+ // space; the key diff drops the update when nothing actually moved.
451
+ function useLiveSnapshot(input, build) {
452
+ const [snapshot, setSnapshot] = useState(null);
453
+ useEffect(() => {
454
+ if (!input) {
455
+ setSnapshot(null);
456
+ return;
457
+ }
458
+ let frame = 0;
459
+ // null = nothing emitted yet; "" = an empty result has been emitted.
460
+ let lastKey = null;
461
+ const recompute = () => {
462
+ frame = 0;
463
+ const nextSnapshot = build(input);
464
+ if (!nextSnapshot) {
465
+ if (lastKey !== "") {
466
+ lastKey = "";
467
+ setSnapshot(null);
468
+ }
469
+ return;
470
+ }
471
+ if (nextSnapshot.key !== lastKey) {
472
+ lastKey = nextSnapshot.key;
473
+ setSnapshot(nextSnapshot);
474
+ }
475
+ };
476
+ const schedule = () => {
477
+ if (!frame) {
478
+ frame = requestAnimationFrame(recompute);
479
+ }
480
+ };
481
+ recompute();
482
+ const resizeObserver = new ResizeObserver(schedule);
483
+ resizeObserver.observe(document.documentElement);
484
+ resizeObserver.observe(document.body);
485
+ const mutationObserver = new MutationObserver((records) => {
486
+ if (records.every(isInspectorMutation)) {
487
+ return;
488
+ }
489
+ schedule();
490
+ });
491
+ mutationObserver.observe(document.body, { attributes: true, childList: true, subtree: true });
492
+ window.addEventListener("scroll", schedule, { capture: true, passive: true });
493
+ window.addEventListener("resize", schedule);
494
+ window.addEventListener("transitionend", schedule, true);
495
+ window.addEventListener("animationend", schedule, true);
496
+ return () => {
497
+ if (frame) {
498
+ cancelAnimationFrame(frame);
499
+ }
500
+ resizeObserver.disconnect();
501
+ mutationObserver.disconnect();
502
+ window.removeEventListener("scroll", schedule, true);
503
+ window.removeEventListener("resize", schedule);
504
+ window.removeEventListener("transitionend", schedule, true);
505
+ window.removeEventListener("animationend", schedule, true);
506
+ };
507
+ }, [input, build]);
508
+ return snapshot;
509
+ }
510
+ function buildOverlaySnapshot(measurement) {
511
+ const from = liveRect(measurement.from);
512
+ const to = liveRect(measurement.to);
513
+ if (!from || !to) {
514
+ return null;
515
+ }
516
+ const occupiedKeys = new Set([rectKey(from), rectKey(to)]);
517
+ const series = repeatedSeriesRects(measurement, occupiedKeys);
518
+ const key = [
519
+ rectKey(from),
520
+ rectKey(to),
521
+ series.map((item) => `${item.kind}:${rectKey(item.rect)}`).join("|")
522
+ ].join("::");
523
+ return { key, from, to, series };
524
+ }
525
+ const SERIES_SCAN_LIMIT = 80;
526
+ const SERIES_RENDER_LIMIT = 28;
527
+ function repeatedSeriesRects(measurement, occupiedKeys) {
528
+ const rects = [];
529
+ const seen = new Set();
530
+ for (const contribution of measurement.contributions) {
531
+ const element = liveElement(contribution.element);
532
+ if (!element) {
533
+ continue;
534
+ }
535
+ const candidates = contribution.kind === "gap"
536
+ ? gapSeriesRects(measurement.axis, element, contribution.kind, occupiedKeys)
537
+ : siblingSeriesRects(element, contribution.kind, contribution.property, contribution.cssValue, occupiedKeys);
538
+ for (const candidate of candidates) {
539
+ const key = `${candidate.kind}:${rectKey(candidate.rect)}`;
540
+ if (seen.has(key)) {
541
+ continue;
542
+ }
543
+ seen.add(key);
544
+ rects.push(candidate);
545
+ if (rects.length >= SERIES_RENDER_LIMIT) {
546
+ return rects;
547
+ }
548
+ }
549
+ }
550
+ return rects;
551
+ }
552
+ function siblingSeriesRects(element, kind, property, cssValue, occupiedKeys) {
553
+ if (kind === "unknown") {
554
+ return [];
555
+ }
556
+ const parent = element.parentElement;
557
+ const cssProperty = normalizeCssProperty(property);
558
+ if (!parent || !cssProperty) {
559
+ return [];
560
+ }
561
+ const style = getComputedStyle(element);
562
+ const expectedValue = normalizeCssValue(cssValue ?? style.getPropertyValue(cssProperty));
563
+ if (!expectedValue) {
564
+ return [];
565
+ }
566
+ const signature = elementSeriesSignature(element);
567
+ const candidates = siblingWindow(parent, element);
568
+ const rects = [];
569
+ for (const candidate of candidates) {
570
+ if (candidate === element || elementSeriesSignature(candidate) !== signature) {
571
+ continue;
572
+ }
573
+ const candidateValue = normalizeCssValue(getComputedStyle(candidate).getPropertyValue(cssProperty));
574
+ if (candidateValue !== expectedValue) {
575
+ continue;
576
+ }
577
+ const rect = visibleRectForElement(candidate);
578
+ if (!rect || occupiedKeys.has(rectKey(rect))) {
579
+ continue;
580
+ }
581
+ rects.push({ kind, rect });
582
+ if (rects.length >= SERIES_RENDER_LIMIT) {
583
+ break;
584
+ }
585
+ }
586
+ return rects;
587
+ }
588
+ function gapSeriesRects(axis, container, kind, occupiedKeys) {
589
+ const children = Array.from(container.children)
590
+ .slice(0, SERIES_SCAN_LIMIT)
591
+ .map((element) => ({ element, rect: visibleRectForElement(element) }))
592
+ .filter((item) => Boolean(item.rect));
593
+ if (children.length < 2) {
594
+ return [];
595
+ }
596
+ const rects = [];
597
+ const sorted = children.sort((a, b) => axisStart(axis, a.rect) - axisStart(axis, b.rect));
598
+ for (const child of sorted) {
599
+ if (!occupiedKeys.has(rectKey(child.rect))) {
600
+ rects.push({ kind, rect: child.rect });
601
+ }
602
+ if (rects.length >= SERIES_RENDER_LIMIT) {
603
+ return rects;
604
+ }
605
+ }
606
+ for (let index = 0; index < sorted.length - 1; index += 1) {
607
+ const before = sorted[index].rect;
608
+ const after = sorted[index + 1].rect;
609
+ const band = repeatedGapBandRect(axis, before, after);
610
+ if (!band || occupiedKeys.has(rectKey(band))) {
611
+ continue;
612
+ }
613
+ rects.push({ kind, rect: band });
614
+ if (rects.length >= SERIES_RENDER_LIMIT) {
615
+ break;
616
+ }
617
+ }
618
+ return rects;
619
+ }
620
+ function siblingWindow(parent, element) {
621
+ const siblings = Array.from(parent.children);
622
+ const index = siblings.indexOf(element);
623
+ if (index === -1 || siblings.length <= SERIES_SCAN_LIMIT) {
624
+ return siblings.slice(0, SERIES_SCAN_LIMIT);
625
+ }
626
+ const halfWindow = Math.floor(SERIES_SCAN_LIMIT / 2);
627
+ const start = Math.max(0, Math.min(index - halfWindow, siblings.length - SERIES_SCAN_LIMIT));
628
+ return siblings.slice(start, start + SERIES_SCAN_LIMIT);
629
+ }
630
+ function normalizeCssProperty(property) {
631
+ const normalized = property.trim().replace(/\s+/g, "-");
632
+ if (!normalized || normalized.includes("layout") || normalized.includes("space-between")) {
633
+ return null;
634
+ }
635
+ return normalized;
636
+ }
637
+ function normalizeCssValue(value) {
638
+ const normalized = value?.trim();
639
+ return normalized && normalized !== "normal" && normalized !== "auto" ? normalized : null;
640
+ }
641
+ function elementSeriesSignature(element) {
642
+ return [
643
+ element.tagName.toLowerCase(),
644
+ Array.from(element.classList).sort().join(".")
645
+ ].join(":");
646
+ }
647
+ function axisStart(axis, rect) {
648
+ return axis === "horizontal" ? rect.left : rect.top;
649
+ }
650
+ function repeatedGapBandRect(axis, before, after) {
651
+ if (axis === "horizontal") {
652
+ const left = before.right;
653
+ const right = after.left;
654
+ const top = Math.max(before.top, after.top);
655
+ const bottom = Math.min(before.bottom, after.bottom);
656
+ return right > left && bottom > top
657
+ ? { left, right, top, bottom, width: roundPx(right - left), height: roundPx(bottom - top) }
658
+ : null;
659
+ }
660
+ const top = before.bottom;
661
+ const bottom = after.top;
662
+ const left = Math.max(before.left, after.left);
663
+ const right = Math.min(before.right, after.right);
664
+ return bottom > top && right > left
665
+ ? { left, right, top, bottom, width: roundPx(right - left), height: roundPx(bottom - top) }
666
+ : null;
667
+ }
668
+ function liveElement(info) {
669
+ if (info.element?.isConnected) {
670
+ return info.element;
671
+ }
672
+ // The stored node was removed (e.g. a framework re-render replaced it).
673
+ // Re-resolve through the unique selector and drop the detached reference so
674
+ // the old subtree can be garbage collected.
675
+ const resolved = resolveSelector(info.selector);
676
+ info.element = resolved ?? undefined;
677
+ return resolved;
678
+ }
679
+ function liveRect(info) {
680
+ const element = liveElement(info);
681
+ if (!element) {
682
+ return null;
683
+ }
684
+ return visibleRectForElement(element);
685
+ }
686
+ function visibleRectForElement(element) {
687
+ let rect = rectFromDomRect(element.getBoundingClientRect());
688
+ if (rect.width <= 0 || rect.height <= 0) {
689
+ return null;
690
+ }
691
+ for (const ancestor of scrollAncestors(element)) {
692
+ rect = intersectRects(rect, rectFromDomRect(ancestor.getBoundingClientRect()));
693
+ if (rect.width <= 0 || rect.height <= 0) {
694
+ return null;
695
+ }
696
+ }
697
+ return offsetRect(rect, window.scrollX, window.scrollY);
698
+ }
699
+ function offsetRect(rect, dx, dy) {
700
+ return {
701
+ top: roundPx(rect.top + dy),
702
+ right: roundPx(rect.right + dx),
703
+ bottom: roundPx(rect.bottom + dy),
704
+ left: roundPx(rect.left + dx),
705
+ width: rect.width,
706
+ height: rect.height
707
+ };
708
+ }
709
+ function rectFromDomRect(rect) {
710
+ return {
711
+ top: roundPx(rect.top),
712
+ right: roundPx(rect.right),
713
+ bottom: roundPx(rect.bottom),
714
+ left: roundPx(rect.left),
715
+ width: roundPx(rect.width),
716
+ height: roundPx(rect.height)
717
+ };
718
+ }
719
+ function scrollAncestors(element) {
720
+ const ancestors = [];
721
+ let current = element.parentElement;
722
+ while (current && current !== document.body && current !== document.documentElement) {
723
+ const style = getComputedStyle(current);
724
+ const overflow = `${style.overflow}${style.overflowX}${style.overflowY}`;
725
+ if (/(auto|scroll|clip|hidden)/.test(overflow)) {
726
+ ancestors.push(current);
727
+ }
728
+ current = current.parentElement;
729
+ }
730
+ return ancestors;
731
+ }
732
+ function intersectRects(a, b) {
733
+ const left = Math.max(a.left, b.left);
734
+ const right = Math.min(a.right, b.right);
735
+ const top = Math.max(a.top, b.top);
736
+ const bottom = Math.min(a.bottom, b.bottom);
737
+ return {
738
+ top: roundPx(top),
739
+ right: roundPx(right),
740
+ bottom: roundPx(bottom),
741
+ left: roundPx(left),
742
+ width: roundPx(Math.max(0, right - left)),
743
+ height: roundPx(Math.max(0, bottom - top))
744
+ };
745
+ }
746
+ function pointKindClass(region) {
747
+ switch (region) {
748
+ case "margin":
749
+ return "gi-kind-margin";
750
+ case "padding":
751
+ return "gi-kind-padding";
752
+ case "border":
753
+ return "gi-kind-border";
754
+ case "gap":
755
+ return "gi-kind-gap";
756
+ case "content":
757
+ return "gi-kind-content";
758
+ default:
759
+ return "gi-kind-unknown";
760
+ }
761
+ }
762
+ function rectKey(rect) {
763
+ return [rect.left, rect.top, rect.width, rect.height].join(":");
764
+ }
765
+ function edgeMarkerRect(axis, rect, side) {
766
+ const thickness = 3;
767
+ if (axis === "horizontal") {
768
+ const left = side === "from" ? rect.right - thickness : rect.left;
769
+ return {
770
+ left,
771
+ right: left + thickness,
772
+ top: rect.top,
773
+ bottom: rect.bottom,
774
+ width: thickness,
775
+ height: rect.height
776
+ };
777
+ }
778
+ const top = side === "from" ? rect.bottom - thickness : rect.top;
779
+ return {
780
+ left: rect.left,
781
+ right: rect.right,
782
+ top,
783
+ bottom: top + thickness,
784
+ width: rect.width,
785
+ height: thickness
786
+ };
787
+ }
788
+ function internalEdgeMarkerRect(axis, rect, side, role) {
789
+ const thickness = 3;
790
+ if (axis === "horizontal") {
791
+ const edge = side === "before"
792
+ ? role === "container" ? rect.left : rect.left
793
+ : role === "container" ? rect.right - thickness : rect.right - thickness;
794
+ return {
795
+ left: edge,
796
+ right: edge + thickness,
797
+ top: rect.top,
798
+ bottom: rect.bottom,
799
+ width: thickness,
800
+ height: rect.height
801
+ };
802
+ }
803
+ const edge = side === "before"
804
+ ? role === "container" ? rect.top : rect.top
805
+ : role === "container" ? rect.bottom - thickness : rect.bottom - thickness;
806
+ return {
807
+ left: rect.left,
808
+ right: rect.right,
809
+ top: edge,
810
+ bottom: edge + thickness,
811
+ width: rect.width,
812
+ height: thickness
813
+ };
814
+ }
815
+ function svgRect(rect) {
816
+ return {
817
+ x: rect.left,
818
+ y: rect.top,
819
+ width: rect.width,
820
+ height: rect.height
821
+ };
822
+ }
823
+ function isInspectorMutation(record) {
824
+ const target = record.target instanceof Element ? record.target : record.target.parentElement;
825
+ return Boolean(target?.closest(".gi-root, .gi-svg"));
826
+ }
827
+ function clampToViewport(x, y, target) {
828
+ const rect = target.getBoundingClientRect();
829
+ const margin = 8;
830
+ return {
831
+ x: Math.min(Math.max(x, margin), Math.max(margin, window.innerWidth - rect.width - margin)),
832
+ y: Math.min(Math.max(y, margin), Math.max(margin, window.innerHeight - rect.height - margin))
833
+ };
834
+ }
835
+ function pointFromEvent(event) {
836
+ return {
837
+ x: event.clientX,
838
+ y: event.clientY
839
+ };
840
+ }
841
+ function toDocumentPoint(point) {
842
+ return {
843
+ x: point.x + window.scrollX,
844
+ y: point.y + window.scrollY
845
+ };
846
+ }
847
+ function toDocumentInspection(inspection) {
848
+ return {
849
+ ...inspection,
850
+ point: toDocumentPoint(inspection.point),
851
+ rect: inspection.rect ? offsetRect(inspection.rect, window.scrollX, window.scrollY) : inspection.rect
852
+ };
853
+ }
854
+ function dragDistance(drag) {
855
+ return Math.hypot(drag.end.x - drag.start.x, drag.end.y - drag.start.y);
856
+ }
857
+ function lineFromDrag(axis, drag) {
858
+ const start = toDocumentPoint(drag.start);
859
+ const end = toDocumentPoint(drag.end);
860
+ const startAlong = axis === "horizontal" ? start.x : start.y;
861
+ const endAlong = axis === "horizontal" ? end.x : end.y;
862
+ const perp = axis === "horizontal"
863
+ ? (start.y + end.y) / 2
864
+ : (start.x + end.x) / 2;
865
+ return {
866
+ min: Math.min(startAlong, endAlong),
867
+ max: Math.max(startAlong, endAlong),
868
+ perp
869
+ };
870
+ }
871
+ function formatPx(value) {
872
+ const rounded = Math.round(value * 100) / 100;
873
+ return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(2)}px`;
874
+ }
875
+ function roundPx(value) {
876
+ return Math.round(value * 100) / 100;
877
+ }
878
+ export { inferAxis, measureGap };