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,402 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The bar's silhouette — one closed path, generated from the cluster boxes.
|
|
5
|
+
*
|
|
6
|
+
* The controls are grouped into clusters, and the shape wraps them: each
|
|
7
|
+
* cluster is a pill, and consecutive pills are joined by a concave bridge that
|
|
8
|
+
* runs tangent to both. The result reads as one blob that has been pinched
|
|
9
|
+
* rather than as three shapes sitting next to each other.
|
|
10
|
+
*
|
|
11
|
+
* That distinction is the whole reason this file exists. The first four
|
|
12
|
+
* attempts drew the clusters as separate rounded elements and painted bridges
|
|
13
|
+
* between them, and every one of them showed a hairline ridge at the junction.
|
|
14
|
+
* The cause is not the geometry: it is that two independently rasterised
|
|
15
|
+
* antialiased edges meeting on the same pixel never sum back to full opacity.
|
|
16
|
+
* A shape with no interior edges cannot seam, so the fix was to stop composing
|
|
17
|
+
* and start describing — the outline below is traversed once, clockwise, and
|
|
18
|
+
* filled once.
|
|
19
|
+
*
|
|
20
|
+
* The buttons then sit on top in ordinary DOM with transparent backgrounds, so
|
|
21
|
+
* text and icons stay crisp. Nothing about the silhouette feeds back into the
|
|
22
|
+
* layout: the boxes are measured from the controls, and the controls never
|
|
23
|
+
* read the shape. The dependency runs one way, which is what keeps it from
|
|
24
|
+
* oscillating the way the viewport framing rule once did.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** How deeply the bridge between two clusters is pinched. Larger = shallower. */
|
|
28
|
+
export const FILLET = 34;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build the whole outline as one closed path.
|
|
32
|
+
*
|
|
33
|
+
* Clockwise: along the top from the far left, around the last cluster's right
|
|
34
|
+
* end, back along the bottom, closed. Every pill end is an arc of radius R;
|
|
35
|
+
* every bridge is a pair of arcs of radius f tangent to the two ends it joins.
|
|
36
|
+
*/
|
|
37
|
+
export function barPath(boxes, H, f) {
|
|
38
|
+
const R = H / 2;
|
|
39
|
+
const top = [];
|
|
40
|
+
const bottom = [];
|
|
41
|
+
|
|
42
|
+
// Tangent points and fillet centre for the bridge between two end circles.
|
|
43
|
+
//
|
|
44
|
+
// A fillet of radius f can only span ends whose centres are at most 2(R + f)
|
|
45
|
+
// apart — past that, (R + f)² − (D/2)² goes negative, the square root is
|
|
46
|
+
// imaginary, and every coordinate downstream becomes NaN. Rather than trust
|
|
47
|
+
// callers to keep f large enough for whatever gap the layout produces, the
|
|
48
|
+
// requirement is enforced here: f is raised to whatever the span needs.
|
|
49
|
+
const bridge = (c1x, c2x, sign) => {
|
|
50
|
+
const D = c2x - c1x;
|
|
51
|
+
const ff = Math.max(f, D / 2 - R + 0.01);
|
|
52
|
+
const hf = Math.sqrt(Math.max(0, (R + ff) ** 2 - (D / 2) ** 2));
|
|
53
|
+
const fc = { x: (c1x + c2x) / 2, y: R + sign * hf };
|
|
54
|
+
const at = (cx) => {
|
|
55
|
+
const dx = fc.x - cx;
|
|
56
|
+
const dy = fc.y - R;
|
|
57
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
58
|
+
return { x: cx + (R * dx) / len, y: R + (R * dy) / len };
|
|
59
|
+
};
|
|
60
|
+
return { p1: at(c1x), p2: at(c2x), f: ff };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
boxes.forEach((b, i) => {
|
|
64
|
+
const lc = b.left + R;
|
|
65
|
+
const rc = b.right - R;
|
|
66
|
+
|
|
67
|
+
if (i === 0) top.push(`M ${b.left} ${R}`, `A ${R} ${R} 0 0 1 ${lc} 0`);
|
|
68
|
+
top.push(`L ${rc} 0`);
|
|
69
|
+
|
|
70
|
+
if (i < boxes.length - 1) {
|
|
71
|
+
const nlc = boxes[i + 1].left + R;
|
|
72
|
+
const { p1, p2, f: ff } = bridge(rc, nlc, -1); // fillet centre above
|
|
73
|
+
top.push(`A ${R} ${R} 0 0 1 ${p1.x} ${p1.y}`); // around this end, down to tangency
|
|
74
|
+
top.push(`A ${ff} ${ff} 0 0 0 ${p2.x} ${p2.y}`); // the concave bridge, sweeping back
|
|
75
|
+
top.push(`A ${R} ${R} 0 0 1 ${nlc} 0`); // around the next end, back up to the top
|
|
76
|
+
} else {
|
|
77
|
+
top.push(`A ${R} ${R} 0 0 1 ${b.right} ${R}`);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
for (let i = boxes.length - 1; i >= 0; i--) {
|
|
82
|
+
const b = boxes[i];
|
|
83
|
+
const lc = b.left + R;
|
|
84
|
+
const rc = b.right - R;
|
|
85
|
+
|
|
86
|
+
if (i === boxes.length - 1) bottom.push(`A ${R} ${R} 0 0 1 ${rc} ${H}`);
|
|
87
|
+
bottom.push(`L ${lc} ${H}`);
|
|
88
|
+
|
|
89
|
+
if (i > 0) {
|
|
90
|
+
const prc = boxes[i - 1].right - R;
|
|
91
|
+
const { p1, p2, f: ff } = bridge(prc, lc, +1); // fillet centre below
|
|
92
|
+
bottom.push(`A ${R} ${R} 0 0 1 ${p2.x} ${p2.y}`);
|
|
93
|
+
bottom.push(`A ${ff} ${ff} 0 0 0 ${p1.x} ${p1.y}`);
|
|
94
|
+
bottom.push(`A ${R} ${R} 0 0 1 ${prc} ${H}`);
|
|
95
|
+
} else {
|
|
96
|
+
bottom.push(`A ${R} ${R} 0 0 1 ${b.left} ${R}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return [...top, ...bottom, "Z"].join(" ");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Turn measured rectangles into boxes in the bar's own layout space.
|
|
105
|
+
*
|
|
106
|
+
* Neither obvious way of measuring is correct on its own.
|
|
107
|
+
* `getBoundingClientRect` keeps sub-pixel precision but is multiplied by any
|
|
108
|
+
* ancestor `zoom` or `transform: scale()` — which would scale the boxes while
|
|
109
|
+
* the fillet stayed in CSS pixels, and did: at 14× magnification R came out as
|
|
110
|
+
* 308 against a fillet of 34, the tangency solve went imaginary, and the bar
|
|
111
|
+
* disappeared. `offsetWidth` ignores that scaling but rounds to whole pixels,
|
|
112
|
+
* and this is a tangency solve; a box rounded by half a pixel puts the curve
|
|
113
|
+
* visibly off the buttons it is meant to wrap. Their ratio recovers the scale
|
|
114
|
+
* factor, so it can be divided out while the precision is kept.
|
|
115
|
+
*
|
|
116
|
+
* Measuring each cluster against the *controls'* own rect rather than the
|
|
117
|
+
* pill's divides out every scale at once, whatever any of them happen to be
|
|
118
|
+
* mid-flight, because a cluster and the container it is compared to always sit
|
|
119
|
+
* in the same scaled space. This was once load-bearing for a subtler reason
|
|
120
|
+
* too: the controls carried a scale of their own that a layout effect read at
|
|
121
|
+
* its *from* value, so every expansion drew a silhouette a few percent narrower
|
|
122
|
+
* than the buttons it wrapped. That transform is gone, but measuring through
|
|
123
|
+
* the container is still the only form of this that cannot be caught out.
|
|
124
|
+
*
|
|
125
|
+
* The horizontal origin comes in as `offsetLeft` — layout space, which sees no
|
|
126
|
+
* transform at all. Callers mid-morph pass 0 and shift the result themselves,
|
|
127
|
+
* because the pill's own width is changing underneath them.
|
|
128
|
+
*
|
|
129
|
+
* Pure on purpose: it takes plain numbers, so the normalisation above can be
|
|
130
|
+
* tested without a browser. See test/bar-shape.test.js.
|
|
131
|
+
*
|
|
132
|
+
* @param {{ height: number, offsetHeight: number }} pill
|
|
133
|
+
* @param {{ left: number, width: number, offsetWidth: number, offsetLeft: number }} controls
|
|
134
|
+
* @param {{ left: number, right: number }[]} clusters client rects, in order
|
|
135
|
+
* @returns {{ H: number, boxes: { left: number, right: number }[] } | null}
|
|
136
|
+
*/
|
|
137
|
+
export function normaliseBoxes(pill, controls, clusters) {
|
|
138
|
+
const k = pill.offsetHeight > 0 ? pill.height / pill.offsetHeight : 1;
|
|
139
|
+
if (!Number.isFinite(k) || k <= 0) return null;
|
|
140
|
+
|
|
141
|
+
const H = pill.height / k;
|
|
142
|
+
if (!(H > 0)) return null;
|
|
143
|
+
|
|
144
|
+
const kc = controls.offsetWidth > 0 ? controls.width / controls.offsetWidth : 1;
|
|
145
|
+
if (!Number.isFinite(kc) || kc <= 0) return null;
|
|
146
|
+
|
|
147
|
+
const x0 = controls.offsetLeft;
|
|
148
|
+
const boxes = clusters.map((r) => ({
|
|
149
|
+
left: x0 + (r.left - controls.left) / kc,
|
|
150
|
+
right: x0 + (r.right - controls.left) / kc,
|
|
151
|
+
}));
|
|
152
|
+
return boxes.length ? { H, boxes } : null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Read the live geometry, in the controls' own layout space.
|
|
157
|
+
*
|
|
158
|
+
* Controls space, not pill space: the controls are a fixed width pinned to the
|
|
159
|
+
* bar's held edge, so these boxes are the same numbers whether the bar is open,
|
|
160
|
+
* shut, or somewhere in between. The pill's width is the thing that moves, and
|
|
161
|
+
* the caller adds it back as a shift.
|
|
162
|
+
*/
|
|
163
|
+
function measure(pill, controls, clusters) {
|
|
164
|
+
const pr = pill.getBoundingClientRect();
|
|
165
|
+
const cr = controls.getBoundingClientRect();
|
|
166
|
+
return normaliseBoxes(
|
|
167
|
+
{ height: pr.height, offsetHeight: pill.offsetHeight },
|
|
168
|
+
{
|
|
169
|
+
left: cr.left,
|
|
170
|
+
width: cr.width,
|
|
171
|
+
offsetWidth: controls.offsetWidth,
|
|
172
|
+
offsetLeft: 0,
|
|
173
|
+
},
|
|
174
|
+
clusters.map((c) => c.getBoundingClientRect()),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function draw(svg, path, boxes, H) {
|
|
179
|
+
const w = boxes[boxes.length - 1].right;
|
|
180
|
+
const d = barPath(boxes, H, FILLET);
|
|
181
|
+
|
|
182
|
+
// Last line of defence. A single non-finite number anywhere makes the whole
|
|
183
|
+
// path invalid, and an invalid path is not a glitch — it is a bar that
|
|
184
|
+
// vanishes. A non-positive width is just as fatal: SVG rejects it outright.
|
|
185
|
+
// Either way, keep the last good outline rather than painting nothing.
|
|
186
|
+
if (!(w > 0) || !(H > 0) || /NaN|Infinity|undefined/.test(d)) {
|
|
187
|
+
console.warn(
|
|
188
|
+
`[nora] refusing an invalid bar path — w=${w} H=${H} boxes=${JSON.stringify(boxes)}`,
|
|
189
|
+
);
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
svg.setAttribute("width", w);
|
|
194
|
+
svg.setAttribute("height", H);
|
|
195
|
+
svg.setAttribute("viewBox", `0 0 ${w} ${H}`);
|
|
196
|
+
path.setAttribute("d", d);
|
|
197
|
+
return d;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** How long the bar takes to open or shut. */
|
|
201
|
+
const DURATION = 240;
|
|
202
|
+
|
|
203
|
+
/** easeInOutCubic — slow at both ends, so the two rests are the quiet part. */
|
|
204
|
+
const ease = (t) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
|
|
205
|
+
|
|
206
|
+
const lerp = (a, b, t) => a + (b - a) * t;
|
|
207
|
+
|
|
208
|
+
const reducedMotion = () =>
|
|
209
|
+
typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Keep the silhouette in sync with the controls, and animate it between the
|
|
213
|
+
* two resting states.
|
|
214
|
+
*
|
|
215
|
+
* The shape is a path, and a path is the one thing CSS cannot interpolate, so
|
|
216
|
+
* opening the bar is a real animation driven from here: every frame lerps the
|
|
217
|
+
* cluster boxes between the shut circle and the open layout, rebuilds `d`, and
|
|
218
|
+
* writes the pill's width to match. Nothing else moves. The controls are a
|
|
219
|
+
* fixed-width element pinned to the bar's held edge, so they neither reflow nor
|
|
220
|
+
* translate while this runs — they only fade, and they fade on a squared curve
|
|
221
|
+
* so the text is still nearly invisible while the outline is narrow enough to
|
|
222
|
+
* crop it.
|
|
223
|
+
*
|
|
224
|
+
* That restraint is the lesson from the version before this one, which also
|
|
225
|
+
* interpolated the boxes and dropped frames on every expansion. The geometry
|
|
226
|
+
* was never the cost. Each frame also assigned `clip-path: path(...)` to the
|
|
227
|
+
* controls — a fresh several-hundred-character path for the style system to
|
|
228
|
+
* parse and the compositor to re-rasterise the clipped subtree against — while
|
|
229
|
+
* those same controls ran a `filter: blur()` transition. Three of the most
|
|
230
|
+
* expensive things a browser can be asked to do, scheduled on the same frames.
|
|
231
|
+
* Pulling the other two out leaves one `setAttribute` per frame on a path with
|
|
232
|
+
* about twenty commands, which is nothing.
|
|
233
|
+
*
|
|
234
|
+
* `barPath` handles the shut end with no special case: every cluster collapses
|
|
235
|
+
* to the *same* circle, coincident end circles make each bridge a zero-length
|
|
236
|
+
* arc, and SVG treats that as a no-op, so the path resolves to a plain circle.
|
|
237
|
+
* That is also why the morph can lerp box-for-box — the two states have the
|
|
238
|
+
* same number of boxes throughout.
|
|
239
|
+
*/
|
|
240
|
+
export function useBarShape(pillRef, controlsRef, expanded, signature) {
|
|
241
|
+
const svgRef = useRef(null);
|
|
242
|
+
const pathRef = useRef(null);
|
|
243
|
+
|
|
244
|
+
/** 0 shut, 1 open. Kept across renders so a reversal starts where it is. */
|
|
245
|
+
const at = useRef(expanded ? 1 : 0);
|
|
246
|
+
const raf = useRef(0);
|
|
247
|
+
const morphing = useRef(false);
|
|
248
|
+
const mounted = useRef(false);
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Everything one frame needs. Null until the controls are in the DOM.
|
|
252
|
+
*
|
|
253
|
+
* @returns {{ H: number, open: {left:number,right:number}[], shut: {left:number,right:number}[], full: number, dia: number } | null}
|
|
254
|
+
*/
|
|
255
|
+
const geometry = () => {
|
|
256
|
+
const pill = pillRef.current;
|
|
257
|
+
const controls = controlsRef.current;
|
|
258
|
+
if (!pill || !controls) return null;
|
|
259
|
+
|
|
260
|
+
const m = measure(pill, controls, [...controls.querySelectorAll(".nora-cluster")]);
|
|
261
|
+
if (!m) return null;
|
|
262
|
+
|
|
263
|
+
const full = controls.offsetWidth;
|
|
264
|
+
const dia = m.H;
|
|
265
|
+
if (!(full > 0) || !(dia > 0)) return null;
|
|
266
|
+
|
|
267
|
+
// Shut, every cluster is the same circle, sitting against the held edge.
|
|
268
|
+
const shut = m.boxes.map(() => ({ left: full - dia, right: full }));
|
|
269
|
+
return { H: dia, open: m.boxes, shut, full, dia };
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/** Paint progress `t`. Boxes are in controls space; the pill's width shifts them. */
|
|
273
|
+
const paint = (g, t) => {
|
|
274
|
+
const svg = svgRef.current;
|
|
275
|
+
const path = pathRef.current;
|
|
276
|
+
const pill = pillRef.current;
|
|
277
|
+
const controls = controlsRef.current;
|
|
278
|
+
if (!svg || !path || !pill || !controls) return;
|
|
279
|
+
|
|
280
|
+
const e = ease(t);
|
|
281
|
+
const w = lerp(g.dia, g.full, e);
|
|
282
|
+
const shift = w - g.full;
|
|
283
|
+
|
|
284
|
+
draw(
|
|
285
|
+
svg,
|
|
286
|
+
path,
|
|
287
|
+
g.open.map((o, i) => ({
|
|
288
|
+
left: lerp(g.shut[i].left, o.left, e) + shift,
|
|
289
|
+
right: lerp(g.shut[i].right, o.right, e) + shift,
|
|
290
|
+
})),
|
|
291
|
+
g.H,
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
pill.style.width = `${w}px`;
|
|
295
|
+
controls.style.opacity = String(e * e);
|
|
296
|
+
// Everything left of the outline is clipped away rather than allowed to
|
|
297
|
+
// fade outside it. The controls are pinned to the held edge and the shape
|
|
298
|
+
// grows from that edge, so the subject label — the far end — would
|
|
299
|
+
// otherwise hang in open canvas for most of the opening. `inset()` is a
|
|
300
|
+
// rectangle the compositor can apply directly; the `path()` clip this file
|
|
301
|
+
// used to write per frame is the one that cost the frames.
|
|
302
|
+
controls.style.clipPath = `inset(0 0 0 ${Math.max(0, g.full - w + 10)}px)`;
|
|
303
|
+
at.current = t;
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
/** Drop every inline value and redraw from the DOM, so rest is measured. */
|
|
307
|
+
const settle = (g) => {
|
|
308
|
+
const pill = pillRef.current;
|
|
309
|
+
const controls = controlsRef.current;
|
|
310
|
+
morphing.current = false;
|
|
311
|
+
pill?.style.removeProperty("width");
|
|
312
|
+
pill?.removeAttribute("data-morphing");
|
|
313
|
+
controls?.style.removeProperty("opacity");
|
|
314
|
+
controls?.style.removeProperty("clip-path");
|
|
315
|
+
|
|
316
|
+
const fresh = geometry() ?? g;
|
|
317
|
+
if (!fresh) return;
|
|
318
|
+
const boxes = expanded
|
|
319
|
+
? fresh.open
|
|
320
|
+
: fresh.shut.map((b) => ({
|
|
321
|
+
left: b.left - (fresh.full - fresh.dia),
|
|
322
|
+
right: b.right - (fresh.full - fresh.dia),
|
|
323
|
+
}));
|
|
324
|
+
const svg = svgRef.current;
|
|
325
|
+
const path = pathRef.current;
|
|
326
|
+
if (svg && path) draw(svg, path, boxes, fresh.H);
|
|
327
|
+
at.current = expanded ? 1 : 0;
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// Repaint on anything that can move a cluster edge: the open/shut state
|
|
331
|
+
// itself, the selected name, the viewport label, an active panel's accent.
|
|
332
|
+
useLayoutEffect(() => {
|
|
333
|
+
cancelAnimationFrame(raf.current);
|
|
334
|
+
|
|
335
|
+
const g = geometry();
|
|
336
|
+
if (!g) return;
|
|
337
|
+
|
|
338
|
+
const goal = expanded ? 1 : 0;
|
|
339
|
+
const jump = !mounted.current || at.current === goal || reducedMotion();
|
|
340
|
+
mounted.current = true;
|
|
341
|
+
|
|
342
|
+
if (jump) {
|
|
343
|
+
settle(g);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
morphing.current = true;
|
|
348
|
+
pillRef.current?.setAttribute("data-morphing", "true");
|
|
349
|
+
|
|
350
|
+
const from = at.current;
|
|
351
|
+
const span = Math.abs(goal - from);
|
|
352
|
+
|
|
353
|
+
// Paint the starting frame here, synchronously, before yielding. React has
|
|
354
|
+
// already flipped `data-state`, so the pill is sitting at the *other* end's
|
|
355
|
+
// CSS width with the old path still on it; leaving that to the first rAF
|
|
356
|
+
// shows one frame of a shape that fits nothing.
|
|
357
|
+
paint(g, from);
|
|
358
|
+
|
|
359
|
+
const start = performance.now();
|
|
360
|
+
|
|
361
|
+
const step = (now) => {
|
|
362
|
+
const p = span > 0 ? Math.min(1, (now - start) / (DURATION * span)) : 1;
|
|
363
|
+
paint(g, from + (goal - from) * p);
|
|
364
|
+
if (p < 1) raf.current = requestAnimationFrame(step);
|
|
365
|
+
else settle(g);
|
|
366
|
+
};
|
|
367
|
+
raf.current = requestAnimationFrame(step);
|
|
368
|
+
|
|
369
|
+
return () => cancelAnimationFrame(raf.current);
|
|
370
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
371
|
+
}, [expanded, signature]);
|
|
372
|
+
|
|
373
|
+
// The observer below is installed once but must call the *current* closure,
|
|
374
|
+
// which knows whether the bar is open. Assigned after render rather than
|
|
375
|
+
// during it: a render that writes a ref is not pure, and React is entitled to
|
|
376
|
+
// discard it. The observer only ever fires asynchronously, so it always sees
|
|
377
|
+
// what this effect wrote, and the initial value covers the first paint.
|
|
378
|
+
const latest = useRef(settle);
|
|
379
|
+
useEffect(() => {
|
|
380
|
+
latest.current = settle;
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// …and when the browser moves an edge without telling React: a window
|
|
384
|
+
// resize, a zoom change, or the fonts arriving after first paint. Never
|
|
385
|
+
// mid-morph — the pill's own width is changing on every one of those frames,
|
|
386
|
+
// and answering it here would fight the animation for the same attribute.
|
|
387
|
+
useEffect(() => {
|
|
388
|
+
const pill = pillRef.current;
|
|
389
|
+
if (!pill) return;
|
|
390
|
+
const repaint = () => {
|
|
391
|
+
if (!morphing.current) latest.current(null);
|
|
392
|
+
};
|
|
393
|
+
const observer = new ResizeObserver(repaint);
|
|
394
|
+
observer.observe(pill);
|
|
395
|
+
if (controlsRef.current) observer.observe(controlsRef.current);
|
|
396
|
+
document.fonts?.ready.then(repaint);
|
|
397
|
+
return () => observer.disconnect();
|
|
398
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
399
|
+
}, []);
|
|
400
|
+
|
|
401
|
+
return { svgRef, pathRef };
|
|
402
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Icons — Lucide 1.34, inlined.
|
|
3
|
+
*
|
|
4
|
+
* The paths are copied rather than imported from `lucide-react`, which keeps the
|
|
5
|
+
* package free of runtime dependencies: the client is compiled by the consumer's
|
|
6
|
+
* own Vite, so anything we depend on becomes something their project has to
|
|
7
|
+
* resolve. Ten glyphs are not worth that.
|
|
8
|
+
*
|
|
9
|
+
* Lucide — ISC License. Copyright (c) 2026 Lucide Icons and Contributors.
|
|
10
|
+
* https://github.com/lucide-icons/lucide/blob/main/LICENSE
|
|
11
|
+
*
|
|
12
|
+
* Stroke 1.75 at 17px rather than a lighter 1.5: light strokes optically thin
|
|
13
|
+
* out on a dark ground, because white on black spreads less than black on
|
|
14
|
+
* white. 1.5 reads as fuzzy here, not as refined.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const Icon = ({ size = 17, strokeWidth = 1.75, children, ...rest }) => (
|
|
18
|
+
<svg
|
|
19
|
+
width={size}
|
|
20
|
+
height={size}
|
|
21
|
+
viewBox="0 0 24 24"
|
|
22
|
+
fill="none"
|
|
23
|
+
stroke="currentColor"
|
|
24
|
+
strokeWidth={strokeWidth}
|
|
25
|
+
strokeLinecap="round"
|
|
26
|
+
strokeLinejoin="round"
|
|
27
|
+
aria-hidden="true"
|
|
28
|
+
style={{ display: "block" }}
|
|
29
|
+
{...rest}
|
|
30
|
+
>
|
|
31
|
+
{children}
|
|
32
|
+
</svg>
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
/** Lucide `grip` */
|
|
36
|
+
export const GridIcon = (p) => (
|
|
37
|
+
<Icon {...p}>
|
|
38
|
+
<circle cx="12" cy="5" r="1" />
|
|
39
|
+
<circle cx="19" cy="5" r="1" />
|
|
40
|
+
<circle cx="5" cy="5" r="1" />
|
|
41
|
+
<circle cx="12" cy="12" r="1" />
|
|
42
|
+
<circle cx="19" cy="12" r="1" />
|
|
43
|
+
<circle cx="5" cy="12" r="1" />
|
|
44
|
+
<circle cx="12" cy="19" r="1" />
|
|
45
|
+
<circle cx="19" cy="19" r="1" />
|
|
46
|
+
<circle cx="5" cy="19" r="1" />
|
|
47
|
+
</Icon>
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
/** Lucide `folder` */
|
|
51
|
+
export const FolderIcon = (p) => (
|
|
52
|
+
<Icon {...p}>
|
|
53
|
+
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
|
54
|
+
</Icon>
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
/** Lucide `search` */
|
|
58
|
+
export const SearchIcon = (p) => (
|
|
59
|
+
<Icon {...p}>
|
|
60
|
+
<path d="m21 21-4.34-4.34" />
|
|
61
|
+
<circle cx="11" cy="11" r="8" />
|
|
62
|
+
</Icon>
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
/** Lucide `scan-line` */
|
|
66
|
+
export const SweepIcon = (p) => (
|
|
67
|
+
<Icon {...p}>
|
|
68
|
+
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
|
|
69
|
+
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
|
|
70
|
+
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
|
|
71
|
+
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
|
|
72
|
+
<path d="M7 12h10" />
|
|
73
|
+
</Icon>
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
/** Lucide `sun` */
|
|
77
|
+
export const SunIcon = (p) => (
|
|
78
|
+
<Icon {...p}>
|
|
79
|
+
<circle cx="12" cy="12" r="4" />
|
|
80
|
+
<path d="M12 2v2" />
|
|
81
|
+
<path d="M12 20v2" />
|
|
82
|
+
<path d="m4.93 4.93 1.41 1.41" />
|
|
83
|
+
<path d="m17.66 17.66 1.41 1.41" />
|
|
84
|
+
<path d="M2 12h2" />
|
|
85
|
+
<path d="M20 12h2" />
|
|
86
|
+
<path d="m6.34 17.66-1.41 1.41" />
|
|
87
|
+
<path d="m19.07 4.93-1.41 1.41" />
|
|
88
|
+
</Icon>
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
/** Lucide `moon` */
|
|
92
|
+
export const MoonIcon = (p) => (
|
|
93
|
+
<Icon {...p}>
|
|
94
|
+
<path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" />
|
|
95
|
+
</Icon>
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
/** Lucide `minimize-2` */
|
|
99
|
+
export const CollapseIcon = (p) => (
|
|
100
|
+
<Icon {...p}>
|
|
101
|
+
<path d="m14 10 7-7" />
|
|
102
|
+
<path d="M20 10h-6V4" />
|
|
103
|
+
<path d="m3 21 7-7" />
|
|
104
|
+
<path d="M4 14h6v6" />
|
|
105
|
+
</Icon>
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
/** Lucide `chevron-up` */
|
|
109
|
+
export const ChevronUpIcon = (p) => (
|
|
110
|
+
<Icon {...p}>
|
|
111
|
+
<path d="m18 15-6-6-6 6" />
|
|
112
|
+
</Icon>
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
/** Lucide `circle-alert` */
|
|
116
|
+
export const AlertIcon = (p) => (
|
|
117
|
+
<Icon {...p}>
|
|
118
|
+
<circle cx="12" cy="12" r="10" />
|
|
119
|
+
<line x1="12" x2="12" y1="8" y2="12" />
|
|
120
|
+
<line x1="12" x2="12.01" y1="16" y2="16" />
|
|
121
|
+
</Icon>
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
/** Lucide `check` */
|
|
125
|
+
export const CheckIcon = (p) => (
|
|
126
|
+
<Icon {...p}>
|
|
127
|
+
<path d="M20 6 9 17l-5-5" />
|
|
128
|
+
</Icon>
|
|
129
|
+
);
|