domotion-svg 0.18.0 → 0.19.1
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/animation/animator.d.ts +5 -0
- package/dist/animation/animator.js +3 -1
- package/dist/animation/cursor-overlay.d.ts +8 -10
- package/dist/animation/cursor-overlay.js +84 -42
- package/dist/cli/capture.js +20 -3
- package/dist/cli/index.js +6 -0
- package/dist/render/element-tree-to-svg.d.ts +7 -0
- package/dist/render/element-tree-to-svg.js +4 -3
- package/dist/render/format.d.ts +14 -0
- package/dist/render/format.js +17 -0
- package/dist/scroll/composer.d.ts +5 -0
- package/dist/scroll/composer.js +6 -1
- package/dist/scrubber/client.bundle.generated.js +1 -1
- package/package.json +8 -4
|
@@ -74,6 +74,11 @@ export interface AnimationConfig {
|
|
|
74
74
|
width: number;
|
|
75
75
|
height: number;
|
|
76
76
|
frames: AnimationFrame[];
|
|
77
|
+
/** DM-1488: accessible name → `role="img"` + `<title>` on the root `<svg>`
|
|
78
|
+
* (for inline-`<svg>` embedding). Omit to leave the output unchanged. */
|
|
79
|
+
title?: string;
|
|
80
|
+
/** DM-1488: accessible long description → `<desc>` on the root `<svg>`. */
|
|
81
|
+
desc?: string;
|
|
77
82
|
/**
|
|
78
83
|
* Markup (e.g. `<path id="g0" d="..."/>...`) hoisted into the top-level
|
|
79
84
|
* `<defs>`. Frames can reference these IDs via `<use href="#...">`. Use for
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { cursorOverlayMarkup, resolveCursorScript } from "./cursor-overlay.js";
|
|
8
8
|
import { escapeHtml } from "../utils/escapeHtml.js";
|
|
9
9
|
import { isTransparentBackground } from "../utils/transparent-background.js";
|
|
10
|
+
import { rootSvgA11y } from "../render/format.js";
|
|
10
11
|
import { DEFAULT_TRANSITION_MS, frameAdvanceMs, transitionDurationMs } from "./frame-timeline.js";
|
|
11
12
|
import { offsetEmbeddedAnimatedSvgTimeline } from "./embed-timeline.js";
|
|
12
13
|
import { KEYFRAME_EPSILON, padAfter, padBefore } from "../utils/keyframe-pad.js";
|
|
@@ -422,8 +423,9 @@ export function generateAnimatedSvg(config) {
|
|
|
422
423
|
const canvasBgRect = (bg != null && !isTransparentBackground(bg))
|
|
423
424
|
? ` <rect width="${width}" height="${height}" fill="${bg}" />\n`
|
|
424
425
|
: "";
|
|
426
|
+
const a11y = rootSvgA11y(config.title, config.desc);
|
|
425
427
|
const out = `<?xml version="1.0" encoding="UTF-8"?>
|
|
426
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"
|
|
428
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"${a11y.roleAttr}>${a11y.markup}
|
|
427
429
|
<defs>
|
|
428
430
|
<clipPath id="viewport-clip"><rect width="${width}" height="${height}" /></clipPath>${sharedDefsMarkup}
|
|
429
431
|
</defs>
|
|
@@ -153,16 +153,14 @@ resolveCursorAt?: CursorAtResolver | null): {
|
|
|
153
153
|
cursorTimeline: CursorTimelineEntry[] | null;
|
|
154
154
|
};
|
|
155
155
|
/**
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* in as the all-glyphs-off `null` state). Without a timeline, the single white
|
|
165
|
-
* arrow paints (back-compat).
|
|
156
|
+
* DM-1507: the cursor overlay is emitted as CSS `@keyframes`, NOT SMIL. SMIL
|
|
157
|
+
* (`<animateTransform>`/`<animate>`) runs on the SVG's *own* timeline while the
|
|
158
|
+
* frame animations run on the CSS/document timeline — two clocks that Safari
|
|
159
|
+
* pauses/throttles independently when the SVG is offscreen (tab-switch, scrolled
|
|
160
|
+
* away), so the pointer drifted out of sync with the animation on return. Driving
|
|
161
|
+
* the cursor with CSS puts everything on ONE timeline; they pause and resume
|
|
162
|
+
* together, so they can't desync. The `@keyframes` are collected into a `<style>`
|
|
163
|
+
* inside the overlay group and applied via inline `animation:` on each element.
|
|
166
164
|
*/
|
|
167
165
|
export declare function cursorOverlayMarkup(positions: KeyframePoint[], clicks: ResolvedClick[], style: CursorStyle, totalDurationMs: number, cursorTimeline?: CursorTimelineEntry[] | null): string;
|
|
168
166
|
export {};
|
|
@@ -231,83 +231,125 @@ function resolveMoveTarget(ev, curX, curY, frameIndex, resolveSelector) {
|
|
|
231
231
|
* in as the all-glyphs-off `null` state). Without a timeline, the single white
|
|
232
232
|
* arrow paints (back-compat).
|
|
233
233
|
*/
|
|
234
|
+
/** Small deterministic hash → 6-char base36, used to namespace this overlay's
|
|
235
|
+
* `@keyframes` so a composited SVG with several overlays can't collide. */
|
|
236
|
+
function overlayUid(seed) {
|
|
237
|
+
let h = 0x811c9dc5;
|
|
238
|
+
for (let i = 0; i < seed.length; i++) {
|
|
239
|
+
h ^= seed.charCodeAt(i);
|
|
240
|
+
h = Math.imul(h, 0x01000193);
|
|
241
|
+
}
|
|
242
|
+
return (h >>> 0).toString(36).padStart(6, "0").slice(0, 6);
|
|
243
|
+
}
|
|
244
|
+
/** Format a 0..1 keyframe fraction as a CSS keyframe percentage. */
|
|
245
|
+
function pct(frac) {
|
|
246
|
+
return `${Number((frac * 100).toFixed(4))}%`;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* DM-1507: the cursor overlay is emitted as CSS `@keyframes`, NOT SMIL. SMIL
|
|
250
|
+
* (`<animateTransform>`/`<animate>`) runs on the SVG's *own* timeline while the
|
|
251
|
+
* frame animations run on the CSS/document timeline — two clocks that Safari
|
|
252
|
+
* pauses/throttles independently when the SVG is offscreen (tab-switch, scrolled
|
|
253
|
+
* away), so the pointer drifted out of sync with the animation on return. Driving
|
|
254
|
+
* the cursor with CSS puts everything on ONE timeline; they pause and resume
|
|
255
|
+
* together, so they can't desync. The `@keyframes` are collected into a `<style>`
|
|
256
|
+
* inside the overlay group and applied via inline `animation:` on each element.
|
|
257
|
+
*/
|
|
234
258
|
export function cursorOverlayMarkup(positions, clicks, style, totalDurationMs, cursorTimeline = null) {
|
|
235
259
|
if (positions.length === 0 || totalDurationMs <= 0)
|
|
236
260
|
return "";
|
|
237
261
|
const totalSec = totalDurationMs / 1000;
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
const posAnim = `<animateTransform attributeName="transform" type="translate" values="${valueStrs.join("; ")}" keyTimes="${keyTimes.join("; ")}" dur="${totalSec}s" repeatCount="indefinite" fill="freeze" />`;
|
|
248
|
-
// Pulse SVG fragments — one per click, with timing keyed off `t`.
|
|
249
|
-
const pulseMarkup = clicks.map((c, i) => buildPulseFragment(c, i, totalDurationMs)).join("\n");
|
|
262
|
+
const uid = overlayUid(`${totalDurationMs}|${positions.map((p) => `${p.x},${p.y},${p.t}`).join(";")}`);
|
|
263
|
+
const kf = []; // @keyframes collected here, injected into <style>
|
|
264
|
+
// Position track — linear translate along the keyframes (holds are duplicate
|
|
265
|
+
// consecutive values, exactly as under SMIL). keyTimes start at 0 and end at 1.
|
|
266
|
+
const posName = `co-pos-${uid}`;
|
|
267
|
+
kf.push(`@keyframes ${posName}{${positions.map((p) => `${pct(p.t / totalDurationMs)}{transform:translate(${num(p.x)}px,${num(p.y)}px)}`).join("")}}`);
|
|
268
|
+
const posAnim = `${posName} ${totalSec}s linear infinite`;
|
|
269
|
+
// Pulse fragments — one per click; each pushes its own keyframes into `kf`.
|
|
270
|
+
const pulseMarkup = clicks.map((c, i) => buildPulseFragment(c, i, uid, kf, totalDurationMs)).join("\n");
|
|
250
271
|
let pointerGroup;
|
|
251
272
|
if (cursorTimeline != null && cursorTimeline.length > 0) {
|
|
252
|
-
// DM-1106: one glyph per distinct keyword, each toggled by a
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
// correctly regardless of its own hotspot.
|
|
273
|
+
// DM-1106: one glyph per distinct keyword, each toggled by a DISCRETE opacity
|
|
274
|
+
// track (SMIL `calcMode="discrete"` → CSS `step-end`, which holds each value
|
|
275
|
+
// until the next keyframe). The parent carries the position animation.
|
|
256
276
|
const size = 22 * (style.cursorScale || 1);
|
|
257
|
-
const tKeyTimes = cursorTimeline.map((e) => (e.t / totalDurationMs).toFixed(4));
|
|
258
277
|
const kinds = Array.from(new Set(cursorTimeline.map((e) => e.cursor).filter((c) => c != null)));
|
|
259
|
-
const glyphLayers = kinds.map((kind) => {
|
|
278
|
+
const glyphLayers = kinds.map((kind, gi) => {
|
|
260
279
|
const glyph = cursorGlyphSvg(kind, 0, 0, size, style.cursorStroke);
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
280
|
+
const gName = `co-glyph-${uid}-${gi}`;
|
|
281
|
+
kf.push(`@keyframes ${gName}{${cursorTimeline.map((e) => `${pct(e.t / totalDurationMs)}{opacity:${e.cursor === kind ? "1" : "0"}}`).join("")}}`);
|
|
282
|
+
return ` <g opacity="0" style="animation:${gName} ${totalSec}s step-end infinite">
|
|
264
283
|
${glyph}
|
|
265
284
|
</g>`;
|
|
266
285
|
}).join("\n");
|
|
267
|
-
pointerGroup = ` <g class="cursor-pointer">
|
|
268
|
-
${posAnim}
|
|
286
|
+
pointerGroup = ` <g class="cursor-pointer" style="animation:${posAnim}">
|
|
269
287
|
${glyphLayers}
|
|
270
288
|
</g>`;
|
|
271
289
|
}
|
|
272
290
|
else {
|
|
273
291
|
// Legacy single-arrow path (no auto cursor-type resolver supplied).
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
<animate attributeName="opacity" values="${visValues.join(";")}" keyTimes="${keyTimes.join(";")}" dur="${totalSec}s" repeatCount="indefinite" calcMode="discrete" fill="freeze" />
|
|
292
|
+
const visName = `co-vis-${uid}`;
|
|
293
|
+
kf.push(`@keyframes ${visName}{${positions.map((p) => `${pct(p.t / totalDurationMs)}{opacity:${p.visible ? "1" : "0"}}`).join("")}}`);
|
|
294
|
+
pointerGroup = ` <g class="cursor-arrow" opacity="0" style="animation:${posAnim},${visName} ${totalSec}s step-end infinite">
|
|
278
295
|
${macosCursorPath(style.cursorScale)}
|
|
279
296
|
</g>`;
|
|
280
297
|
}
|
|
281
298
|
return ` <g class="cursor-overlay" pointer-events="none">
|
|
299
|
+
<style>${kf.join("")}</style>
|
|
282
300
|
${pointerGroup}
|
|
283
301
|
${pulseMarkup}
|
|
284
302
|
</g>`;
|
|
285
303
|
}
|
|
286
|
-
/** Build the SVG fragment for a single click pulse
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
304
|
+
/** Build the SVG fragment for a single click pulse, pushing its keyframes into
|
|
305
|
+
* `kf`. The expanding ring is `transform:scale` with `non-scaling-stroke` (so
|
|
306
|
+
* it matches the old SMIL `r` growth but on the CSS timeline).
|
|
307
|
+
*
|
|
308
|
+
* DM-1510: the pulse animation spans the WHOLE loop (`totalDurationMs`) and runs
|
|
309
|
+
* `infinite`, exactly like the cursor position track — NOT a `durSec`-long
|
|
310
|
+
* one-shot with a start delay. The pulse curve occupies its click window
|
|
311
|
+
* `[c.t, c.t + pulseDurationMs]` as a fraction of the loop and holds invisible
|
|
312
|
+
* (opacity 0, scale reset) the rest of the loop. A one-shot `forwards`
|
|
313
|
+
* animation only played on the first loop iteration, so the click rings
|
|
314
|
+
* vanished after the SVG looped even though the cursor kept moving. */
|
|
315
|
+
function buildPulseFragment(c, idx, uid, kf, totalDurationMs) {
|
|
316
|
+
const totalSec = totalDurationMs / 1000;
|
|
317
|
+
const durMs = c.style.pulseDurationMs;
|
|
290
318
|
const r0 = 4;
|
|
291
319
|
const r1 = c.style.pulseRadius;
|
|
292
320
|
const innerR = r1 * 0.55;
|
|
321
|
+
const outerName = `co-pulse-${uid}-${idx}o`;
|
|
322
|
+
const innerName = `co-pulse-${uid}-${idx}i`;
|
|
323
|
+
// Fractions of the full loop where this pulse's window lands. The window's
|
|
324
|
+
// start/peak/end map to the old one-shot's 0% / 15% / 100% keyframes.
|
|
325
|
+
const fStart = c.t / totalDurationMs;
|
|
326
|
+
const fPeak = Math.min(1, (c.t + 0.15 * durMs) / totalDurationMs);
|
|
327
|
+
const fEnd = Math.min(1, (c.t + durMs) / totalDurationMs);
|
|
328
|
+
// Hold scale(1)+opacity 0 before the click (only needed when the click is not
|
|
329
|
+
// at t=0) and after the window (reset for the next loop, unless the window
|
|
330
|
+
// already reaches the loop end).
|
|
331
|
+
const lead = fStart > 0 ? `${pct(fStart)}{transform:scale(1);opacity:0}` : "";
|
|
332
|
+
const tail = fEnd < 1 ? `100%{transform:scale(1);opacity:0}` : "";
|
|
333
|
+
const peak = (op) => (fPeak < fEnd ? `${pct(fPeak)}{opacity:${op}}` : "");
|
|
334
|
+
kf.push(`@keyframes ${outerName}{0%{transform:scale(1);opacity:0}${lead}${peak(0.9)}${pct(fEnd)}{transform:scale(${num(r1 / r0)});opacity:0}${tail}}`);
|
|
335
|
+
kf.push(`@keyframes ${innerName}{0%{transform:scale(1);opacity:0}${lead}${peak(0.95)}${pct(fEnd)}{transform:scale(${num((r1 - 1) / r0)});opacity:0}${tail}}`);
|
|
336
|
+
const ringStyle = (name) => `transform-box:fill-box;transform-origin:center;vector-effect:non-scaling-stroke;animation:${name} ${totalSec}s linear infinite`;
|
|
293
337
|
// Right-half-disc fill for secondary clicks.
|
|
294
338
|
let secondaryHalf = "";
|
|
295
339
|
if (c.button === "secondary") {
|
|
296
340
|
const halfPath = `M ${num(c.x)} ${num(c.y - innerR)} A ${num(innerR)} ${num(innerR)} 0 0 1 ${num(c.x)} ${num(c.y + innerR)} Z`;
|
|
341
|
+
const halfName = `co-pulse-${uid}-${idx}h`;
|
|
342
|
+
const fHalfPeak = Math.min(1, (c.t + 0.20 * durMs) / totalDurationMs);
|
|
343
|
+
const leadH = fStart > 0 ? `${pct(fStart)}{opacity:0}` : "";
|
|
344
|
+
const tailH = fEnd < 1 ? `100%{opacity:0}` : "";
|
|
345
|
+
const peakH = fHalfPeak < fEnd ? `${pct(fHalfPeak)}{opacity:1}` : "";
|
|
346
|
+
kf.push(`@keyframes ${halfName}{0%{opacity:0}${leadH}${peakH}${pct(fEnd)}{opacity:0}${tailH}}`);
|
|
297
347
|
secondaryHalf = `
|
|
298
|
-
<path d="${halfPath}" fill="rgba(0,0,0,0.2)" opacity="0"
|
|
299
|
-
<animate attributeName="opacity" values="0; 1; 0" keyTimes="0; 0.2; 1" dur="${durSec}s" begin="${beginSec}s" fill="freeze" />
|
|
300
|
-
</path>`;
|
|
348
|
+
<path d="${halfPath}" fill="rgba(0,0,0,0.2)" opacity="0" style="animation:${halfName} ${totalSec}s linear infinite" />`;
|
|
301
349
|
}
|
|
302
350
|
return ` <g class="cursor-click cursor-click-${idx}">
|
|
303
|
-
<circle cx="${num(c.x)}" cy="${num(c.y)}" r="${r0}" fill="none" stroke="${c.style.pulseStrokeOuter}" stroke-width="2" opacity="0"
|
|
304
|
-
|
|
305
|
-
<animate attributeName="opacity" values="0; 0.9; 0" keyTimes="0; 0.15; 1" dur="${durSec}s" begin="${beginSec}s" fill="freeze" />
|
|
306
|
-
</circle>
|
|
307
|
-
<circle cx="${num(c.x)}" cy="${num(c.y)}" r="${r0}" fill="none" stroke="${c.style.pulseStroke}" stroke-width="1" opacity="0">
|
|
308
|
-
<animate attributeName="r" values="${r0}; ${r1 - 1}" keyTimes="0; 1" dur="${durSec}s" begin="${beginSec}s" fill="freeze" />
|
|
309
|
-
<animate attributeName="opacity" values="0; 0.95; 0" keyTimes="0; 0.15; 1" dur="${durSec}s" begin="${beginSec}s" fill="freeze" />
|
|
310
|
-
</circle>${secondaryHalf}
|
|
351
|
+
<circle cx="${num(c.x)}" cy="${num(c.y)}" r="${r0}" fill="none" stroke="${c.style.pulseStrokeOuter}" stroke-width="2" opacity="0" style="${ringStyle(outerName)}" />
|
|
352
|
+
<circle cx="${num(c.x)}" cy="${num(c.y)}" r="${r0}" fill="none" stroke="${c.style.pulseStroke}" stroke-width="1" opacity="0" style="${ringStyle(innerName)}" />${secondaryHalf}
|
|
311
353
|
</g>`;
|
|
312
354
|
}
|
|
313
355
|
/** macOS-style cursor arrow path. The hot point (0, 0) sits at the tip. */
|
package/dist/cli/capture.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* scroll machinery.
|
|
7
7
|
*/
|
|
8
8
|
import { parseArgs } from "node:util";
|
|
9
|
-
import { captureElementTree, clearEmbeddedFonts, clearGlyphDefs, clearWebfonts, composeScrollSvg, cullElementsOutsideViewBox, elementTreeToSvgInner, executeScrollPattern, isDeviceChrome, DEVICE_CHROMES, isChromeTheme, CHROME_THEMES, launchChromium, logCaptureWarnings, optimizeSvg, parseScrollPattern, wrapInDeviceChrome, wrapSvg, } from "../index.js";
|
|
9
|
+
import { captureElementTree, clearEmbeddedFonts, clearGlyphDefs, clearWebfonts, composeScrollSvg, cullElementsOutsideViewBox, elementTreeToSvgInner, embedRemoteImages, executeScrollPattern, isDeviceChrome, DEVICE_CHROMES, isChromeTheme, CHROME_THEMES, launchChromium, logCaptureWarnings, optimizeSvg, parseScrollPattern, wrapInDeviceChrome, wrapSvg, } from "../index.js";
|
|
10
10
|
import { attachWebfontTracker, crossOriginFramesLaunchArgs, discoverAndRegisterWebfonts } from "../capture/index.js";
|
|
11
11
|
import { parseCrossOriginAllowlist } from "../capture/script/cross-origin.js";
|
|
12
12
|
import { applyReadyWaits, inferHarPageUrl, isHarPath, isSvgzPath, loadInputIntoPage, makeLogger, parseColorScheme, parseIntFlag, parseTuple, resolveOutputPath, timed, writeOutput, } from "./common.js";
|
|
@@ -90,6 +90,9 @@ export async function runCapture(args, help) {
|
|
|
90
90
|
"chrome-label": { type: "string" },
|
|
91
91
|
"chrome-theme": { type: "string" },
|
|
92
92
|
"color-scheme": { type: "string" },
|
|
93
|
+
title: { type: "string" },
|
|
94
|
+
desc: { type: "string" },
|
|
95
|
+
"no-embed-images": { type: "boolean" },
|
|
93
96
|
"cross-origin-frames": { type: "string" },
|
|
94
97
|
scroll: { type: "string" },
|
|
95
98
|
"scroll-speed": { type: "string" },
|
|
@@ -225,7 +228,15 @@ export async function runCapture(args, help) {
|
|
|
225
228
|
}
|
|
226
229
|
return Promise.resolve();
|
|
227
230
|
});
|
|
228
|
-
|
|
231
|
+
// DM-1506: embed remote images per segment so the composed scroll SVG is
|
|
232
|
+
// self-contained too. `--no-embed-images` opts out.
|
|
233
|
+
if (values["no-embed-images"] !== true) {
|
|
234
|
+
await timed(log, ` embedded images`, async () => {
|
|
235
|
+
for (const seg of segments)
|
|
236
|
+
await embedRemoteImages(seg.tree);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
svg = await timed(log, ` composed scroll SVG`, () => Promise.resolve(composeScrollSvg(segments, { viewportW: clip[2], viewportH: clip[3], title: values.title, desc: values.desc })));
|
|
229
240
|
}
|
|
230
241
|
else {
|
|
231
242
|
log(`Capturing element tree…`);
|
|
@@ -254,10 +265,16 @@ export async function runCapture(args, help) {
|
|
|
254
265
|
// so this is a defense-in-depth pass for the position:fixed-descendant
|
|
255
266
|
// escape cases where an off-viewport ancestor still gets captured.
|
|
256
267
|
cullElementsOutsideViewBox(tree, clip[2], clip[3], undefined, 0, 1);
|
|
268
|
+
// DM-1506: embed remote images (Node-side fetch → data URIs) so the output
|
|
269
|
+
// is genuinely self-contained, matching the animate/template pipelines and
|
|
270
|
+
// the "no external assets" promise. `--no-embed-images` opts out.
|
|
271
|
+
if (values["no-embed-images"] !== true) {
|
|
272
|
+
await timed(log, " embedded images", () => embedRemoteImages(tree));
|
|
273
|
+
}
|
|
257
274
|
clearEmbeddedFonts(); // DM-839: reset embedded-font builder before this single-frame render
|
|
258
275
|
clearGlyphDefs(); // DM-1338: glyph registry (paths mode) shares the per-generation lifecycle
|
|
259
276
|
const inner = elementTreeToSvgInner(tree, clip[2], clip[3]);
|
|
260
|
-
svg = wrapSvg(inner, clip[2], clip[3]);
|
|
277
|
+
svg = wrapSvg(inner, clip[2], clip[3], { title: values.title, desc: values.desc });
|
|
261
278
|
}
|
|
262
279
|
// DM-1206: wrap the finished capture in a device bezel. Nests the produced
|
|
263
280
|
// SVG (no re-render), so glyph paths match the bare capture exactly.
|
package/dist/cli/index.js
CHANGED
|
@@ -89,6 +89,12 @@ capture options:
|
|
|
89
89
|
--chrome-label <s> Text for the chrome bar (browser URL / window title).
|
|
90
90
|
--chrome-theme <s> browser/window bezel theme: "dark" (default) | "light".
|
|
91
91
|
--color-scheme <s> Set prefers-color-scheme: "light" | "dark" | "no-preference".
|
|
92
|
+
--title <text> Accessible name → role="img" + <title> on the root <svg>
|
|
93
|
+
(for screen readers when the SVG is inlined, not <img>).
|
|
94
|
+
--desc <text> Accessible long description → <desc> on the root <svg>.
|
|
95
|
+
--no-embed-images Leave <img>/background images as external URL refs
|
|
96
|
+
instead of embedding them (default: embed so the SVG
|
|
97
|
+
is self-contained).
|
|
92
98
|
--cross-origin-frames <v> Recurse cross-origin <iframe> docs into native SVG
|
|
93
99
|
(else they stay raster). "*" = all; or a comma-separated
|
|
94
100
|
host[:port] allowlist (e.g. "youtube.com,localhost:3000").
|
|
@@ -25,6 +25,8 @@ export declare const _conicTileCache: Map<string, Map<string, string>>;
|
|
|
25
25
|
*/
|
|
26
26
|
export declare function wrapSvg(inner: string, width: number, height: number, opts?: {
|
|
27
27
|
tree?: CapturedElement[];
|
|
28
|
+
title?: string;
|
|
29
|
+
desc?: string;
|
|
28
30
|
}): string;
|
|
29
31
|
/**
|
|
30
32
|
* DM-552: returns ` color-scheme="dark"` (with leading space, suitable for
|
|
@@ -107,6 +109,11 @@ export declare function elementTreeToSvg(elements: CapturedElement[], width: num
|
|
|
107
109
|
hiDPIFactor?: number;
|
|
108
110
|
/** Forwarded to `elementTreeToSvgInner`. */
|
|
109
111
|
includeEmbeddedFontCss?: boolean;
|
|
112
|
+
/** DM-1488: accessible name → `role="img"` + `<title>` on the root `<svg>`
|
|
113
|
+
* (for inline-`<svg>` embedding). Omit to leave the output unchanged. */
|
|
114
|
+
title?: string;
|
|
115
|
+
/** DM-1488: accessible long description → `<desc>` on the root `<svg>`. */
|
|
116
|
+
desc?: string;
|
|
110
117
|
}): string;
|
|
111
118
|
/**
|
|
112
119
|
* Translate CSS object-fit + object-position to an SVG preserveAspectRatio
|
|
@@ -8,7 +8,7 @@ import { renderVerticalSegments, hasVerticalSegments } from "./vertical-text.js"
|
|
|
8
8
|
import { getEmbeddedFontFaceCss, getGlyphDefs, measureLastGlyphRsb, renderRadicalGlyph } from "./text-to-path.js";
|
|
9
9
|
import { profAccum, profNow } from "./render-profile.js";
|
|
10
10
|
import { renderFormControl } from "./form-controls.js";
|
|
11
|
-
import { r, esc } from "./format.js";
|
|
11
|
+
import { r, esc, rootSvgA11y } from "./format.js";
|
|
12
12
|
import { translateClipPath } from "./clip-path.js";
|
|
13
13
|
import { buildImagePatternDef } from "./image-pattern.js";
|
|
14
14
|
import { buildLinearGradientDef, buildRadialGradientDef, parseBgPositionPx } from "./gradient-defs.js";
|
|
@@ -56,7 +56,8 @@ export function wrapSvg(inner, width, height, opts) {
|
|
|
56
56
|
// XML parsing fails with "Namespace prefix xlink for href is not defined"
|
|
57
57
|
// and Chrome refuses to render past the first occurrence.
|
|
58
58
|
const xlinkAttr = inner.includes("xlink:") ? ` xmlns:xlink="http://www.w3.org/1999/xlink"` : "";
|
|
59
|
-
|
|
59
|
+
const a11y = rootSvgA11y(opts?.title, opts?.desc);
|
|
60
|
+
return `<svg xmlns="http://www.w3.org/2000/svg"${xlinkAttr} viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"${schemeAttr}${a11y.roleAttr}>${a11y.markup}${rootBgRect}${inner}</svg>`;
|
|
60
61
|
}
|
|
61
62
|
/**
|
|
62
63
|
* DM-552: returns ` color-scheme="dark"` (with leading space, suitable for
|
|
@@ -3931,7 +3932,7 @@ function renderElement(state, el, depth, parentDisplayForEl) {
|
|
|
3931
3932
|
*/
|
|
3932
3933
|
export function elementTreeToSvg(elements, width, height, opts) {
|
|
3933
3934
|
const inner = elementTreeToSvgInner(elements, width, height, opts?.idPrefix ?? "", opts?.includeGlyphDefs ?? true, opts?.hiDPIFactor ?? 2, opts?.includeEmbeddedFontCss ?? (opts?.includeGlyphDefs ?? true));
|
|
3934
|
-
return wrapSvg(inner, width, height, { tree: elements });
|
|
3935
|
+
return wrapSvg(inner, width, height, { tree: elements, title: opts?.title, desc: opts?.desc });
|
|
3935
3936
|
}
|
|
3936
3937
|
/**
|
|
3937
3938
|
* Emit a macOS-style overlay scrollbar thumb when the captured element has
|
package/dist/render/format.d.ts
CHANGED
|
@@ -9,3 +9,17 @@
|
|
|
9
9
|
export declare function r(n: number): string;
|
|
10
10
|
export declare function esc(s: string): string;
|
|
11
11
|
export declare function stopFmt(n: number): string;
|
|
12
|
+
/**
|
|
13
|
+
* Build the root-`<svg>` accessibility bits (DM-1488): a `role="img"` attribute
|
|
14
|
+
* plus `<title>`/`<desc>` child markup to inject as the FIRST children of the
|
|
15
|
+
* root `<svg>`, so an inline-embedded demo has an accessible name for screen
|
|
16
|
+
* readers. Emitted ONLY when an accessible name (`title`) is provided — an
|
|
17
|
+
* `<svg role="img">` with no name is an a11y anti-pattern (announced as an
|
|
18
|
+
* unlabeled image), so without a title we emit nothing and the output stays
|
|
19
|
+
* byte-for-byte unchanged. (When embedded via `<img src alt>` the host `alt`
|
|
20
|
+
* already names it; this covers the inline-`<svg>` case.)
|
|
21
|
+
*/
|
|
22
|
+
export declare function rootSvgA11y(title?: string, desc?: string): {
|
|
23
|
+
roleAttr: string;
|
|
24
|
+
markup: string;
|
|
25
|
+
};
|
package/dist/render/format.js
CHANGED
|
@@ -19,3 +19,20 @@ export function esc(s) {
|
|
|
19
19
|
export function stopFmt(n) {
|
|
20
20
|
return Number(n.toFixed(4)).toString();
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Build the root-`<svg>` accessibility bits (DM-1488): a `role="img"` attribute
|
|
24
|
+
* plus `<title>`/`<desc>` child markup to inject as the FIRST children of the
|
|
25
|
+
* root `<svg>`, so an inline-embedded demo has an accessible name for screen
|
|
26
|
+
* readers. Emitted ONLY when an accessible name (`title`) is provided — an
|
|
27
|
+
* `<svg role="img">` with no name is an a11y anti-pattern (announced as an
|
|
28
|
+
* unlabeled image), so without a title we emit nothing and the output stays
|
|
29
|
+
* byte-for-byte unchanged. (When embedded via `<img src alt>` the host `alt`
|
|
30
|
+
* already names it; this covers the inline-`<svg>` case.)
|
|
31
|
+
*/
|
|
32
|
+
export function rootSvgA11y(title, desc) {
|
|
33
|
+
if (title == null || title === "")
|
|
34
|
+
return { roleAttr: "", markup: "" };
|
|
35
|
+
const titleEl = `<title>${esc(title)}</title>`;
|
|
36
|
+
const descEl = desc != null && desc !== "" ? `<desc>${esc(desc)}</desc>` : "";
|
|
37
|
+
return { roleAttr: ` role="img"`, markup: titleEl + descEl };
|
|
38
|
+
}
|
|
@@ -29,6 +29,11 @@ export interface ScrollComposerOptions {
|
|
|
29
29
|
viewportH: number;
|
|
30
30
|
/** Which axis the scroll moves along. Default `"y"`. */
|
|
31
31
|
axis?: "x" | "y";
|
|
32
|
+
/** DM-1488: accessible name → `role="img"` + `<title>` on the root `<svg>`
|
|
33
|
+
* (for inline-`<svg>` embedding). Omit to leave the output unchanged. */
|
|
34
|
+
title?: string;
|
|
35
|
+
/** DM-1488: accessible long description → `<desc>` on the root `<svg>`. */
|
|
36
|
+
desc?: string;
|
|
32
37
|
/**
|
|
33
38
|
* Background color painted behind the captures (a full-viewport rect, visible
|
|
34
39
|
* at seams). When omitted it defaults to the captured page's root background
|
package/dist/scroll/composer.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* shrinking output size on mostly-static-content pages.
|
|
22
22
|
*/
|
|
23
23
|
import { elementTreeToSvgInner } from "../render/element-tree-to-svg.js";
|
|
24
|
+
import { rootSvgA11y } from "../render/format.js";
|
|
24
25
|
import { isTransparentBackground } from "../utils/transparent-background.js";
|
|
25
26
|
import { resetGeneration, getEmbeddedFontFaceCss, withRenderTextMode, } from "../render/text-to-path.js";
|
|
26
27
|
import { extractFixedSubtrees, dedupeFixedAcrossSegments } from "./hoist-fixed.js";
|
|
@@ -351,8 +352,9 @@ function composeScrollSvgBody(segments, opts, ctx) {
|
|
|
351
352
|
// all per-segment rendering has finished.
|
|
352
353
|
const fontFaceCss = getEmbeddedFontFaceCss();
|
|
353
354
|
// ── Compose final SVG ──
|
|
355
|
+
const a11y = rootSvgA11y(opts.title, opts.desc);
|
|
354
356
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
355
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${VH}" width="${W}" height="${VH}"
|
|
357
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${VH}" width="${W}" height="${VH}"${a11y.roleAttr}>${a11y.markup}
|
|
356
358
|
<defs>
|
|
357
359
|
<clipPath id="${animClass}-clip"><rect width="${W}" height="${VH}"/></clipPath>
|
|
358
360
|
<style>
|
|
@@ -362,6 +364,9 @@ ${keyframes}
|
|
|
362
364
|
}
|
|
363
365
|
${segmentCullCss.join("\n")}
|
|
364
366
|
${stickyCullCss.join("\n")}
|
|
367
|
+
@media (prefers-reduced-motion: reduce) {
|
|
368
|
+
.${animClass}, .${animClass} * { animation-play-state: paused !important; }
|
|
369
|
+
}
|
|
365
370
|
</style>
|
|
366
371
|
</defs>
|
|
367
372
|
${paintBg ? ` <rect width="${W}" height="${VH}" fill="${bg}"/>\n` : ""} <g clip-path="url(#${animClass}-clip)">
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// AUTO-GENERATED by scripts/build-scrubber-client.mjs — do NOT edit by hand.
|
|
2
2
|
// The source lives at src/scrubber/client.tsx; rerun the build to regenerate.
|
|
3
|
-
export const SCRUBBER_CLIENT_JS = "\"use strict\";\n(() => {\n // node_modules/kerfjs/dist/chunk-4VT4YZOO.js\n function flatten(segment, withMarkers) {\n if (segment.kind === \"static\") return segment.html;\n if (segment.kind === \"list\") {\n const items = segment.items.map((i2) => i2.html).join(\"\");\n return withMarkers ? `<!--kf-list:${segment.id}-->${items}` : items;\n }\n return segment.parts.map((p2) => flatten(p2, withMarkers)).join(\"\");\n }\n function flattenWithoutListItems(segment) {\n if (segment.kind === \"static\") return segment.html;\n if (segment.kind === \"list\") return `<!--kf-list:${segment.id}-->`;\n return segment.parts.map(flattenWithoutListItems).join(\"\");\n }\n function collectLists(segment, out = /* @__PURE__ */ new Map()) {\n if (segment.kind === \"list\") out.set(segment.id, segment);\n else if (segment.kind === \"mixed\") {\n for (const part of segment.parts) collectLists(part, out);\n }\n return out;\n }\n function mergeChildSegments(parts) {\n if (parts.length === 0) return { kind: \"static\", html: \"\" };\n if (parts.every((p2) => p2.kind === \"static\")) {\n return {\n kind: \"static\",\n html: parts.map((p2) => p2.html).join(\"\")\n };\n }\n const merged = [];\n let coalesced = \"\";\n for (const p2 of parts) {\n if (p2.kind === \"static\") {\n coalesced += p2.html;\n } else {\n if (coalesced !== \"\") {\n merged.push({ kind: \"static\", html: coalesced });\n coalesced = \"\";\n }\n merged.push(p2);\n }\n }\n if (coalesced !== \"\") merged.push({ kind: \"static\", html: coalesced });\n return { kind: \"mixed\", parts: merged };\n }\n function wrapWithTags(child, openTag, closeTag) {\n if (child.kind === \"static\") {\n return { kind: \"static\", html: openTag + child.html + closeTag };\n }\n if (child.kind === \"mixed\") {\n return {\n kind: \"mixed\",\n parts: [\n { kind: \"static\", html: openTag },\n ...child.parts,\n { kind: \"static\", html: closeTag }\n ]\n };\n }\n return {\n kind: \"mixed\",\n parts: [\n { kind: \"static\", html: openTag },\n child,\n { kind: \"static\", html: closeTag }\n ]\n };\n }\n function escapeHtml(str) {\n return str.replace(/&/g, \"&\").replace(/</g, \"<\").replace(/>/g, \">\").replace(/\"/g, \""\");\n }\n function escapeAttr(str) {\n return str.replace(/&/g, \"&\").replace(/\"/g, \""\").replace(/'/g, \"'\").replace(/</g, \"<\").replace(/>/g, \">\");\n }\n var ATTR_ALIASES = {\n // HTML attributes\n className: \"class\",\n htmlFor: \"for\",\n httpEquiv: \"http-equiv\",\n acceptCharset: \"accept-charset\",\n accessKey: \"accesskey\",\n autoCapitalize: \"autocapitalize\",\n autoComplete: \"autocomplete\",\n autoFocus: \"autofocus\",\n autoPlay: \"autoplay\",\n colSpan: \"colspan\",\n contentEditable: \"contenteditable\",\n crossOrigin: \"crossorigin\",\n dateTime: \"datetime\",\n defaultChecked: \"checked\",\n defaultValue: \"value\",\n encType: \"enctype\",\n formAction: \"formaction\",\n formEncType: \"formenctype\",\n formMethod: \"formmethod\",\n formNoValidate: \"formnovalidate\",\n formTarget: \"formtarget\",\n hrefLang: \"hreflang\",\n inputMode: \"inputmode\",\n maxLength: \"maxlength\",\n minLength: \"minlength\",\n noModule: \"nomodule\",\n noValidate: \"novalidate\",\n readOnly: \"readonly\",\n referrerPolicy: \"referrerpolicy\",\n rowSpan: \"rowspan\",\n spellCheck: \"spellcheck\",\n srcDoc: \"srcdoc\",\n srcLang: \"srclang\",\n srcSet: \"srcset\",\n tabIndex: \"tabindex\",\n useMap: \"usemap\",\n // SVG presentation attributes (camelCase → kebab-case)\n strokeWidth: \"stroke-width\",\n strokeLinecap: \"stroke-linecap\",\n strokeLinejoin: \"stroke-linejoin\",\n strokeDasharray: \"stroke-dasharray\",\n strokeDashoffset: \"stroke-dashoffset\",\n strokeMiterlimit: \"stroke-miterlimit\",\n strokeOpacity: \"stroke-opacity\",\n fillOpacity: \"fill-opacity\",\n fillRule: \"fill-rule\",\n clipPath: \"clip-path\",\n clipRule: \"clip-rule\",\n colorInterpolation: \"color-interpolation\",\n colorInterpolationFilters: \"color-interpolation-filters\",\n floodColor: \"flood-color\",\n floodOpacity: \"flood-opacity\",\n lightingColor: \"lighting-color\",\n stopColor: \"stop-color\",\n stopOpacity: \"stop-opacity\",\n shapeRendering: \"shape-rendering\",\n imageRendering: \"image-rendering\",\n textRendering: \"text-rendering\",\n pointerEvents: \"pointer-events\",\n vectorEffect: \"vector-effect\",\n paintOrder: \"paint-order\",\n // SVG text/font attributes\n fontFamily: \"font-family\",\n fontSize: \"font-size\",\n fontStyle: \"font-style\",\n fontVariant: \"font-variant\",\n fontWeight: \"font-weight\",\n fontStretch: \"font-stretch\",\n textAnchor: \"text-anchor\",\n textDecoration: \"text-decoration\",\n dominantBaseline: \"dominant-baseline\",\n alignmentBaseline: \"alignment-baseline\",\n baselineShift: \"baseline-shift\",\n letterSpacing: \"letter-spacing\",\n wordSpacing: \"word-spacing\",\n writingMode: \"writing-mode\",\n // SVG marker attributes\n markerStart: \"marker-start\",\n markerMid: \"marker-mid\",\n markerEnd: \"marker-end\",\n // SVG xlink (legacy but still used)\n xlinkHref: \"xlink:href\",\n xlinkShow: \"xlink:show\",\n xlinkActuate: \"xlink:actuate\",\n xlinkType: \"xlink:type\",\n xlinkRole: \"xlink:role\",\n xlinkTitle: \"xlink:title\",\n xlinkArcrole: \"xlink:arcrole\",\n xmlBase: \"xml:base\",\n xmlLang: \"xml:lang\",\n xmlSpace: \"xml:space\",\n xmlnsXlink: \"xmlns:xlink\"\n };\n var SAFE_HTML_BRAND = /* @__PURE__ */ Symbol.for(\"kerfjs.SafeHtml\");\n var SafeHtml = class {\n __html;\n __segment;\n // Branded so `isSafeHtml()` recognizes instances from any copy of this module.\n [SAFE_HTML_BRAND] = true;\n constructor(input) {\n if (typeof input === \"string\") {\n this.__segment = { kind: \"static\", html: input };\n this.__html = input;\n } else {\n this.__segment = input;\n this.__html = flatten(input, false);\n }\n }\n toString() {\n return this.__html;\n }\n };\n function isSafeHtml(value) {\n return typeof value === \"object\" && value !== null && value[SAFE_HTML_BRAND] === true;\n }\n var VOID_TAGS = /* @__PURE__ */ new Set([\n \"area\",\n \"base\",\n \"br\",\n \"col\",\n \"embed\",\n \"hr\",\n \"img\",\n \"input\",\n \"link\",\n \"meta\",\n \"source\",\n \"track\",\n \"wbr\"\n ]);\n function toSegment(child) {\n if (child == null || typeof child === \"boolean\") return { kind: \"static\", html: \"\" };\n if (isSafeHtml(child)) {\n return child.__segment ?? { kind: \"static\", html: child.__html };\n }\n if (typeof child === \"string\") return { kind: \"static\", html: escapeHtml(child) };\n if (typeof child === \"number\") return { kind: \"static\", html: String(child) };\n if (Array.isArray(child)) return mergeChildSegments(child.map(toSegment));\n const maybeNode = child;\n if (typeof maybeNode === \"object\" && maybeNode !== null && (\"nodeType\" in maybeNode || \"outerHTML\" in maybeNode)) {\n throw new Error(\n \"JSX: DOM elements cannot be passed as children (the JSX runtime renders to HTML strings). Build the tree in one JSX expression and use querySelector after toElement() to get element refs.\"\n );\n }\n throw new Error(\n `JSX: unsupported child of type ${describeValue(child)}. Children must be SafeHtml, string, number, boolean, null, undefined, or an array of those. Common mistakes: passing a Signal/Store object directly (use signal.value or store.state.value), passing a function (call it first), or passing a Promise (await it before render).`\n );\n }\n function describeValue(v2) {\n if (Array.isArray(v2)) return \"array\";\n if (typeof v2 === \"object\" && v2 !== null) {\n const ctor = v2.constructor?.name;\n return ctor && ctor !== \"Object\" ? `object (${ctor})` : \"object\";\n }\n return typeof v2;\n }\n var URL_ATTRS = /* @__PURE__ */ new Set([\"href\", \"src\", \"xlink:href\", \"formaction\", \"action\"]);\n var DANGEROUS_URL_RE = /^\\s*(?:(?:java|vb)script:|data:text\\/html[;,])/i;\n function renderAttr(key, value) {\n const name = ATTR_ALIASES[key] ?? key;\n if (value == null || value === false) return \"\";\n if (value === true) return ` ${name}`;\n let strValue;\n if (isSafeHtml(value)) {\n strValue = value.__html;\n } else if (typeof value === \"number\") {\n strValue = String(value);\n } else if (typeof value === \"string\") {\n if (URL_ATTRS.has(name) && DANGEROUS_URL_RE.test(value)) {\n console.warn(\n `JSX: dropped dangerous URL value for ${name}=${JSON.stringify(value.slice(0, 80))}. kerf blocks javascript:, vbscript:, and data:text/html URLs in href/src/formaction/action/xlink:href by default. Wrap in raw() if this is intentional (e.g. bookmarklets), or sanitize upstream.`\n );\n return \"\";\n }\n strValue = escapeAttr(value);\n } else if (typeof value === \"function\" && /^on[A-Z]/.test(key)) {\n throw new Error(\n `JSX: inline event handlers like ${key}={fn} are not supported by kerf's JSX \\u2192 HTML-string runtime. Use event delegation from the mount root instead:\n\n delegate(rootEl, 'click', '[data-action=\"...\"]', (evt, target) => { ... });\n <button data-action=\"...\">click</button>\n\nSee docs/5-event-delegation.md for the tier-1/tier-2/tier-3 model.`\n );\n } else {\n throw new Error(\n `JSX: unsupported value for attribute \"${key}\" \\u2014 got ${describeValue(value)}. Attribute values must be string, number, boolean, null, undefined, or SafeHtml. Did you mean to read .value off a Signal, or stringify the object first?`\n );\n }\n return ` ${name}=\"${strValue}\"`;\n }\n function jsx(tag, props) {\n if (typeof tag === \"function\") return tag(props);\n const { children, ...attrs } = props;\n const attrStr = Object.entries(attrs).map(([k, v2]) => renderAttr(k, v2)).join(\"\");\n if (VOID_TAGS.has(tag)) return new SafeHtml(`<${tag}${attrStr}>`);\n const childSegment = children != null ? toSegment(children) : { kind: \"static\", html: \"\" };\n return new SafeHtml(wrapWithTags(childSegment, `<${tag}${attrStr}>`, `</${tag}>`));\n }\n function Fragment({ children }) {\n return new SafeHtml(children != null ? toSegment(children) : { kind: \"static\", html: \"\" });\n }\n\n // node_modules/@preact/signals-core/dist/signals-core.module.js\n var i = /* @__PURE__ */ Symbol.for(\"preact-signals\");\n function t() {\n if (!(s > 1)) {\n var i2, t2 = false;\n !(function() {\n var i3 = c;\n c = void 0;\n while (void 0 !== i3) {\n if (i3.S.v === i3.v) i3.S.i = i3.i;\n i3 = i3.o;\n }\n })();\n while (void 0 !== h) {\n var n2 = h;\n h = void 0;\n v++;\n while (void 0 !== n2) {\n var r2 = n2.u;\n n2.u = void 0;\n n2.f &= -3;\n if (!(8 & n2.f) && w(n2)) try {\n n2.c();\n } catch (n3) {\n if (!t2) {\n i2 = n3;\n t2 = true;\n }\n }\n n2 = r2;\n }\n }\n v = 0;\n s--;\n if (t2) throw i2;\n } else s--;\n }\n var r = void 0;\n function o(i2) {\n var t2 = r;\n r = void 0;\n try {\n return i2();\n } finally {\n r = t2;\n }\n }\n var f;\n var h = void 0;\n var s = 0;\n var v = 0;\n var e = 0;\n var c = void 0;\n var d = 0;\n function a(i2) {\n if (void 0 !== r) {\n var t2 = i2.n;\n if (void 0 === t2 || t2.t !== r) {\n t2 = { i: 0, S: i2, p: r.s, n: void 0, t: r, e: void 0, x: void 0, r: t2 };\n if (void 0 !== r.s) r.s.n = t2;\n r.s = t2;\n i2.n = t2;\n if (32 & r.f) i2.S(t2);\n return t2;\n } else if (-1 === t2.i) {\n t2.i = 0;\n if (void 0 !== t2.n) {\n t2.n.p = t2.p;\n if (void 0 !== t2.p) t2.p.n = t2.n;\n t2.p = r.s;\n t2.n = void 0;\n r.s.n = t2;\n r.s = t2;\n }\n return t2;\n }\n }\n }\n function l(i2, t2) {\n this.v = i2;\n this.i = 0;\n this.n = void 0;\n this.t = void 0;\n this.l = 0;\n this.W = null == t2 ? void 0 : t2.watched;\n this.Z = null == t2 ? void 0 : t2.unwatched;\n this.name = null == t2 ? void 0 : t2.name;\n }\n l.prototype.brand = i;\n l.prototype.h = function() {\n return true;\n };\n l.prototype.S = function(i2) {\n var t2 = this, n2 = this.t;\n if (n2 !== i2 && void 0 === i2.e) {\n i2.x = n2;\n this.t = i2;\n if (void 0 !== n2) n2.e = i2;\n else o(function() {\n var i3;\n null == (i3 = t2.W) || i3.call(t2);\n });\n }\n };\n l.prototype.U = function(i2) {\n var t2 = this;\n if (void 0 !== this.t) {\n var n2 = i2.e, r2 = i2.x;\n if (void 0 !== n2) {\n n2.x = r2;\n i2.e = void 0;\n }\n if (void 0 !== r2) {\n r2.e = n2;\n i2.x = void 0;\n }\n if (i2 === this.t) {\n this.t = r2;\n if (void 0 === r2) o(function() {\n var i3;\n null == (i3 = t2.Z) || i3.call(t2);\n });\n }\n }\n };\n l.prototype.subscribe = function(i2) {\n var t2 = this;\n return j(function() {\n var n2 = t2.value, o2 = r;\n r = void 0;\n try {\n i2(n2);\n } finally {\n r = o2;\n }\n }, { name: \"sub\" });\n };\n l.prototype.valueOf = function() {\n return this.value;\n };\n l.prototype.toString = function() {\n return this.value + \"\";\n };\n l.prototype.toJSON = function() {\n return this.value;\n };\n l.prototype.peek = function() {\n var i2 = r;\n r = void 0;\n try {\n return this.value;\n } finally {\n r = i2;\n }\n };\n Object.defineProperty(l.prototype, \"value\", { get: function() {\n var i2 = a(this);\n if (void 0 !== i2) i2.i = this.i;\n return this.v;\n }, set: function(i2) {\n if (i2 !== this.v) {\n if (v > 100) throw new Error(\"Cycle detected\");\n !(function(i3) {\n if (0 !== s && 0 === v) {\n if (i3.l !== e) {\n i3.l = e;\n c = { S: i3, v: i3.v, i: i3.i, o: c };\n }\n }\n })(this);\n this.v = i2;\n this.i++;\n d++;\n s++;\n try {\n for (var n2 = this.t; void 0 !== n2; n2 = n2.x) n2.t.N();\n } finally {\n t();\n }\n }\n } });\n function y(i2, t2) {\n return new l(i2, t2);\n }\n function w(i2) {\n for (var t2 = i2.s; void 0 !== t2; t2 = t2.n) if (t2.S.i !== t2.i || !t2.S.h() || t2.S.i !== t2.i) return true;\n return false;\n }\n function _(i2) {\n for (var t2 = i2.s; void 0 !== t2; t2 = t2.n) {\n var n2 = t2.S.n;\n if (void 0 !== n2) t2.r = n2;\n t2.S.n = t2;\n t2.i = -1;\n if (void 0 === t2.n) {\n i2.s = t2;\n break;\n }\n }\n }\n function b(i2) {\n var t2 = i2.s, n2 = void 0;\n while (void 0 !== t2) {\n var r2 = t2.p;\n if (-1 === t2.i) {\n t2.S.U(t2);\n if (void 0 !== r2) r2.n = t2.n;\n if (void 0 !== t2.n) t2.n.p = r2;\n } else n2 = t2;\n t2.S.n = t2.r;\n if (void 0 !== t2.r) t2.r = void 0;\n t2 = r2;\n }\n i2.s = n2;\n }\n function p(i2, t2) {\n l.call(this, void 0);\n this.x = i2;\n this.s = void 0;\n this.g = d - 1;\n this.f = 4;\n this.W = null == t2 ? void 0 : t2.watched;\n this.Z = null == t2 ? void 0 : t2.unwatched;\n this.name = null == t2 ? void 0 : t2.name;\n }\n p.prototype = new l();\n p.prototype.h = function() {\n this.f &= -3;\n if (1 & this.f) return false;\n if (32 == (36 & this.f)) return true;\n this.f &= -5;\n if (this.g === d) return true;\n this.g = d;\n this.f |= 1;\n if (this.i > 0 && !w(this)) {\n this.f &= -2;\n return true;\n }\n var i2 = r;\n try {\n _(this);\n r = this;\n var t2 = this.x();\n if (16 & this.f || this.v !== t2 || 0 === this.i) {\n this.v = t2;\n this.f &= -17;\n this.i++;\n }\n } catch (i3) {\n this.v = i3;\n this.f |= 16;\n this.i++;\n }\n r = i2;\n b(this);\n this.f &= -2;\n return true;\n };\n p.prototype.S = function(i2) {\n if (void 0 === this.t) {\n this.f |= 36;\n for (var t2 = this.s; void 0 !== t2; t2 = t2.n) t2.S.S(t2);\n }\n l.prototype.S.call(this, i2);\n };\n p.prototype.U = function(i2) {\n if (void 0 !== this.t) {\n l.prototype.U.call(this, i2);\n if (void 0 === this.t) {\n this.f &= -33;\n for (var t2 = this.s; void 0 !== t2; t2 = t2.n) t2.S.U(t2);\n }\n }\n };\n p.prototype.N = function() {\n if (!(2 & this.f)) {\n this.f |= 6;\n for (var i2 = this.t; void 0 !== i2; i2 = i2.x) i2.t.N();\n }\n };\n Object.defineProperty(p.prototype, \"value\", { get: function() {\n if (1 & this.f) throw new Error(\"Cycle detected\");\n var i2 = a(this);\n this.h();\n if (void 0 !== i2) i2.i = this.i;\n if (16 & this.f) throw this.v;\n return this.v;\n } });\n function S(i2) {\n var n2 = i2.m;\n i2.m = void 0;\n if (\"function\" == typeof n2) {\n s++;\n var o2 = r;\n r = void 0;\n try {\n n2();\n } catch (t2) {\n i2.f &= -2;\n i2.f |= 8;\n m(i2);\n throw t2;\n } finally {\n r = o2;\n t();\n }\n }\n }\n function m(i2) {\n for (var t2 = i2.s; void 0 !== t2; t2 = t2.n) t2.S.U(t2);\n i2.x = void 0;\n i2.s = void 0;\n S(i2);\n }\n function x(i2) {\n if (r !== this) throw new Error(\"Out-of-order effect\");\n b(this);\n r = i2;\n this.f &= -2;\n if (8 & this.f) m(this);\n t();\n }\n function E(i2, t2) {\n this.x = i2;\n this.m = void 0;\n this.s = void 0;\n this.u = void 0;\n this.f = 32;\n this.name = null == t2 ? void 0 : t2.name;\n if (f) f.push(this);\n }\n E.prototype.c = function() {\n var i2 = this.S();\n try {\n if (8 & this.f) return;\n if (void 0 === this.x) return;\n var t2 = this.x();\n if (\"function\" == typeof t2) this.m = t2;\n } finally {\n i2();\n }\n };\n E.prototype.S = function() {\n if (1 & this.f) throw new Error(\"Cycle detected\");\n this.f |= 1;\n this.f &= -9;\n S(this);\n _(this);\n s++;\n var i2 = r;\n r = this;\n return x.bind(this, i2);\n };\n E.prototype.N = function() {\n if (!(2 & this.f)) {\n this.f |= 2;\n this.u = h;\n h = this;\n }\n };\n E.prototype.d = function() {\n this.f |= 8;\n if (!(1 & this.f)) m(this);\n };\n E.prototype.dispose = function() {\n this.d();\n };\n function j(i2, t2) {\n var n2 = new E(i2, t2);\n try {\n n2.c();\n } catch (i3) {\n n2.d();\n throw i3;\n }\n var r2 = n2.d.bind(n2);\n r2[Symbol.dispose] = r2;\n return r2;\n }\n\n // node_modules/kerfjs/dist/chunk-N4KF3GD2.js\n var depth = 0;\n var warned = false;\n function isOptedIn() {\n const proc = globalThis.process;\n if (proc?.env?.NODE_ENV === \"production\") return false;\n return proc?.env?.KERF_DEV_WARN_DELEGATE_IN_EFFECT === \"1\";\n }\n function enterEffect() {\n depth++;\n }\n function exitEffect() {\n depth--;\n }\n function isDevWarnDelegateInEffectEnabled() {\n return isOptedIn();\n }\n function warnIfInsideEffect(fn) {\n if (!isOptedIn()) return;\n if (depth === 0) return;\n if (warned) return;\n warned = true;\n console.warn(\n `kerf: ${fn}() was called inside an effect() body. Every effect re-run installs a fresh root listener; the effect disposer cleans up the reactive subscription but not the listeners, so listener count grows linearly with signal churn and each listener pins its handler closure. Register the delegate once at module or setup scope and gate behavior on the signal *inside the handler* where the read is free. See docs/5-event-delegation.md \\xA75.3 \"When capturing the disposer still isn't enough\". Set KERF_DEV_WARN_DELEGATE_IN_EFFECT=0 (or unset it) to silence this warning.`\n );\n }\n var WARNING_MESSAGE = \"kerf: signal was written but has no subscribers. Did you read `.value` outside of a render fn / effect()? Hoisted reads do not subscribe, so subsequent writes will not re-render. Move the read inside mount()'s render fn or effect() callback. Set KERF_DEV_WARN_UNTRACKED_SIGNALS=0 (or unset it) to silence this warning.\";\n var DevSignal = class extends l {\n __hasSubscriber = false;\n __warned = false;\n __constructed = false;\n constructor(initial) {\n super(initial, {\n watched() {\n this.__hasSubscriber = true;\n }\n });\n this.__constructed = true;\n }\n get value() {\n return super.value;\n }\n set value(v2) {\n super.value = v2;\n if (this.__constructed && !this.__hasSubscriber && !this.__warned) {\n this.__warned = true;\n console.warn(WARNING_MESSAGE);\n }\n }\n };\n function isDevWarnUntrackedEnabled() {\n const proc = globalThis.process;\n if (proc?.env?.NODE_ENV === \"production\") return false;\n return proc?.env?.KERF_DEV_WARN_UNTRACKED_SIGNALS === \"1\";\n }\n function signal(value) {\n if (isDevWarnUntrackedEnabled()) return new DevSignal(value);\n return y(value);\n }\n function effect(fn) {\n if (!isDevWarnDelegateInEffectEnabled()) return j(fn);\n return j(() => {\n enterEffect();\n try {\n return fn();\n } finally {\n exitEffect();\n }\n });\n }\n\n // node_modules/kerfjs/dist/index.js\n var NON_BUBBLING = /* @__PURE__ */ new Set([\n \"focus\",\n \"blur\",\n \"scroll\",\n \"load\",\n \"error\",\n \"mouseenter\",\n \"mouseleave\"\n ]);\n function assertValidSelector(selector, fn) {\n try {\n document.createElement(\"div\").matches(selector);\n } catch {\n throw new Error(\n `${fn}: invalid selector \"${selector}\". Pass a valid CSS selector (e.g. '[data-action=\"add\"]', '.btn', 'input').`\n );\n }\n }\n function delegate(rootEl, type, selector, handler) {\n assertValidSelector(selector, \"delegate\");\n warnIfInsideEffect(\"delegate\");\n const listener = (event) => {\n const target = event.target;\n if (!(target instanceof Element)) return;\n const matched = target.closest(selector);\n if (matched !== null && rootEl.contains(matched)) {\n handler(event, matched);\n }\n };\n const capture = NON_BUBBLING.has(type);\n rootEl.addEventListener(type, listener, capture);\n return () => {\n rootEl.removeEventListener(type, listener, capture);\n };\n }\n var warnedIds = /* @__PURE__ */ new Set();\n function isOptedIn2() {\n const proc = globalThis.process;\n if (proc?.env?.NODE_ENV === \"production\") return false;\n return proc?.env?.KERF_DEV_WARN_EACH_IN_MORPH_SKIP === \"1\";\n }\n function hasMorphSkipAncestor(el, root) {\n let ancestor = el.parentElement;\n while (ancestor !== null && ancestor !== root) {\n if (ancestor.dataset.morphSkip !== void 0) return true;\n ancestor = ancestor.parentElement;\n }\n return false;\n }\n function maybeWarnEachInMorphSkip(id, liveParent, rootEl) {\n if (!isOptedIn2()) return;\n if (warnedIds.has(id)) return;\n if (!hasMorphSkipAncestor(liveParent, rootEl)) return;\n warnedIds.add(id);\n console.warn(\n `kerf: each() list '${id}' is inside a data-morph-skip subtree. The keyed reconciler still updates the list rows, but any static signal-reactive JSX inside the same skipped ancestor (e.g. <p>{count.value}</p>) is frozen \\u2014 the morph never visits it. Remove data-morph-skip from any element that contains reactive JSX content and reserve it for truly library-owned hosts. Set KERF_DEV_WARN_EACH_IN_MORPH_SKIP=0 (or unset it) to silence this warning.`\n );\n }\n var context = null;\n function _setRenderContext(c2) {\n context = c2;\n }\n var ID_KEY_PREFIX = \"id:\";\n var DATA_KEY_PREFIX = \"data-key:\";\n var ELEMENT_NODE = 1;\n var TEXT_NODE = 3;\n var COMMENT_NODE = 8;\n function getNodeKey(node) {\n if (node.nodeType !== ELEMENT_NODE) return void 0;\n const el = node;\n if (el.id !== \"\") return `${ID_KEY_PREFIX}${el.id}`;\n if (el.dataset !== void 0 && el.dataset.key !== void 0) {\n return `${DATA_KEY_PREFIX}${el.dataset.key}`;\n }\n return void 0;\n }\n var EMPTY_OWNED = /* @__PURE__ */ new Set();\n function morph(liveRoot, template, ownedItems = EMPTY_OWNED) {\n const templateEl = isElementNode(template) ? template : parseTemplate(liveRoot, template);\n morphChildren(liveRoot, templateEl, ownedItems);\n }\n function _morphElement(fromEl, toEl, ownedItems = EMPTY_OWNED) {\n morphElement(fromEl, toEl, ownedItems);\n }\n function isElementNode(t2) {\n return typeof t2 === \"object\" && t2 !== null && t2.nodeType === ELEMENT_NODE;\n }\n function parseTemplate(liveRoot, template) {\n const el = liveRoot.cloneNode(false);\n el.innerHTML = String(template);\n return el;\n }\n function skipOwned(node, ownedItems) {\n while (node !== null && node.nodeType === ELEMENT_NODE && ownedItems.has(node)) {\n node = node.nextSibling;\n }\n return node;\n }\n function morphChildren(fromParent, toParent, ownedItems) {\n const keyed = /* @__PURE__ */ new Map();\n for (let c2 = fromParent.firstChild; c2 !== null; c2 = c2.nextSibling) {\n if (c2.nodeType === ELEMENT_NODE && ownedItems.has(c2)) continue;\n const k = getNodeKey(c2);\n if (k !== void 0) keyed.set(k, c2);\n }\n let fromChild = skipOwned(fromParent.firstChild, ownedItems);\n let toChild = toParent.firstChild;\n while (toChild !== null) {\n const toNext = toChild.nextSibling;\n let matched = null;\n const toKey = getNodeKey(toChild);\n if (toKey !== void 0 && keyed.has(toKey)) {\n matched = keyed.get(toKey);\n keyed.delete(toKey);\n if (matched !== fromChild) {\n fromParent.insertBefore(matched, fromChild);\n } else {\n fromChild = skipOwned(fromChild.nextSibling, ownedItems);\n }\n }\n if (matched === null && fromChild !== null && fromChild.nodeType === toChild.nodeType && (toChild.nodeType !== ELEMENT_NODE || fromChild.tagName === toChild.tagName && getNodeKey(fromChild) === void 0)) {\n matched = fromChild;\n fromChild = skipOwned(fromChild.nextSibling, ownedItems);\n }\n if (matched !== null) {\n morphNode(matched, toChild, ownedItems);\n } else {\n const cloned = toChild.cloneNode(true);\n fromParent.insertBefore(cloned, fromChild);\n }\n toChild = toNext;\n }\n while (fromChild !== null) {\n const next = fromChild.nextSibling;\n if (fromChild.nodeType === ELEMENT_NODE) {\n const el = fromChild;\n if (!ownedItems.has(el) && el.dataset.morphPreserve === void 0) {\n fromParent.removeChild(fromChild);\n }\n } else {\n fromParent.removeChild(fromChild);\n }\n fromChild = next;\n }\n }\n function morphNode(fromNode, toNode, ownedItems) {\n if (fromNode.nodeType === ELEMENT_NODE) {\n morphElement(fromNode, toNode, ownedItems);\n return;\n }\n if (fromNode.nodeType === TEXT_NODE || fromNode.nodeType === COMMENT_NODE) {\n const fromText = fromNode;\n const toText = toNode;\n if (fromText.data !== toText.data) fromText.data = toText.data;\n }\n }\n function morphElement(fromEl, toEl, ownedItems) {\n if (fromEl.tagName !== toEl.tagName) {\n const replacement = toEl.cloneNode(true);\n fromEl.parentNode?.replaceChild(replacement, fromEl);\n return;\n }\n if (fromEl.dataset.morphSkip !== void 0) return;\n if (fromEl.isEqualNode(toEl)) return;\n if (fromEl === document.activeElement) {\n const ce = fromEl.getAttribute(\"contenteditable\");\n if (ce !== null && ce.toLowerCase() !== \"false\") return;\n if (isTextInputOrTextarea(fromEl)) preserveTextEntryState(fromEl, toEl);\n }\n morphAttributes(fromEl, toEl);\n if (fromEl.dataset.morphSkipChildren !== void 0) return;\n morphChildren(fromEl, toEl, ownedItems);\n }\n function isUserAgentOwnedAttr(tagName, name) {\n return name === \"open\" && (tagName === \"DETAILS\" || tagName === \"DIALOG\");\n }\n function morphAttributes(fromEl, toEl) {\n const toAttrs = toEl.attributes;\n for (let i2 = 0; i2 < toAttrs.length; i2++) {\n const attr2 = toAttrs[i2];\n const ns = attr2.namespaceURI;\n const name = attr2.localName;\n const value = attr2.value;\n if (ns !== null) {\n if (fromEl.getAttributeNS(ns, name) !== value) {\n fromEl.setAttributeNS(ns, attr2.name, value);\n }\n } else if (fromEl.getAttribute(name) !== value) {\n fromEl.setAttribute(name, value);\n }\n }\n const fromAttrs = fromEl.attributes;\n const fromTag = fromEl.tagName;\n for (let i2 = fromAttrs.length - 1; i2 >= 0; i2--) {\n const attr2 = fromAttrs[i2];\n const ns = attr2.namespaceURI;\n const name = attr2.localName;\n if (ns !== null) {\n if (!toEl.hasAttributeNS(ns, name)) fromEl.removeAttributeNS(ns, name);\n } else if (!toEl.hasAttribute(name) && !isUserAgentOwnedAttr(fromTag, name)) {\n fromEl.removeAttribute(name);\n }\n }\n }\n function isTextInputOrTextarea(el) {\n if (el.tagName === \"TEXTAREA\") return true;\n if (el.tagName === \"INPUT\") {\n const type = el.type;\n return type === \"text\" || type === \"search\" || type === \"url\" || type === \"email\" || type === \"tel\" || type === \"password\" || type === \"\";\n }\n return false;\n }\n function preserveTextEntryState(fromEl, toEl) {\n if (fromEl.tagName === \"TEXTAREA\" || fromEl.tagName === \"INPUT\") {\n const fromInput = fromEl;\n const toInput = toEl;\n toInput.value = fromInput.value;\n try {\n toInput.setSelectionRange(fromInput.selectionStart, fromInput.selectionEnd);\n } catch {\n }\n }\n }\n var LISTENER_MARKER = /* @__PURE__ */ Symbol.for(\"kerfjs.devListener\");\n var patched = false;\n var warned2 = false;\n function isOptedIn22() {\n const proc = globalThis.process;\n if (proc?.env?.NODE_ENV === \"production\") return false;\n return proc?.env?.KERF_DEV_WARN_REBUILT_LISTENERS === \"1\";\n }\n function findAddEventListenerProto() {\n const probe = document.createElement(\"div\");\n let proto = Object.getPrototypeOf(probe);\n while (!Object.prototype.hasOwnProperty.call(proto, \"addEventListener\")) {\n proto = Object.getPrototypeOf(proto);\n }\n return proto;\n }\n function patchAddEventListenerOnce() {\n if (patched) return;\n patched = true;\n const proto = findAddEventListenerProto();\n const orig = proto.addEventListener;\n proto.addEventListener = function(type, listener, options) {\n if (this instanceof Element) {\n this[LISTENER_MARKER] = true;\n }\n return orig.call(this, type, listener, options);\n };\n }\n function hasMarkedListener(el) {\n if (el[LISTENER_MARKER] === true) return true;\n const stack = [];\n for (let i2 = 0; i2 < el.children.length; i2++) stack.push(el.children[i2]);\n while (stack.length > 0) {\n const cur = stack.pop();\n if (cur[LISTENER_MARKER] === true) return true;\n for (let i2 = 0; i2 < cur.children.length; i2++) stack.push(cur.children[i2]);\n }\n return false;\n }\n function emitWarning() {\n if (warned2) return;\n warned2 = true;\n console.warn(\n \"kerf: a node inside a mount()-managed tree was removed/rebuilt while carrying an imperative addEventListener listener. The listener is gone with the old node. Use `delegate(rootEl, 'click', '[data-action=\\\"...\\\"]', handler)` so the listener lives on a stable ancestor and survives re-renders, or wrap the host in `data-morph-skip` if the subtree is library-owned (Monaco, xterm, D3 charts). Set KERF_DEV_WARN_REBUILT_LISTENERS=0 (or unset it) to silence this warning.\"\n );\n }\n function installListenerRebuildWarn(rootEl) {\n if (!isOptedIn22()) return null;\n patchAddEventListenerOnce();\n const observer = new MutationObserver((mutations) => {\n if (warned2) return;\n for (const m2 of mutations) {\n for (let i2 = 0; i2 < m2.removedNodes.length; i2++) {\n const removed = m2.removedNodes[i2];\n if (!(removed instanceof Element)) continue;\n if (hasMarkedListener(removed)) {\n emitWarning();\n return;\n }\n }\n }\n });\n observer.observe(rootEl, { childList: true, subtree: true });\n return observer;\n }\n function endAnchor(binding) {\n if (binding.items.length > 0) {\n return binding.items[binding.items.length - 1].node.nextElementSibling;\n }\n return binding.marker.nextElementSibling;\n }\n var LT = 60;\n var GT = 62;\n var DQUOTE = 34;\n var SQUOTE = 39;\n var AMP = 38;\n var EQ = 61;\n var SLASH = 47;\n var TEXT_NODE2 = 3;\n var ELEMENT_NODE2 = 1;\n function isWhitespace(cc) {\n return cc === 32 || cc === 9 || cc === 10 || cc === 13;\n }\n function tryAttributeOnlyFastPath(liveNode, oldHtml, newHtml) {\n const oldGt = oldHtml.indexOf(\">\");\n const newGt = newHtml.indexOf(\">\");\n if (oldGt === -1 || newGt === -1) return false;\n if (oldHtml.length - oldGt !== newHtml.length - newGt) return false;\n if (oldHtml.slice(oldGt) !== newHtml.slice(newGt)) return false;\n if (containsDataMorphSkip(oldHtml) || containsDataMorphSkip(newHtml)) return false;\n const oldTag = parseOpeningTag(oldHtml, oldGt);\n const newTag = parseOpeningTag(newHtml, newGt);\n if (oldTag === null || newTag === null) return false;\n if (oldTag.tagName !== newTag.tagName) return false;\n for (const name of oldTag.attrs.keys()) {\n if (name.indexOf(\":\") !== -1) return false;\n }\n for (const name of newTag.attrs.keys()) {\n if (name.indexOf(\":\") !== -1) return false;\n }\n const liveTagUpper = liveNode.tagName;\n for (const [name, rawValue] of newTag.attrs) {\n const oldValue = oldTag.attrs.get(name);\n if (oldValue === rawValue) continue;\n liveNode.setAttribute(name, unescapeAttrValue(rawValue));\n }\n for (const name of oldTag.attrs.keys()) {\n if (newTag.attrs.has(name)) continue;\n if (isUserAgentOwnedAttr2(liveTagUpper, name)) continue;\n liveNode.removeAttribute(name);\n }\n return true;\n }\n function tryTextContentFastPath(liveNode, oldHtml, newHtml) {\n if (containsDataMorphSkip(oldHtml) || containsDataMorphSkip(newHtml)) return false;\n let p2 = 0;\n const minLen = Math.min(oldHtml.length, newHtml.length);\n while (p2 < minLen && oldHtml.charCodeAt(p2) === newHtml.charCodeAt(p2)) p2++;\n let s2 = 0;\n const maxS = minLen - p2;\n while (s2 < maxS && oldHtml.charCodeAt(oldHtml.length - 1 - s2) === newHtml.charCodeAt(newHtml.length - 1 - s2)) {\n s2++;\n }\n const oldWinEnd = oldHtml.length - s2;\n const newWinEnd = newHtml.length - s2;\n if (!isPureTextWindow(oldHtml, p2, oldWinEnd)) return false;\n if (!isPureTextWindow(newHtml, p2, newWinEnd)) return false;\n if (p2 === 0) return false;\n const boundaryCc = oldHtml.charCodeAt(p2 - 1);\n if (boundaryCc === LT || boundaryCc === DQUOTE || boundaryCc === SQUOTE || boundaryCc === EQ || boundaryCc === AMP) return false;\n const textStart = lastIndexOfChar(oldHtml, GT, p2 - 1);\n if (textStart === -1) return false;\n const textEnd = oldHtml.indexOf(\"<\", p2);\n if (textEnd === -1) return false;\n if (textEnd < oldWinEnd) return false;\n const newTextEnd = textEnd + (newHtml.length - oldHtml.length);\n const oldText = oldHtml.slice(textStart + 1, textEnd);\n const newText = newHtml.slice(textStart + 1, newTextEnd);\n const textIdx = countTextNodesBefore(oldHtml, textStart + 1);\n const targetNode = nthTextNodeDescendant(liveNode, textIdx);\n if (targetNode === null) return false;\n if (targetNode.nodeValue !== oldText) return false;\n targetNode.nodeValue = newText;\n return true;\n }\n function containsDataMorphSkip(html) {\n return html.indexOf(\"data-morph-skip\") !== -1;\n }\n function isPureTextWindow(html, start, end) {\n for (let i2 = start; i2 < end; i2++) {\n const cc = html.charCodeAt(i2);\n if (cc === LT || cc === GT || cc === DQUOTE || cc === SQUOTE || cc === AMP || cc === EQ) return false;\n }\n return true;\n }\n function lastIndexOfChar(html, target, beforeInclusive) {\n for (let i2 = beforeInclusive; i2 >= 0; i2--) {\n if (html.charCodeAt(i2) === target) return i2;\n }\n return -1;\n }\n function countTextNodesBefore(html, beforePos) {\n let count = 0;\n let i2 = 0;\n while (i2 < beforePos) {\n if (html.charCodeAt(i2) === LT) {\n while (i2 < beforePos && html.charCodeAt(i2) !== GT) i2++;\n i2++;\n } else {\n const start = i2;\n while (i2 < beforePos && html.charCodeAt(i2) !== LT) i2++;\n if (i2 > start) count++;\n }\n }\n return count;\n }\n function nthTextNodeDescendant(root, n2) {\n let count = 0;\n let result = null;\n function walk(node) {\n for (let c2 = node.firstChild; c2 !== null; c2 = c2.nextSibling) {\n if (result !== null) return;\n if (c2.nodeType === TEXT_NODE2) {\n if (count === n2) {\n result = c2;\n return;\n }\n count++;\n } else if (c2.nodeType === ELEMENT_NODE2) {\n walk(c2);\n }\n }\n }\n walk(root);\n return result;\n }\n function parseOpeningTag(html, gtPos) {\n if (html.charCodeAt(0) !== LT) return null;\n let i2 = 1;\n let end = gtPos;\n if (i2 < end && html.charCodeAt(end - 1) === SLASH) end -= 1;\n const nameStart = i2;\n while (i2 < end) {\n const cc = html.charCodeAt(i2);\n if (isWhitespace(cc)) break;\n i2++;\n }\n const tagName = html.slice(nameStart, i2);\n if (tagName.length === 0) return null;\n const attrs = /* @__PURE__ */ new Map();\n while (i2 < end) {\n while (i2 < end && isWhitespace(html.charCodeAt(i2))) i2++;\n if (i2 >= end) break;\n const aNameStart = i2;\n while (i2 < end) {\n const cc = html.charCodeAt(i2);\n if (cc === EQ || isWhitespace(cc)) break;\n i2++;\n }\n const aName = html.slice(aNameStart, i2);\n if (aName.length === 0) return null;\n while (i2 < end && isWhitespace(html.charCodeAt(i2))) i2++;\n if (i2 < end && html.charCodeAt(i2) === EQ) {\n i2++;\n while (i2 < end && isWhitespace(html.charCodeAt(i2))) i2++;\n if (i2 >= end) return null;\n const q = html.charCodeAt(i2);\n if (q !== DQUOTE && q !== SQUOTE) return null;\n i2++;\n const vStart = i2;\n while (i2 < end && html.charCodeAt(i2) !== q) i2++;\n if (i2 >= end) return null;\n attrs.set(aName, html.slice(vStart, i2));\n i2++;\n } else {\n attrs.set(aName, \"\");\n }\n }\n return { tagName, attrs };\n }\n function unescapeAttrValue(s2) {\n if (s2.indexOf(\"&\") === -1) return s2;\n return s2.replace(/"/g, '\"').replace(/'/g, \"'\").replace(/</g, \"<\").replace(/>/g, \">\").replace(/&/g, \"&\");\n }\n function isUserAgentOwnedAttr2(tagNameUpper, name) {\n return name === \"open\" && (tagNameUpper === \"DETAILS\" || tagNameUpper === \"DIALOG\");\n }\n function captureFocus(liveParent) {\n const active = document.activeElement;\n if (active === null || active === document.body) return null;\n if (!liveParent.contains(active)) return null;\n const el = active;\n let selStart = null;\n let selEnd = null;\n if (el.tagName === \"INPUT\" || el.tagName === \"TEXTAREA\") {\n try {\n selStart = el.selectionStart;\n selEnd = el.selectionEnd;\n } catch {\n }\n }\n return { el, selStart, selEnd };\n }\n function restoreFocus(snap) {\n if (document.activeElement === snap.el) return;\n if (!snap.el.isConnected) return;\n snap.el.focus();\n if (snap.selStart !== null && snap.selEnd !== null) {\n try {\n snap.el.setSelectionRange(snap.selStart, snap.selEnd);\n } catch {\n }\n }\n }\n var ROW_HTML_SNIPPET_MAX = 120;\n function truncateRowHtml(html) {\n return html.length > ROW_HTML_SNIPPET_MAX ? html.slice(0, ROW_HTML_SNIPPET_MAX) + \"\\u2026\" : html;\n }\n function parseRowTemplate(html) {\n const tpl = document.createElement(\"template\");\n tpl.innerHTML = html;\n return { tpl, count: tpl.content.children.length };\n }\n function rowContractError(index, html) {\n const { count } = parseRowTemplate(html);\n const reason = count === 0 ? \"produced no top-level element\" : `produced ${count} top-level elements; exactly one is required`;\n return new Error(\n `each(): row render at index ${index} ${reason}. Each item's render must return exactly one element \\u2014 wrap multiple roots in a single parent (e.g. <li>...</li>). Got HTML: ${JSON.stringify(truncateRowHtml(html))}`\n );\n }\n function isDevMode() {\n const proc = globalThis.process;\n return proc?.env?.NODE_ENV !== \"production\";\n }\n function maybeWarnMissingRowKey(rowEl, rowIndex, rowHtml, binding) {\n if (!isDevMode()) return;\n if (binding.warnedMissingKey === true) return;\n binding.warnedMissingKey = true;\n if (rowEl.id !== \"\" || rowEl.hasAttribute(\"data-key\")) return;\n console.warn(\n `kerf each(): row at index ${rowIndex} has no \\`id\\` or \\`data-key\\` attribute. Without one, rows match positionally \\u2014 an insert/remove at the head shifts every row's identity, so focused inputs jump to the wrong row, mid-edit textareas swap content with their neighbor, and any per-row state silently follows the wrong item. Add \\`data-key={item.id}\\` (or set \\`id\\`) to the top-level element returned by the row render. Row HTML: ${JSON.stringify(truncateRowHtml(rowHtml))}`\n );\n }\n function reconcileGranular(binding, patches) {\n const { liveParent } = binding;\n const items = binding.items;\n const focusSnap = captureFocus(liveParent);\n let i2 = 0;\n while (i2 < patches.length) {\n const patch = patches[i2];\n if (patch.type === \"replace\") {\n i2 += 1;\n continue;\n }\n if (patch.type === \"update\") {\n let runEnd = i2 + 1;\n while (runEnd < patches.length && patches[runEnd].type === \"update\") {\n runEnd += 1;\n }\n const runLen = runEnd - i2;\n if (runLen === 1) {\n applySingleUpdate(liveParent, items, patch);\n } else {\n applyBulkUpdate(liveParent, items, patches, i2, runEnd);\n }\n i2 = runEnd;\n continue;\n }\n if (patch.type === \"insert\") {\n let runEnd = i2 + 1;\n while (runEnd < patches.length && patches[runEnd].type === \"insert\" && patches[runEnd].index === patches[runEnd - 1].index + 1) {\n runEnd += 1;\n }\n const runLen = runEnd - i2;\n if (runLen === 1) {\n applySingleInsert(liveParent, items, patch, endAnchor(binding));\n } else {\n applyBulkInsert(liveParent, items, patches, i2, runEnd, endAnchor(binding));\n }\n i2 = runEnd;\n continue;\n }\n if (patch.type === \"remove\") {\n const entry = items[patch.index];\n liveParent.removeChild(entry.node);\n items.splice(patch.index, 1);\n i2 += 1;\n continue;\n }\n if (patch.type === \"move\") {\n const moved = items[patch.from];\n let anchorIdx = patch.to;\n if (patch.from < patch.to) anchorIdx += 1;\n const anchor = anchorIdx < items.length ? items[anchorIdx].node : endAnchor(binding);\n liveParent.insertBefore(moved.node, anchor);\n items.splice(patch.from, 1);\n items.splice(patch.to, 0, moved);\n i2 += 1;\n continue;\n }\n }\n if (focusSnap !== null) restoreFocus(focusSnap);\n if (items.length > 0) {\n maybeWarnMissingRowKey(items[0].node, 0, items[0].html, binding);\n }\n }\n function applySingleInsert(liveParent, items, patch, tailAnchor) {\n const { html } = patch;\n const newNode = parseSingleRow(html);\n const anchor = patch.index < items.length ? items[patch.index].node : tailAnchor;\n liveParent.insertBefore(newNode, anchor);\n items.splice(patch.index, 0, {\n ref: patch.item,\n cacheKey: void 0,\n html,\n node: newNode\n });\n }\n function applySingleUpdate(liveParent, items, patch) {\n const { html } = patch;\n const oldEntry = items[patch.index];\n if (html === oldEntry.html) return;\n if (tryAttributeOnlyFastPath(oldEntry.node, oldEntry.html, html) || tryTextContentFastPath(oldEntry.node, oldEntry.html, html)) {\n items[patch.index] = { ref: patch.item, cacheKey: void 0, html, node: oldEntry.node };\n return;\n }\n const newNode = parseSingleRow(html);\n if (oldEntry.node.tagName === newNode.tagName) {\n _morphElement(oldEntry.node, newNode);\n items[patch.index] = { ref: patch.item, cacheKey: void 0, html, node: oldEntry.node };\n } else {\n liveParent.replaceChild(newNode, oldEntry.node);\n items[patch.index] = { ref: patch.item, cacheKey: void 0, html, node: newNode };\n }\n }\n function applyBulkUpdate(liveParent, items, patches, start, end) {\n const morphChanges = [];\n for (let k = start; k < end; k++) {\n const p2 = patches[k];\n const oldEntry = items[p2.index];\n if (p2.html === oldEntry.html) continue;\n if (tryAttributeOnlyFastPath(oldEntry.node, oldEntry.html, p2.html) || tryTextContentFastPath(oldEntry.node, oldEntry.html, p2.html)) {\n items[p2.index] = { ref: p2.item, cacheKey: void 0, html: p2.html, node: oldEntry.node };\n continue;\n }\n morphChanges.push({ patchIdx: k, html: p2.html });\n }\n if (morphChanges.length === 0) return;\n const { tpl, count } = parseRowTemplate(morphChanges.map((c2) => c2.html).join(\"\"));\n if (count !== morphChanges.length) {\n throw findOffendingChange(patches, morphChanges);\n }\n const newNodes = new Array(morphChanges.length);\n let child = tpl.content.firstElementChild;\n for (let k = 0; k < newNodes.length; k++) {\n newNodes[k] = child;\n child = child.nextElementSibling;\n }\n for (let k = 0; k < morphChanges.length; k++) {\n const c2 = morphChanges[k];\n const p2 = patches[c2.patchIdx];\n const oldEntry = items[p2.index];\n if (oldEntry.node.tagName === newNodes[k].tagName) {\n _morphElement(oldEntry.node, newNodes[k]);\n items[p2.index] = { ref: p2.item, cacheKey: void 0, html: c2.html, node: oldEntry.node };\n } else {\n liveParent.replaceChild(newNodes[k], oldEntry.node);\n items[p2.index] = { ref: p2.item, cacheKey: void 0, html: c2.html, node: newNodes[k] };\n }\n }\n }\n function applyBulkInsert(liveParent, items, patches, start, end, tailAnchor) {\n const startIdx = patches[start].index;\n const htmls = new Array(end - start);\n for (let k = start; k < end; k++) {\n const p2 = patches[k];\n htmls[k - start] = p2.html;\n }\n const { tpl, count } = parseRowTemplate(htmls.join(\"\"));\n if (count !== htmls.length) {\n throw findOffendingInsert(patches, start, htmls);\n }\n const newNodes = new Array(end - start);\n let child = tpl.content.firstElementChild;\n for (let k = 0; k < newNodes.length; k++) {\n newNodes[k] = child;\n child = child.nextElementSibling;\n }\n const anchor = startIdx < items.length ? items[startIdx].node : tailAnchor;\n liveParent.insertBefore(tpl.content, anchor);\n const newEntries = new Array(end - start);\n for (let k = 0; k < newEntries.length; k++) {\n const p2 = patches[start + k];\n newEntries[k] = {\n ref: p2.item,\n cacheKey: void 0,\n html: htmls[k],\n node: newNodes[k]\n };\n }\n items.splice(startIdx, 0, ...newEntries);\n }\n function parseSingleRow(html) {\n const { tpl, count } = parseRowTemplate(html);\n if (count !== 1) {\n const reason = count === 0 ? \"produced no top-level element\" : `produced ${count} top-level elements; exactly one is required`;\n throw new Error(\n `each() granular reconcile: row render ${reason}. Each item's render must return exactly one element. Got HTML: ${JSON.stringify(truncateRowHtml(html))}`\n );\n }\n return tpl.content.firstElementChild;\n }\n function findOffendingInsert(patches, start, htmls) {\n for (let i2 = 0; i2 < htmls.length; i2++) {\n if (parseRowTemplate(htmls[i2]).count !== 1) {\n const p2 = patches[start + i2];\n return rowContractError(p2.index, htmls[i2]);\n }\n }\n return new Error(\"each(): bulk-insert mismatch with no per-row offender (kerf bug).\");\n }\n function findOffendingChange(patches, changes) {\n for (const c2 of changes) {\n if (parseRowTemplate(c2.html).count !== 1) {\n const p2 = patches[c2.patchIdx];\n return rowContractError(p2.index, c2.html);\n }\n }\n return new Error(\"each(): bulk-update mismatch with no per-row offender (kerf bug).\");\n }\n function reconcileSnapshot(binding, listSeg) {\n const { liveParent } = binding;\n const { newRecord, prevIdx, replacedNodes, freshIndices, freshHtmls } = classifyItems(binding.items, listSeg);\n if (replacedNodes.length === 0 && freshIndices.length === 0 && isInOrder(prevIdx)) {\n binding.items = newRecord;\n if (newRecord.length > 0) {\n maybeWarnMissingRowKey(newRecord[0].node, 0, newRecord[0].html, binding);\n }\n return;\n }\n const tailAnchor = endAnchor(binding);\n buildFreshNodes(newRecord, freshIndices, freshHtmls);\n const focusSnap = captureFocus(liveParent);\n removeOldNodes(liveParent, replacedNodes);\n applyMoves(liveParent, newRecord, prevIdx, lis(prevIdx), tailAnchor);\n if (focusSnap !== null) restoreFocus(focusSnap);\n binding.items = newRecord;\n if (newRecord.length > 0) {\n maybeWarnMissingRowKey(newRecord[0].node, 0, newRecord[0].html, binding);\n }\n }\n function classifyItems(oldItems, listSeg) {\n const oldByRef = /* @__PURE__ */ new Map();\n for (let i2 = 0; i2 < oldItems.length; i2++) {\n oldByRef.set(oldItems[i2].ref, [oldItems[i2], i2]);\n }\n const newRecord = new Array(listSeg.items.length);\n const prevIdx = new Array(listSeg.items.length);\n const replacedNodes = [];\n const freshIndices = [];\n const freshHtmls = [];\n for (let i2 = 0; i2 < listSeg.items.length; i2++) {\n const ni = listSeg.items[i2];\n const oi = oldByRef.get(ni.ref);\n if (oi !== void 0) {\n oldByRef.delete(ni.ref);\n if (oi[0].html === ni.html) {\n newRecord[i2] = oi[0];\n prevIdx[i2] = oi[1];\n continue;\n }\n replacedNodes.push(oi[0].node);\n }\n newRecord[i2] = {\n ref: ni.ref,\n cacheKey: ni.cacheKey,\n html: ni.html,\n node: null\n };\n prevIdx[i2] = -1;\n freshIndices.push(i2);\n freshHtmls.push(ni.html);\n }\n for (const [, orphan] of oldByRef) replacedNodes.push(orphan[0].node);\n return { newRecord, prevIdx, replacedNodes, freshIndices, freshHtmls };\n }\n function buildFreshNodes(newRecord, freshIndices, freshHtmls) {\n if (freshHtmls.length === 0) return;\n const { tpl, count } = parseRowTemplate(freshHtmls.join(\"\"));\n if (count !== freshHtmls.length) {\n throw findOffendingRow(newRecord, freshIndices, freshHtmls);\n }\n let node = tpl.content.firstElementChild;\n for (const idx of freshIndices) {\n const next = node.nextElementSibling;\n newRecord[idx].node = node;\n node = next;\n }\n }\n function findOffendingRow(newRecord, freshIndices, freshHtmls) {\n for (let i2 = 0; i2 < freshHtmls.length; i2++) {\n if (parseRowTemplate(freshHtmls[i2]).count !== 1) {\n return rowContractError(freshIndices[i2], newRecord[freshIndices[i2]].html);\n }\n }\n return new Error(\"each(): bulk-parse mismatch with no per-row offender (kerf bug).\");\n }\n function removeOldNodes(liveParent, replacedNodes) {\n for (const node of replacedNodes) {\n if (node.parentElement === liveParent) liveParent.removeChild(node);\n }\n }\n function applyMoves(liveParent, newRecord, prevIdx, stable, tailAnchor) {\n let nextSibling = tailAnchor;\n for (let i2 = newRecord.length - 1; i2 >= 0; i2--) {\n const node = newRecord[i2].node;\n if (prevIdx[i2] === -1 || !stable.has(i2)) {\n liveParent.insertBefore(node, nextSibling);\n }\n nextSibling = node;\n }\n }\n function lis(arr) {\n const tails = [];\n const tailIdx = [];\n const prev = new Array(arr.length);\n for (let i2 = 0; i2 < arr.length; i2++) {\n const v2 = arr[i2];\n if (v2 === -1) {\n prev[i2] = -1;\n continue;\n }\n let lo = 0;\n let hi = tails.length;\n while (lo < hi) {\n const mid = lo + hi >> 1;\n if (tails[mid] < v2) lo = mid + 1;\n else hi = mid;\n }\n prev[i2] = lo > 0 ? tailIdx[lo - 1] : -1;\n tails[lo] = v2;\n tailIdx[lo] = i2;\n }\n const out = /* @__PURE__ */ new Set();\n let k = tailIdx.length > 0 ? tailIdx[tailIdx.length - 1] : -1;\n while (k !== -1) {\n out.add(k);\n k = prev[k];\n }\n return out;\n }\n function isInOrder(prevIdx) {\n for (let i2 = 0; i2 < prevIdx.length; i2++) {\n if (prevIdx[i2] !== i2) return false;\n }\n return true;\n }\n function reconcileList(binding, listSeg) {\n if (listSeg.patches !== void 0 && binding.items.length > 0) {\n reconcileGranular(binding, listSeg.patches);\n return;\n }\n reconcileSnapshot(binding, listSeg);\n }\n var LIST_MARKER_PREFIX = \"kf-list:\";\n var MOUNTED_MARKER = /* @__PURE__ */ Symbol.for(\"kerfjs.mounted\");\n function describeEl(el) {\n const tag = el.tagName.toLowerCase();\n const id = el.id ? `#${el.id}` : \"\";\n return `<${tag}${id}>`;\n }\n function assertNotInsideMountedTree(rootEl) {\n if (rootEl[MOUNTED_MARKER] === true) {\n throw new Error(\n `mount: ${describeEl(rootEl)} is already mounted. Call the disposer returned by the first mount() before mounting again. kerf supports one mount per element \\u2014 compose with plain functions that return JSX instead of nesting mounts.`\n );\n }\n let ancestor = rootEl.parentElement;\n while (ancestor !== null) {\n if (ancestor[MOUNTED_MARKER] === true) {\n throw new Error(\n \"mount: rootEl is already inside (or contains) a mounted tree. kerf supports one mount per tree \\u2014 compose with plain functions that return JSX instead of nesting mounts.\"\n );\n }\n ancestor = ancestor.parentElement;\n }\n const stack = [];\n for (let i2 = 0; i2 < rootEl.children.length; i2++) stack.push(rootEl.children[i2]);\n while (stack.length > 0) {\n const cur = stack.pop();\n if (cur[MOUNTED_MARKER] === true) {\n throw new Error(\n \"mount: rootEl is already inside (or contains) a mounted tree. kerf supports one mount per tree \\u2014 compose with plain functions that return JSX instead of nesting mounts.\"\n );\n }\n for (let i2 = 0; i2 < cur.children.length; i2++) stack.push(cur.children[i2]);\n }\n }\n function mount(rootEl, render2) {\n if (rootEl == null) {\n throw new Error(\n 'mount: rootEl is null/undefined \\u2014 pass the live element, e.g. mount(document.getElementById(\"app\")!, render). A common cause is a typo in the id or selector that returns null at runtime even though the TypeScript types say HTMLElement.'\n );\n }\n const owner = rootEl.ownerDocument;\n if (owner !== document) {\n if (owner.defaultView === null) document.adoptNode(rootEl);\n }\n assertNotInsideMountedTree(rootEl);\n rootEl[MOUNTED_MARKER] = true;\n const listenerWarnObserver = installListenerRebuildWarn(rootEl);\n const bindings = /* @__PURE__ */ new Map();\n const renderCtx = {\n counter: 0,\n caches: /* @__PURE__ */ new Map(),\n bindingCounts: /* @__PURE__ */ new Map()\n };\n let isFirst = true;\n let prevStaticHtml = \"\";\n const disposeEffect = effect(() => {\n renderCtx.counter = 0;\n _setRenderContext(renderCtx);\n let result;\n try {\n result = render2();\n } finally {\n _setRenderContext(null);\n }\n const segment = isSafeHtml(result) ? result.__segment ?? { kind: \"static\", html: result.__html } : { kind: \"static\", html: coerceRenderResult(result) };\n if (isFirst) {\n runFirstRender(rootEl, segment, bindings);\n prevStaticHtml = flattenWithoutListItems(segment);\n isFirst = false;\n } else {\n prevStaticHtml = runSubsequentRender(rootEl, segment, bindings, renderCtx, prevStaticHtml);\n }\n for (const listSeg of collectLists(segment).values()) {\n const binding = bindings.get(listSeg.id);\n reconcileList(binding, listSeg);\n renderCtx.bindingCounts.set(listSeg.id, binding.items.length);\n }\n });\n return () => {\n disposeEffect();\n listenerWarnObserver?.disconnect();\n delete rootEl[MOUNTED_MARKER];\n };\n }\n function runFirstRender(rootEl, segment, bindings) {\n rootEl.innerHTML = flatten(segment, true);\n bindListsFromMarkers(rootEl, segment, bindings, true);\n }\n function runSubsequentRender(rootEl, segment, bindings, renderCtx, prevStaticHtml) {\n const currentStaticHtml = flattenWithoutListItems(segment);\n if (currentStaticHtml === prevStaticHtml) {\n return prevStaticHtml;\n }\n cleanupOrphanBindings(segment, bindings, renderCtx);\n const template = rootEl.cloneNode(false);\n template.innerHTML = currentStaticHtml;\n morph(rootEl, template, collectOwnedItems(bindings));\n bindListsFromMarkers(rootEl, segment, bindings, false);\n return currentStaticHtml;\n }\n function coerceRenderResult(result) {\n if (result === null || result === void 0) return \"\";\n if (result === false || result === true) return \"\";\n return String(result);\n }\n function bindListsFromMarkers(rootEl, segment, bindings, inlinedItems) {\n const lists = collectLists(segment);\n const found = [];\n collectComments(rootEl, found);\n for (const marker of found) {\n if (!marker.data.startsWith(LIST_MARKER_PREFIX)) continue;\n const id = marker.data.slice(LIST_MARKER_PREFIX.length);\n if (bindings.has(id)) continue;\n const listSeg = lists.get(id);\n const liveParent = marker.parentElement;\n const items = [];\n if (inlinedItems) {\n let next = marker.nextElementSibling;\n for (let i2 = 0; i2 < listSeg.items.length && next !== null; i2++) {\n validateInlinedRowMatch(listSeg.items[i2].html, i2, next);\n items.push({\n ref: listSeg.items[i2].ref,\n cacheKey: listSeg.items[i2].cacheKey,\n html: listSeg.items[i2].html,\n node: next\n });\n next = next.nextElementSibling;\n }\n }\n const binding = { liveParent, items, marker };\n if (items.length > 0) {\n maybeWarnMissingRowKey(items[0].node, 0, items[0].html, binding);\n }\n maybeWarnEachInMorphSkip(id, liveParent, rootEl);\n bindings.set(id, binding);\n }\n }\n function validateInlinedRowMatch(expectedHtml, index, boundEl) {\n if (boundEl.outerHTML === expectedHtml) return;\n const { count } = parseRowTemplate(expectedHtml);\n if (count === 1) return;\n throw rowContractError(index, expectedHtml);\n }\n function collectOwnedItems(bindings) {\n const owned = /* @__PURE__ */ new Set();\n for (const b2 of bindings.values()) {\n for (const item of b2.items) owned.add(item.node);\n }\n return owned;\n }\n function cleanupOrphanBindings(segment, bindings, renderCtx) {\n const liveIds = collectLists(segment);\n for (const [id, binding] of bindings) {\n if (liveIds.has(id)) continue;\n for (const item of binding.items) {\n if (item.node.parentElement !== null) {\n item.node.parentElement.removeChild(item.node);\n }\n }\n if (binding.marker.parentElement !== null) {\n binding.marker.parentElement.removeChild(binding.marker);\n }\n bindings.delete(id);\n renderCtx.bindingCounts.delete(id);\n renderCtx.caches.delete(id);\n }\n }\n function collectComments(node, out) {\n for (let c2 = node.firstChild; c2 !== null; c2 = c2.nextSibling) {\n if (c2.nodeType === Node.COMMENT_NODE) out.push(c2);\n else if (c2.nodeType === Node.ELEMENT_NODE) collectComments(c2, out);\n }\n }\n\n // src/scrubber/crop.ts\n var _clamp = (v2, lo, hi) => Math.max(lo, Math.min(v2, hi));\n function fitRectToAspect(rect, ar, frameW, frameH) {\n if (!(ar > 0)) return rect;\n const cx = rect.x + rect.w / 2, cy = rect.y + rect.h / 2;\n let w2 = rect.w, h2 = w2 / ar;\n if (h2 > rect.h) {\n h2 = rect.h;\n w2 = h2 * ar;\n }\n if (w2 > frameW) {\n w2 = frameW;\n h2 = w2 / ar;\n }\n if (h2 > frameH) {\n h2 = frameH;\n w2 = h2 * ar;\n }\n const x2 = _clamp(cx - w2 / 2, 0, frameW - w2), y2 = _clamp(cy - h2 / 2, 0, frameH - h2);\n return { x: x2, y: y2, w: w2, h: h2 };\n }\n function constrainResizeToAspect(free, handle, ar, frameW, frameH, minSize) {\n if (!(ar > 0)) return free;\n const L = free.x, R = free.x + free.w, T = free.y, B = free.y + free.h;\n let w2 = free.w, h2 = free.h;\n if (handle.includes(\"w\") || handle.includes(\"e\")) h2 = w2 / ar;\n else w2 = h2 * ar;\n const anchorH = handle.includes(\"w\") ? \"right\" : handle.includes(\"e\") ? \"left\" : \"center\";\n const anchorV = handle.includes(\"n\") ? \"bottom\" : handle.includes(\"s\") ? \"top\" : \"middle\";\n const ax = anchorH === \"right\" ? R : anchorH === \"left\" ? L : (L + R) / 2;\n const ay = anchorV === \"bottom\" ? B : anchorV === \"top\" ? T : (T + B) / 2;\n const maxW = anchorH === \"right\" ? ax : anchorH === \"left\" ? frameW - ax : 2 * Math.min(ax, frameW - ax);\n const maxH = anchorV === \"bottom\" ? ay : anchorV === \"top\" ? frameH - ay : 2 * Math.min(ay, frameH - ay);\n if (w2 > maxW) {\n w2 = maxW;\n h2 = w2 / ar;\n }\n if (h2 > maxH) {\n h2 = maxH;\n w2 = h2 * ar;\n }\n if (w2 < minSize) {\n w2 = minSize;\n h2 = w2 / ar;\n }\n if (h2 < minSize) {\n h2 = minSize;\n w2 = h2 * ar;\n }\n let x2 = anchorH === \"right\" ? ax - w2 : anchorH === \"left\" ? ax : ax - w2 / 2;\n let y2 = anchorV === \"bottom\" ? ay - h2 : anchorV === \"top\" ? ay : ay - h2 / 2;\n x2 = _clamp(x2, 0, Math.max(0, frameW - w2));\n y2 = _clamp(y2, 0, Math.max(0, frameH - h2));\n return { x: x2, y: y2, w: w2, h: h2 };\n }\n\n // src/scrubber/client.tsx\n var CSS = `\n*{box-sizing:border-box}\nbody{margin:0;font:14px/1.4 -apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;background:#0e0f13;color:#e7e9ee}\n.wrap{display:flex;flex-direction:column;height:100vh}\n.stage{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;\n background:repeating-conic-gradient(#1a1c22 0% 25%,#15171c 0% 50%) 50%/24px 24px;overflow:hidden;position:relative;touch-action:none}\n.svg-host{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;\n transform-origin:center center;will-change:transform;pointer-events:none}\n.svg-host svg{display:block;filter:drop-shadow(0 4px 24px rgba(0,0,0,.5))}\n.drop{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;\n gap:10px;color:#9aa0ad;border:2px dashed #2a2e38;margin:18px;border-radius:12px;text-align:center;padding:24px;\n background:rgba(14,15,19,.7);cursor:pointer}\n.drop.over{border-color:#5b8cff;color:#cdd5ff;background:#161b2b}\n.bar{background:#15171c;border-top:1px solid #23262f;padding:10px 14px;display:flex;flex-direction:column;gap:8px}\n.row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}\n.row2{justify-content:space-between}\n.grp{display:flex;align-items:center;gap:8px}\nbutton{background:#262a35;color:#e7e9ee;border:1px solid #333845;border-radius:7px;padding:6px 11px;cursor:pointer;font:inherit}\nbutton:hover{background:#2f3543}button:disabled{opacity:.45;cursor:default}\nbutton.primary{background:#3a63d8;border-color:#3a63d8}button.primary:hover{background:#4a73e8}\n.play{display:inline-flex;align-items:center;justify-content:center;padding:6px 9px}\n.play svg{display:block}\n.iconbtn{padding:6px 8px;min-width:32px}\nselect,input[type=number]{background:#1c1f27;color:#e7e9ee;border:1px solid #333845;border-radius:6px;padding:5px 7px;font:inherit}\ninput[type=number]{width:84px}\n.scrub-wrap{position:relative;flex:1;min-width:200px;display:flex;align-items:center;height:24px}\n.scrub{width:100%;position:relative;z-index:2;margin:0;background:transparent}\ninput[type=range]{accent-color:#5b8cff}\n.range-band{position:absolute;top:50%;transform:translateY(-50%);height:9px;\n background:rgba(91,140,255,.22);border:1px solid rgba(120,160,255,.5);border-radius:3px;pointer-events:none;z-index:1}\n.range-tick{position:absolute;top:50%;transform:translate(-50%,-50%);width:14px;height:22px;z-index:3;\n cursor:ew-resize;display:flex;align-items:center;justify-content:center;touch-action:none}\n.range-tick::before{content:\"\";width:3px;height:18px;background:#9bb4ff;border-radius:2px;box-shadow:0 0 0 1px rgba(0,0,0,.35)}\n.range-tick span{position:absolute;top:-14px;font-size:9px;color:#9bb4ff;font-weight:600;pointer-events:none}\n.time{font-variant-numeric:tabular-nums;color:#aab0bd;min-width:120px;text-align:right}\n.muted{color:#8a90a0;font-size:12px}\n.rng{display:flex;align-items:center;gap:8px}\nlabel{display:inline-flex;align-items:center;gap:5px;cursor:pointer}\n.tag{background:#23262f;border-radius:5px;padding:2px 7px;font-size:12px;color:#aab0bd}\n.export-wrap{position:relative}\n.export-menu{position:absolute;bottom:calc(100% + 6px);right:0;background:#1c1f27;border:1px solid #333845;\n border-radius:8px;padding:4px;display:flex;flex-direction:column;gap:2px;min-width:150px;box-shadow:0 8px 24px rgba(0,0,0,.5);z-index:10}\n.export-menu button{text-align:left;background:transparent;border:0}\n.export-menu button:hover{background:#2f3543}\na.dl{display:none}\n.iconbtn.active{background:#3257d6;color:#fff}\n/* DM-1104: crop overlay. The layer covers the stage; the box dims everything\n outside it via a huge spread shadow and carries 8 resize handles. */\n.crop-layer{position:absolute;inset:0;z-index:6;pointer-events:none}\n.crop-box{position:absolute;outline:1px solid #cfe0ff;box-shadow:0 0 0 100vmax rgba(0,0,0,.55);\n pointer-events:auto;cursor:move;touch-action:none}\n.crop-h{position:absolute;width:12px;height:12px;background:#cfe0ff;border:1px solid #2a2f3a;border-radius:2px;\n pointer-events:auto;touch-action:none;transform:translate(-50%,-50%)}\n.crop-h[data-handle=nw]{cursor:nwse-resize}.crop-h[data-handle=se]{cursor:nwse-resize}\n.crop-h[data-handle=ne]{cursor:nesw-resize}.crop-h[data-handle=sw]{cursor:nesw-resize}\n.crop-h[data-handle=n]{cursor:ns-resize}.crop-h[data-handle=s]{cursor:ns-resize}\n.crop-h[data-handle=w]{cursor:ew-resize}.crop-h[data-handle=e]{cursor:ew-resize}\n.crop-dims{position:absolute;top:-22px;left:0;background:#1c1f27;border:1px solid #333845;border-radius:4px;\n padding:1px 6px;font:12px/1.4 ui-monospace,monospace;color:#cfe0ff;white-space:nowrap;pointer-events:none}\n/* DM-1445: review mode \\u2014 issue-reporting panel + region overlay. */\n.review{background:#12141a;border:1px solid #2a2e38;border-radius:8px;padding:8px;flex-direction:column;align-items:stretch;gap:8px}\n.rv-title{flex:1;min-width:180px;background:#1c1f27;color:#e7e9ee;border:1px solid #333845;border-radius:6px;padding:6px 8px;font:inherit}\n.rv-note{width:100%;min-height:46px;resize:vertical;background:#1c1f27;color:#e7e9ee;border:1px solid #333845;border-radius:6px;padding:6px 8px;font:inherit}\n.rv-status{font-size:12px;color:#8a90a0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.rv-status.ok{color:#7fd88f}.rv-status.err{color:#ff8a8a}\n.region-layer{position:absolute;inset:0;z-index:7;display:none;touch-action:none;cursor:crosshair}\n.region-box{position:absolute;outline:2px solid #ff5b8a;background:rgba(255,91,138,.14);pointer-events:none}\n.region-box::after{content:\"issue region\";position:absolute;top:-18px;left:0;font-size:10px;color:#ff8fb0;font-weight:600;white-space:nowrap}\n`;\n var ZOOM_PRESETS = [0.1, 0.25, 0.5, 0.75, 1, 1.5, 2, 4];\n var ICON_PLAY = /* @__PURE__ */ jsx(\"svg\", { width: \"18\", height: \"18\", viewBox: \"0 0 24 24\", fill: \"none\", stroke: \"currentColor\", \"stroke-width\": \"2\", \"stroke-linecap\": \"round\", \"stroke-linejoin\": \"round\", \"aria-hidden\": \"true\", children: /* @__PURE__ */ jsx(\"polygon\", { points: \"6 3 20 12 6 21 6 3\" }) });\n var ICON_PAUSE = /* @__PURE__ */ jsx(\"svg\", { width: \"18\", height: \"18\", viewBox: \"0 0 24 24\", fill: \"none\", stroke: \"currentColor\", \"stroke-width\": \"2\", \"stroke-linecap\": \"round\", \"stroke-linejoin\": \"round\", \"aria-hidden\": \"true\", children: [\n /* @__PURE__ */ jsx(\"rect\", { x: \"14\", y: \"4\", width: \"4\", height: \"16\", rx: \"1\" }),\n /* @__PURE__ */ jsx(\"rect\", { x: \"6\", y: \"4\", width: \"4\", height: \"16\", rx: \"1\" })\n ] });\n var ICON_DOT = /* @__PURE__ */ jsx(\"svg\", { width: \"18\", height: \"18\", viewBox: \"0 0 24 24\", fill: \"currentColor\", stroke: \"currentColor\", \"stroke-width\": \"2\", \"stroke-linecap\": \"round\", \"stroke-linejoin\": \"round\", \"aria-hidden\": \"true\", children: /* @__PURE__ */ jsx(\"circle\", { cx: \"12.1\", cy: \"12.1\", r: \"1\" }) });\n var ICON_CROP = /* @__PURE__ */ jsx(\"svg\", { width: \"18\", height: \"18\", viewBox: \"0 0 24 24\", fill: \"none\", stroke: \"currentColor\", \"stroke-width\": \"2\", \"stroke-linecap\": \"round\", \"stroke-linejoin\": \"round\", \"aria-hidden\": \"true\", children: [\n /* @__PURE__ */ jsx(\"path\", { d: \"M6 2v14a2 2 0 0 0 2 2h14\" }),\n /* @__PURE__ */ jsx(\"path\", { d: \"M18 22V8a2 2 0 0 0-2-2H2\" })\n ] });\n function fmt(ms) {\n const s2 = ms / 1e3;\n const m2 = Math.floor(s2 / 60);\n return `${m2}:${(s2 - m2 * 60).toFixed(2).padStart(5, \"0\")}`;\n }\n var svgLoaded = signal(false);\n var dragging = signal(false);\n var durationMs = signal(0);\n var playhead = signal(0);\n var playing = signal(false);\n var speed = signal(1);\n var rangeStart = signal(0);\n var rangeEnd = signal(0);\n var loop = signal(true);\n var zoom = signal(1);\n var panX = signal(0);\n var panY = signal(0);\n var exportMenuOpen = signal(false);\n var busy = signal(false);\n var trackW = signal(0);\n var cropMode = signal(false);\n var cropRect = signal(null);\n var cropTick = signal(0);\n var cropAspect = signal(\"free\");\n var reviewMode = window.__SCRUBBER_BOOTSTRAP__?.review === true;\n var regionMode = signal(false);\n var regions = signal([]);\n var drawingRect = signal(null);\n var regionTick = signal(0);\n var attachFrame = signal(true);\n var ticketStatus = signal({ kind: \"\", msg: \"\" });\n var savingTicket = signal(false);\n var svgText = \"\";\n var svgName = \"animation\";\n var svgPath = window.__SCRUBBER_BOOTSTRAP__?.path ?? null;\n var lastTs = 0;\n var svgEl = null;\n var head = document.createElement(\"style\");\n head.textContent = CSS;\n document.head.append(head);\n var THUMB_R = 8;\n function markerLeft(ms) {\n const dur = durationMs.value || 1;\n const w2 = trackW.value;\n const usable = Math.max(1, w2 - THUMB_R * 2);\n return `${THUMB_R + Math.min(Math.max(ms, 0), dur) / dur * usable}px`;\n }\n function render() {\n const dur = durationMs.value;\n const hi = rangeEnd.value > rangeStart.value ? rangeEnd.value : dur;\n const zoomPct = Math.round(zoom.value * 100);\n return /* @__PURE__ */ jsx(\"div\", { class: \"wrap\", children: [\n /* @__PURE__ */ jsx(\"div\", { class: \"stage\", \"data-stage\": true, children: [\n /* @__PURE__ */ jsx(\"div\", { class: \"svg-host\", \"data-svg-host\": true, \"data-morph-skip\": true }),\n /* @__PURE__ */ jsx(\"div\", { class: \"crop-layer\", \"data-crop-layer\": true, \"data-morph-skip\": true, style: \"display:none\" }),\n /* @__PURE__ */ jsx(\"div\", { class: \"region-layer\", \"data-region-layer\": true, \"data-morph-skip\": true, style: \"display:none\" }),\n (!svgLoaded.value || dragging.value) && /* @__PURE__ */ jsx(\"div\", { class: dragging.value ? \"drop over\" : \"drop\", \"data-drop\": true, children: [\n \"Drop an animated SVG here\",\n /* @__PURE__ */ jsx(\"span\", { class: \"muted\", children: \"or click to choose a file\" })\n ] })\n ] }),\n /* @__PURE__ */ jsx(\"div\", { class: \"bar\", children: [\n /* @__PURE__ */ jsx(\"div\", { class: \"row\", children: [\n /* @__PURE__ */ jsx(\"button\", { class: \"play primary\", \"data-action\": \"play\", \"aria-label\": playing.value ? \"Pause\" : \"Play\", disabled: !svgLoaded.value, children: playing.value ? ICON_PAUSE : ICON_PLAY }),\n /* @__PURE__ */ jsx(\"select\", { \"data-action\": \"speed\", disabled: !svgLoaded.value, children: [\"0.1\", \"0.25\", \"0.5\", \"1\", \"1.5\", \"2\", \"4\"].map((v2) => /* @__PURE__ */ jsx(\"option\", { value: v2, selected: parseFloat(v2) === speed.value, children: [\n v2,\n \"x\"\n ] })) }),\n /* @__PURE__ */ jsx(\"div\", { class: \"scrub-wrap\", children: [\n svgLoaded.value && dur > 0 && trackW.value > 0 && /* @__PURE__ */ jsx(Fragment, { children: [\n /* @__PURE__ */ jsx(\"div\", { class: \"range-band\", style: `left:${markerLeft(rangeStart.value)};width:${Math.max(0, parseFloat(markerLeft(hi)) - parseFloat(markerLeft(rangeStart.value)))}px` }),\n /* @__PURE__ */ jsx(\"div\", { class: \"range-tick\", \"data-tick\": \"in\", title: \"drag to set range start\", style: `left:${markerLeft(rangeStart.value)}`, children: /* @__PURE__ */ jsx(\"span\", { children: \"in\" }) }),\n /* @__PURE__ */ jsx(\"div\", { class: \"range-tick\", \"data-tick\": \"out\", title: \"drag to set range end\", style: `left:${markerLeft(hi)}`, children: /* @__PURE__ */ jsx(\"span\", { children: \"out\" }) })\n ] }),\n /* @__PURE__ */ jsx(\"input\", { type: \"range\", class: \"scrub\", \"data-action\": \"scrub\", min: \"0\", max: \"1000\", step: \"0.1\", disabled: !svgLoaded.value })\n ] }),\n /* @__PURE__ */ jsx(\"div\", { class: \"time\", children: [\n fmt(playhead.value),\n \" / \",\n fmt(dur)\n ] })\n ] }),\n /* @__PURE__ */ jsx(\"div\", { class: \"row row2\", children: [\n /* @__PURE__ */ jsx(\"div\", { class: \"grp\", children: [\n /* @__PURE__ */ jsx(\"button\", { \"data-action\": \"setin\", disabled: !svgLoaded.value, children: \"In\" }),\n /* @__PURE__ */ jsx(\"input\", { type: \"number\", \"data-action\": \"inn\", min: \"0\", step: \"0.01\", title: \"range start (s)\", value: (rangeStart.value / 1e3).toFixed(2), disabled: !svgLoaded.value }),\n /* @__PURE__ */ jsx(\"span\", { class: \"muted\", children: \"->\" }),\n /* @__PURE__ */ jsx(\"input\", { type: \"number\", \"data-action\": \"outn\", min: \"0\", step: \"0.01\", title: \"range end (s)\", value: (rangeEnd.value / 1e3).toFixed(2), disabled: !svgLoaded.value }),\n /* @__PURE__ */ jsx(\"button\", { \"data-action\": \"setout\", disabled: !svgLoaded.value, children: \"Out\" }),\n /* @__PURE__ */ jsx(\"label\", { children: [\n /* @__PURE__ */ jsx(\"input\", { type: \"checkbox\", \"data-action\": \"loop\", checked: loop.value, disabled: !svgLoaded.value }),\n \"loop\"\n ] }),\n /* @__PURE__ */ jsx(\"button\", { \"data-action\": \"resetrange\", disabled: !svgLoaded.value, children: \"Reset\" })\n ] }),\n /* @__PURE__ */ jsx(\"div\", { class: \"grp\", children: [\n /* @__PURE__ */ jsx(\"button\", { class: \"iconbtn\", \"data-action\": \"zoomout\", title: \"zoom out\", disabled: !svgLoaded.value, children: \"-\" }),\n /* @__PURE__ */ jsx(\"select\", { \"data-action\": \"zoompreset\", title: \"zoom\", disabled: !svgLoaded.value, children: [\n ZOOM_PRESETS.map((z) => /* @__PURE__ */ jsx(\"option\", { value: String(z), selected: Math.round(z * 100) === zoomPct, children: [\n Math.round(z * 100),\n \"%\"\n ] })),\n !ZOOM_PRESETS.some((z) => Math.round(z * 100) === zoomPct) && /* @__PURE__ */ jsx(\"option\", { value: \"custom\", selected: true, children: [\n zoomPct,\n \"%\"\n ] }),\n /* @__PURE__ */ jsx(\"option\", { value: \"fit\", children: \"Fit\" }),\n /* @__PURE__ */ jsx(\"option\", { value: \"fill\", children: \"Fill\" })\n ] }),\n /* @__PURE__ */ jsx(\"button\", { class: \"iconbtn\", \"data-action\": \"zoomin\", title: \"zoom in\", disabled: !svgLoaded.value, children: \"+\" }),\n /* @__PURE__ */ jsx(\"button\", { class: \"iconbtn\", \"data-action\": \"center\", title: \"reset pan to center\", \"aria-label\": \"center\", disabled: !svgLoaded.value, children: ICON_DOT })\n ] }),\n /* @__PURE__ */ jsx(\"div\", { class: \"grp\", children: [\n /* @__PURE__ */ jsx(\"button\", { class: cropMode.value ? \"iconbtn active\" : \"iconbtn\", \"data-action\": \"croptoggle\", title: \"crop\", \"aria-label\": \"crop\", \"aria-pressed\": cropMode.value ? \"true\" : \"false\", disabled: !svgLoaded.value, children: ICON_CROP }),\n /* @__PURE__ */ jsx(\"select\", { \"data-action\": \"cropaspect\", title: \"crop aspect ratio\", disabled: !svgLoaded.value || !cropMode.value, children: [\n /* @__PURE__ */ jsx(\"option\", { value: \"free\", selected: cropAspect.value === \"free\", children: \"Free\" }),\n /* @__PURE__ */ jsx(\"option\", { value: \"1\", selected: cropAspect.value === \"1\", children: \"1:1\" }),\n /* @__PURE__ */ jsx(\"option\", { value: \"1.7778\", selected: cropAspect.value === \"1.7778\", children: \"16:9\" }),\n /* @__PURE__ */ jsx(\"option\", { value: \"1.3333\", selected: cropAspect.value === \"1.3333\", children: \"4:3\" }),\n /* @__PURE__ */ jsx(\"option\", { value: \"orig\", selected: cropAspect.value === \"orig\", children: \"Original\" })\n ] })\n ] }),\n /* @__PURE__ */ jsx(\"div\", { class: \"grp export-wrap\", children: [\n /* @__PURE__ */ jsx(\"button\", { class: \"primary\", \"data-action\": \"exporttoggle\", disabled: !svgLoaded.value || busy.value, children: \"Export\" }),\n exportMenuOpen.value && /* @__PURE__ */ jsx(\"div\", { class: \"export-menu\", \"data-export-menu\": true, children: [\n /* @__PURE__ */ jsx(\"button\", { \"data-action\": \"export-frame\", children: \"Frame (PNG)\" }),\n /* @__PURE__ */ jsx(\"button\", { \"data-action\": \"export-trim\", children: \"Trim (SVG)\" }),\n /* @__PURE__ */ jsx(\"button\", { \"data-action\": \"export-video\", children: \"Range (MP4)\" })\n ] })\n ] })\n ] }),\n reviewMode && /* @__PURE__ */ jsx(\"div\", { class: \"row review\", children: [\n /* @__PURE__ */ jsx(\"div\", { class: \"grp\", style: \"flex-wrap:wrap;width:100%\", children: [\n /* @__PURE__ */ jsx(\"input\", { class: \"rv-title\", \"data-action\": \"rv-title\", type: \"text\", placeholder: \"Issue title\", disabled: !svgLoaded.value }),\n /* @__PURE__ */ jsx(\"select\", { \"data-action\": \"rv-category\", title: \"ticket category\", disabled: !svgLoaded.value, children: [\"bug\", \"issue\", \"feature\", \"task\", \"investigation\"].map((c2) => /* @__PURE__ */ jsx(\"option\", { value: c2, children: c2 })) }),\n /* @__PURE__ */ jsx(\"button\", { class: regionMode.value ? \"iconbtn active\" : \"iconbtn\", \"data-action\": \"rv-region\", title: \"drag rectangles over the problem area(s); stays armed so you can add several\", disabled: !svgLoaded.value, children: regionMode.value ? \"Drawing\\u2026\" : regions.value.length > 0 ? `Regions \\u2713 (${regions.value.length})` : \"Mark region\" }),\n /* @__PURE__ */ jsx(\"button\", { \"data-action\": \"rv-clear-region\", disabled: !svgLoaded.value || regions.value.length === 0, children: \"Clear\" }),\n /* @__PURE__ */ jsx(\"label\", { class: \"muted\", children: [\n /* @__PURE__ */ jsx(\"input\", { type: \"checkbox\", \"data-action\": \"rv-attach\", checked: attachFrame.value, disabled: !svgLoaded.value }),\n \"attach frame\"\n ] }),\n /* @__PURE__ */ jsx(\"span\", { class: \"muted\", children: [\n \"frame @ \",\n fmt(playhead.value),\n \" \\xB7 range \",\n fmt(rangeStart.value),\n \"\\u2013\",\n fmt(hi)\n ] }),\n /* @__PURE__ */ jsx(\"button\", { class: \"primary\", \"data-action\": \"rv-save\", disabled: !svgLoaded.value || savingTicket.value, children: savingTicket.value ? \"Saving\\u2026\" : \"Save issue\" })\n ] }),\n /* @__PURE__ */ jsx(\"textarea\", { class: \"rv-note\", \"data-action\": \"rv-note\", placeholder: \"Describe the issue (becomes the ticket body)\\u2026\", disabled: !svgLoaded.value }),\n ticketStatus.value.msg !== \"\" && /* @__PURE__ */ jsx(\"div\", { class: `rv-status ${ticketStatus.value.kind}`, children: ticketStatus.value.msg })\n ] })\n ] })\n ] });\n }\n var app = document.getElementById(\"app\");\n mount(app, render);\n var svgHost = app.querySelector(\"[data-svg-host]\");\n var dlAnchor = document.createElement(\"a\");\n dlAnchor.className = \"dl\";\n app.append(dlAnchor);\n effect(() => {\n svgHost.style.transform = `translate(${panX.value}px, ${panY.value}px) scale(${zoom.value})`;\n });\n effect(() => {\n const p2 = playhead.value, dur = durationMs.value;\n const sc = app.querySelector(\".scrub\");\n if (sc != null && document.activeElement !== sc) sc.value = String(p2 / (dur || 1) * 1e3);\n });\n function measureTrack() {\n const el = app.querySelector(\".scrub\");\n if (el) trackW.value = el.clientWidth;\n }\n var stageEl = app.querySelector(\".stage\");\n new ResizeObserver(() => {\n measureTrack();\n fitZoomKeep();\n cropTick.value++;\n regionTick.value++;\n }).observe(stageEl);\n window.addEventListener(\"resize\", () => {\n measureTrack();\n fitZoomKeep();\n cropTick.value++;\n regionTick.value++;\n });\n var CROP_MIN = 8;\n var cropLayer = app.querySelector(\"[data-crop-layer]\");\n var cropBox = document.createElement(\"div\");\n cropBox.className = \"crop-box\";\n var cropDimsEl = document.createElement(\"div\");\n cropDimsEl.className = \"crop-dims\";\n cropBox.appendChild(cropDimsEl);\n var CROP_HANDLES = [\"nw\", \"n\", \"ne\", \"e\", \"se\", \"s\", \"sw\", \"w\"];\n var cropHandleEls = {};\n for (const hh of CROP_HANDLES) {\n const d2 = document.createElement(\"div\");\n d2.className = \"crop-h\";\n d2.dataset.handle = hh;\n cropBox.appendChild(d2);\n cropHandleEls[hh] = d2;\n }\n cropLayer.appendChild(cropBox);\n effect(() => {\n const cr = cropRect.value;\n const on = cropMode.value && svgLoaded.value && cr != null;\n cropTick.value;\n cropLayer.style.display = on ? \"block\" : \"none\";\n if (!on || cr == null) return;\n const n2 = svgNaturalSize();\n const z = zoom.value;\n const sw = stageEl.clientWidth, sh = stageEl.clientHeight;\n const originX = sw / 2 + panX.value - n2.w * z / 2;\n const originY = sh / 2 + panY.value - n2.h * z / 2;\n const bx = originX + cr.x * z, by = originY + cr.y * z;\n const bw = cr.w * z, bh = cr.h * z;\n cropBox.style.left = `${bx}px`;\n cropBox.style.top = `${by}px`;\n cropBox.style.width = `${bw}px`;\n cropBox.style.height = `${bh}px`;\n const pos = {\n nw: [0, 0],\n n: [bw / 2, 0],\n ne: [bw, 0],\n e: [bw, bh / 2],\n se: [bw, bh],\n s: [bw / 2, bh],\n sw: [0, bh],\n w: [0, bh / 2]\n };\n for (const hh of CROP_HANDLES) {\n cropHandleEls[hh].style.left = `${pos[hh][0]}px`;\n cropHandleEls[hh].style.top = `${pos[hh][1]}px`;\n }\n cropDimsEl.textContent = `${Math.round(cr.w)} \\xD7 ${Math.round(cr.h)}`;\n });\n function cropAspectRatio() {\n const v2 = cropAspect.value;\n if (v2 === \"free\") return null;\n if (v2 === \"orig\") {\n const n2 = svgNaturalSize();\n return n2.h > 0 ? n2.w / n2.h : null;\n }\n const r2 = parseFloat(v2);\n return Number.isFinite(r2) && r2 > 0 ? r2 : null;\n }\n function applyCropDrag(start, handle, dxU, dyU, n2, aspect) {\n const clamp = (v2, lo, hi) => Math.max(lo, Math.min(v2, hi));\n if (handle === \"move\") {\n return { x: clamp(start.x + dxU, 0, n2.w - start.w), y: clamp(start.y + dyU, 0, n2.h - start.h), w: start.w, h: start.h };\n }\n let L = start.x, R = start.x + start.w, T = start.y, B = start.y + start.h;\n if (handle.includes(\"w\")) L = clamp(start.x + dxU, 0, R - CROP_MIN);\n if (handle.includes(\"e\")) R = clamp(start.x + start.w + dxU, L + CROP_MIN, n2.w);\n if (handle.includes(\"n\")) T = clamp(start.y + dyU, 0, B - CROP_MIN);\n if (handle.includes(\"s\")) B = clamp(start.y + start.h + dyU, T + CROP_MIN, n2.h);\n const free = { x: L, y: T, w: R - L, h: B - T };\n if (aspect == null || aspect <= 0) return free;\n return constrainResizeToAspect(free, handle, aspect, n2.w, n2.h, CROP_MIN);\n }\n var cropDrag = null;\n void delegate(app, \"pointerdown\", \".crop-box\", (e2, _box) => {\n if (cropRect.value == null) return;\n const ev = e2;\n const realTarget = ev.target;\n const handle = realTarget.classList.contains(\"crop-h\") ? realTarget.dataset.handle : \"move\";\n cropDrag = { handle, start: { ...cropRect.value }, px: ev.clientX, py: ev.clientY };\n cropBox.setPointerCapture(ev.pointerId);\n ev.preventDefault();\n ev.stopPropagation();\n });\n cropBox.addEventListener(\"pointermove\", (ev) => {\n if (cropDrag == null) return;\n const z = zoom.value || 1;\n const dxU = (ev.clientX - cropDrag.px) / z, dyU = (ev.clientY - cropDrag.py) / z;\n cropRect.value = applyCropDrag(cropDrag.start, cropDrag.handle, dxU, dyU, svgNaturalSize(), cropAspectRatio());\n });\n var endCropDrag = () => {\n cropDrag = null;\n };\n cropBox.addEventListener(\"pointerup\", endCropDrag);\n cropBox.addEventListener(\"pointercancel\", endCropDrag);\n var REGION_MIN = 3;\n var regionLayer = app.querySelector(\"[data-region-layer]\");\n function svgOriginLocal() {\n const n2 = svgNaturalSize();\n const z = zoom.value || 1;\n return { ox: stageEl.clientWidth / 2 + panX.value - n2.w * z / 2, oy: stageEl.clientHeight / 2 + panY.value - n2.h * z / 2, z };\n }\n function clientToSvgUnits(clientX, clientY) {\n const r2 = stageEl.getBoundingClientRect();\n const { ox, oy, z } = svgOriginLocal();\n return { x: (clientX - r2.left - ox) / z, y: (clientY - r2.top - oy) / z };\n }\n effect(() => {\n const rs = regions.value;\n const draw = drawingRect.value;\n const on = regionMode.value || rs.length > 0;\n regionTick.value;\n regionLayer.style.display = on && svgLoaded.value ? \"block\" : \"none\";\n regionLayer.style.pointerEvents = regionMode.value && svgLoaded.value ? \"auto\" : \"none\";\n const { ox, oy, z } = svgOriginLocal();\n const all = draw != null ? [...rs, draw] : rs;\n while (regionLayer.children.length < all.length) {\n const b2 = document.createElement(\"div\");\n b2.className = \"region-box\";\n regionLayer.appendChild(b2);\n }\n while (regionLayer.children.length > all.length) regionLayer.lastChild.remove();\n all.forEach((rr, i2) => {\n const b2 = regionLayer.children[i2];\n b2.style.left = `${ox + rr.x * z}px`;\n b2.style.top = `${oy + rr.y * z}px`;\n b2.style.width = `${rr.w * z}px`;\n b2.style.height = `${rr.h * z}px`;\n });\n });\n var regionDraw = null;\n regionLayer.addEventListener(\"pointerdown\", (ev) => {\n if (!regionMode.value || !svgLoaded.value) return;\n const p2 = clientToSvgUnits(ev.clientX, ev.clientY);\n regionDraw = { ox: p2.x, oy: p2.y };\n drawingRect.value = { x: p2.x, y: p2.y, w: 0, h: 0 };\n regionLayer.setPointerCapture(ev.pointerId);\n ev.preventDefault();\n });\n regionLayer.addEventListener(\"pointermove\", (ev) => {\n if (regionDraw == null) return;\n const p2 = clientToSvgUnits(ev.clientX, ev.clientY);\n drawingRect.value = {\n x: Math.min(regionDraw.ox, p2.x),\n y: Math.min(regionDraw.oy, p2.y),\n w: Math.abs(p2.x - regionDraw.ox),\n h: Math.abs(p2.y - regionDraw.oy)\n };\n });\n var endRegionDraw = () => {\n if (regionDraw == null) return;\n regionDraw = null;\n const rr = drawingRect.value;\n drawingRect.value = null;\n if (rr != null && rr.w >= REGION_MIN && rr.h >= REGION_MIN) regions.value = [...regions.value, rr];\n regionTick.value++;\n };\n regionLayer.addEventListener(\"pointerup\", endRegionDraw);\n regionLayer.addEventListener(\"pointercancel\", endRegionDraw);\n function stageAnims() {\n if (typeof document.getAnimations !== \"function\" || svgEl == null) return [];\n return document.getAnimations().filter((a2) => {\n const t2 = a2.effect?.target;\n return t2 != null && svgEl.contains(t2);\n });\n }\n function seekAll(ms) {\n for (const a2 of stageAnims()) {\n try {\n a2.pause();\n a2.currentTime = ms;\n } catch {\n }\n }\n if (svgEl != null && typeof svgEl.pauseAnimations === \"function\") {\n try {\n svgEl.pauseAnimations();\n svgEl.setCurrentTime(ms / 1e3);\n } catch {\n }\n }\n }\n function localDuration() {\n let finite = 0, period = 0;\n for (const a2 of stageAnims()) {\n const ct = a2.effect.getComputedTiming();\n const d2 = Number(ct.duration);\n if (Number.isFinite(ct.endTime)) finite = Math.max(finite, Number(ct.endTime));\n if (!Number.isFinite(ct.iterations) && d2 > 0) period = Math.max(period, d2);\n }\n return finite || period;\n }\n function tick(ts) {\n if (playing.value) {\n if (lastTs === 0) lastTs = ts;\n const dt = (ts - lastTs) * speed.value;\n lastTs = ts;\n let p2 = playhead.value + dt;\n const lo = rangeStart.value, hi = rangeEnd.value > rangeStart.value ? rangeEnd.value : durationMs.value;\n if (p2 >= hi) {\n if (loop.value) p2 = lo + (p2 - lo) % Math.max(1, hi - lo);\n else {\n p2 = hi;\n playing.value = false;\n }\n }\n playhead.value = p2;\n seekAll(p2);\n } else {\n lastTs = 0;\n }\n requestAnimationFrame(tick);\n }\n requestAnimationFrame(tick);\n function svgNaturalSize() {\n if (svgEl == null) return { w: 1, h: 1 };\n const vb = svgEl.viewBox?.baseVal;\n if (vb && vb.width > 0 && vb.height > 0) return { w: vb.width, h: vb.height };\n const r2 = svgEl.getBoundingClientRect();\n return { w: r2.width / (zoom.value || 1) || 1, h: r2.height / (zoom.value || 1) || 1 };\n }\n function stageSize() {\n const r2 = app.querySelector(\".stage\").getBoundingClientRect();\n return { w: r2.width, h: r2.height };\n }\n function fitZoom(mode) {\n const s2 = stageSize(), n2 = svgNaturalSize();\n const sx = s2.w / n2.w, sy = s2.h / n2.h;\n return mode === \"fit\" ? Math.min(sx, sy) : Math.max(sx, sy);\n }\n var zoomMode = \"fit\";\n function fitZoomKeep() {\n if (zoomMode !== \"manual\" && svgEl != null) zoom.value = fitZoom(zoomMode);\n }\n function setZoom(z) {\n zoom.value = Math.min(16, Math.max(0.02, z));\n zoomMode = \"manual\";\n }\n async function loadSvg(text, name) {\n svgText = text;\n svgName = name.replace(/\\.svg$/i, \"\") || \"animation\";\n const tmp = document.createElement(\"div\");\n tmp.innerHTML = text;\n const svg = tmp.querySelector(\"svg\");\n if (svg == null) {\n alert(\"No <svg> element found in the file.\");\n return;\n }\n svgHost.replaceChildren(svg);\n svgEl = svg;\n const n2 = (() => {\n const vb = svg.viewBox?.baseVal;\n return vb && vb.width > 0 ? { w: vb.width, h: vb.height } : { w: 800, h: 600 };\n })();\n svg.style.width = `${n2.w}px`;\n svg.style.height = `${n2.h}px`;\n svg.removeAttribute(\"width\");\n svg.removeAttribute(\"height\");\n svgLoaded.value = true;\n let dur = 0;\n try {\n const r2 = await fetch(\"/timing\", { method: \"POST\", headers: { \"content-type\": \"application/json\" }, body: JSON.stringify({ svg: text }) });\n dur = (await r2.json()).durationMs ?? localDuration();\n } catch {\n dur = localDuration();\n }\n if (!(dur > 0)) dur = 1e3;\n durationMs.value = dur;\n playhead.value = 0;\n rangeStart.value = 0;\n rangeEnd.value = dur;\n playing.value = false;\n seekAll(0);\n zoomMode = \"fit\";\n panX.value = 0;\n panY.value = 0;\n fitZoomKeep();\n cropMode.value = false;\n cropRect.value = null;\n cropAspect.value = \"free\";\n cropTick.value++;\n regionMode.value = false;\n regions.value = [];\n drawingRect.value = null;\n regionTick.value++;\n ticketStatus.value = { kind: \"\", msg: \"\" };\n measureTrack();\n }\n function svgPxSize() {\n if (svgEl == null) return { w: 800, h: 600 };\n const vb = svgEl.viewBox?.baseVal;\n return vb && vb.width > 0 ? { w: vb.width, h: vb.height } : { w: 800, h: 600 };\n }\n function download(blob, filename) {\n const url = URL.createObjectURL(blob);\n dlAnchor.href = url;\n dlAnchor.download = filename;\n dlAnchor.click();\n setTimeout(() => URL.revokeObjectURL(url), 4e3);\n }\n function rangeSE() {\n return { s: rangeStart.value, e: rangeEnd.value > rangeStart.value ? rangeEnd.value : durationMs.value };\n }\n function activeCrop() {\n if (!cropMode.value) return void 0;\n const cr = cropRect.value;\n if (cr == null) return void 0;\n const n2 = svgNaturalSize();\n const full = cr.x <= 0.5 && cr.y <= 0.5 && cr.w >= n2.w - 0.5 && cr.h >= n2.h - 0.5;\n return full ? void 0 : { x: cr.x, y: cr.y, w: cr.w, h: cr.h };\n }\n async function exportFrame() {\n busy.value = true;\n try {\n const { w: w2, h: h2 } = svgPxSize();\n const r2 = await fetch(\"/export-frame\", { method: \"POST\", headers: { \"content-type\": \"application/json\" }, body: JSON.stringify({ svg: svgText, timeMs: playhead.value, width: w2, height: h2, crop: activeCrop() }) });\n if (!r2.ok) throw new Error(`export failed (${r2.status})`);\n download(await r2.blob(), `${svgName}-${Math.round(playhead.value)}ms.png`);\n } catch (err) {\n alert(err instanceof Error ? err.message : \"export failed\");\n } finally {\n busy.value = false;\n }\n }\n async function exportTrim() {\n const { s: s2, e: e2 } = rangeSE();\n busy.value = true;\n try {\n const r2 = await fetch(\"/trim\", { method: \"POST\", headers: { \"content-type\": \"application/json\" }, body: JSON.stringify({ svg: svgText, startMs: s2, endMs: e2, periodMs: durationMs.value, crop: activeCrop() }) });\n if (!r2.ok) throw new Error(`trim failed (${r2.status})`);\n download(new Blob([(await r2.json()).svg], { type: \"image/svg+xml\" }), `${svgName}-trim-${Math.round(s2)}-${Math.round(e2)}ms.svg`);\n } catch (err) {\n alert(err instanceof Error ? err.message : \"trim failed\");\n } finally {\n busy.value = false;\n }\n }\n async function exportVideo() {\n const { s: s2, e: e2 } = rangeSE();\n const { w: w2, h: h2 } = svgPxSize();\n busy.value = true;\n try {\n const r2 = await fetch(\"/export-range-video\", { method: \"POST\", headers: { \"content-type\": \"application/json\" }, body: JSON.stringify({ svg: svgText, startMs: s2, endMs: e2, width: w2, height: h2, crop: activeCrop() }) });\n if (!r2.ok) {\n let msg = `export failed (${r2.status})`;\n try {\n msg = (await r2.json()).error ?? msg;\n } catch {\n }\n throw new Error(msg);\n }\n download(await r2.blob(), `${svgName}-${Math.round(s2)}-${Math.round(e2)}ms.mp4`);\n } catch (err) {\n alert(err instanceof Error ? err.message : \"video export failed\");\n } finally {\n busy.value = false;\n }\n }\n async function saveTicket() {\n const titleEl = app.querySelector(\".rv-title\");\n const noteEl = app.querySelector(\".rv-note\");\n const catEl = app.querySelector(\"[data-action=rv-category]\");\n const title = (titleEl?.value ?? \"\").trim();\n if (title === \"\") {\n ticketStatus.value = { kind: \"err\", msg: \"\\u26A0 enter an issue title first\" };\n return;\n }\n const { s: s2, e: e2 } = rangeSE();\n savingTicket.value = true;\n ticketStatus.value = { kind: \"\", msg: \"saving\\u2026\" };\n try {\n const r2 = await fetch(\"/ticket\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({\n title,\n note: noteEl?.value ?? \"\",\n category: catEl?.value ?? \"bug\",\n svgPath,\n svgName,\n frameTimeMs: playhead.value,\n rangeStartMs: s2,\n rangeEndMs: e2,\n regions: regions.value,\n // DM-1449: attach the current frame as a sibling PNG (server renders it).\n attachFrame: attachFrame.value,\n svg: attachFrame.value ? svgText : void 0\n })\n });\n if (!r2.ok) {\n let msg = `save failed (${r2.status})`;\n try {\n msg = (await r2.json()).error ?? msg;\n } catch {\n }\n throw new Error(msg);\n }\n const { path, framePng } = await r2.json();\n ticketStatus.value = { kind: \"ok\", msg: `\\u2713 wrote ${path}${framePng ? ` (+ frame PNG)` : \"\"}` };\n if (titleEl) titleEl.value = \"\";\n if (noteEl) noteEl.value = \"\";\n regions.value = [];\n drawingRect.value = null;\n regionMode.value = false;\n regionTick.value++;\n } catch (err) {\n ticketStatus.value = { kind: \"err\", msg: `\\u26A0 ${err instanceof Error ? err.message : \"save failed\"}` };\n } finally {\n savingTicket.value = false;\n }\n }\n var togglePlay = () => {\n if (durationMs.value <= 0) return;\n const hi = rangeEnd.value > rangeStart.value ? rangeEnd.value : durationMs.value;\n if (!playing.value && playhead.value >= hi) playhead.value = rangeStart.value;\n playing.value = !playing.value;\n lastTs = 0;\n };\n var stepFrame = (deltaMs) => {\n playing.value = false;\n playhead.value = Math.min(durationMs.value, Math.max(0, playhead.value + deltaMs));\n seekAll(playhead.value);\n };\n var CLICK = {\n play: togglePlay,\n setin: () => {\n rangeStart.value = Math.min(playhead.value, rangeEnd.value);\n },\n setout: () => {\n rangeEnd.value = Math.max(playhead.value, rangeStart.value);\n },\n resetrange: () => {\n rangeStart.value = 0;\n rangeEnd.value = durationMs.value;\n },\n zoomin: () => setZoom(zoom.value * 1.25),\n zoomout: () => setZoom(zoom.value / 1.25),\n center: () => {\n panX.value = 0;\n panY.value = 0;\n },\n // DM-1104: toggle crop mode. Enabling seeds the rect to the whole frame (drag\n // the handles in to crop); disabling resets the rect so the next enable starts\n // fresh from the full frame.\n croptoggle: () => {\n cropMode.value = !cropMode.value;\n if (cropMode.value) {\n const n2 = svgNaturalSize();\n cropRect.value = { x: 0, y: 0, w: n2.w, h: n2.h };\n } else {\n cropRect.value = null;\n cropAspect.value = \"free\";\n }\n cropTick.value++;\n },\n exporttoggle: () => {\n exportMenuOpen.value = !exportMenuOpen.value;\n },\n \"export-frame\": () => {\n exportMenuOpen.value = false;\n void exportFrame();\n },\n \"export-trim\": () => {\n exportMenuOpen.value = false;\n void exportTrim();\n },\n \"export-video\": () => {\n exportMenuOpen.value = false;\n void exportVideo();\n },\n // DM-1445/DM-1449: review-mode controls.\n \"rv-region\": () => {\n regionMode.value = !regionMode.value;\n regionTick.value++;\n },\n \"rv-clear-region\": () => {\n regions.value = [];\n drawingRect.value = null;\n regionMode.value = false;\n regionTick.value++;\n },\n \"rv-save\": () => {\n void saveTicket();\n }\n };\n void delegate(app, \"click\", \"[data-action]\", (e2, target) => {\n const a2 = target.getAttribute(\"data-action\");\n if (a2 && CLICK[a2]) CLICK[a2]();\n });\n void delegate(app, \"click\", \"[data-drop]\", () => fileInput.click());\n void delegate(app, \"click\", \"[data-stage]\", () => {\n if (exportMenuOpen.value) exportMenuOpen.value = false;\n });\n void delegate(app, \"input\", \"[data-action=scrub]\", (e2, target) => {\n playing.value = false;\n playhead.value = parseFloat(target.value) / 1e3 * (durationMs.value || 1);\n seekAll(playhead.value);\n });\n void delegate(app, \"change\", \"[data-action=speed]\", (e2, target) => {\n speed.value = parseFloat(target.value) || 1;\n });\n void delegate(app, \"change\", \"[data-action=inn]\", (e2, target) => {\n rangeStart.value = Math.max(0, Math.min((parseFloat(target.value) || 0) * 1e3, durationMs.value));\n });\n void delegate(app, \"change\", \"[data-action=outn]\", (e2, target) => {\n rangeEnd.value = Math.max(rangeStart.value, Math.min((parseFloat(target.value) || 0) * 1e3, durationMs.value));\n });\n void delegate(app, \"change\", \"[data-action=loop]\", (e2, target) => {\n loop.value = target.checked;\n });\n void delegate(app, \"change\", \"[data-action=rv-attach]\", (e2, target) => {\n attachFrame.value = target.checked;\n });\n void delegate(app, \"change\", \"[data-action=cropaspect]\", (e2, target) => {\n cropAspect.value = target.value;\n const ar = cropAspectRatio();\n if (ar != null && cropRect.value != null) {\n const n2 = svgNaturalSize();\n cropRect.value = fitRectToAspect(cropRect.value, ar, n2.w, n2.h);\n }\n });\n void delegate(app, \"change\", \"[data-action=zoompreset]\", (e2, target) => {\n const v2 = target.value;\n panX.value = 0;\n panY.value = 0;\n if (v2 === \"fit\" || v2 === \"fill\") {\n zoomMode = v2;\n fitZoomKeep();\n } else if (v2 !== \"custom\") setZoom(parseFloat(v2));\n });\n var dragTick = null;\n function timeAtClientX(clientX) {\n const sc = app.querySelector(\".scrub\");\n if (sc == null) return 0;\n const r2 = sc.getBoundingClientRect();\n const usable = Math.max(1, r2.width - THUMB_R * 2);\n const frac = Math.min(1, Math.max(0, (clientX - r2.left - THUMB_R) / usable));\n return frac * (durationMs.value || 1);\n }\n void delegate(app, \"pointerdown\", \"[data-tick]\", (e2, target) => {\n const t2 = target.getAttribute(\"data-tick\");\n if (t2 !== \"in\" && t2 !== \"out\") return;\n dragTick = t2;\n target.setPointerCapture(e2.pointerId);\n e2.preventDefault();\n });\n void delegate(app, \"pointermove\", \"[data-tick]\", (e2) => {\n if (dragTick == null) return;\n const ms = timeAtClientX(e2.clientX);\n if (dragTick === \"in\") rangeStart.value = Math.min(ms, rangeEnd.value);\n else rangeEnd.value = Math.max(ms, rangeStart.value);\n });\n void delegate(app, \"pointerup\", \"[data-tick]\", () => {\n dragTick = null;\n });\n void delegate(app, \"wheel\", \"[data-stage]\", (e2) => {\n const w2 = e2;\n w2.preventDefault();\n if (w2.ctrlKey || w2.metaKey) {\n const factor = Math.exp(-w2.deltaY * 0.01);\n setZoom(zoom.value * factor);\n } else {\n panX.value -= w2.deltaX;\n panY.value -= w2.deltaY;\n }\n });\n function readFile(f2) {\n if (f2) {\n svgPath = null;\n void f2.text().then((t2) => loadSvg(t2, f2.name));\n }\n }\n void delegate(app, \"dragover\", \"[data-stage]\", (e2) => {\n e2.preventDefault();\n dragging.value = true;\n });\n void delegate(app, \"dragleave\", \"[data-stage]\", (e2) => {\n e2.preventDefault();\n dragging.value = false;\n });\n void delegate(app, \"drop\", \"[data-stage]\", (e2) => {\n e2.preventDefault();\n dragging.value = false;\n readFile(e2.dataTransfer?.files?.[0]);\n });\n var fileInput = document.createElement(\"input\");\n fileInput.type = \"file\";\n fileInput.accept = \".svg,image/svg+xml\";\n fileInput.style.display = \"none\";\n fileInput.addEventListener(\"change\", () => readFile(fileInput.files?.[0] ?? void 0));\n app.append(fileInput);\n window.addEventListener(\"keydown\", (e2) => {\n if (!svgLoaded.value) return;\n const tag = e2.target?.tagName;\n if (tag === \"INPUT\" && e2.target.type !== \"range\") return;\n if (tag === \"TEXTAREA\" || tag === \"SELECT\") return;\n if (e2.code === \"Space\") {\n e2.preventDefault();\n togglePlay();\n } else if (e2.code === \"ArrowLeft\") {\n stepFrame(-(e2.shiftKey ? 1 : 1e3 / 30));\n } else if (e2.code === \"ArrowRight\") {\n stepFrame(e2.shiftKey ? 1 : 1e3 / 30);\n }\n });\n requestAnimationFrame(measureTrack);\n var boot = window.__SCRUBBER_BOOTSTRAP__;\n if (boot?.svg) void loadSvg(boot.svg, boot.name ?? \"animation\");\n})();\n";
|
|
3
|
+
export const SCRUBBER_CLIENT_JS = "\"use strict\";\n(() => {\n // node_modules/kerfjs/dist/chunk-4VT4YZOO.js\n function flatten(segment, withMarkers) {\n if (segment.kind === \"static\") return segment.html;\n if (segment.kind === \"list\") {\n const items = segment.items.map((i2) => i2.html).join(\"\");\n return withMarkers ? `<!--kf-list:${segment.id}-->${items}` : items;\n }\n return segment.parts.map((p2) => flatten(p2, withMarkers)).join(\"\");\n }\n function flattenWithoutListItems(segment) {\n if (segment.kind === \"static\") return segment.html;\n if (segment.kind === \"list\") return `<!--kf-list:${segment.id}-->`;\n return segment.parts.map(flattenWithoutListItems).join(\"\");\n }\n function collectLists(segment, out = /* @__PURE__ */ new Map()) {\n if (segment.kind === \"list\") out.set(segment.id, segment);\n else if (segment.kind === \"mixed\") {\n for (const part of segment.parts) collectLists(part, out);\n }\n return out;\n }\n function mergeChildSegments(parts) {\n if (parts.length === 0) return { kind: \"static\", html: \"\" };\n if (parts.every((p2) => p2.kind === \"static\")) {\n return {\n kind: \"static\",\n html: parts.map((p2) => p2.html).join(\"\")\n };\n }\n const merged = [];\n let coalesced = \"\";\n for (const p2 of parts) {\n if (p2.kind === \"static\") {\n coalesced += p2.html;\n } else {\n if (coalesced !== \"\") {\n merged.push({ kind: \"static\", html: coalesced });\n coalesced = \"\";\n }\n merged.push(p2);\n }\n }\n if (coalesced !== \"\") merged.push({ kind: \"static\", html: coalesced });\n return { kind: \"mixed\", parts: merged };\n }\n function wrapWithTags(child, openTag, closeTag) {\n if (child.kind === \"static\") {\n return { kind: \"static\", html: openTag + child.html + closeTag };\n }\n if (child.kind === \"mixed\") {\n return {\n kind: \"mixed\",\n parts: [\n { kind: \"static\", html: openTag },\n ...child.parts,\n { kind: \"static\", html: closeTag }\n ]\n };\n }\n return {\n kind: \"mixed\",\n parts: [\n { kind: \"static\", html: openTag },\n child,\n { kind: \"static\", html: closeTag }\n ]\n };\n }\n function escapeHtml(str) {\n return str.replace(/&/g, \"&\").replace(/</g, \"<\").replace(/>/g, \">\").replace(/\"/g, \""\");\n }\n function escapeAttr(str) {\n return str.replace(/&/g, \"&\").replace(/\"/g, \""\").replace(/'/g, \"'\").replace(/</g, \"<\").replace(/>/g, \">\");\n }\n var ATTR_ALIASES = {\n // HTML attributes\n className: \"class\",\n htmlFor: \"for\",\n httpEquiv: \"http-equiv\",\n acceptCharset: \"accept-charset\",\n accessKey: \"accesskey\",\n autoCapitalize: \"autocapitalize\",\n autoComplete: \"autocomplete\",\n autoFocus: \"autofocus\",\n autoPlay: \"autoplay\",\n colSpan: \"colspan\",\n contentEditable: \"contenteditable\",\n crossOrigin: \"crossorigin\",\n dateTime: \"datetime\",\n defaultChecked: \"checked\",\n defaultValue: \"value\",\n encType: \"enctype\",\n formAction: \"formaction\",\n formEncType: \"formenctype\",\n formMethod: \"formmethod\",\n formNoValidate: \"formnovalidate\",\n formTarget: \"formtarget\",\n hrefLang: \"hreflang\",\n inputMode: \"inputmode\",\n maxLength: \"maxlength\",\n minLength: \"minlength\",\n noModule: \"nomodule\",\n noValidate: \"novalidate\",\n readOnly: \"readonly\",\n referrerPolicy: \"referrerpolicy\",\n rowSpan: \"rowspan\",\n spellCheck: \"spellcheck\",\n srcDoc: \"srcdoc\",\n srcLang: \"srclang\",\n srcSet: \"srcset\",\n tabIndex: \"tabindex\",\n useMap: \"usemap\",\n // SVG presentation attributes (camelCase → kebab-case)\n strokeWidth: \"stroke-width\",\n strokeLinecap: \"stroke-linecap\",\n strokeLinejoin: \"stroke-linejoin\",\n strokeDasharray: \"stroke-dasharray\",\n strokeDashoffset: \"stroke-dashoffset\",\n strokeMiterlimit: \"stroke-miterlimit\",\n strokeOpacity: \"stroke-opacity\",\n fillOpacity: \"fill-opacity\",\n fillRule: \"fill-rule\",\n clipPath: \"clip-path\",\n clipRule: \"clip-rule\",\n colorInterpolation: \"color-interpolation\",\n colorInterpolationFilters: \"color-interpolation-filters\",\n floodColor: \"flood-color\",\n floodOpacity: \"flood-opacity\",\n lightingColor: \"lighting-color\",\n stopColor: \"stop-color\",\n stopOpacity: \"stop-opacity\",\n shapeRendering: \"shape-rendering\",\n imageRendering: \"image-rendering\",\n textRendering: \"text-rendering\",\n pointerEvents: \"pointer-events\",\n vectorEffect: \"vector-effect\",\n paintOrder: \"paint-order\",\n // SVG text/font attributes\n fontFamily: \"font-family\",\n fontSize: \"font-size\",\n fontStyle: \"font-style\",\n fontVariant: \"font-variant\",\n fontWeight: \"font-weight\",\n fontStretch: \"font-stretch\",\n textAnchor: \"text-anchor\",\n textDecoration: \"text-decoration\",\n dominantBaseline: \"dominant-baseline\",\n alignmentBaseline: \"alignment-baseline\",\n baselineShift: \"baseline-shift\",\n letterSpacing: \"letter-spacing\",\n wordSpacing: \"word-spacing\",\n writingMode: \"writing-mode\",\n // SVG marker attributes\n markerStart: \"marker-start\",\n markerMid: \"marker-mid\",\n markerEnd: \"marker-end\",\n // SVG xlink (legacy but still used)\n xlinkHref: \"xlink:href\",\n xlinkShow: \"xlink:show\",\n xlinkActuate: \"xlink:actuate\",\n xlinkType: \"xlink:type\",\n xlinkRole: \"xlink:role\",\n xlinkTitle: \"xlink:title\",\n xlinkArcrole: \"xlink:arcrole\",\n xmlBase: \"xml:base\",\n xmlLang: \"xml:lang\",\n xmlSpace: \"xml:space\",\n xmlnsXlink: \"xmlns:xlink\"\n };\n var SAFE_HTML_BRAND = /* @__PURE__ */ Symbol.for(\"kerfjs.SafeHtml\");\n var SafeHtml = class {\n __html;\n __segment;\n // Branded so `isSafeHtml()` recognizes instances from any copy of this module.\n [SAFE_HTML_BRAND] = true;\n constructor(input) {\n if (typeof input === \"string\") {\n this.__segment = { kind: \"static\", html: input };\n this.__html = input;\n } else {\n this.__segment = input;\n this.__html = flatten(input, false);\n }\n }\n toString() {\n return this.__html;\n }\n };\n function isSafeHtml(value) {\n return typeof value === \"object\" && value !== null && value[SAFE_HTML_BRAND] === true;\n }\n var VOID_TAGS = /* @__PURE__ */ new Set([\n \"area\",\n \"base\",\n \"br\",\n \"col\",\n \"embed\",\n \"hr\",\n \"img\",\n \"input\",\n \"link\",\n \"meta\",\n \"source\",\n \"track\",\n \"wbr\"\n ]);\n function toSegment(child) {\n if (child == null || typeof child === \"boolean\") return { kind: \"static\", html: \"\" };\n if (isSafeHtml(child)) {\n return child.__segment ?? { kind: \"static\", html: child.__html };\n }\n if (typeof child === \"string\") return { kind: \"static\", html: escapeHtml(child) };\n if (typeof child === \"number\") return { kind: \"static\", html: String(child) };\n if (Array.isArray(child)) return mergeChildSegments(child.map(toSegment));\n const maybeNode = child;\n if (typeof maybeNode === \"object\" && maybeNode !== null && (\"nodeType\" in maybeNode || \"outerHTML\" in maybeNode)) {\n throw new Error(\n \"JSX: DOM elements cannot be passed as children (the JSX runtime renders to HTML strings). Build the tree in one JSX expression and use querySelector after toElement() to get element refs.\"\n );\n }\n throw new Error(\n `JSX: unsupported child of type ${describeValue(child)}. Children must be SafeHtml, string, number, boolean, null, undefined, or an array of those. Common mistakes: passing a Signal/Store object directly (use signal.value or store.state.value), passing a function (call it first), or passing a Promise (await it before render).`\n );\n }\n function describeValue(v2) {\n if (Array.isArray(v2)) return \"array\";\n if (typeof v2 === \"object\" && v2 !== null) {\n const ctor = v2.constructor?.name;\n return ctor && ctor !== \"Object\" ? `object (${ctor})` : \"object\";\n }\n return typeof v2;\n }\n var URL_ATTRS = /* @__PURE__ */ new Set([\"href\", \"src\", \"xlink:href\", \"formaction\", \"action\"]);\n var DANGEROUS_URL_RE = /^\\s*(?:(?:java|vb)script:|data:text\\/html[;,])/i;\n function renderAttr(key, value) {\n const name = ATTR_ALIASES[key] ?? key;\n if (value == null || value === false) return \"\";\n if (value === true) return ` ${name}`;\n let strValue;\n if (isSafeHtml(value)) {\n strValue = value.__html;\n } else if (typeof value === \"number\") {\n strValue = String(value);\n } else if (typeof value === \"string\") {\n if (URL_ATTRS.has(name) && DANGEROUS_URL_RE.test(value)) {\n console.warn(\n `JSX: dropped dangerous URL value for ${name}=${JSON.stringify(value.slice(0, 80))}. kerf blocks javascript:, vbscript:, and data:text/html URLs in href/src/formaction/action/xlink:href by default. Wrap in raw() if this is intentional (e.g. bookmarklets), or sanitize upstream.`\n );\n return \"\";\n }\n strValue = escapeAttr(value);\n } else if (typeof value === \"function\" && /^on[A-Z]/.test(key)) {\n throw new Error(\n `JSX: inline event handlers like ${key}={fn} are not supported by kerf's JSX \\u2192 HTML-string runtime. Use event delegation from the mount root instead:\n\n delegate(rootEl, 'click', '[data-action=\"...\"]', (evt, target) => { ... });\n <button data-action=\"...\">click</button>\n\nSee docs/5-event-delegation.md for the tier-1/tier-2/tier-3 model.`\n );\n } else {\n throw new Error(\n `JSX: unsupported value for attribute \"${key}\" \\u2014 got ${describeValue(value)}. Attribute values must be string, number, boolean, null, undefined, or SafeHtml. Did you mean to read .value off a Signal, or stringify the object first?`\n );\n }\n return ` ${name}=\"${strValue}\"`;\n }\n function jsx(tag, props) {\n if (typeof tag === \"function\") return tag(props);\n const { children, ...attrs } = props;\n const attrStr = Object.entries(attrs).map(([k, v2]) => renderAttr(k, v2)).join(\"\");\n if (VOID_TAGS.has(tag)) return new SafeHtml(`<${tag}${attrStr}>`);\n const childSegment = children != null ? toSegment(children) : { kind: \"static\", html: \"\" };\n return new SafeHtml(wrapWithTags(childSegment, `<${tag}${attrStr}>`, `</${tag}>`));\n }\n function Fragment({ children }) {\n return new SafeHtml(children != null ? toSegment(children) : { kind: \"static\", html: \"\" });\n }\n\n // node_modules/@preact/signals-core/dist/signals-core.module.js\n var i = /* @__PURE__ */ Symbol.for(\"preact-signals\");\n function t() {\n if (!(s > 1)) {\n var i2, t2 = false;\n !(function() {\n var i3 = c;\n c = void 0;\n while (void 0 !== i3) {\n if (i3.S.v === i3.v) i3.S.i = i3.i;\n i3 = i3.o;\n }\n })();\n while (void 0 !== h) {\n var n2 = h;\n h = void 0;\n v++;\n while (void 0 !== n2) {\n var r2 = n2.u;\n n2.u = void 0;\n n2.f &= -3;\n if (!(8 & n2.f) && w(n2)) try {\n n2.c();\n } catch (n3) {\n if (!t2) {\n i2 = n3;\n t2 = true;\n }\n }\n n2 = r2;\n }\n }\n v = 0;\n s--;\n if (t2) throw i2;\n } else s--;\n }\n var r = void 0;\n function o(i2) {\n var t2 = r;\n r = void 0;\n try {\n return i2();\n } finally {\n r = t2;\n }\n }\n var f;\n var h = void 0;\n var s = 0;\n var v = 0;\n var e = 0;\n var c = void 0;\n var d = 0;\n function a(i2) {\n if (void 0 !== r) {\n var t2 = i2.n;\n if (void 0 === t2 || t2.t !== r) {\n t2 = { i: 0, S: i2, p: r.s, n: void 0, t: r, e: void 0, x: void 0, r: t2 };\n if (void 0 !== r.s) r.s.n = t2;\n r.s = t2;\n i2.n = t2;\n if (32 & r.f) i2.S(t2);\n return t2;\n } else if (-1 === t2.i) {\n t2.i = 0;\n if (void 0 !== t2.n) {\n t2.n.p = t2.p;\n if (void 0 !== t2.p) t2.p.n = t2.n;\n t2.p = r.s;\n t2.n = void 0;\n r.s.n = t2;\n r.s = t2;\n }\n return t2;\n }\n }\n }\n function l(i2, t2) {\n this.v = i2;\n this.i = 0;\n this.n = void 0;\n this.t = void 0;\n this.l = 0;\n this.W = null == t2 ? void 0 : t2.watched;\n this.Z = null == t2 ? void 0 : t2.unwatched;\n this.name = null == t2 ? void 0 : t2.name;\n }\n l.prototype.brand = i;\n l.prototype.h = function() {\n return true;\n };\n l.prototype.S = function(i2) {\n var t2 = this, n2 = this.t;\n if (n2 !== i2 && void 0 === i2.e) {\n i2.x = n2;\n this.t = i2;\n if (void 0 !== n2) n2.e = i2;\n else o(function() {\n var i3;\n null == (i3 = t2.W) || i3.call(t2);\n });\n }\n };\n l.prototype.U = function(i2) {\n var t2 = this;\n if (void 0 !== this.t) {\n var n2 = i2.e, r2 = i2.x;\n if (void 0 !== n2) {\n n2.x = r2;\n i2.e = void 0;\n }\n if (void 0 !== r2) {\n r2.e = n2;\n i2.x = void 0;\n }\n if (i2 === this.t) {\n this.t = r2;\n if (void 0 === r2) o(function() {\n var i3;\n null == (i3 = t2.Z) || i3.call(t2);\n });\n }\n }\n };\n l.prototype.subscribe = function(i2) {\n var t2 = this;\n return j(function() {\n var n2 = t2.value, o2 = r;\n r = void 0;\n try {\n i2(n2);\n } finally {\n r = o2;\n }\n }, { name: \"sub\" });\n };\n l.prototype.valueOf = function() {\n return this.value;\n };\n l.prototype.toString = function() {\n return this.value + \"\";\n };\n l.prototype.toJSON = function() {\n return this.value;\n };\n l.prototype.peek = function() {\n var i2 = r;\n r = void 0;\n try {\n return this.value;\n } finally {\n r = i2;\n }\n };\n Object.defineProperty(l.prototype, \"value\", { get: function() {\n var i2 = a(this);\n if (void 0 !== i2) i2.i = this.i;\n return this.v;\n }, set: function(i2) {\n if (i2 !== this.v) {\n if (v > 100) throw new Error(\"Cycle detected\");\n !(function(i3) {\n if (0 !== s && 0 === v) {\n if (i3.l !== e) {\n i3.l = e;\n c = { S: i3, v: i3.v, i: i3.i, o: c };\n }\n }\n })(this);\n this.v = i2;\n this.i++;\n d++;\n s++;\n try {\n for (var n2 = this.t; void 0 !== n2; n2 = n2.x) n2.t.N();\n } finally {\n t();\n }\n }\n } });\n function y(i2, t2) {\n return new l(i2, t2);\n }\n function w(i2) {\n for (var t2 = i2.s; void 0 !== t2; t2 = t2.n) if (t2.S.i !== t2.i || !t2.S.h() || t2.S.i !== t2.i) return true;\n return false;\n }\n function _(i2) {\n for (var t2 = i2.s; void 0 !== t2; t2 = t2.n) {\n var n2 = t2.S.n;\n if (void 0 !== n2) t2.r = n2;\n t2.S.n = t2;\n t2.i = -1;\n if (void 0 === t2.n) {\n i2.s = t2;\n break;\n }\n }\n }\n function b(i2) {\n var t2 = i2.s, n2 = void 0;\n while (void 0 !== t2) {\n var r2 = t2.p;\n if (-1 === t2.i) {\n t2.S.U(t2);\n if (void 0 !== r2) r2.n = t2.n;\n if (void 0 !== t2.n) t2.n.p = r2;\n } else n2 = t2;\n t2.S.n = t2.r;\n if (void 0 !== t2.r) t2.r = void 0;\n t2 = r2;\n }\n i2.s = n2;\n }\n function p(i2, t2) {\n l.call(this, void 0);\n this.x = i2;\n this.s = void 0;\n this.g = d - 1;\n this.f = 4;\n this.W = null == t2 ? void 0 : t2.watched;\n this.Z = null == t2 ? void 0 : t2.unwatched;\n this.name = null == t2 ? void 0 : t2.name;\n }\n p.prototype = new l();\n p.prototype.h = function() {\n this.f &= -3;\n if (1 & this.f) return false;\n if (32 == (36 & this.f)) return true;\n this.f &= -5;\n if (this.g === d) return true;\n this.g = d;\n this.f |= 1;\n if (this.i > 0 && !w(this)) {\n this.f &= -2;\n return true;\n }\n var i2 = r;\n try {\n _(this);\n r = this;\n var t2 = this.x();\n if (16 & this.f || this.v !== t2 || 0 === this.i) {\n this.v = t2;\n this.f &= -17;\n this.i++;\n }\n } catch (i3) {\n this.v = i3;\n this.f |= 16;\n this.i++;\n }\n r = i2;\n b(this);\n this.f &= -2;\n return true;\n };\n p.prototype.S = function(i2) {\n if (void 0 === this.t) {\n this.f |= 36;\n for (var t2 = this.s; void 0 !== t2; t2 = t2.n) t2.S.S(t2);\n }\n l.prototype.S.call(this, i2);\n };\n p.prototype.U = function(i2) {\n if (void 0 !== this.t) {\n l.prototype.U.call(this, i2);\n if (void 0 === this.t) {\n this.f &= -33;\n for (var t2 = this.s; void 0 !== t2; t2 = t2.n) t2.S.U(t2);\n }\n }\n };\n p.prototype.N = function() {\n if (!(2 & this.f)) {\n this.f |= 6;\n for (var i2 = this.t; void 0 !== i2; i2 = i2.x) i2.t.N();\n }\n };\n Object.defineProperty(p.prototype, \"value\", { get: function() {\n if (1 & this.f) throw new Error(\"Cycle detected\");\n var i2 = a(this);\n this.h();\n if (void 0 !== i2) i2.i = this.i;\n if (16 & this.f) throw this.v;\n return this.v;\n } });\n function S(i2) {\n var n2 = i2.m;\n i2.m = void 0;\n if (\"function\" == typeof n2) {\n s++;\n var o2 = r;\n r = void 0;\n try {\n n2();\n } catch (t2) {\n i2.f &= -2;\n i2.f |= 8;\n m(i2);\n throw t2;\n } finally {\n r = o2;\n t();\n }\n }\n }\n function m(i2) {\n for (var t2 = i2.s; void 0 !== t2; t2 = t2.n) t2.S.U(t2);\n i2.x = void 0;\n i2.s = void 0;\n S(i2);\n }\n function x(i2) {\n if (r !== this) throw new Error(\"Out-of-order effect\");\n b(this);\n r = i2;\n this.f &= -2;\n if (8 & this.f) m(this);\n t();\n }\n function E(i2, t2) {\n this.x = i2;\n this.m = void 0;\n this.s = void 0;\n this.u = void 0;\n this.f = 32;\n this.name = null == t2 ? void 0 : t2.name;\n if (f) f.push(this);\n }\n E.prototype.c = function() {\n var i2 = this.S();\n try {\n if (8 & this.f) return;\n if (void 0 === this.x) return;\n var t2 = this.x();\n if (\"function\" == typeof t2) this.m = t2;\n } finally {\n i2();\n }\n };\n E.prototype.S = function() {\n if (1 & this.f) throw new Error(\"Cycle detected\");\n this.f |= 1;\n this.f &= -9;\n S(this);\n _(this);\n s++;\n var i2 = r;\n r = this;\n return x.bind(this, i2);\n };\n E.prototype.N = function() {\n if (!(2 & this.f)) {\n this.f |= 2;\n this.u = h;\n h = this;\n }\n };\n E.prototype.d = function() {\n this.f |= 8;\n if (!(1 & this.f)) m(this);\n };\n E.prototype.dispose = function() {\n this.d();\n };\n function j(i2, t2) {\n var n2 = new E(i2, t2);\n try {\n n2.c();\n } catch (i3) {\n n2.d();\n throw i3;\n }\n var r2 = n2.d.bind(n2);\n r2[Symbol.dispose] = r2;\n return r2;\n }\n\n // node_modules/kerfjs/dist/chunk-N4KF3GD2.js\n var depth = 0;\n var warned = false;\n function isOptedIn() {\n const proc = globalThis.process;\n if (proc?.env?.NODE_ENV === \"production\") return false;\n return proc?.env?.KERF_DEV_WARN_DELEGATE_IN_EFFECT === \"1\";\n }\n function enterEffect() {\n depth++;\n }\n function exitEffect() {\n depth--;\n }\n function isDevWarnDelegateInEffectEnabled() {\n return isOptedIn();\n }\n function warnIfInsideEffect(fn) {\n if (!isOptedIn()) return;\n if (depth === 0) return;\n if (warned) return;\n warned = true;\n console.warn(\n `kerf: ${fn}() was called inside an effect() body. Every effect re-run installs a fresh root listener; the effect disposer cleans up the reactive subscription but not the listeners, so listener count grows linearly with signal churn and each listener pins its handler closure. Register the delegate once at module or setup scope and gate behavior on the signal *inside the handler* where the read is free. See docs/5-event-delegation.md \\xA75.3 \"When capturing the disposer still isn't enough\". Set KERF_DEV_WARN_DELEGATE_IN_EFFECT=0 (or unset it) to silence this warning.`\n );\n }\n var WARNING_MESSAGE = \"kerf: signal was written but has no subscribers. Did you read `.value` outside of a render fn / effect()? Hoisted reads do not subscribe, so subsequent writes will not re-render. Move the read inside mount()'s render fn or effect() callback. Set KERF_DEV_WARN_UNTRACKED_SIGNALS=0 (or unset it) to silence this warning.\";\n var DevSignal = class extends l {\n __hasSubscriber = false;\n __warned = false;\n __constructed = false;\n constructor(initial) {\n super(initial, {\n watched() {\n this.__hasSubscriber = true;\n }\n });\n this.__constructed = true;\n }\n get value() {\n return super.value;\n }\n set value(v2) {\n super.value = v2;\n if (this.__constructed && !this.__hasSubscriber && !this.__warned) {\n this.__warned = true;\n console.warn(WARNING_MESSAGE);\n }\n }\n };\n function isDevWarnUntrackedEnabled() {\n const proc = globalThis.process;\n if (proc?.env?.NODE_ENV === \"production\") return false;\n return proc?.env?.KERF_DEV_WARN_UNTRACKED_SIGNALS === \"1\";\n }\n function signal(value) {\n if (isDevWarnUntrackedEnabled()) return new DevSignal(value);\n return y(value);\n }\n function effect(fn) {\n if (!isDevWarnDelegateInEffectEnabled()) return j(fn);\n return j(() => {\n enterEffect();\n try {\n return fn();\n } finally {\n exitEffect();\n }\n });\n }\n\n // node_modules/kerfjs/dist/index.js\n var NON_BUBBLING = /* @__PURE__ */ new Set([\n \"focus\",\n \"blur\",\n \"scroll\",\n \"load\",\n \"error\",\n \"mouseenter\",\n \"mouseleave\"\n ]);\n function assertValidSelector(selector, fn) {\n try {\n document.createElement(\"div\").matches(selector);\n } catch {\n throw new Error(\n `${fn}: invalid selector \"${selector}\". Pass a valid CSS selector (e.g. '[data-action=\"add\"]', '.btn', 'input').`\n );\n }\n }\n function delegate(rootEl, type, selector, handler) {\n assertValidSelector(selector, \"delegate\");\n warnIfInsideEffect(\"delegate\");\n const listener = (event) => {\n const target = event.target;\n if (!(target instanceof Element)) return;\n const matched = target.closest(selector);\n if (matched !== null && rootEl.contains(matched)) {\n handler(event, matched);\n }\n };\n const capture = NON_BUBBLING.has(type);\n rootEl.addEventListener(type, listener, capture);\n return () => {\n rootEl.removeEventListener(type, listener, capture);\n };\n }\n var warnedIds = /* @__PURE__ */ new Set();\n function isOptedIn2() {\n const proc = globalThis.process;\n if (proc?.env?.NODE_ENV === \"production\") return false;\n return proc?.env?.KERF_DEV_WARN_EACH_IN_MORPH_SKIP === \"1\";\n }\n function hasMorphSkipAncestor(el, root) {\n let ancestor = el.parentElement;\n while (ancestor !== null && ancestor !== root) {\n if (ancestor.dataset.morphSkip !== void 0) return true;\n ancestor = ancestor.parentElement;\n }\n return false;\n }\n function maybeWarnEachInMorphSkip(id, liveParent, rootEl) {\n if (!isOptedIn2()) return;\n if (warnedIds.has(id)) return;\n if (!hasMorphSkipAncestor(liveParent, rootEl)) return;\n warnedIds.add(id);\n console.warn(\n `kerf: each() list '${id}' is inside a data-morph-skip subtree. The keyed reconciler still updates the list rows, but any static signal-reactive JSX inside the same skipped ancestor (e.g. <p>{count.value}</p>) is frozen \\u2014 the morph never visits it. Remove data-morph-skip from any element that contains reactive JSX content and reserve it for truly library-owned hosts. Set KERF_DEV_WARN_EACH_IN_MORPH_SKIP=0 (or unset it) to silence this warning.`\n );\n }\n var context = null;\n function _setRenderContext(c2) {\n context = c2;\n }\n var ID_KEY_PREFIX = \"id:\";\n var DATA_KEY_PREFIX = \"data-key:\";\n var ELEMENT_NODE = 1;\n var TEXT_NODE = 3;\n var COMMENT_NODE = 8;\n function getNodeKey(node) {\n if (node.nodeType !== ELEMENT_NODE) return void 0;\n const el = node;\n if (el.id !== \"\") return `${ID_KEY_PREFIX}${el.id}`;\n if (el.dataset !== void 0 && el.dataset.key !== void 0) {\n return `${DATA_KEY_PREFIX}${el.dataset.key}`;\n }\n return void 0;\n }\n var EMPTY_OWNED = /* @__PURE__ */ new Set();\n function morph(liveRoot, template, ownedItems = EMPTY_OWNED) {\n const templateEl = isElementNode(template) ? template : parseTemplate(liveRoot, template);\n morphChildren(liveRoot, templateEl, ownedItems);\n }\n function _morphElement(fromEl, toEl, ownedItems = EMPTY_OWNED) {\n morphElement(fromEl, toEl, ownedItems);\n }\n function isElementNode(t2) {\n return typeof t2 === \"object\" && t2 !== null && t2.nodeType === ELEMENT_NODE;\n }\n function parseTemplate(liveRoot, template) {\n const el = liveRoot.cloneNode(false);\n el.innerHTML = String(template);\n return el;\n }\n function skipOwned(node, ownedItems) {\n while (node !== null && node.nodeType === ELEMENT_NODE && ownedItems.has(node)) {\n node = node.nextSibling;\n }\n return node;\n }\n function morphChildren(fromParent, toParent, ownedItems) {\n const keyed = /* @__PURE__ */ new Map();\n for (let c2 = fromParent.firstChild; c2 !== null; c2 = c2.nextSibling) {\n if (c2.nodeType === ELEMENT_NODE && ownedItems.has(c2)) continue;\n const k = getNodeKey(c2);\n if (k !== void 0) keyed.set(k, c2);\n }\n let fromChild = skipOwned(fromParent.firstChild, ownedItems);\n let toChild = toParent.firstChild;\n while (toChild !== null) {\n const toNext = toChild.nextSibling;\n let matched = null;\n const toKey = getNodeKey(toChild);\n if (toKey !== void 0 && keyed.has(toKey)) {\n matched = keyed.get(toKey);\n keyed.delete(toKey);\n if (matched !== fromChild) {\n fromParent.insertBefore(matched, fromChild);\n } else {\n fromChild = skipOwned(fromChild.nextSibling, ownedItems);\n }\n }\n if (matched === null && fromChild !== null && fromChild.nodeType === toChild.nodeType && (toChild.nodeType !== ELEMENT_NODE || fromChild.tagName === toChild.tagName && getNodeKey(fromChild) === void 0)) {\n matched = fromChild;\n fromChild = skipOwned(fromChild.nextSibling, ownedItems);\n }\n if (matched !== null) {\n morphNode(matched, toChild, ownedItems);\n } else {\n const cloned = toChild.cloneNode(true);\n fromParent.insertBefore(cloned, fromChild);\n }\n toChild = toNext;\n }\n while (fromChild !== null) {\n const next = fromChild.nextSibling;\n if (fromChild.nodeType === ELEMENT_NODE) {\n const el = fromChild;\n if (!ownedItems.has(el) && el.dataset.morphPreserve === void 0) {\n fromParent.removeChild(fromChild);\n }\n } else {\n fromParent.removeChild(fromChild);\n }\n fromChild = next;\n }\n }\n function morphNode(fromNode, toNode, ownedItems) {\n if (fromNode.nodeType === ELEMENT_NODE) {\n morphElement(fromNode, toNode, ownedItems);\n return;\n }\n if (fromNode.nodeType === TEXT_NODE || fromNode.nodeType === COMMENT_NODE) {\n const fromText = fromNode;\n const toText = toNode;\n if (fromText.data !== toText.data) fromText.data = toText.data;\n }\n }\n function morphElement(fromEl, toEl, ownedItems) {\n if (fromEl.tagName !== toEl.tagName) {\n const replacement = toEl.cloneNode(true);\n fromEl.parentNode?.replaceChild(replacement, fromEl);\n return;\n }\n if (fromEl.dataset.morphSkip !== void 0) return;\n if (fromEl.isEqualNode(toEl)) return;\n if (fromEl === document.activeElement) {\n const ce = fromEl.getAttribute(\"contenteditable\");\n if (ce !== null && ce.toLowerCase() !== \"false\") return;\n if (isTextInputOrTextarea(fromEl)) preserveTextEntryState(fromEl, toEl);\n }\n morphAttributes(fromEl, toEl);\n if (fromEl.dataset.morphSkipChildren !== void 0) return;\n morphChildren(fromEl, toEl, ownedItems);\n }\n function isUserAgentOwnedAttr(tagName, name) {\n return name === \"open\" && (tagName === \"DETAILS\" || tagName === \"DIALOG\");\n }\n function morphAttributes(fromEl, toEl) {\n const toAttrs = toEl.attributes;\n for (let i2 = 0; i2 < toAttrs.length; i2++) {\n const attr2 = toAttrs[i2];\n const ns = attr2.namespaceURI;\n const name = attr2.localName;\n const value = attr2.value;\n if (ns !== null) {\n if (fromEl.getAttributeNS(ns, name) !== value) {\n fromEl.setAttributeNS(ns, attr2.name, value);\n }\n } else if (fromEl.getAttribute(name) !== value) {\n fromEl.setAttribute(name, value);\n }\n }\n const fromAttrs = fromEl.attributes;\n const fromTag = fromEl.tagName;\n for (let i2 = fromAttrs.length - 1; i2 >= 0; i2--) {\n const attr2 = fromAttrs[i2];\n const ns = attr2.namespaceURI;\n const name = attr2.localName;\n if (ns !== null) {\n if (!toEl.hasAttributeNS(ns, name)) fromEl.removeAttributeNS(ns, name);\n } else if (!toEl.hasAttribute(name) && !isUserAgentOwnedAttr(fromTag, name)) {\n fromEl.removeAttribute(name);\n }\n }\n }\n function isTextInputOrTextarea(el) {\n if (el.tagName === \"TEXTAREA\") return true;\n if (el.tagName === \"INPUT\") {\n const type = el.type;\n return type === \"text\" || type === \"search\" || type === \"url\" || type === \"email\" || type === \"tel\" || type === \"password\" || type === \"\";\n }\n return false;\n }\n function preserveTextEntryState(fromEl, toEl) {\n if (fromEl.tagName === \"TEXTAREA\" || fromEl.tagName === \"INPUT\") {\n const fromInput = fromEl;\n const toInput = toEl;\n toInput.value = fromInput.value;\n try {\n toInput.setSelectionRange(fromInput.selectionStart, fromInput.selectionEnd);\n } catch {\n }\n }\n }\n var LISTENER_MARKER = /* @__PURE__ */ Symbol.for(\"kerfjs.devListener\");\n var patched = false;\n var warned2 = false;\n function isOptedIn22() {\n const proc = globalThis.process;\n if (proc?.env?.NODE_ENV === \"production\") return false;\n return proc?.env?.KERF_DEV_WARN_REBUILT_LISTENERS === \"1\";\n }\n function findAddEventListenerProto() {\n const probe = document.createElement(\"div\");\n let proto = Object.getPrototypeOf(probe);\n while (!Object.prototype.hasOwnProperty.call(proto, \"addEventListener\")) {\n proto = Object.getPrototypeOf(proto);\n }\n return proto;\n }\n function patchAddEventListenerOnce() {\n if (patched) return;\n patched = true;\n const proto = findAddEventListenerProto();\n const orig = proto.addEventListener;\n proto.addEventListener = function(type, listener, options) {\n if (this instanceof Element) {\n this[LISTENER_MARKER] = true;\n }\n return orig.call(this, type, listener, options);\n };\n }\n function hasMarkedListener(el) {\n if (el[LISTENER_MARKER] === true) return true;\n const stack = [];\n for (let i2 = 0; i2 < el.children.length; i2++) stack.push(el.children[i2]);\n while (stack.length > 0) {\n const cur = stack.pop();\n if (cur[LISTENER_MARKER] === true) return true;\n for (let i2 = 0; i2 < cur.children.length; i2++) stack.push(cur.children[i2]);\n }\n return false;\n }\n function emitWarning() {\n if (warned2) return;\n warned2 = true;\n console.warn(\n \"kerf: a node inside a mount()-managed tree was removed/rebuilt while carrying an imperative addEventListener listener. The listener is gone with the old node. Use `delegate(rootEl, 'click', '[data-action=\\\"...\\\"]', handler)` so the listener lives on a stable ancestor and survives re-renders, or wrap the host in `data-morph-skip` if the subtree is library-owned (Monaco, xterm, D3 charts). Set KERF_DEV_WARN_REBUILT_LISTENERS=0 (or unset it) to silence this warning.\"\n );\n }\n function installListenerRebuildWarn(rootEl) {\n if (!isOptedIn22()) return null;\n patchAddEventListenerOnce();\n const observer = new MutationObserver((mutations) => {\n if (warned2) return;\n for (const m2 of mutations) {\n for (let i2 = 0; i2 < m2.removedNodes.length; i2++) {\n const removed = m2.removedNodes[i2];\n if (!(removed instanceof Element)) continue;\n if (hasMarkedListener(removed)) {\n emitWarning();\n return;\n }\n }\n }\n });\n observer.observe(rootEl, { childList: true, subtree: true });\n return observer;\n }\n function endAnchor(binding) {\n if (binding.items.length > 0) {\n return binding.items[binding.items.length - 1].node.nextElementSibling;\n }\n return binding.marker.nextElementSibling;\n }\n var LT = 60;\n var GT = 62;\n var DQUOTE = 34;\n var SQUOTE = 39;\n var AMP = 38;\n var EQ = 61;\n var SLASH = 47;\n var TEXT_NODE2 = 3;\n var ELEMENT_NODE2 = 1;\n function isWhitespace(cc) {\n return cc === 32 || cc === 9 || cc === 10 || cc === 13;\n }\n function tryAttributeOnlyFastPath(liveNode, oldHtml, newHtml) {\n const oldGt = oldHtml.indexOf(\">\");\n const newGt = newHtml.indexOf(\">\");\n if (oldGt === -1 || newGt === -1) return false;\n if (oldHtml.length - oldGt !== newHtml.length - newGt) return false;\n if (oldHtml.slice(oldGt) !== newHtml.slice(newGt)) return false;\n if (containsDataMorphSkip(oldHtml) || containsDataMorphSkip(newHtml)) return false;\n const oldTag = parseOpeningTag(oldHtml, oldGt);\n const newTag = parseOpeningTag(newHtml, newGt);\n if (oldTag === null || newTag === null) return false;\n if (oldTag.tagName !== newTag.tagName) return false;\n for (const name of oldTag.attrs.keys()) {\n if (name.indexOf(\":\") !== -1) return false;\n }\n for (const name of newTag.attrs.keys()) {\n if (name.indexOf(\":\") !== -1) return false;\n }\n const liveTagUpper = liveNode.tagName;\n for (const [name, rawValue] of newTag.attrs) {\n const oldValue = oldTag.attrs.get(name);\n if (oldValue === rawValue) continue;\n liveNode.setAttribute(name, unescapeAttrValue(rawValue));\n }\n for (const name of oldTag.attrs.keys()) {\n if (newTag.attrs.has(name)) continue;\n if (isUserAgentOwnedAttr2(liveTagUpper, name)) continue;\n liveNode.removeAttribute(name);\n }\n return true;\n }\n function tryTextContentFastPath(liveNode, oldHtml, newHtml) {\n if (containsDataMorphSkip(oldHtml) || containsDataMorphSkip(newHtml)) return false;\n let p2 = 0;\n const minLen = Math.min(oldHtml.length, newHtml.length);\n while (p2 < minLen && oldHtml.charCodeAt(p2) === newHtml.charCodeAt(p2)) p2++;\n let s2 = 0;\n const maxS = minLen - p2;\n while (s2 < maxS && oldHtml.charCodeAt(oldHtml.length - 1 - s2) === newHtml.charCodeAt(newHtml.length - 1 - s2)) {\n s2++;\n }\n const oldWinEnd = oldHtml.length - s2;\n const newWinEnd = newHtml.length - s2;\n if (!isPureTextWindow(oldHtml, p2, oldWinEnd)) return false;\n if (!isPureTextWindow(newHtml, p2, newWinEnd)) return false;\n if (p2 === 0) return false;\n const boundaryCc = oldHtml.charCodeAt(p2 - 1);\n if (boundaryCc === LT || boundaryCc === DQUOTE || boundaryCc === SQUOTE || boundaryCc === EQ || boundaryCc === AMP) return false;\n const textStart = lastIndexOfChar(oldHtml, GT, p2 - 1);\n if (textStart === -1) return false;\n const textEnd = oldHtml.indexOf(\"<\", p2);\n if (textEnd === -1) return false;\n if (textEnd < oldWinEnd) return false;\n const newTextEnd = textEnd + (newHtml.length - oldHtml.length);\n const oldText = oldHtml.slice(textStart + 1, textEnd);\n const newText = newHtml.slice(textStart + 1, newTextEnd);\n const textIdx = countTextNodesBefore(oldHtml, textStart + 1);\n const targetNode = nthTextNodeDescendant(liveNode, textIdx);\n if (targetNode === null) return false;\n if (targetNode.nodeValue !== oldText) return false;\n targetNode.nodeValue = newText;\n return true;\n }\n function containsDataMorphSkip(html) {\n return html.indexOf(\"data-morph-skip\") !== -1;\n }\n function isPureTextWindow(html, start, end) {\n for (let i2 = start; i2 < end; i2++) {\n const cc = html.charCodeAt(i2);\n if (cc === LT || cc === GT || cc === DQUOTE || cc === SQUOTE || cc === AMP || cc === EQ) return false;\n }\n return true;\n }\n function lastIndexOfChar(html, target, beforeInclusive) {\n for (let i2 = beforeInclusive; i2 >= 0; i2--) {\n if (html.charCodeAt(i2) === target) return i2;\n }\n return -1;\n }\n function countTextNodesBefore(html, beforePos) {\n let count = 0;\n let i2 = 0;\n while (i2 < beforePos) {\n if (html.charCodeAt(i2) === LT) {\n while (i2 < beforePos && html.charCodeAt(i2) !== GT) i2++;\n i2++;\n } else {\n const start = i2;\n while (i2 < beforePos && html.charCodeAt(i2) !== LT) i2++;\n if (i2 > start) count++;\n }\n }\n return count;\n }\n function nthTextNodeDescendant(root, n2) {\n let count = 0;\n let result = null;\n function walk(node) {\n for (let c2 = node.firstChild; c2 !== null; c2 = c2.nextSibling) {\n if (result !== null) return;\n if (c2.nodeType === TEXT_NODE2) {\n if (count === n2) {\n result = c2;\n return;\n }\n count++;\n } else if (c2.nodeType === ELEMENT_NODE2) {\n walk(c2);\n }\n }\n }\n walk(root);\n return result;\n }\n function parseOpeningTag(html, gtPos) {\n if (html.charCodeAt(0) !== LT) return null;\n let i2 = 1;\n let end = gtPos;\n if (i2 < end && html.charCodeAt(end - 1) === SLASH) end -= 1;\n const nameStart = i2;\n while (i2 < end) {\n const cc = html.charCodeAt(i2);\n if (isWhitespace(cc)) break;\n i2++;\n }\n const tagName = html.slice(nameStart, i2);\n if (tagName.length === 0) return null;\n const attrs = /* @__PURE__ */ new Map();\n while (i2 < end) {\n while (i2 < end && isWhitespace(html.charCodeAt(i2))) i2++;\n if (i2 >= end) break;\n const aNameStart = i2;\n while (i2 < end) {\n const cc = html.charCodeAt(i2);\n if (cc === EQ || isWhitespace(cc)) break;\n i2++;\n }\n const aName = html.slice(aNameStart, i2);\n if (aName.length === 0) return null;\n while (i2 < end && isWhitespace(html.charCodeAt(i2))) i2++;\n if (i2 < end && html.charCodeAt(i2) === EQ) {\n i2++;\n while (i2 < end && isWhitespace(html.charCodeAt(i2))) i2++;\n if (i2 >= end) return null;\n const q = html.charCodeAt(i2);\n if (q !== DQUOTE && q !== SQUOTE) return null;\n i2++;\n const vStart = i2;\n while (i2 < end && html.charCodeAt(i2) !== q) i2++;\n if (i2 >= end) return null;\n attrs.set(aName, html.slice(vStart, i2));\n i2++;\n } else {\n attrs.set(aName, \"\");\n }\n }\n return { tagName, attrs };\n }\n function unescapeAttrValue(s2) {\n if (s2.indexOf(\"&\") === -1) return s2;\n return s2.replace(/"/g, '\"').replace(/'/g, \"'\").replace(/</g, \"<\").replace(/>/g, \">\").replace(/&/g, \"&\");\n }\n function isUserAgentOwnedAttr2(tagNameUpper, name) {\n return name === \"open\" && (tagNameUpper === \"DETAILS\" || tagNameUpper === \"DIALOG\");\n }\n function captureFocus(liveParent) {\n const active = document.activeElement;\n if (active === null || active === document.body) return null;\n if (!liveParent.contains(active)) return null;\n const el = active;\n let selStart = null;\n let selEnd = null;\n if (el.tagName === \"INPUT\" || el.tagName === \"TEXTAREA\") {\n try {\n selStart = el.selectionStart;\n selEnd = el.selectionEnd;\n } catch {\n }\n }\n return { el, selStart, selEnd };\n }\n function restoreFocus(snap) {\n if (document.activeElement === snap.el) return;\n if (!snap.el.isConnected) return;\n snap.el.focus();\n if (snap.selStart !== null && snap.selEnd !== null) {\n try {\n snap.el.setSelectionRange(snap.selStart, snap.selEnd);\n } catch {\n }\n }\n }\n var ROW_HTML_SNIPPET_MAX = 120;\n function truncateRowHtml(html) {\n return html.length > ROW_HTML_SNIPPET_MAX ? html.slice(0, ROW_HTML_SNIPPET_MAX) + \"\\u2026\" : html;\n }\n function parseRowTemplate(html) {\n const tpl = document.createElement(\"template\");\n tpl.innerHTML = html;\n return { tpl, count: tpl.content.children.length };\n }\n function rowContractError(index, html) {\n const { count } = parseRowTemplate(html);\n const reason = count === 0 ? \"produced no top-level element\" : `produced ${count} top-level elements; exactly one is required`;\n return new Error(\n `each(): row render at index ${index} ${reason}. Each item's render must return exactly one element \\u2014 wrap multiple roots in a single parent (e.g. <li>...</li>). Got HTML: ${JSON.stringify(truncateRowHtml(html))}`\n );\n }\n function isDevMode() {\n const proc = globalThis.process;\n return proc?.env?.NODE_ENV !== \"production\";\n }\n function maybeWarnMissingRowKey(rowEl, rowIndex, rowHtml, binding) {\n if (!isDevMode()) return;\n if (binding.warnedMissingKey === true) return;\n binding.warnedMissingKey = true;\n if (rowEl.id !== \"\" || rowEl.hasAttribute(\"data-key\")) return;\n console.warn(\n `kerf each(): row at index ${rowIndex} has no \\`id\\` or \\`data-key\\` attribute. Without one, rows match positionally \\u2014 an insert/remove at the head shifts every row's identity, so focused inputs jump to the wrong row, mid-edit textareas swap content with their neighbor, and any per-row state silently follows the wrong item. Add \\`data-key={item.id}\\` (or set \\`id\\`) to the top-level element returned by the row render. Row HTML: ${JSON.stringify(truncateRowHtml(rowHtml))}`\n );\n }\n function reconcileGranular(binding, patches) {\n const { liveParent } = binding;\n const items = binding.items;\n const focusSnap = captureFocus(liveParent);\n let i2 = 0;\n while (i2 < patches.length) {\n const patch = patches[i2];\n if (patch.type === \"replace\") {\n i2 += 1;\n continue;\n }\n if (patch.type === \"update\") {\n let runEnd = i2 + 1;\n while (runEnd < patches.length && patches[runEnd].type === \"update\") {\n runEnd += 1;\n }\n const runLen = runEnd - i2;\n if (runLen === 1) {\n applySingleUpdate(liveParent, items, patch);\n } else {\n applyBulkUpdate(liveParent, items, patches, i2, runEnd);\n }\n i2 = runEnd;\n continue;\n }\n if (patch.type === \"insert\") {\n let runEnd = i2 + 1;\n while (runEnd < patches.length && patches[runEnd].type === \"insert\" && patches[runEnd].index === patches[runEnd - 1].index + 1) {\n runEnd += 1;\n }\n const runLen = runEnd - i2;\n if (runLen === 1) {\n applySingleInsert(liveParent, items, patch, endAnchor(binding));\n } else {\n applyBulkInsert(liveParent, items, patches, i2, runEnd, endAnchor(binding));\n }\n i2 = runEnd;\n continue;\n }\n if (patch.type === \"remove\") {\n const entry = items[patch.index];\n liveParent.removeChild(entry.node);\n items.splice(patch.index, 1);\n i2 += 1;\n continue;\n }\n if (patch.type === \"move\") {\n const moved = items[patch.from];\n let anchorIdx = patch.to;\n if (patch.from < patch.to) anchorIdx += 1;\n const anchor = anchorIdx < items.length ? items[anchorIdx].node : endAnchor(binding);\n liveParent.insertBefore(moved.node, anchor);\n items.splice(patch.from, 1);\n items.splice(patch.to, 0, moved);\n i2 += 1;\n continue;\n }\n }\n if (focusSnap !== null) restoreFocus(focusSnap);\n if (items.length > 0) {\n maybeWarnMissingRowKey(items[0].node, 0, items[0].html, binding);\n }\n }\n function applySingleInsert(liveParent, items, patch, tailAnchor) {\n const { html } = patch;\n const newNode = parseSingleRow(html);\n const anchor = patch.index < items.length ? items[patch.index].node : tailAnchor;\n liveParent.insertBefore(newNode, anchor);\n items.splice(patch.index, 0, {\n ref: patch.item,\n cacheKey: void 0,\n html,\n node: newNode\n });\n }\n function applySingleUpdate(liveParent, items, patch) {\n const { html } = patch;\n const oldEntry = items[patch.index];\n if (html === oldEntry.html) return;\n if (tryAttributeOnlyFastPath(oldEntry.node, oldEntry.html, html) || tryTextContentFastPath(oldEntry.node, oldEntry.html, html)) {\n items[patch.index] = { ref: patch.item, cacheKey: void 0, html, node: oldEntry.node };\n return;\n }\n const newNode = parseSingleRow(html);\n if (oldEntry.node.tagName === newNode.tagName) {\n _morphElement(oldEntry.node, newNode);\n items[patch.index] = { ref: patch.item, cacheKey: void 0, html, node: oldEntry.node };\n } else {\n liveParent.replaceChild(newNode, oldEntry.node);\n items[patch.index] = { ref: patch.item, cacheKey: void 0, html, node: newNode };\n }\n }\n function applyBulkUpdate(liveParent, items, patches, start, end) {\n const morphChanges = [];\n for (let k = start; k < end; k++) {\n const p2 = patches[k];\n const oldEntry = items[p2.index];\n if (p2.html === oldEntry.html) continue;\n if (tryAttributeOnlyFastPath(oldEntry.node, oldEntry.html, p2.html) || tryTextContentFastPath(oldEntry.node, oldEntry.html, p2.html)) {\n items[p2.index] = { ref: p2.item, cacheKey: void 0, html: p2.html, node: oldEntry.node };\n continue;\n }\n morphChanges.push({ patchIdx: k, html: p2.html });\n }\n if (morphChanges.length === 0) return;\n const { tpl, count } = parseRowTemplate(morphChanges.map((c2) => c2.html).join(\"\"));\n if (count !== morphChanges.length) {\n throw findOffendingChange(patches, morphChanges);\n }\n const newNodes = new Array(morphChanges.length);\n let child = tpl.content.firstElementChild;\n for (let k = 0; k < newNodes.length; k++) {\n newNodes[k] = child;\n child = child.nextElementSibling;\n }\n for (let k = 0; k < morphChanges.length; k++) {\n const c2 = morphChanges[k];\n const p2 = patches[c2.patchIdx];\n const oldEntry = items[p2.index];\n if (oldEntry.node.tagName === newNodes[k].tagName) {\n _morphElement(oldEntry.node, newNodes[k]);\n items[p2.index] = { ref: p2.item, cacheKey: void 0, html: c2.html, node: oldEntry.node };\n } else {\n liveParent.replaceChild(newNodes[k], oldEntry.node);\n items[p2.index] = { ref: p2.item, cacheKey: void 0, html: c2.html, node: newNodes[k] };\n }\n }\n }\n function applyBulkInsert(liveParent, items, patches, start, end, tailAnchor) {\n const startIdx = patches[start].index;\n const htmls = new Array(end - start);\n for (let k = start; k < end; k++) {\n const p2 = patches[k];\n htmls[k - start] = p2.html;\n }\n const { tpl, count } = parseRowTemplate(htmls.join(\"\"));\n if (count !== htmls.length) {\n throw findOffendingInsert(patches, start, htmls);\n }\n const newNodes = new Array(end - start);\n let child = tpl.content.firstElementChild;\n for (let k = 0; k < newNodes.length; k++) {\n newNodes[k] = child;\n child = child.nextElementSibling;\n }\n const anchor = startIdx < items.length ? items[startIdx].node : tailAnchor;\n liveParent.insertBefore(tpl.content, anchor);\n const newEntries = new Array(end - start);\n for (let k = 0; k < newEntries.length; k++) {\n const p2 = patches[start + k];\n newEntries[k] = {\n ref: p2.item,\n cacheKey: void 0,\n html: htmls[k],\n node: newNodes[k]\n };\n }\n items.splice(startIdx, 0, ...newEntries);\n }\n function parseSingleRow(html) {\n const { tpl, count } = parseRowTemplate(html);\n if (count !== 1) {\n const reason = count === 0 ? \"produced no top-level element\" : `produced ${count} top-level elements; exactly one is required`;\n throw new Error(\n `each() granular reconcile: row render ${reason}. Each item's render must return exactly one element. Got HTML: ${JSON.stringify(truncateRowHtml(html))}`\n );\n }\n return tpl.content.firstElementChild;\n }\n function findOffendingInsert(patches, start, htmls) {\n for (let i2 = 0; i2 < htmls.length; i2++) {\n if (parseRowTemplate(htmls[i2]).count !== 1) {\n const p2 = patches[start + i2];\n return rowContractError(p2.index, htmls[i2]);\n }\n }\n return new Error(\"each(): bulk-insert mismatch with no per-row offender (kerf bug).\");\n }\n function findOffendingChange(patches, changes) {\n for (const c2 of changes) {\n if (parseRowTemplate(c2.html).count !== 1) {\n const p2 = patches[c2.patchIdx];\n return rowContractError(p2.index, c2.html);\n }\n }\n return new Error(\"each(): bulk-update mismatch with no per-row offender (kerf bug).\");\n }\n function tryInPlaceContentUpdate(binding, listSeg) {\n const oldItems = binding.items;\n const items = listSeg.items;\n const n2 = items.length;\n if (n2 === 0 || n2 !== oldItems.length) return false;\n for (let i2 = 0; i2 < n2; i2++) {\n if (items[i2].ref !== oldItems[i2].ref) return false;\n }\n const { liveParent } = binding;\n const newRecord = new Array(n2);\n const focusSnap = captureFocus(liveParent);\n for (let i2 = 0; i2 < n2; i2++) {\n newRecord[i2] = updateRowInPlace(liveParent, oldItems[i2], items[i2], i2);\n }\n if (focusSnap !== null) restoreFocus(focusSnap);\n binding.items = newRecord;\n maybeWarnMissingRowKey(newRecord[0].node, 0, newRecord[0].html, binding);\n return true;\n }\n function updateRowInPlace(liveParent, old, ni, index) {\n if (old.html === ni.html || tryAttributeOnlyFastPath(old.node, old.html, ni.html) || tryTextContentFastPath(old.node, old.html, ni.html)) {\n return { ref: ni.ref, cacheKey: ni.cacheKey, html: ni.html, node: old.node };\n }\n const newNode = parseSingleRow2(ni.html, index);\n if (old.node.tagName === newNode.tagName) {\n _morphElement(old.node, newNode);\n return { ref: ni.ref, cacheKey: ni.cacheKey, html: ni.html, node: old.node };\n }\n liveParent.replaceChild(newNode, old.node);\n return { ref: ni.ref, cacheKey: ni.cacheKey, html: ni.html, node: newNode };\n }\n function parseSingleRow2(html, index) {\n const { tpl, count } = parseRowTemplate(html);\n if (count !== 1) throw rowContractError(index, html);\n return tpl.content.firstElementChild;\n }\n function reconcileSnapshot(binding, listSeg) {\n if (tryInPlaceContentUpdate(binding, listSeg)) return;\n const { liveParent } = binding;\n const { newRecord, prevIdx, replacedNodes, freshIndices, freshHtmls } = classifyItems(binding.items, listSeg);\n const tailAnchor = endAnchor(binding);\n buildFreshNodes(newRecord, freshIndices, freshHtmls);\n const focusSnap = captureFocus(liveParent);\n removeOldNodes(liveParent, replacedNodes);\n applyMoves(liveParent, newRecord, prevIdx, lis(prevIdx), tailAnchor);\n if (focusSnap !== null) restoreFocus(focusSnap);\n binding.items = newRecord;\n if (newRecord.length > 0) {\n maybeWarnMissingRowKey(newRecord[0].node, 0, newRecord[0].html, binding);\n }\n }\n function classifyItems(oldItems, listSeg) {\n const oldByRef = /* @__PURE__ */ new Map();\n for (let i2 = 0; i2 < oldItems.length; i2++) {\n oldByRef.set(oldItems[i2].ref, [oldItems[i2], i2]);\n }\n const newRecord = new Array(listSeg.items.length);\n const prevIdx = new Array(listSeg.items.length);\n const replacedNodes = [];\n const freshIndices = [];\n const freshHtmls = [];\n for (let i2 = 0; i2 < listSeg.items.length; i2++) {\n const ni = listSeg.items[i2];\n const oi = oldByRef.get(ni.ref);\n if (oi !== void 0) {\n oldByRef.delete(ni.ref);\n if (oi[0].html === ni.html) {\n newRecord[i2] = oi[0];\n prevIdx[i2] = oi[1];\n continue;\n }\n replacedNodes.push(oi[0].node);\n }\n newRecord[i2] = {\n ref: ni.ref,\n cacheKey: ni.cacheKey,\n html: ni.html,\n node: null\n };\n prevIdx[i2] = -1;\n freshIndices.push(i2);\n freshHtmls.push(ni.html);\n }\n for (const [, orphan] of oldByRef) replacedNodes.push(orphan[0].node);\n return { newRecord, prevIdx, replacedNodes, freshIndices, freshHtmls };\n }\n function buildFreshNodes(newRecord, freshIndices, freshHtmls) {\n if (freshHtmls.length === 0) return;\n const { tpl, count } = parseRowTemplate(freshHtmls.join(\"\"));\n if (count !== freshHtmls.length) {\n throw findOffendingRow(newRecord, freshIndices, freshHtmls);\n }\n let node = tpl.content.firstElementChild;\n for (const idx of freshIndices) {\n const next = node.nextElementSibling;\n newRecord[idx].node = node;\n node = next;\n }\n }\n function findOffendingRow(newRecord, freshIndices, freshHtmls) {\n for (let i2 = 0; i2 < freshHtmls.length; i2++) {\n if (parseRowTemplate(freshHtmls[i2]).count !== 1) {\n return rowContractError(freshIndices[i2], newRecord[freshIndices[i2]].html);\n }\n }\n return new Error(\"each(): bulk-parse mismatch with no per-row offender (kerf bug).\");\n }\n function removeOldNodes(liveParent, replacedNodes) {\n for (const node of replacedNodes) {\n if (node.parentElement === liveParent) liveParent.removeChild(node);\n }\n }\n function applyMoves(liveParent, newRecord, prevIdx, stable, tailAnchor) {\n let nextSibling = tailAnchor;\n for (let i2 = newRecord.length - 1; i2 >= 0; i2--) {\n const node = newRecord[i2].node;\n if (prevIdx[i2] === -1 || !stable.has(i2)) {\n liveParent.insertBefore(node, nextSibling);\n }\n nextSibling = node;\n }\n }\n function lis(arr) {\n const tails = [];\n const tailIdx = [];\n const prev = new Array(arr.length);\n for (let i2 = 0; i2 < arr.length; i2++) {\n const v2 = arr[i2];\n if (v2 === -1) {\n prev[i2] = -1;\n continue;\n }\n let lo = 0;\n let hi = tails.length;\n while (lo < hi) {\n const mid = lo + hi >> 1;\n if (tails[mid] < v2) lo = mid + 1;\n else hi = mid;\n }\n prev[i2] = lo > 0 ? tailIdx[lo - 1] : -1;\n tails[lo] = v2;\n tailIdx[lo] = i2;\n }\n const out = /* @__PURE__ */ new Set();\n let k = tailIdx.length > 0 ? tailIdx[tailIdx.length - 1] : -1;\n while (k !== -1) {\n out.add(k);\n k = prev[k];\n }\n return out;\n }\n function reconcileList(binding, listSeg) {\n if (listSeg.patches !== void 0 && binding.items.length > 0) {\n reconcileGranular(binding, listSeg.patches);\n return;\n }\n reconcileSnapshot(binding, listSeg);\n }\n var LIST_MARKER_PREFIX = \"kf-list:\";\n var MOUNTED_MARKER = /* @__PURE__ */ Symbol.for(\"kerfjs.mounted\");\n function describeEl(el) {\n const tag = el.tagName.toLowerCase();\n const id = el.id ? `#${el.id}` : \"\";\n return `<${tag}${id}>`;\n }\n function assertNotInsideMountedTree(rootEl) {\n if (rootEl[MOUNTED_MARKER] === true) {\n throw new Error(\n `mount: ${describeEl(rootEl)} is already mounted. Call the disposer returned by the first mount() before mounting again. kerf supports one mount per element \\u2014 compose with plain functions that return JSX instead of nesting mounts.`\n );\n }\n let ancestor = rootEl.parentElement;\n while (ancestor !== null) {\n if (ancestor[MOUNTED_MARKER] === true) {\n throw new Error(\n \"mount: rootEl is already inside (or contains) a mounted tree. kerf supports one mount per tree \\u2014 compose with plain functions that return JSX instead of nesting mounts.\"\n );\n }\n ancestor = ancestor.parentElement;\n }\n const stack = [];\n for (let i2 = 0; i2 < rootEl.children.length; i2++) stack.push(rootEl.children[i2]);\n while (stack.length > 0) {\n const cur = stack.pop();\n if (cur[MOUNTED_MARKER] === true) {\n throw new Error(\n \"mount: rootEl is already inside (or contains) a mounted tree. kerf supports one mount per tree \\u2014 compose with plain functions that return JSX instead of nesting mounts.\"\n );\n }\n for (let i2 = 0; i2 < cur.children.length; i2++) stack.push(cur.children[i2]);\n }\n }\n function mount(rootEl, render2) {\n if (rootEl == null) {\n throw new Error(\n 'mount: rootEl is null/undefined \\u2014 pass the live element, e.g. mount(document.getElementById(\"app\")!, render). A common cause is a typo in the id or selector that returns null at runtime even though the TypeScript types say HTMLElement.'\n );\n }\n const owner = rootEl.ownerDocument;\n if (owner !== document) {\n if (owner.defaultView === null) document.adoptNode(rootEl);\n }\n assertNotInsideMountedTree(rootEl);\n rootEl[MOUNTED_MARKER] = true;\n const listenerWarnObserver = installListenerRebuildWarn(rootEl);\n const bindings = /* @__PURE__ */ new Map();\n const renderCtx = {\n counter: 0,\n caches: /* @__PURE__ */ new Map(),\n bindingCounts: /* @__PURE__ */ new Map()\n };\n let isFirst = true;\n let prevStaticHtml = \"\";\n const disposeEffect = effect(() => {\n renderCtx.counter = 0;\n _setRenderContext(renderCtx);\n let result;\n try {\n result = render2();\n } finally {\n _setRenderContext(null);\n }\n const segment = isSafeHtml(result) ? result.__segment ?? { kind: \"static\", html: result.__html } : { kind: \"static\", html: coerceRenderResult(result) };\n if (isFirst) {\n runFirstRender(rootEl, segment, bindings);\n prevStaticHtml = flattenWithoutListItems(segment);\n isFirst = false;\n } else {\n prevStaticHtml = runSubsequentRender(rootEl, segment, bindings, renderCtx, prevStaticHtml);\n }\n for (const listSeg of collectLists(segment).values()) {\n const binding = bindings.get(listSeg.id);\n reconcileList(binding, listSeg);\n renderCtx.bindingCounts.set(listSeg.id, binding.items.length);\n }\n });\n return () => {\n disposeEffect();\n listenerWarnObserver?.disconnect();\n delete rootEl[MOUNTED_MARKER];\n };\n }\n function runFirstRender(rootEl, segment, bindings) {\n rootEl.innerHTML = flatten(segment, true);\n bindListsFromMarkers(rootEl, segment, bindings, true);\n }\n function runSubsequentRender(rootEl, segment, bindings, renderCtx, prevStaticHtml) {\n const currentStaticHtml = flattenWithoutListItems(segment);\n if (currentStaticHtml === prevStaticHtml) {\n return prevStaticHtml;\n }\n cleanupOrphanBindings(segment, bindings, renderCtx);\n const template = rootEl.cloneNode(false);\n template.innerHTML = currentStaticHtml;\n morph(rootEl, template, collectOwnedItems(bindings));\n bindListsFromMarkers(rootEl, segment, bindings, false);\n return currentStaticHtml;\n }\n function coerceRenderResult(result) {\n if (result === null || result === void 0) return \"\";\n if (result === false || result === true) return \"\";\n return String(result);\n }\n function bindListsFromMarkers(rootEl, segment, bindings, inlinedItems) {\n const lists = collectLists(segment);\n const found = [];\n collectComments(rootEl, found);\n for (const marker of found) {\n if (!marker.data.startsWith(LIST_MARKER_PREFIX)) continue;\n const id = marker.data.slice(LIST_MARKER_PREFIX.length);\n if (bindings.has(id)) continue;\n const listSeg = lists.get(id);\n const liveParent = marker.parentElement;\n const items = [];\n if (inlinedItems) {\n let next = marker.nextElementSibling;\n for (let i2 = 0; i2 < listSeg.items.length && next !== null; i2++) {\n validateInlinedRowMatch(listSeg.items[i2].html, i2, next);\n items.push({\n ref: listSeg.items[i2].ref,\n cacheKey: listSeg.items[i2].cacheKey,\n html: listSeg.items[i2].html,\n node: next\n });\n next = next.nextElementSibling;\n }\n }\n const binding = { liveParent, items, marker };\n if (items.length > 0) {\n maybeWarnMissingRowKey(items[0].node, 0, items[0].html, binding);\n }\n maybeWarnEachInMorphSkip(id, liveParent, rootEl);\n bindings.set(id, binding);\n }\n }\n function validateInlinedRowMatch(expectedHtml, index, boundEl) {\n if (boundEl.outerHTML === expectedHtml) return;\n const { count } = parseRowTemplate(expectedHtml);\n if (count === 1) return;\n throw rowContractError(index, expectedHtml);\n }\n function collectOwnedItems(bindings) {\n const owned = /* @__PURE__ */ new Set();\n for (const b2 of bindings.values()) {\n for (const item of b2.items) owned.add(item.node);\n }\n return owned;\n }\n function cleanupOrphanBindings(segment, bindings, renderCtx) {\n const liveIds = collectLists(segment);\n for (const [id, binding] of bindings) {\n if (liveIds.has(id)) continue;\n for (const item of binding.items) {\n if (item.node.parentElement !== null) {\n item.node.parentElement.removeChild(item.node);\n }\n }\n if (binding.marker.parentElement !== null) {\n binding.marker.parentElement.removeChild(binding.marker);\n }\n bindings.delete(id);\n renderCtx.bindingCounts.delete(id);\n renderCtx.caches.delete(id);\n }\n }\n function collectComments(node, out) {\n for (let c2 = node.firstChild; c2 !== null; c2 = c2.nextSibling) {\n if (c2.nodeType === Node.COMMENT_NODE) out.push(c2);\n else if (c2.nodeType === Node.ELEMENT_NODE) collectComments(c2, out);\n }\n }\n\n // src/scrubber/crop.ts\n var _clamp = (v2, lo, hi) => Math.max(lo, Math.min(v2, hi));\n function fitRectToAspect(rect, ar, frameW, frameH) {\n if (!(ar > 0)) return rect;\n const cx = rect.x + rect.w / 2, cy = rect.y + rect.h / 2;\n let w2 = rect.w, h2 = w2 / ar;\n if (h2 > rect.h) {\n h2 = rect.h;\n w2 = h2 * ar;\n }\n if (w2 > frameW) {\n w2 = frameW;\n h2 = w2 / ar;\n }\n if (h2 > frameH) {\n h2 = frameH;\n w2 = h2 * ar;\n }\n const x2 = _clamp(cx - w2 / 2, 0, frameW - w2), y2 = _clamp(cy - h2 / 2, 0, frameH - h2);\n return { x: x2, y: y2, w: w2, h: h2 };\n }\n function constrainResizeToAspect(free, handle, ar, frameW, frameH, minSize) {\n if (!(ar > 0)) return free;\n const L = free.x, R = free.x + free.w, T = free.y, B = free.y + free.h;\n let w2 = free.w, h2 = free.h;\n if (handle.includes(\"w\") || handle.includes(\"e\")) h2 = w2 / ar;\n else w2 = h2 * ar;\n const anchorH = handle.includes(\"w\") ? \"right\" : handle.includes(\"e\") ? \"left\" : \"center\";\n const anchorV = handle.includes(\"n\") ? \"bottom\" : handle.includes(\"s\") ? \"top\" : \"middle\";\n const ax = anchorH === \"right\" ? R : anchorH === \"left\" ? L : (L + R) / 2;\n const ay = anchorV === \"bottom\" ? B : anchorV === \"top\" ? T : (T + B) / 2;\n const maxW = anchorH === \"right\" ? ax : anchorH === \"left\" ? frameW - ax : 2 * Math.min(ax, frameW - ax);\n const maxH = anchorV === \"bottom\" ? ay : anchorV === \"top\" ? frameH - ay : 2 * Math.min(ay, frameH - ay);\n if (w2 > maxW) {\n w2 = maxW;\n h2 = w2 / ar;\n }\n if (h2 > maxH) {\n h2 = maxH;\n w2 = h2 * ar;\n }\n if (w2 < minSize) {\n w2 = minSize;\n h2 = w2 / ar;\n }\n if (h2 < minSize) {\n h2 = minSize;\n w2 = h2 * ar;\n }\n let x2 = anchorH === \"right\" ? ax - w2 : anchorH === \"left\" ? ax : ax - w2 / 2;\n let y2 = anchorV === \"bottom\" ? ay - h2 : anchorV === \"top\" ? ay : ay - h2 / 2;\n x2 = _clamp(x2, 0, Math.max(0, frameW - w2));\n y2 = _clamp(y2, 0, Math.max(0, frameH - h2));\n return { x: x2, y: y2, w: w2, h: h2 };\n }\n\n // src/scrubber/client.tsx\n var CSS = `\n*{box-sizing:border-box}\nbody{margin:0;font:14px/1.4 -apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;background:#0e0f13;color:#e7e9ee}\n.wrap{display:flex;flex-direction:column;height:100vh}\n.stage{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;\n background:repeating-conic-gradient(#1a1c22 0% 25%,#15171c 0% 50%) 50%/24px 24px;overflow:hidden;position:relative;touch-action:none}\n.svg-host{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;\n transform-origin:center center;will-change:transform;pointer-events:none}\n.svg-host svg{display:block;filter:drop-shadow(0 4px 24px rgba(0,0,0,.5))}\n.drop{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;\n gap:10px;color:#9aa0ad;border:2px dashed #2a2e38;margin:18px;border-radius:12px;text-align:center;padding:24px;\n background:rgba(14,15,19,.7);cursor:pointer}\n.drop.over{border-color:#5b8cff;color:#cdd5ff;background:#161b2b}\n.bar{background:#15171c;border-top:1px solid #23262f;padding:10px 14px;display:flex;flex-direction:column;gap:8px}\n.row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}\n.row2{justify-content:space-between}\n.grp{display:flex;align-items:center;gap:8px}\nbutton{background:#262a35;color:#e7e9ee;border:1px solid #333845;border-radius:7px;padding:6px 11px;cursor:pointer;font:inherit}\nbutton:hover{background:#2f3543}button:disabled{opacity:.45;cursor:default}\nbutton.primary{background:#3a63d8;border-color:#3a63d8}button.primary:hover{background:#4a73e8}\n.play{display:inline-flex;align-items:center;justify-content:center;padding:6px 9px}\n.play svg{display:block}\n.iconbtn{padding:6px 8px;min-width:32px}\nselect,input[type=number]{background:#1c1f27;color:#e7e9ee;border:1px solid #333845;border-radius:6px;padding:5px 7px;font:inherit}\ninput[type=number]{width:84px}\n.scrub-wrap{position:relative;flex:1;min-width:200px;display:flex;align-items:center;height:24px}\n.scrub{width:100%;position:relative;z-index:2;margin:0;background:transparent}\ninput[type=range]{accent-color:#5b8cff}\n.range-band{position:absolute;top:50%;transform:translateY(-50%);height:9px;\n background:rgba(91,140,255,.22);border:1px solid rgba(120,160,255,.5);border-radius:3px;pointer-events:none;z-index:1}\n.range-tick{position:absolute;top:50%;transform:translate(-50%,-50%);width:14px;height:22px;z-index:3;\n cursor:ew-resize;display:flex;align-items:center;justify-content:center;touch-action:none}\n.range-tick::before{content:\"\";width:3px;height:18px;background:#9bb4ff;border-radius:2px;box-shadow:0 0 0 1px rgba(0,0,0,.35)}\n.range-tick span{position:absolute;top:-14px;font-size:9px;color:#9bb4ff;font-weight:600;pointer-events:none}\n.time{font-variant-numeric:tabular-nums;color:#aab0bd;min-width:120px;text-align:right}\n.muted{color:#8a90a0;font-size:12px}\n.rng{display:flex;align-items:center;gap:8px}\nlabel{display:inline-flex;align-items:center;gap:5px;cursor:pointer}\n.tag{background:#23262f;border-radius:5px;padding:2px 7px;font-size:12px;color:#aab0bd}\n.export-wrap{position:relative}\n.export-menu{position:absolute;bottom:calc(100% + 6px);right:0;background:#1c1f27;border:1px solid #333845;\n border-radius:8px;padding:4px;display:flex;flex-direction:column;gap:2px;min-width:150px;box-shadow:0 8px 24px rgba(0,0,0,.5);z-index:10}\n.export-menu button{text-align:left;background:transparent;border:0}\n.export-menu button:hover{background:#2f3543}\na.dl{display:none}\n.iconbtn.active{background:#3257d6;color:#fff}\n/* DM-1104: crop overlay. The layer covers the stage; the box dims everything\n outside it via a huge spread shadow and carries 8 resize handles. */\n.crop-layer{position:absolute;inset:0;z-index:6;pointer-events:none}\n.crop-box{position:absolute;outline:1px solid #cfe0ff;box-shadow:0 0 0 100vmax rgba(0,0,0,.55);\n pointer-events:auto;cursor:move;touch-action:none}\n.crop-h{position:absolute;width:12px;height:12px;background:#cfe0ff;border:1px solid #2a2f3a;border-radius:2px;\n pointer-events:auto;touch-action:none;transform:translate(-50%,-50%)}\n.crop-h[data-handle=nw]{cursor:nwse-resize}.crop-h[data-handle=se]{cursor:nwse-resize}\n.crop-h[data-handle=ne]{cursor:nesw-resize}.crop-h[data-handle=sw]{cursor:nesw-resize}\n.crop-h[data-handle=n]{cursor:ns-resize}.crop-h[data-handle=s]{cursor:ns-resize}\n.crop-h[data-handle=w]{cursor:ew-resize}.crop-h[data-handle=e]{cursor:ew-resize}\n.crop-dims{position:absolute;top:-22px;left:0;background:#1c1f27;border:1px solid #333845;border-radius:4px;\n padding:1px 6px;font:12px/1.4 ui-monospace,monospace;color:#cfe0ff;white-space:nowrap;pointer-events:none}\n/* DM-1445: review mode \\u2014 issue-reporting panel + region overlay. */\n.review{background:#12141a;border:1px solid #2a2e38;border-radius:8px;padding:8px;flex-direction:column;align-items:stretch;gap:8px}\n.rv-title{flex:1;min-width:180px;background:#1c1f27;color:#e7e9ee;border:1px solid #333845;border-radius:6px;padding:6px 8px;font:inherit}\n.rv-note{width:100%;min-height:46px;resize:vertical;background:#1c1f27;color:#e7e9ee;border:1px solid #333845;border-radius:6px;padding:6px 8px;font:inherit}\n.rv-status{font-size:12px;color:#8a90a0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.rv-status.ok{color:#7fd88f}.rv-status.err{color:#ff8a8a}\n.region-layer{position:absolute;inset:0;z-index:7;display:none;touch-action:none;cursor:crosshair}\n.region-box{position:absolute;outline:2px solid #ff5b8a;background:rgba(255,91,138,.14);pointer-events:none}\n.region-box::after{content:\"issue region\";position:absolute;top:-18px;left:0;font-size:10px;color:#ff8fb0;font-weight:600;white-space:nowrap}\n`;\n var ZOOM_PRESETS = [0.1, 0.25, 0.5, 0.75, 1, 1.5, 2, 4];\n var ICON_PLAY = /* @__PURE__ */ jsx(\"svg\", { width: \"18\", height: \"18\", viewBox: \"0 0 24 24\", fill: \"none\", stroke: \"currentColor\", \"stroke-width\": \"2\", \"stroke-linecap\": \"round\", \"stroke-linejoin\": \"round\", \"aria-hidden\": \"true\", children: /* @__PURE__ */ jsx(\"polygon\", { points: \"6 3 20 12 6 21 6 3\" }) });\n var ICON_PAUSE = /* @__PURE__ */ jsx(\"svg\", { width: \"18\", height: \"18\", viewBox: \"0 0 24 24\", fill: \"none\", stroke: \"currentColor\", \"stroke-width\": \"2\", \"stroke-linecap\": \"round\", \"stroke-linejoin\": \"round\", \"aria-hidden\": \"true\", children: [\n /* @__PURE__ */ jsx(\"rect\", { x: \"14\", y: \"4\", width: \"4\", height: \"16\", rx: \"1\" }),\n /* @__PURE__ */ jsx(\"rect\", { x: \"6\", y: \"4\", width: \"4\", height: \"16\", rx: \"1\" })\n ] });\n var ICON_DOT = /* @__PURE__ */ jsx(\"svg\", { width: \"18\", height: \"18\", viewBox: \"0 0 24 24\", fill: \"currentColor\", stroke: \"currentColor\", \"stroke-width\": \"2\", \"stroke-linecap\": \"round\", \"stroke-linejoin\": \"round\", \"aria-hidden\": \"true\", children: /* @__PURE__ */ jsx(\"circle\", { cx: \"12.1\", cy: \"12.1\", r: \"1\" }) });\n var ICON_CROP = /* @__PURE__ */ jsx(\"svg\", { width: \"18\", height: \"18\", viewBox: \"0 0 24 24\", fill: \"none\", stroke: \"currentColor\", \"stroke-width\": \"2\", \"stroke-linecap\": \"round\", \"stroke-linejoin\": \"round\", \"aria-hidden\": \"true\", children: [\n /* @__PURE__ */ jsx(\"path\", { d: \"M6 2v14a2 2 0 0 0 2 2h14\" }),\n /* @__PURE__ */ jsx(\"path\", { d: \"M18 22V8a2 2 0 0 0-2-2H2\" })\n ] });\n function fmt(ms) {\n const s2 = ms / 1e3;\n const m2 = Math.floor(s2 / 60);\n return `${m2}:${(s2 - m2 * 60).toFixed(2).padStart(5, \"0\")}`;\n }\n var svgLoaded = signal(false);\n var dragging = signal(false);\n var durationMs = signal(0);\n var playhead = signal(0);\n var playing = signal(false);\n var speed = signal(1);\n var rangeStart = signal(0);\n var rangeEnd = signal(0);\n var loop = signal(true);\n var zoom = signal(1);\n var panX = signal(0);\n var panY = signal(0);\n var exportMenuOpen = signal(false);\n var busy = signal(false);\n var trackW = signal(0);\n var cropMode = signal(false);\n var cropRect = signal(null);\n var cropTick = signal(0);\n var cropAspect = signal(\"free\");\n var reviewMode = window.__SCRUBBER_BOOTSTRAP__?.review === true;\n var regionMode = signal(false);\n var regions = signal([]);\n var drawingRect = signal(null);\n var regionTick = signal(0);\n var attachFrame = signal(true);\n var ticketStatus = signal({ kind: \"\", msg: \"\" });\n var savingTicket = signal(false);\n var svgText = \"\";\n var svgName = \"animation\";\n var svgPath = window.__SCRUBBER_BOOTSTRAP__?.path ?? null;\n var lastTs = 0;\n var svgEl = null;\n var head = document.createElement(\"style\");\n head.textContent = CSS;\n document.head.append(head);\n var THUMB_R = 8;\n function markerLeft(ms) {\n const dur = durationMs.value || 1;\n const w2 = trackW.value;\n const usable = Math.max(1, w2 - THUMB_R * 2);\n return `${THUMB_R + Math.min(Math.max(ms, 0), dur) / dur * usable}px`;\n }\n function render() {\n const dur = durationMs.value;\n const hi = rangeEnd.value > rangeStart.value ? rangeEnd.value : dur;\n const zoomPct = Math.round(zoom.value * 100);\n return /* @__PURE__ */ jsx(\"div\", { class: \"wrap\", children: [\n /* @__PURE__ */ jsx(\"div\", { class: \"stage\", \"data-stage\": true, children: [\n /* @__PURE__ */ jsx(\"div\", { class: \"svg-host\", \"data-svg-host\": true, \"data-morph-skip\": true }),\n /* @__PURE__ */ jsx(\"div\", { class: \"crop-layer\", \"data-crop-layer\": true, \"data-morph-skip\": true, style: \"display:none\" }),\n /* @__PURE__ */ jsx(\"div\", { class: \"region-layer\", \"data-region-layer\": true, \"data-morph-skip\": true, style: \"display:none\" }),\n (!svgLoaded.value || dragging.value) && /* @__PURE__ */ jsx(\"div\", { class: dragging.value ? \"drop over\" : \"drop\", \"data-drop\": true, children: [\n \"Drop an animated SVG here\",\n /* @__PURE__ */ jsx(\"span\", { class: \"muted\", children: \"or click to choose a file\" })\n ] })\n ] }),\n /* @__PURE__ */ jsx(\"div\", { class: \"bar\", children: [\n /* @__PURE__ */ jsx(\"div\", { class: \"row\", children: [\n /* @__PURE__ */ jsx(\"button\", { class: \"play primary\", \"data-action\": \"play\", \"aria-label\": playing.value ? \"Pause\" : \"Play\", disabled: !svgLoaded.value, children: playing.value ? ICON_PAUSE : ICON_PLAY }),\n /* @__PURE__ */ jsx(\"select\", { \"data-action\": \"speed\", disabled: !svgLoaded.value, children: [\"0.1\", \"0.25\", \"0.5\", \"1\", \"1.5\", \"2\", \"4\"].map((v2) => /* @__PURE__ */ jsx(\"option\", { value: v2, selected: parseFloat(v2) === speed.value, children: [\n v2,\n \"x\"\n ] })) }),\n /* @__PURE__ */ jsx(\"div\", { class: \"scrub-wrap\", children: [\n svgLoaded.value && dur > 0 && trackW.value > 0 && /* @__PURE__ */ jsx(Fragment, { children: [\n /* @__PURE__ */ jsx(\"div\", { class: \"range-band\", style: `left:${markerLeft(rangeStart.value)};width:${Math.max(0, parseFloat(markerLeft(hi)) - parseFloat(markerLeft(rangeStart.value)))}px` }),\n /* @__PURE__ */ jsx(\"div\", { class: \"range-tick\", \"data-tick\": \"in\", title: \"drag to set range start\", style: `left:${markerLeft(rangeStart.value)}`, children: /* @__PURE__ */ jsx(\"span\", { children: \"in\" }) }),\n /* @__PURE__ */ jsx(\"div\", { class: \"range-tick\", \"data-tick\": \"out\", title: \"drag to set range end\", style: `left:${markerLeft(hi)}`, children: /* @__PURE__ */ jsx(\"span\", { children: \"out\" }) })\n ] }),\n /* @__PURE__ */ jsx(\"input\", { type: \"range\", class: \"scrub\", \"data-action\": \"scrub\", min: \"0\", max: \"1000\", step: \"0.1\", disabled: !svgLoaded.value })\n ] }),\n /* @__PURE__ */ jsx(\"div\", { class: \"time\", children: [\n fmt(playhead.value),\n \" / \",\n fmt(dur)\n ] })\n ] }),\n /* @__PURE__ */ jsx(\"div\", { class: \"row row2\", children: [\n /* @__PURE__ */ jsx(\"div\", { class: \"grp\", children: [\n /* @__PURE__ */ jsx(\"button\", { \"data-action\": \"setin\", disabled: !svgLoaded.value, children: \"In\" }),\n /* @__PURE__ */ jsx(\"input\", { type: \"number\", \"data-action\": \"inn\", min: \"0\", step: \"0.01\", title: \"range start (s)\", value: (rangeStart.value / 1e3).toFixed(2), disabled: !svgLoaded.value }),\n /* @__PURE__ */ jsx(\"span\", { class: \"muted\", children: \"->\" }),\n /* @__PURE__ */ jsx(\"input\", { type: \"number\", \"data-action\": \"outn\", min: \"0\", step: \"0.01\", title: \"range end (s)\", value: (rangeEnd.value / 1e3).toFixed(2), disabled: !svgLoaded.value }),\n /* @__PURE__ */ jsx(\"button\", { \"data-action\": \"setout\", disabled: !svgLoaded.value, children: \"Out\" }),\n /* @__PURE__ */ jsx(\"label\", { children: [\n /* @__PURE__ */ jsx(\"input\", { type: \"checkbox\", \"data-action\": \"loop\", checked: loop.value, disabled: !svgLoaded.value }),\n \"loop\"\n ] }),\n /* @__PURE__ */ jsx(\"button\", { \"data-action\": \"resetrange\", disabled: !svgLoaded.value, children: \"Reset\" })\n ] }),\n /* @__PURE__ */ jsx(\"div\", { class: \"grp\", children: [\n /* @__PURE__ */ jsx(\"button\", { class: \"iconbtn\", \"data-action\": \"zoomout\", title: \"zoom out\", disabled: !svgLoaded.value, children: \"-\" }),\n /* @__PURE__ */ jsx(\"select\", { \"data-action\": \"zoompreset\", title: \"zoom\", disabled: !svgLoaded.value, children: [\n ZOOM_PRESETS.map((z) => /* @__PURE__ */ jsx(\"option\", { value: String(z), selected: Math.round(z * 100) === zoomPct, children: [\n Math.round(z * 100),\n \"%\"\n ] })),\n !ZOOM_PRESETS.some((z) => Math.round(z * 100) === zoomPct) && /* @__PURE__ */ jsx(\"option\", { value: \"custom\", selected: true, children: [\n zoomPct,\n \"%\"\n ] }),\n /* @__PURE__ */ jsx(\"option\", { value: \"fit\", children: \"Fit\" }),\n /* @__PURE__ */ jsx(\"option\", { value: \"fill\", children: \"Fill\" })\n ] }),\n /* @__PURE__ */ jsx(\"button\", { class: \"iconbtn\", \"data-action\": \"zoomin\", title: \"zoom in\", disabled: !svgLoaded.value, children: \"+\" }),\n /* @__PURE__ */ jsx(\"button\", { class: \"iconbtn\", \"data-action\": \"center\", title: \"reset pan to center\", \"aria-label\": \"center\", disabled: !svgLoaded.value, children: ICON_DOT })\n ] }),\n /* @__PURE__ */ jsx(\"div\", { class: \"grp\", children: [\n /* @__PURE__ */ jsx(\"button\", { class: cropMode.value ? \"iconbtn active\" : \"iconbtn\", \"data-action\": \"croptoggle\", title: \"crop\", \"aria-label\": \"crop\", \"aria-pressed\": cropMode.value ? \"true\" : \"false\", disabled: !svgLoaded.value, children: ICON_CROP }),\n /* @__PURE__ */ jsx(\"select\", { \"data-action\": \"cropaspect\", title: \"crop aspect ratio\", disabled: !svgLoaded.value || !cropMode.value, children: [\n /* @__PURE__ */ jsx(\"option\", { value: \"free\", selected: cropAspect.value === \"free\", children: \"Free\" }),\n /* @__PURE__ */ jsx(\"option\", { value: \"1\", selected: cropAspect.value === \"1\", children: \"1:1\" }),\n /* @__PURE__ */ jsx(\"option\", { value: \"1.7778\", selected: cropAspect.value === \"1.7778\", children: \"16:9\" }),\n /* @__PURE__ */ jsx(\"option\", { value: \"1.3333\", selected: cropAspect.value === \"1.3333\", children: \"4:3\" }),\n /* @__PURE__ */ jsx(\"option\", { value: \"orig\", selected: cropAspect.value === \"orig\", children: \"Original\" })\n ] })\n ] }),\n /* @__PURE__ */ jsx(\"div\", { class: \"grp export-wrap\", children: [\n /* @__PURE__ */ jsx(\"button\", { class: \"primary\", \"data-action\": \"exporttoggle\", disabled: !svgLoaded.value || busy.value, children: \"Export\" }),\n exportMenuOpen.value && /* @__PURE__ */ jsx(\"div\", { class: \"export-menu\", \"data-export-menu\": true, children: [\n /* @__PURE__ */ jsx(\"button\", { \"data-action\": \"export-frame\", children: \"Frame (PNG)\" }),\n /* @__PURE__ */ jsx(\"button\", { \"data-action\": \"export-trim\", children: \"Trim (SVG)\" }),\n /* @__PURE__ */ jsx(\"button\", { \"data-action\": \"export-video\", children: \"Range (MP4)\" })\n ] })\n ] })\n ] }),\n reviewMode && /* @__PURE__ */ jsx(\"div\", { class: \"row review\", children: [\n /* @__PURE__ */ jsx(\"div\", { class: \"grp\", style: \"flex-wrap:wrap;width:100%\", children: [\n /* @__PURE__ */ jsx(\"input\", { class: \"rv-title\", \"data-action\": \"rv-title\", type: \"text\", placeholder: \"Issue title\", disabled: !svgLoaded.value }),\n /* @__PURE__ */ jsx(\"select\", { \"data-action\": \"rv-category\", title: \"ticket category\", disabled: !svgLoaded.value, children: [\"bug\", \"issue\", \"feature\", \"task\", \"investigation\"].map((c2) => /* @__PURE__ */ jsx(\"option\", { value: c2, children: c2 })) }),\n /* @__PURE__ */ jsx(\"button\", { class: regionMode.value ? \"iconbtn active\" : \"iconbtn\", \"data-action\": \"rv-region\", title: \"drag rectangles over the problem area(s); stays armed so you can add several\", disabled: !svgLoaded.value, children: regionMode.value ? \"Drawing\\u2026\" : regions.value.length > 0 ? `Regions \\u2713 (${regions.value.length})` : \"Mark region\" }),\n /* @__PURE__ */ jsx(\"button\", { \"data-action\": \"rv-clear-region\", disabled: !svgLoaded.value || regions.value.length === 0, children: \"Clear\" }),\n /* @__PURE__ */ jsx(\"label\", { class: \"muted\", children: [\n /* @__PURE__ */ jsx(\"input\", { type: \"checkbox\", \"data-action\": \"rv-attach\", checked: attachFrame.value, disabled: !svgLoaded.value }),\n \"attach frame\"\n ] }),\n /* @__PURE__ */ jsx(\"span\", { class: \"muted\", children: [\n \"frame @ \",\n fmt(playhead.value),\n \" \\xB7 range \",\n fmt(rangeStart.value),\n \"\\u2013\",\n fmt(hi)\n ] }),\n /* @__PURE__ */ jsx(\"button\", { class: \"primary\", \"data-action\": \"rv-save\", disabled: !svgLoaded.value || savingTicket.value, children: savingTicket.value ? \"Saving\\u2026\" : \"Save issue\" })\n ] }),\n /* @__PURE__ */ jsx(\"textarea\", { class: \"rv-note\", \"data-action\": \"rv-note\", placeholder: \"Describe the issue (becomes the ticket body)\\u2026\", disabled: !svgLoaded.value }),\n ticketStatus.value.msg !== \"\" && /* @__PURE__ */ jsx(\"div\", { class: `rv-status ${ticketStatus.value.kind}`, children: ticketStatus.value.msg })\n ] })\n ] })\n ] });\n }\n var app = document.getElementById(\"app\");\n mount(app, render);\n var svgHost = app.querySelector(\"[data-svg-host]\");\n var dlAnchor = document.createElement(\"a\");\n dlAnchor.className = \"dl\";\n app.append(dlAnchor);\n effect(() => {\n svgHost.style.transform = `translate(${panX.value}px, ${panY.value}px) scale(${zoom.value})`;\n });\n effect(() => {\n const p2 = playhead.value, dur = durationMs.value;\n const sc = app.querySelector(\".scrub\");\n if (sc != null && document.activeElement !== sc) sc.value = String(p2 / (dur || 1) * 1e3);\n });\n function measureTrack() {\n const el = app.querySelector(\".scrub\");\n if (el) trackW.value = el.clientWidth;\n }\n var stageEl = app.querySelector(\".stage\");\n new ResizeObserver(() => {\n measureTrack();\n fitZoomKeep();\n cropTick.value++;\n regionTick.value++;\n }).observe(stageEl);\n window.addEventListener(\"resize\", () => {\n measureTrack();\n fitZoomKeep();\n cropTick.value++;\n regionTick.value++;\n });\n var CROP_MIN = 8;\n var cropLayer = app.querySelector(\"[data-crop-layer]\");\n var cropBox = document.createElement(\"div\");\n cropBox.className = \"crop-box\";\n var cropDimsEl = document.createElement(\"div\");\n cropDimsEl.className = \"crop-dims\";\n cropBox.appendChild(cropDimsEl);\n var CROP_HANDLES = [\"nw\", \"n\", \"ne\", \"e\", \"se\", \"s\", \"sw\", \"w\"];\n var cropHandleEls = {};\n for (const hh of CROP_HANDLES) {\n const d2 = document.createElement(\"div\");\n d2.className = \"crop-h\";\n d2.dataset.handle = hh;\n cropBox.appendChild(d2);\n cropHandleEls[hh] = d2;\n }\n cropLayer.appendChild(cropBox);\n effect(() => {\n const cr = cropRect.value;\n const on = cropMode.value && svgLoaded.value && cr != null;\n cropTick.value;\n cropLayer.style.display = on ? \"block\" : \"none\";\n if (!on || cr == null) return;\n const n2 = svgNaturalSize();\n const z = zoom.value;\n const sw = stageEl.clientWidth, sh = stageEl.clientHeight;\n const originX = sw / 2 + panX.value - n2.w * z / 2;\n const originY = sh / 2 + panY.value - n2.h * z / 2;\n const bx = originX + cr.x * z, by = originY + cr.y * z;\n const bw = cr.w * z, bh = cr.h * z;\n cropBox.style.left = `${bx}px`;\n cropBox.style.top = `${by}px`;\n cropBox.style.width = `${bw}px`;\n cropBox.style.height = `${bh}px`;\n const pos = {\n nw: [0, 0],\n n: [bw / 2, 0],\n ne: [bw, 0],\n e: [bw, bh / 2],\n se: [bw, bh],\n s: [bw / 2, bh],\n sw: [0, bh],\n w: [0, bh / 2]\n };\n for (const hh of CROP_HANDLES) {\n cropHandleEls[hh].style.left = `${pos[hh][0]}px`;\n cropHandleEls[hh].style.top = `${pos[hh][1]}px`;\n }\n cropDimsEl.textContent = `${Math.round(cr.w)} \\xD7 ${Math.round(cr.h)}`;\n });\n function cropAspectRatio() {\n const v2 = cropAspect.value;\n if (v2 === \"free\") return null;\n if (v2 === \"orig\") {\n const n2 = svgNaturalSize();\n return n2.h > 0 ? n2.w / n2.h : null;\n }\n const r2 = parseFloat(v2);\n return Number.isFinite(r2) && r2 > 0 ? r2 : null;\n }\n function applyCropDrag(start, handle, dxU, dyU, n2, aspect) {\n const clamp = (v2, lo, hi) => Math.max(lo, Math.min(v2, hi));\n if (handle === \"move\") {\n return { x: clamp(start.x + dxU, 0, n2.w - start.w), y: clamp(start.y + dyU, 0, n2.h - start.h), w: start.w, h: start.h };\n }\n let L = start.x, R = start.x + start.w, T = start.y, B = start.y + start.h;\n if (handle.includes(\"w\")) L = clamp(start.x + dxU, 0, R - CROP_MIN);\n if (handle.includes(\"e\")) R = clamp(start.x + start.w + dxU, L + CROP_MIN, n2.w);\n if (handle.includes(\"n\")) T = clamp(start.y + dyU, 0, B - CROP_MIN);\n if (handle.includes(\"s\")) B = clamp(start.y + start.h + dyU, T + CROP_MIN, n2.h);\n const free = { x: L, y: T, w: R - L, h: B - T };\n if (aspect == null || aspect <= 0) return free;\n return constrainResizeToAspect(free, handle, aspect, n2.w, n2.h, CROP_MIN);\n }\n var cropDrag = null;\n void delegate(app, \"pointerdown\", \".crop-box\", (e2, _box) => {\n if (cropRect.value == null) return;\n const ev = e2;\n const realTarget = ev.target;\n const handle = realTarget.classList.contains(\"crop-h\") ? realTarget.dataset.handle : \"move\";\n cropDrag = { handle, start: { ...cropRect.value }, px: ev.clientX, py: ev.clientY };\n cropBox.setPointerCapture(ev.pointerId);\n ev.preventDefault();\n ev.stopPropagation();\n });\n cropBox.addEventListener(\"pointermove\", (ev) => {\n if (cropDrag == null) return;\n const z = zoom.value || 1;\n const dxU = (ev.clientX - cropDrag.px) / z, dyU = (ev.clientY - cropDrag.py) / z;\n cropRect.value = applyCropDrag(cropDrag.start, cropDrag.handle, dxU, dyU, svgNaturalSize(), cropAspectRatio());\n });\n var endCropDrag = () => {\n cropDrag = null;\n };\n cropBox.addEventListener(\"pointerup\", endCropDrag);\n cropBox.addEventListener(\"pointercancel\", endCropDrag);\n var REGION_MIN = 3;\n var regionLayer = app.querySelector(\"[data-region-layer]\");\n function svgOriginLocal() {\n const n2 = svgNaturalSize();\n const z = zoom.value || 1;\n return { ox: stageEl.clientWidth / 2 + panX.value - n2.w * z / 2, oy: stageEl.clientHeight / 2 + panY.value - n2.h * z / 2, z };\n }\n function clientToSvgUnits(clientX, clientY) {\n const r2 = stageEl.getBoundingClientRect();\n const { ox, oy, z } = svgOriginLocal();\n return { x: (clientX - r2.left - ox) / z, y: (clientY - r2.top - oy) / z };\n }\n effect(() => {\n const rs = regions.value;\n const draw = drawingRect.value;\n const on = regionMode.value || rs.length > 0;\n regionTick.value;\n regionLayer.style.display = on && svgLoaded.value ? \"block\" : \"none\";\n regionLayer.style.pointerEvents = regionMode.value && svgLoaded.value ? \"auto\" : \"none\";\n const { ox, oy, z } = svgOriginLocal();\n const all = draw != null ? [...rs, draw] : rs;\n while (regionLayer.children.length < all.length) {\n const b2 = document.createElement(\"div\");\n b2.className = \"region-box\";\n regionLayer.appendChild(b2);\n }\n while (regionLayer.children.length > all.length) regionLayer.lastChild.remove();\n all.forEach((rr, i2) => {\n const b2 = regionLayer.children[i2];\n b2.style.left = `${ox + rr.x * z}px`;\n b2.style.top = `${oy + rr.y * z}px`;\n b2.style.width = `${rr.w * z}px`;\n b2.style.height = `${rr.h * z}px`;\n });\n });\n var regionDraw = null;\n regionLayer.addEventListener(\"pointerdown\", (ev) => {\n if (!regionMode.value || !svgLoaded.value) return;\n const p2 = clientToSvgUnits(ev.clientX, ev.clientY);\n regionDraw = { ox: p2.x, oy: p2.y };\n drawingRect.value = { x: p2.x, y: p2.y, w: 0, h: 0 };\n regionLayer.setPointerCapture(ev.pointerId);\n ev.preventDefault();\n });\n regionLayer.addEventListener(\"pointermove\", (ev) => {\n if (regionDraw == null) return;\n const p2 = clientToSvgUnits(ev.clientX, ev.clientY);\n drawingRect.value = {\n x: Math.min(regionDraw.ox, p2.x),\n y: Math.min(regionDraw.oy, p2.y),\n w: Math.abs(p2.x - regionDraw.ox),\n h: Math.abs(p2.y - regionDraw.oy)\n };\n });\n var endRegionDraw = () => {\n if (regionDraw == null) return;\n regionDraw = null;\n const rr = drawingRect.value;\n drawingRect.value = null;\n if (rr != null && rr.w >= REGION_MIN && rr.h >= REGION_MIN) regions.value = [...regions.value, rr];\n regionTick.value++;\n };\n regionLayer.addEventListener(\"pointerup\", endRegionDraw);\n regionLayer.addEventListener(\"pointercancel\", endRegionDraw);\n function stageAnims() {\n if (typeof document.getAnimations !== \"function\" || svgEl == null) return [];\n return document.getAnimations().filter((a2) => {\n const t2 = a2.effect?.target;\n return t2 != null && svgEl.contains(t2);\n });\n }\n function seekAll(ms) {\n for (const a2 of stageAnims()) {\n try {\n a2.pause();\n a2.currentTime = ms;\n } catch {\n }\n }\n if (svgEl != null && typeof svgEl.pauseAnimations === \"function\") {\n try {\n svgEl.pauseAnimations();\n svgEl.setCurrentTime(ms / 1e3);\n } catch {\n }\n }\n }\n function localDuration() {\n let finite = 0, period = 0;\n for (const a2 of stageAnims()) {\n const ct = a2.effect.getComputedTiming();\n const d2 = Number(ct.duration);\n if (Number.isFinite(ct.endTime)) finite = Math.max(finite, Number(ct.endTime));\n if (!Number.isFinite(ct.iterations) && d2 > 0) period = Math.max(period, d2);\n }\n return finite || period;\n }\n function tick(ts) {\n if (playing.value) {\n if (lastTs === 0) lastTs = ts;\n const dt = (ts - lastTs) * speed.value;\n lastTs = ts;\n let p2 = playhead.value + dt;\n const lo = rangeStart.value, hi = rangeEnd.value > rangeStart.value ? rangeEnd.value : durationMs.value;\n if (p2 >= hi) {\n if (loop.value) p2 = lo + (p2 - lo) % Math.max(1, hi - lo);\n else {\n p2 = hi;\n playing.value = false;\n }\n }\n playhead.value = p2;\n seekAll(p2);\n } else {\n lastTs = 0;\n }\n requestAnimationFrame(tick);\n }\n requestAnimationFrame(tick);\n function svgNaturalSize() {\n if (svgEl == null) return { w: 1, h: 1 };\n const vb = svgEl.viewBox?.baseVal;\n if (vb && vb.width > 0 && vb.height > 0) return { w: vb.width, h: vb.height };\n const r2 = svgEl.getBoundingClientRect();\n return { w: r2.width / (zoom.value || 1) || 1, h: r2.height / (zoom.value || 1) || 1 };\n }\n function stageSize() {\n const r2 = app.querySelector(\".stage\").getBoundingClientRect();\n return { w: r2.width, h: r2.height };\n }\n function fitZoom(mode) {\n const s2 = stageSize(), n2 = svgNaturalSize();\n const sx = s2.w / n2.w, sy = s2.h / n2.h;\n return mode === \"fit\" ? Math.min(sx, sy) : Math.max(sx, sy);\n }\n var zoomMode = \"fit\";\n function fitZoomKeep() {\n if (zoomMode !== \"manual\" && svgEl != null) zoom.value = fitZoom(zoomMode);\n }\n function setZoom(z) {\n zoom.value = Math.min(16, Math.max(0.02, z));\n zoomMode = \"manual\";\n }\n async function loadSvg(text, name) {\n svgText = text;\n svgName = name.replace(/\\.svg$/i, \"\") || \"animation\";\n const tmp = document.createElement(\"div\");\n tmp.innerHTML = text;\n const svg = tmp.querySelector(\"svg\");\n if (svg == null) {\n alert(\"No <svg> element found in the file.\");\n return;\n }\n svgHost.replaceChildren(svg);\n svgEl = svg;\n const n2 = (() => {\n const vb = svg.viewBox?.baseVal;\n return vb && vb.width > 0 ? { w: vb.width, h: vb.height } : { w: 800, h: 600 };\n })();\n svg.style.width = `${n2.w}px`;\n svg.style.height = `${n2.h}px`;\n svg.removeAttribute(\"width\");\n svg.removeAttribute(\"height\");\n svgLoaded.value = true;\n let dur = 0;\n try {\n const r2 = await fetch(\"/timing\", { method: \"POST\", headers: { \"content-type\": \"application/json\" }, body: JSON.stringify({ svg: text }) });\n dur = (await r2.json()).durationMs ?? localDuration();\n } catch {\n dur = localDuration();\n }\n if (!(dur > 0)) dur = 1e3;\n durationMs.value = dur;\n playhead.value = 0;\n rangeStart.value = 0;\n rangeEnd.value = dur;\n playing.value = false;\n seekAll(0);\n zoomMode = \"fit\";\n panX.value = 0;\n panY.value = 0;\n fitZoomKeep();\n cropMode.value = false;\n cropRect.value = null;\n cropAspect.value = \"free\";\n cropTick.value++;\n regionMode.value = false;\n regions.value = [];\n drawingRect.value = null;\n regionTick.value++;\n ticketStatus.value = { kind: \"\", msg: \"\" };\n measureTrack();\n }\n function svgPxSize() {\n if (svgEl == null) return { w: 800, h: 600 };\n const vb = svgEl.viewBox?.baseVal;\n return vb && vb.width > 0 ? { w: vb.width, h: vb.height } : { w: 800, h: 600 };\n }\n function download(blob, filename) {\n const url = URL.createObjectURL(blob);\n dlAnchor.href = url;\n dlAnchor.download = filename;\n dlAnchor.click();\n setTimeout(() => URL.revokeObjectURL(url), 4e3);\n }\n function rangeSE() {\n return { s: rangeStart.value, e: rangeEnd.value > rangeStart.value ? rangeEnd.value : durationMs.value };\n }\n function activeCrop() {\n if (!cropMode.value) return void 0;\n const cr = cropRect.value;\n if (cr == null) return void 0;\n const n2 = svgNaturalSize();\n const full = cr.x <= 0.5 && cr.y <= 0.5 && cr.w >= n2.w - 0.5 && cr.h >= n2.h - 0.5;\n return full ? void 0 : { x: cr.x, y: cr.y, w: cr.w, h: cr.h };\n }\n async function exportFrame() {\n busy.value = true;\n try {\n const { w: w2, h: h2 } = svgPxSize();\n const r2 = await fetch(\"/export-frame\", { method: \"POST\", headers: { \"content-type\": \"application/json\" }, body: JSON.stringify({ svg: svgText, timeMs: playhead.value, width: w2, height: h2, crop: activeCrop() }) });\n if (!r2.ok) throw new Error(`export failed (${r2.status})`);\n download(await r2.blob(), `${svgName}-${Math.round(playhead.value)}ms.png`);\n } catch (err) {\n alert(err instanceof Error ? err.message : \"export failed\");\n } finally {\n busy.value = false;\n }\n }\n async function exportTrim() {\n const { s: s2, e: e2 } = rangeSE();\n busy.value = true;\n try {\n const r2 = await fetch(\"/trim\", { method: \"POST\", headers: { \"content-type\": \"application/json\" }, body: JSON.stringify({ svg: svgText, startMs: s2, endMs: e2, periodMs: durationMs.value, crop: activeCrop() }) });\n if (!r2.ok) throw new Error(`trim failed (${r2.status})`);\n download(new Blob([(await r2.json()).svg], { type: \"image/svg+xml\" }), `${svgName}-trim-${Math.round(s2)}-${Math.round(e2)}ms.svg`);\n } catch (err) {\n alert(err instanceof Error ? err.message : \"trim failed\");\n } finally {\n busy.value = false;\n }\n }\n async function exportVideo() {\n const { s: s2, e: e2 } = rangeSE();\n const { w: w2, h: h2 } = svgPxSize();\n busy.value = true;\n try {\n const r2 = await fetch(\"/export-range-video\", { method: \"POST\", headers: { \"content-type\": \"application/json\" }, body: JSON.stringify({ svg: svgText, startMs: s2, endMs: e2, width: w2, height: h2, crop: activeCrop() }) });\n if (!r2.ok) {\n let msg = `export failed (${r2.status})`;\n try {\n msg = (await r2.json()).error ?? msg;\n } catch {\n }\n throw new Error(msg);\n }\n download(await r2.blob(), `${svgName}-${Math.round(s2)}-${Math.round(e2)}ms.mp4`);\n } catch (err) {\n alert(err instanceof Error ? err.message : \"video export failed\");\n } finally {\n busy.value = false;\n }\n }\n async function saveTicket() {\n const titleEl = app.querySelector(\".rv-title\");\n const noteEl = app.querySelector(\".rv-note\");\n const catEl = app.querySelector(\"[data-action=rv-category]\");\n const title = (titleEl?.value ?? \"\").trim();\n if (title === \"\") {\n ticketStatus.value = { kind: \"err\", msg: \"\\u26A0 enter an issue title first\" };\n return;\n }\n const { s: s2, e: e2 } = rangeSE();\n savingTicket.value = true;\n ticketStatus.value = { kind: \"\", msg: \"saving\\u2026\" };\n try {\n const r2 = await fetch(\"/ticket\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({\n title,\n note: noteEl?.value ?? \"\",\n category: catEl?.value ?? \"bug\",\n svgPath,\n svgName,\n frameTimeMs: playhead.value,\n rangeStartMs: s2,\n rangeEndMs: e2,\n regions: regions.value,\n // DM-1449: attach the current frame as a sibling PNG (server renders it).\n attachFrame: attachFrame.value,\n svg: attachFrame.value ? svgText : void 0\n })\n });\n if (!r2.ok) {\n let msg = `save failed (${r2.status})`;\n try {\n msg = (await r2.json()).error ?? msg;\n } catch {\n }\n throw new Error(msg);\n }\n const { path, framePng } = await r2.json();\n ticketStatus.value = { kind: \"ok\", msg: `\\u2713 wrote ${path}${framePng ? ` (+ frame PNG)` : \"\"}` };\n if (titleEl) titleEl.value = \"\";\n if (noteEl) noteEl.value = \"\";\n regions.value = [];\n drawingRect.value = null;\n regionMode.value = false;\n regionTick.value++;\n } catch (err) {\n ticketStatus.value = { kind: \"err\", msg: `\\u26A0 ${err instanceof Error ? err.message : \"save failed\"}` };\n } finally {\n savingTicket.value = false;\n }\n }\n var togglePlay = () => {\n if (durationMs.value <= 0) return;\n const hi = rangeEnd.value > rangeStart.value ? rangeEnd.value : durationMs.value;\n if (!playing.value && playhead.value >= hi) playhead.value = rangeStart.value;\n playing.value = !playing.value;\n lastTs = 0;\n };\n var stepFrame = (deltaMs) => {\n playing.value = false;\n playhead.value = Math.min(durationMs.value, Math.max(0, playhead.value + deltaMs));\n seekAll(playhead.value);\n };\n var CLICK = {\n play: togglePlay,\n setin: () => {\n rangeStart.value = Math.min(playhead.value, rangeEnd.value);\n },\n setout: () => {\n rangeEnd.value = Math.max(playhead.value, rangeStart.value);\n },\n resetrange: () => {\n rangeStart.value = 0;\n rangeEnd.value = durationMs.value;\n },\n zoomin: () => setZoom(zoom.value * 1.25),\n zoomout: () => setZoom(zoom.value / 1.25),\n center: () => {\n panX.value = 0;\n panY.value = 0;\n },\n // DM-1104: toggle crop mode. Enabling seeds the rect to the whole frame (drag\n // the handles in to crop); disabling resets the rect so the next enable starts\n // fresh from the full frame.\n croptoggle: () => {\n cropMode.value = !cropMode.value;\n if (cropMode.value) {\n const n2 = svgNaturalSize();\n cropRect.value = { x: 0, y: 0, w: n2.w, h: n2.h };\n } else {\n cropRect.value = null;\n cropAspect.value = \"free\";\n }\n cropTick.value++;\n },\n exporttoggle: () => {\n exportMenuOpen.value = !exportMenuOpen.value;\n },\n \"export-frame\": () => {\n exportMenuOpen.value = false;\n void exportFrame();\n },\n \"export-trim\": () => {\n exportMenuOpen.value = false;\n void exportTrim();\n },\n \"export-video\": () => {\n exportMenuOpen.value = false;\n void exportVideo();\n },\n // DM-1445/DM-1449: review-mode controls.\n \"rv-region\": () => {\n regionMode.value = !regionMode.value;\n regionTick.value++;\n },\n \"rv-clear-region\": () => {\n regions.value = [];\n drawingRect.value = null;\n regionMode.value = false;\n regionTick.value++;\n },\n \"rv-save\": () => {\n void saveTicket();\n }\n };\n void delegate(app, \"click\", \"[data-action]\", (e2, target) => {\n const a2 = target.getAttribute(\"data-action\");\n if (a2 && CLICK[a2]) CLICK[a2]();\n });\n void delegate(app, \"click\", \"[data-drop]\", () => fileInput.click());\n void delegate(app, \"click\", \"[data-stage]\", () => {\n if (exportMenuOpen.value) exportMenuOpen.value = false;\n });\n void delegate(app, \"input\", \"[data-action=scrub]\", (e2, target) => {\n playing.value = false;\n playhead.value = parseFloat(target.value) / 1e3 * (durationMs.value || 1);\n seekAll(playhead.value);\n });\n void delegate(app, \"change\", \"[data-action=speed]\", (e2, target) => {\n speed.value = parseFloat(target.value) || 1;\n });\n void delegate(app, \"change\", \"[data-action=inn]\", (e2, target) => {\n rangeStart.value = Math.max(0, Math.min((parseFloat(target.value) || 0) * 1e3, durationMs.value));\n });\n void delegate(app, \"change\", \"[data-action=outn]\", (e2, target) => {\n rangeEnd.value = Math.max(rangeStart.value, Math.min((parseFloat(target.value) || 0) * 1e3, durationMs.value));\n });\n void delegate(app, \"change\", \"[data-action=loop]\", (e2, target) => {\n loop.value = target.checked;\n });\n void delegate(app, \"change\", \"[data-action=rv-attach]\", (e2, target) => {\n attachFrame.value = target.checked;\n });\n void delegate(app, \"change\", \"[data-action=cropaspect]\", (e2, target) => {\n cropAspect.value = target.value;\n const ar = cropAspectRatio();\n if (ar != null && cropRect.value != null) {\n const n2 = svgNaturalSize();\n cropRect.value = fitRectToAspect(cropRect.value, ar, n2.w, n2.h);\n }\n });\n void delegate(app, \"change\", \"[data-action=zoompreset]\", (e2, target) => {\n const v2 = target.value;\n panX.value = 0;\n panY.value = 0;\n if (v2 === \"fit\" || v2 === \"fill\") {\n zoomMode = v2;\n fitZoomKeep();\n } else if (v2 !== \"custom\") setZoom(parseFloat(v2));\n });\n var dragTick = null;\n function timeAtClientX(clientX) {\n const sc = app.querySelector(\".scrub\");\n if (sc == null) return 0;\n const r2 = sc.getBoundingClientRect();\n const usable = Math.max(1, r2.width - THUMB_R * 2);\n const frac = Math.min(1, Math.max(0, (clientX - r2.left - THUMB_R) / usable));\n return frac * (durationMs.value || 1);\n }\n void delegate(app, \"pointerdown\", \"[data-tick]\", (e2, target) => {\n const t2 = target.getAttribute(\"data-tick\");\n if (t2 !== \"in\" && t2 !== \"out\") return;\n dragTick = t2;\n target.setPointerCapture(e2.pointerId);\n e2.preventDefault();\n });\n void delegate(app, \"pointermove\", \"[data-tick]\", (e2) => {\n if (dragTick == null) return;\n const ms = timeAtClientX(e2.clientX);\n if (dragTick === \"in\") rangeStart.value = Math.min(ms, rangeEnd.value);\n else rangeEnd.value = Math.max(ms, rangeStart.value);\n });\n void delegate(app, \"pointerup\", \"[data-tick]\", () => {\n dragTick = null;\n });\n void delegate(app, \"wheel\", \"[data-stage]\", (e2) => {\n const w2 = e2;\n w2.preventDefault();\n if (w2.ctrlKey || w2.metaKey) {\n const factor = Math.exp(-w2.deltaY * 0.01);\n setZoom(zoom.value * factor);\n } else {\n panX.value -= w2.deltaX;\n panY.value -= w2.deltaY;\n }\n });\n function readFile(f2) {\n if (f2) {\n svgPath = null;\n void f2.text().then((t2) => loadSvg(t2, f2.name));\n }\n }\n void delegate(app, \"dragover\", \"[data-stage]\", (e2) => {\n e2.preventDefault();\n dragging.value = true;\n });\n void delegate(app, \"dragleave\", \"[data-stage]\", (e2) => {\n e2.preventDefault();\n dragging.value = false;\n });\n void delegate(app, \"drop\", \"[data-stage]\", (e2) => {\n e2.preventDefault();\n dragging.value = false;\n readFile(e2.dataTransfer?.files?.[0]);\n });\n var fileInput = document.createElement(\"input\");\n fileInput.type = \"file\";\n fileInput.accept = \".svg,image/svg+xml\";\n fileInput.style.display = \"none\";\n fileInput.addEventListener(\"change\", () => readFile(fileInput.files?.[0] ?? void 0));\n app.append(fileInput);\n window.addEventListener(\"keydown\", (e2) => {\n if (!svgLoaded.value) return;\n const tag = e2.target?.tagName;\n if (tag === \"INPUT\" && e2.target.type !== \"range\") return;\n if (tag === \"TEXTAREA\" || tag === \"SELECT\") return;\n if (e2.code === \"Space\") {\n e2.preventDefault();\n togglePlay();\n } else if (e2.code === \"ArrowLeft\") {\n stepFrame(-(e2.shiftKey ? 1 : 1e3 / 30));\n } else if (e2.code === \"ArrowRight\") {\n stepFrame(e2.shiftKey ? 1 : 1e3 / 30);\n }\n });\n requestAnimationFrame(measureTrack);\n var boot = window.__SCRUBBER_BOOTSTRAP__;\n if (boot?.svg) void loadSvg(boot.svg, boot.name ?? \"animation\");\n})();\n";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "domotion-svg",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.1",
|
|
4
4
|
"description": "DOM-to-animated-SVG renderer. Captures HTML/CSS via Playwright Chromium and converts it to self-contained SVG with CSS animations — pixel-faithful demos that scale crisply and load lazily.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Brian Westphal",
|
|
@@ -60,7 +60,11 @@
|
|
|
60
60
|
"demos:test:animate": "npm run build:capture-script && tsx tests/animate-examples.tsx",
|
|
61
61
|
"demos:test:all": "npm run build:capture-script && (tsx tests/features.ts ; tsx tests/snapshot-isolation.tsx ; tsx tests/showcase.tsx ; tsx tests/animate-examples.tsx ; tsx tests/html-test-suite.tsx ; tsx tests/real-world.tsx)",
|
|
62
62
|
"demos:review": "tsx tests/review-server.tsx",
|
|
63
|
-
"demos:examples": "npm run build:capture-script && tsx examples/terminal-demo.ts && tsx examples/terminal-onboarding.ts && tsx examples/hero-product-demo.ts && tsx examples/showcase-rendering.ts && tsx examples/showcase-transitions.ts && tsx examples/transition-tour.ts && tsx examples/svg-to-video-demo.ts && tsx examples/templates-demo.ts && tsx examples/terminal-window-scroll.ts"
|
|
63
|
+
"demos:examples": "npm run build:capture-script && tsx examples/terminal-demo.ts && tsx examples/terminal-onboarding.ts && tsx examples/hero-product-demo.ts && tsx examples/showcase-rendering.ts && tsx examples/showcase-transitions.ts && tsx examples/transition-tour.ts && tsx examples/svg-to-video-demo.ts && tsx examples/templates-demo.ts && tsx examples/terminal-window-scroll.ts",
|
|
64
|
+
"check:features": "tsx tools/check-feature-coverage.ts",
|
|
65
|
+
"site:dev": "cd site && npm install && npm run dev",
|
|
66
|
+
"site:build": "cd site && npm install && npm run build",
|
|
67
|
+
"site:preview": "cd site && npm install && npm run preview"
|
|
64
68
|
},
|
|
65
69
|
"dependencies": {
|
|
66
70
|
"@playwright/test": "^1.59.1",
|
|
@@ -68,7 +72,7 @@
|
|
|
68
72
|
"bidi-js": "^1.0.3",
|
|
69
73
|
"fontkit": "^2.0.4",
|
|
70
74
|
"harfbuzzjs": "^1.4.0",
|
|
71
|
-
"kerfjs": "^0.
|
|
75
|
+
"kerfjs": "^0.16.0",
|
|
72
76
|
"opentype.js": "^2.0.0",
|
|
73
77
|
"sharp": "^0.34.5",
|
|
74
78
|
"svgo": "^4.0.1",
|
|
@@ -82,7 +86,7 @@
|
|
|
82
86
|
"c8": "^11.0.0",
|
|
83
87
|
"esbuild": "^0.27.7",
|
|
84
88
|
"eslint": "^9.39.4",
|
|
85
|
-
"eslint-plugin-kerfjs": "^0.
|
|
89
|
+
"eslint-plugin-kerfjs": "^0.16.0",
|
|
86
90
|
"gitgist": "^1.1.0",
|
|
87
91
|
"happy-dom": "^20.9.0",
|
|
88
92
|
"tsx": "^4.19.0",
|